diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 080d97f3197cf..1e216b7f5ad92 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -97,6 +97,7 @@ const vscodeResourceIncludes = [ // Welcome 'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}', + 'out-build/vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', // Sessions 'out-build/vs/sessions/contrib/chat/browser/media/*.svg', diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index 3e6b29adfe9fa..9af2afecb38ba 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -74,6 +74,7 @@ export const vscodeWebResourceIncludes = [ // Welcome 'out-build/vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.{svg,png}', + 'out-build/vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', // Extensions 'out-build/vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', diff --git a/build/lib/stylelint/vscode-known-variables.json b/build/lib/stylelint/vscode-known-variables.json index ead57c9b5d23a..b9672335fda09 100644 --- a/build/lib/stylelint/vscode-known-variables.json +++ b/build/lib/stylelint/vscode-known-variables.json @@ -941,9 +941,9 @@ "--notebook-editor-font-weight", "--outline-element-color", "--separator-border", - "--session-bar-background", - "--session-tab-active-foreground", - "--session-tab-inactive-foreground", + "--chat-bar-background", + "--chat-tab-active-foreground", + "--chat-tab-inactive-foreground", "--status-border-top-color", "--tab-border-bottom-color", "--tab-border-top-color", diff --git a/build/next/index.ts b/build/next/index.ts index 93c7a035c0635..3b530ea1e5184 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -278,6 +278,7 @@ const desktopResourcePatterns = [ // Media - images 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg', 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.png', + 'vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', 'vs/workbench/contrib/extensions/browser/media/{theme-icon.png,language-icon.svg}', 'vs/workbench/services/extensionManagement/common/media/*.svg', 'vs/workbench/services/extensionManagement/common/media/*.png', @@ -337,6 +338,7 @@ const serverWebResourcePatterns = [ // Media - images 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg', 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.png', + 'vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', 'vs/workbench/contrib/extensions/browser/media/*.svg', 'vs/workbench/contrib/extensions/browser/media/*.png', 'vs/workbench/services/extensionManagement/common/media/*.svg', @@ -363,6 +365,7 @@ const webResourcePatterns = [ // Media - images 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg', 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.png', + 'vs/workbench/contrib/welcomeOnboarding/browser/media/*.svg', 'vs/workbench/contrib/extensions/browser/media/*.svg', 'vs/workbench/contrib/extensions/browser/media/*.png', 'vs/workbench/services/extensionManagement/common/media/*.svg', diff --git a/build/rspack/package-lock.json b/build/rspack/package-lock.json index d27a0a57d00b1..fc330131170d7 100644 --- a/build/rspack/package-lock.json +++ b/build/rspack/package-lock.json @@ -2298,9 +2298,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { diff --git a/extensions/copilot/package-lock.json b/extensions/copilot/package-lock.json index fe7e1b7604499..ba7e764533cf1 100644 --- a/extensions/copilot/package-lock.json +++ b/extensions/copilot/package-lock.json @@ -101,6 +101,7 @@ "csv-parse": "^6.0.0", "dotenv": "^17.2.0", "electron": "^39.8.5", + "esbuild": "0.27.2", "eslint": "^9.30.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-header": "^3.1.1", @@ -902,9 +903,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.6.tgz", - "integrity": "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", + "integrity": "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==", "cpu": [ "ppc64" ], @@ -919,9 +920,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.6.tgz", - "integrity": "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.2.tgz", + "integrity": "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==", "cpu": [ "arm" ], @@ -936,9 +937,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.6.tgz", - "integrity": "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz", + "integrity": "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==", "cpu": [ "arm64" ], @@ -953,9 +954,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.6.tgz", - "integrity": "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.2.tgz", + "integrity": "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==", "cpu": [ "x64" ], @@ -970,9 +971,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.6.tgz", - "integrity": "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz", + "integrity": "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==", "cpu": [ "arm64" ], @@ -987,9 +988,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.6.tgz", - "integrity": "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz", + "integrity": "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==", "cpu": [ "x64" ], @@ -1004,9 +1005,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.6.tgz", - "integrity": "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz", + "integrity": "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==", "cpu": [ "arm64" ], @@ -1021,9 +1022,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.6.tgz", - "integrity": "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz", + "integrity": "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==", "cpu": [ "x64" ], @@ -1038,9 +1039,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.6.tgz", - "integrity": "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz", + "integrity": "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==", "cpu": [ "arm" ], @@ -1055,9 +1056,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.6.tgz", - "integrity": "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz", + "integrity": "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==", "cpu": [ "arm64" ], @@ -1072,9 +1073,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.6.tgz", - "integrity": "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz", + "integrity": "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==", "cpu": [ "ia32" ], @@ -1089,9 +1090,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.6.tgz", - "integrity": "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz", + "integrity": "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==", "cpu": [ "loong64" ], @@ -1106,9 +1107,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.6.tgz", - "integrity": "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz", + "integrity": "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==", "cpu": [ "mips64el" ], @@ -1123,9 +1124,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.6.tgz", - "integrity": "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz", + "integrity": "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==", "cpu": [ "ppc64" ], @@ -1140,9 +1141,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.6.tgz", - "integrity": "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz", + "integrity": "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==", "cpu": [ "riscv64" ], @@ -1157,9 +1158,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.6.tgz", - "integrity": "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz", + "integrity": "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==", "cpu": [ "s390x" ], @@ -1174,9 +1175,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.6.tgz", - "integrity": "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz", + "integrity": "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==", "cpu": [ "x64" ], @@ -1191,9 +1192,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.6.tgz", - "integrity": "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz", + "integrity": "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==", "cpu": [ "arm64" ], @@ -1208,9 +1209,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.6.tgz", - "integrity": "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz", + "integrity": "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==", "cpu": [ "x64" ], @@ -1225,9 +1226,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.6.tgz", - "integrity": "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz", + "integrity": "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==", "cpu": [ "arm64" ], @@ -1242,9 +1243,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.6.tgz", - "integrity": "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz", + "integrity": "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==", "cpu": [ "x64" ], @@ -1259,9 +1260,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.6.tgz", - "integrity": "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz", + "integrity": "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==", "cpu": [ "arm64" ], @@ -1276,9 +1277,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.6.tgz", - "integrity": "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz", + "integrity": "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==", "cpu": [ "x64" ], @@ -1293,9 +1294,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.6.tgz", - "integrity": "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz", + "integrity": "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==", "cpu": [ "arm64" ], @@ -1310,9 +1311,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.6.tgz", - "integrity": "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz", + "integrity": "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==", "cpu": [ "ia32" ], @@ -1327,9 +1328,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.6.tgz", - "integrity": "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz", + "integrity": "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==", "cpu": [ "x64" ], @@ -11563,9 +11564,9 @@ "optional": true }, "node_modules/esbuild": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.6.tgz", - "integrity": "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg==", + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", + "integrity": "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -11576,32 +11577,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.6", - "@esbuild/android-arm": "0.25.6", - "@esbuild/android-arm64": "0.25.6", - "@esbuild/android-x64": "0.25.6", - "@esbuild/darwin-arm64": "0.25.6", - "@esbuild/darwin-x64": "0.25.6", - "@esbuild/freebsd-arm64": "0.25.6", - "@esbuild/freebsd-x64": "0.25.6", - "@esbuild/linux-arm": "0.25.6", - "@esbuild/linux-arm64": "0.25.6", - "@esbuild/linux-ia32": "0.25.6", - "@esbuild/linux-loong64": "0.25.6", - "@esbuild/linux-mips64el": "0.25.6", - "@esbuild/linux-ppc64": "0.25.6", - "@esbuild/linux-riscv64": "0.25.6", - "@esbuild/linux-s390x": "0.25.6", - "@esbuild/linux-x64": "0.25.6", - "@esbuild/netbsd-arm64": "0.25.6", - "@esbuild/netbsd-x64": "0.25.6", - "@esbuild/openbsd-arm64": "0.25.6", - "@esbuild/openbsd-x64": "0.25.6", - "@esbuild/openharmony-arm64": "0.25.6", - "@esbuild/sunos-x64": "0.25.6", - "@esbuild/win32-arm64": "0.25.6", - "@esbuild/win32-ia32": "0.25.6", - "@esbuild/win32-x64": "0.25.6" + "@esbuild/aix-ppc64": "0.27.2", + "@esbuild/android-arm": "0.27.2", + "@esbuild/android-arm64": "0.27.2", + "@esbuild/android-x64": "0.27.2", + "@esbuild/darwin-arm64": "0.27.2", + "@esbuild/darwin-x64": "0.27.2", + "@esbuild/freebsd-arm64": "0.27.2", + "@esbuild/freebsd-x64": "0.27.2", + "@esbuild/linux-arm": "0.27.2", + "@esbuild/linux-arm64": "0.27.2", + "@esbuild/linux-ia32": "0.27.2", + "@esbuild/linux-loong64": "0.27.2", + "@esbuild/linux-mips64el": "0.27.2", + "@esbuild/linux-ppc64": "0.27.2", + "@esbuild/linux-riscv64": "0.27.2", + "@esbuild/linux-s390x": "0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/netbsd-arm64": "0.27.2", + "@esbuild/netbsd-x64": "0.27.2", + "@esbuild/openbsd-arm64": "0.27.2", + "@esbuild/openbsd-x64": "0.27.2", + "@esbuild/openharmony-arm64": "0.27.2", + "@esbuild/sunos-x64": "0.27.2", + "@esbuild/win32-arm64": "0.27.2", + "@esbuild/win32-ia32": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/escalade": { @@ -21067,6 +21068,490 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/tunnel": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/tunnel/-/tunnel-0.0.6.tgz", diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 28eebe41ebc59..7656d4d8a6864 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -6296,6 +6296,7 @@ "csv-parse": "^6.0.0", "dotenv": "^17.2.0", "electron": "^39.8.5", + "esbuild": "0.27.2", "eslint": "^9.30.0", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-header": "^3.1.1", diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts index 9712ad5b68a81..15df93ab2a4fa 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts @@ -72,7 +72,7 @@ export interface IChatSessionWorktreeService { getSessionIdForWorktree(folder: vscode.Uri): Promise; - getWorktreeChanges(sessionId: string): Promise; + getWorktreeChanges(sessionId: string): Promise; handleRequestCompleted(sessionId: string): Promise; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index c895b61622344..4624cad738383 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -319,7 +319,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } } - async getWorktreeChanges(sessionId: string): Promise { + async getWorktreeChanges(sessionId: string): Promise { const worktreeProperties = await this.getWorktreeProperties(sessionId); if (!worktreeProperties || typeof worktreeProperties === 'string') { return undefined; @@ -800,7 +800,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi return { changes, ...repositoryState }; } - private _toChatSessionChangedFile2(sessionId: string, change: ChatSessionWorktreeFile, worktreeProperties: ChatSessionWorktreeProperties): vscode.ChatSessionChangedFile2 { + private _toChatSessionChangedFile2(sessionId: string, change: ChatSessionWorktreeFile, worktreeProperties: ChatSessionWorktreeProperties): vscode.ChatSessionChangedFile { let originalFileRef: string, modifiedFileRef: string | undefined; if (worktreeProperties.version === 2) { // Commit | Working tree @@ -816,7 +816,7 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi modifiedFileRef = worktreeProperties.branchName; } - return new vscode.ChatSessionChangedFile2( + return new vscode.ChatSessionChangedFile( vscode.Uri.file(change.filePath), change.originalFilePath ? toGitUri(vscode.Uri.file(change.originalFilePath), originalFileRef) diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index e2d7378a1ac34..2269bc9ae04e8 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -438,7 +438,7 @@ export class ClaudeChatSessionItemController extends Disposable { permissionMode, cwd: folder, }; - this._controller.items.add(item); + this._inProgressItems.set(newSessionId, item); return item; }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 234c12b2ab1d5..e2afe793172c3 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -169,7 +169,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } } )); - const inputStateForNewSession = new ResourceMap>(); controller.newChatSessionItemHandler = async (context) => { const sessionId = this.sessionService.createNewSessionId(); const resource = SessionIdForCLI.getResource(sessionId); @@ -185,8 +184,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements controller.items.add(session); this.newSessions.set(resource, session); - const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.inputState); - inputStateForNewSession.set(resource, new WeakRef(controller.createChatSessionInputState(groups))); return session; }; if (this.configurationService.getConfig(ConfigKey.Advanced.CLIForkSessionsEnabled)) { @@ -249,12 +246,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const groups = await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token); return controller.createChatSessionInputState(groups); } else { - // Possible we've already handled the newChatSessionItemHandler for this same uri - // In which case the proper inputState would have been sent. - // There's a bug in core where after newChatSessionItemHandler is called, we get - // another call for getChatSessionInputState, but this time the previous input state is incorrect. - const previousInputState = sessionResource ? inputStateForNewSession.get(sessionResource)?.deref() : undefined; - const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(previousInputState); + const groups = await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState); const state = controller.createChatSessionInputState(groups); // Only wire dynamic updates for new sessions (existing sessions are fully locked). // Note: don't use the getChatSessionInputState token here — it's a one-shot token @@ -371,13 +363,13 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements sessionId: string, worktreeProperties: Awaited>, workingDirectory: vscode.Uri | undefined, - ): Promise { - const changes: vscode.ChatSessionChangedFile2[] = []; + ): Promise { + const changes: vscode.ChatSessionChangedFile[] = []; if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) { changes.push(...(await this.copilotCLIWorktreeManagerService.getWorktreeChanges(sessionId) ?? [])); } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { const workspaceChanges = await this._workspaceFolderService.getWorkspaceChanges(sessionId) ?? []; - changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile2( + changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile( vscode.Uri.file(change.filePath), change.originalFilePath ? toGitUri(vscode.Uri.file(change.originalFilePath), 'HEAD') diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index d0d5c47642e1c..fcea0a3993337 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -266,14 +266,14 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } // Statistics (only returned for trusted workspace/worktree folders) - const changes: vscode.ChatSessionChangedFile2[] = []; + const changes: vscode.ChatSessionChangedFile[] = []; if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) { // Worktree changes.push(...(await this.worktreeManager.getWorktreeChanges(session.id) ?? [])); } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { // Workspace const workspaceChanges = await this.workspaceFolderService.getWorkspaceChanges(session.id) ?? []; - changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile2( + changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile( vscode.Uri.file(change.filePath), change.originalFilePath ? toGitUri(vscode.Uri.file(change.originalFilePath), 'HEAD') @@ -1295,6 +1295,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { initialSessionOptions: undefined, inputState: { groups: [], + sessionResource: undefined, onDidChange: Event.None } }; diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index a828aefc503eb..4213358f52770 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -1176,7 +1176,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } const multiDiffPart = await this._prFileChangesService.getFileChangesMultiDiffPart(pr); - const changes = multiDiffPart?.value?.map(change => new vscode.ChatSessionChangedFile2( + const changes = multiDiffPart?.value?.map(change => new vscode.ChatSessionChangedFile( change.goToFileUri!, change.originalUri, change.modifiedUri, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 4a5dfc8221cf5..6ae9071eaeaac 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -654,7 +654,7 @@ describe('ChatSessionContentProvider', () => { label: 'Test Session', }, initialSessionOptions, - inputState: { groups: [], onDidChange: Event.None }, + inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } @@ -764,7 +764,7 @@ describe('ChatSessionContentProvider', () => { label: 'Test Session', }, initialSessionOptions, - inputState: { groups: [], onDidChange: Event.None }, + inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } @@ -835,7 +835,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], onDidChange: Event.None }, + inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } @@ -935,7 +935,7 @@ describe('ChatSessionContentProvider', () => { resource: ClaudeSessionUri.forSessionId(sessionId), label: 'Test Session', }, - inputState: { groups: [], onDidChange: Event.None }, + inputState: { groups: [], sessionResource: undefined, onDidChange: Event.None }, }, } as vscode.ChatContext; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts index b36e9d044231b..270be0dde4965 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts @@ -104,6 +104,14 @@ function makeRef(name: string, type: number = 0 /* Head */): { name: string; typ return { name, type }; } +function createMockChatSessionInputState(groups: readonly vscode.ChatSessionProviderOptionGroup[]): vscode.ChatSessionInputState { + return { + onDidChange: Event.None, + groups, + sessionResource: undefined + }; +} + // ─── Pure function tests ───────────────────────────────────────── describe('SessionOptionGroupBuilder', () => { @@ -130,14 +138,11 @@ describe('SessionOptionGroupBuilder', () => { describe('getSelectedSessionOptions', () => { it('extracts folder, branch, and isolation from input state groups', () => { - const inputState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { id: REPOSITORY_OPTION_ID, name: 'Folder', items: [{ id: '/my-repo', name: 'my-repo' }], selected: { id: '/my-repo', name: 'my-repo' } }, - { id: BRANCH_OPTION_ID, name: 'Branch', items: [{ id: 'main', name: 'main' }], selected: { id: 'main', name: 'main' } }, - { id: ISOLATION_OPTION_ID, name: 'Isolation', items: [{ id: IsolationMode.Worktree, name: 'Worktree' }], selected: { id: IsolationMode.Worktree, name: 'Worktree' } }, - ], - }; + const inputState = createMockChatSessionInputState([ + { id: REPOSITORY_OPTION_ID, name: 'Folder', items: [{ id: '/my-repo', name: 'my-repo' }], selected: { id: '/my-repo', name: 'my-repo' } }, + { id: BRANCH_OPTION_ID, name: 'Branch', items: [{ id: 'main', name: 'main' }], selected: { id: 'main', name: 'main' } }, + { id: ISOLATION_OPTION_ID, name: 'Isolation', items: [{ id: IsolationMode.Worktree, name: 'Worktree' }], selected: { id: IsolationMode.Worktree, name: 'Worktree' } }, + ]); const result = getSelectedSessionOptions(inputState); expect(result.folder?.fsPath).toBe(URI.file('/my-repo').fsPath); expect(result.branch).toBe('main'); @@ -145,10 +150,7 @@ describe('SessionOptionGroupBuilder', () => { }); it('returns undefined values when no groups are present', () => { - const inputState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [], - }; + const inputState = createMockChatSessionInputState([]); const result = getSelectedSessionOptions(inputState); expect(result.folder).toBeUndefined(); expect(result.branch).toBeUndefined(); @@ -156,14 +158,11 @@ describe('SessionOptionGroupBuilder', () => { }); it('returns undefined values when groups have no selection', () => { - const inputState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { id: REPOSITORY_OPTION_ID, name: 'Folder', items: [] }, - { id: BRANCH_OPTION_ID, name: 'Branch', items: [] }, - { id: ISOLATION_OPTION_ID, name: 'Isolation', items: [] }, - ], - }; + const inputState = createMockChatSessionInputState([ + { id: REPOSITORY_OPTION_ID, name: 'Folder', items: [] }, + { id: BRANCH_OPTION_ID, name: 'Branch', items: [] }, + { id: ISOLATION_OPTION_ID, name: 'Isolation', items: [] }, + ]); const result = getSelectedSessionOptions(inputState); expect(result.folder).toBeUndefined(); expect(result.branch).toBeUndefined(); @@ -571,16 +570,13 @@ describe('SessionOptionGroupBuilder', () => { gitService.getRepository.mockResolvedValue(makeRepo('/repo2')); await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/repo1').fsPath, name: 'repo1' }, - }], - }; + const previousState = createMockChatSessionInputState([{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/repo1').fsPath, name: 'repo1' }, + }]); const groups = await builder.provideChatSessionProviderOptionGroups(previousState, URI.file('/repo2') as any); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); @@ -647,16 +643,13 @@ describe('SessionOptionGroupBuilder', () => { gitService.repositories = [repo]; gitService.getRepository.mockResolvedValue(repo); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [], - selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }], - }; + const previousState = createMockChatSessionInputState([{ + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }]); const groups = await builder.provideChatSessionProviderOptionGroups(previousState); const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID); @@ -734,16 +727,13 @@ describe('SessionOptionGroupBuilder', () => { { folder: mruUri2, repository: mruUri2, lastAccessed: Date.now() - 1000 }, ]); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: mruUri1.fsPath, name: 'repo-a' }, - }], - }; + const previousState = createMockChatSessionInputState([{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: mruUri1.fsPath, name: 'repo-a' }, + }]); const groups = await builder.provideChatSessionProviderOptionGroups(previousState, mruUri2 as any); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); @@ -801,16 +791,13 @@ describe('SessionOptionGroupBuilder', () => { ]); gitService.getRepository.mockResolvedValue(undefined); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: removedUri.fsPath, name: 'removed-repo' }, - }], - }; + const previousState = createMockChatSessionInputState([{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: removedUri.fsPath, name: 'removed-repo' }, + }]); const groups = await builder.provideChatSessionProviderOptionGroups(previousState); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); @@ -934,16 +921,13 @@ describe('SessionOptionGroupBuilder', () => { const prevRepo = makeRepo(prevUri.fsPath); gitService.getRepository.mockResolvedValue(prevRepo); - const previousState: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: prevUri.fsPath, name: 'prev-repo' }, - }], - }; + const previousState = createMockChatSessionInputState([{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: prevUri.fsPath, name: 'prev-repo' }, + }]); const groups = await builder.provideChatSessionProviderOptionGroups(previousState); const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID); @@ -961,32 +945,29 @@ describe('SessionOptionGroupBuilder', () => { gitService.getRepository.mockResolvedValue(repo); gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [], - selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }, - { - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' }, - }, - { - id: BRANCH_OPTION_ID, - name: 'Branch', - description: '', - items: [{ id: 'old-branch', name: 'old-branch' }], - selected: { id: 'old-branch', name: 'old-branch' }, - }, - ], - }; + const state = createMockChatSessionInputState([ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }, + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' }, + }, + { + id: BRANCH_OPTION_ID, + name: 'Branch', + description: '', + items: [{ id: 'old-branch', name: 'old-branch' }], + selected: { id: 'old-branch', name: 'old-branch' }, + }, + ]); await builder.handleInputStateChange(state); const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); @@ -999,24 +980,21 @@ describe('SessionOptionGroupBuilder', () => { gitService.getRepository.mockResolvedValue(makeRepo('/repo')); gitService.getRefs.mockResolvedValue([]); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/repo').fsPath, name: 'repo' }, - }, - { - id: BRANCH_OPTION_ID, - name: 'Branch', - description: '', - items: [{ id: 'old', name: 'old' }], - }, - ], - }; + const state = createMockChatSessionInputState([ + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/repo').fsPath, name: 'repo' }, + }, + { + id: BRANCH_OPTION_ID, + name: 'Branch', + description: '', + items: [{ id: 'old', name: 'old' }], + }, + ]); await builder.handleInputStateChange(state); const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID); @@ -1026,16 +1004,13 @@ describe('SessionOptionGroupBuilder', () => { it('does not add branch group when branch feature is disabled', async () => { await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/repo').fsPath, name: 'repo' }, - }], - }; + const state = createMockChatSessionInputState([{ + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/repo').fsPath, name: 'repo' }, + }]); await builder.handleInputStateChange(state); expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); @@ -1045,16 +1020,13 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [{ - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [], - selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }], - }; + const state = createMockChatSessionInputState([{ + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }]); await builder.handleInputStateChange(state); expect(context.globalState.get('github.copilot.cli.lastUsedIsolationOption')).toBe(IsolationMode.Worktree); @@ -1064,28 +1036,25 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); gitService.getRepository.mockResolvedValue(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [ - { id: IsolationMode.Workspace, name: 'Workspace' }, - { id: IsolationMode.Worktree, name: 'Worktree' }, - ], - selected: { id: IsolationMode.Worktree, name: 'Worktree' }, - }, - { - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/non-git').fsPath, name: 'non-git' }, - }, - ], - }; + const state = createMockChatSessionInputState([ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [ + { id: IsolationMode.Workspace, name: 'Workspace' }, + { id: IsolationMode.Worktree, name: 'Worktree' }, + ], + selected: { id: IsolationMode.Worktree, name: 'Worktree' }, + }, + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/non-git').fsPath, name: 'non-git' }, + }, + ]); await builder.handleInputStateChange(state); @@ -1098,28 +1067,25 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); gitService.getRepository.mockResolvedValue(makeRepo('/workspace')); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: ISOLATION_OPTION_ID, - name: 'Isolation', - description: '', - items: [ - { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, - { id: IsolationMode.Worktree, name: 'Worktree', locked: true }, - ], - selected: { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, - }, - { - id: REPOSITORY_OPTION_ID, - name: 'Folder', - description: '', - items: [], - selected: { id: URI.file('/workspace').fsPath, name: 'workspace' }, - }, - ], - }; + const state = createMockChatSessionInputState([ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + description: '', + items: [ + { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, + { id: IsolationMode.Worktree, name: 'Worktree', locked: true }, + ], + selected: { id: IsolationMode.Workspace, name: 'Workspace', locked: true }, + }, + { + id: REPOSITORY_OPTION_ID, + name: 'Folder', + description: '', + items: [], + selected: { id: URI.file('/workspace').fsPath, name: 'workspace' }, + }, + ]); await builder.handleInputStateChange(state); @@ -1282,10 +1248,7 @@ describe('SessionOptionGroupBuilder', () => { const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeUndefined(); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Simulate adding a second workspace folder workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/workspace2')]); @@ -1317,10 +1280,7 @@ describe('SessionOptionGroupBuilder', () => { const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); expect(initialGroups.find(g => g.id === REPOSITORY_OPTION_ID)).toBeDefined(); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Simulate removing a workspace folder workspaceService = new NullWorkspaceService([URI.file('/repo1')]); @@ -1345,10 +1305,7 @@ describe('SessionOptionGroupBuilder', () => { const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); expect(initialGroups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined(); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Simulate git init — repo now discovered const repo = makeRepo('/workspace'); @@ -1381,10 +1338,7 @@ describe('SessionOptionGroupBuilder', () => { const repoGroup = initialGroups[repoGroupIndex]; initialGroups[repoGroupIndex] = { ...repoGroup, selected: repoGroup.items.find(i => i.id === URI.file('/repo2').fsPath) }; - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Add a third folder workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2'), URI.file('/repo3')]); @@ -1415,10 +1369,7 @@ describe('SessionOptionGroupBuilder', () => { // Should be locked to workspace for non-git expect(isolationGroup!.selected?.locked).toBe(true); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Simulate git init const repo = makeRepo('/workspace'); @@ -1445,10 +1396,7 @@ describe('SessionOptionGroupBuilder', () => { // Build initial groups (worktree isolation → branch editable) const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Simulate selecting worktree isolation const isolationIdx = state.groups.findIndex(g => g.id === ISOLATION_OPTION_ID); @@ -1494,10 +1442,7 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Default isolation is workspace → branch should be locked builder.lockInputStateGroups(state); @@ -1516,10 +1461,7 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); builder.lockInputStateGroups(state); await builder.rebuildInputState(state); @@ -1551,10 +1493,7 @@ describe('SessionOptionGroupBuilder', () => { // Initial build — empty const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Simulate "Browse folders…" — rebuild with the browsed folder await builder.rebuildInputState(state, browsedUri as any); @@ -1579,10 +1518,7 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Verify some items are unlocked before locking const isolationBefore = state.groups.find(g => g.id === ISOLATION_OPTION_ID); @@ -1611,10 +1547,7 @@ describe('SessionOptionGroupBuilder', () => { const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); const groupIds = initialGroups.map(g => g.id); const selectedIds = initialGroups.map(g => g.selected?.id); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); builder.lockInputStateGroups(state); @@ -1623,12 +1556,9 @@ describe('SessionOptionGroupBuilder', () => { }); it('handles groups with no selected item', () => { - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { id: 'test', name: 'Test', items: [{ id: 'a', name: 'A' }] }, - ], - }; + const state = createMockChatSessionInputState([ + { id: 'test', name: 'Test', items: [{ id: 'a', name: 'A' }] }, + ]); builder.lockInputStateGroups(state); @@ -1637,10 +1567,7 @@ describe('SessionOptionGroupBuilder', () => { }); it('handles empty groups array', () => { - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [], - }; + const state = createMockChatSessionInputState([]); builder.lockInputStateGroups(state); @@ -1661,10 +1588,7 @@ describe('SessionOptionGroupBuilder', () => { await context.globalState.update('github.copilot.cli.lastUsedIsolationOption', IsolationMode.Worktree); const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); // Verify branch group exists with multiple items (worktree → editable) const branchBefore = state.groups.find(g => g.id === BRANCH_OPTION_ID); @@ -1683,17 +1607,14 @@ describe('SessionOptionGroupBuilder', () => { }); it('does not add branch group when none exists', () => { - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: [ - { - id: ISOLATION_OPTION_ID, - name: 'Isolation', - items: [{ id: IsolationMode.Workspace, name: 'Workspace' }], - selected: { id: IsolationMode.Workspace, name: 'Workspace' }, - }, - ], - }; + const state = createMockChatSessionInputState([ + { + id: ISOLATION_OPTION_ID, + name: 'Isolation', + items: [{ id: IsolationMode.Workspace, name: 'Workspace' }], + selected: { id: IsolationMode.Workspace, name: 'Workspace' }, + }, + ]); builder.updateBranchInInputState(state, 'copilot/my-feature'); @@ -1711,10 +1632,7 @@ describe('SessionOptionGroupBuilder', () => { await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true); const initialGroups = await builder.provideChatSessionProviderOptionGroups(undefined); - const state: vscode.ChatSessionInputState = { - onDidChange: Event.None, - groups: initialGroups, - }; + const state = createMockChatSessionInputState(initialGroups); const isolationBefore = state.groups.find(g => g.id === ISOLATION_OPTION_ID); diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index 3677676cb711d..53f42df6b694a 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -120,6 +120,9 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop allowedExecutionTools.has(tool.name as ToolName)); diff --git a/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx index 805efe9c28b8b..663a2cd3fc13b 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx @@ -36,6 +36,8 @@ export class ExecutionSubagentPrompt extends PromptElement For example, if you are asked to `make` a project but there is no Makefile, you might instead run "cmake . && make" to successfully build the code.

+ Always use mode="sync" when calling run_in_terminal. Use a generous timeout (e.g. 30000ms or more) so commands have time to finish. Do NOT use mode="async" — you must wait for each command to complete before proceeding. If a sync command times out, use get_terminal_output to check its status, send_to_terminal if it needs input, or kill_terminal to stop it.
+

Once you have finished, return a message with ONLY: the <final_answer> tag to provide a compact summary of each command that was run.
diff --git a/extensions/copilot/src/extension/tools/common/toolDeferralService.ts b/extensions/copilot/src/extension/tools/common/toolDeferralService.ts index 9301651d377db..5f96517de6250 100644 --- a/extensions/copilot/src/extension/tools/common/toolDeferralService.ts +++ b/extensions/copilot/src/extension/tools/common/toolDeferralService.ts @@ -16,6 +16,8 @@ const additionalNonDeferredToolNames = new Set([ // Core tools provided by VS Code (not registered via ToolRegistry.registerTool) ToolName.CoreRunInTerminal, ToolName.CoreGetTerminalOutput, + ToolName.CoreSendToTerminal, + ToolName.CoreKillTerminal, ToolName.CoreRunSubagent, ToolName.CoreRunTest, ToolName.CoreAskQuestions, diff --git a/extensions/copilot/src/extension/tools/common/toolNames.ts b/extensions/copilot/src/extension/tools/common/toolNames.ts index 95a1782f6c311..c6cb7b4b19e31 100644 --- a/extensions/copilot/src/extension/tools/common/toolNames.ts +++ b/extensions/copilot/src/extension/tools/common/toolNames.ts @@ -54,6 +54,8 @@ export enum ToolName { CoreManageTodoList = 'manage_todo_list', CoreRunInTerminal = 'run_in_terminal', CoreGetTerminalOutput = 'get_terminal_output', + CoreSendToTerminal = 'send_to_terminal', + CoreKillTerminal = 'kill_terminal', CoreTerminalSelection = 'terminal_selection', CoreTerminalLastCommand = 'terminal_last_command', CoreCreateAndRunTask = 'create_and_run_task', @@ -194,6 +196,8 @@ export const toolCategories: Record = { [ToolName.CoreRunInTerminal]: ToolCategory.Core, [ToolName.ListDirectory]: ToolCategory.Core, [ToolName.CoreGetTerminalOutput]: ToolCategory.Core, + [ToolName.CoreSendToTerminal]: ToolCategory.Core, + [ToolName.CoreKillTerminal]: ToolCategory.Core, [ToolName.CoreManageTodoList]: ToolCategory.Core, [ToolName.MultiReplaceString]: ToolCategory.Core, [ToolName.FindFiles]: ToolCategory.Core, diff --git a/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts b/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts index 773d93bc71c58..0a35246611293 100644 --- a/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts +++ b/extensions/copilot/src/extension/xtab/node/cursorLineDivergence.ts @@ -8,53 +8,54 @@ import { Position } from '../../../util/vs/editor/common/core/position'; import { PositionOffsetTransformer } from '../../../util/vs/editor/common/core/text/positionToOffset'; /** - * Resolves the current content of the cursor line after applying an intermediate + * Resolves the current content of a line after applying an intermediate * user edit to the original document. * - * The cursor line's 0-based index in the original document may no longer be - * valid after the user inserts or deletes lines above the cursor. This function - * maps the cursor line's character offset through the edit to find the correct + * The line's 0-based index in the original document may no longer be + * valid after the user inserts or deletes lines above it. This function + * maps the line's character offset through the edit to find the correct * line in the resulting document. * * @param originalDoc A transformer for the original document text (reused to * avoid recomputing line offsets). - * @param cursorDocLineIdx 0-based line index in the original document. - * @returns The line content, or `undefined` if the cursor line index is out of + * @param docLineIdx 0-based line index in the original document. + * @returns The line content, or `undefined` if the line index is out of * bounds or the original position falls inside a replacement range * (making the mapping ambiguous). */ -export function getCurrentCursorLine( +export function getCurrentLine( originalDoc: PositionOffsetTransformer, - cursorDocLineIdx: number, + docLineIdx: number, intermediateEdit: StringEdit, + precomputed?: { currentDoc: string; currentTransformer: PositionOffsetTransformer }, ): string | undefined { - const lineNumber = cursorDocLineIdx + 1; // 1-based + const lineNumber = docLineIdx + 1; // 1-based const lineCount = originalDoc.textLength.lineCount + 1; if (lineNumber < 1 || lineNumber > lineCount) { return undefined; } - const cursorLineStartOffset = originalDoc.getOffset(new Position(lineNumber, 1)); + const lineStartOffset = originalDoc.getOffset(new Position(lineNumber, 1)); // Walk through the edit's replacements (sorted, non-overlapping) and // accumulate the character-offset delta for replacements entirely before - // the cursor line start. + // the line start. let delta = 0; for (const replacement of intermediateEdit.replacements) { - if (replacement.replaceRange.endExclusive <= cursorLineStartOffset) { + if (replacement.replaceRange.endExclusive <= lineStartOffset) { delta += replacement.newText.length - replacement.replaceRange.length; - } else if (replacement.replaceRange.start < cursorLineStartOffset) { - // The cursor line start falls inside a replacement — ambiguous. + } else if (replacement.replaceRange.start < lineStartOffset) { + // The line start falls inside a replacement — ambiguous. return undefined; } else { break; } } - const mappedOffset = cursorLineStartOffset + delta; - const currentDoc = intermediateEdit.apply(originalDoc.text); - const currentTransformer = new PositionOffsetTransformer(currentDoc); + const mappedOffset = lineStartOffset + delta; + const currentDoc = precomputed?.currentDoc ?? intermediateEdit.apply(originalDoc.text); + const currentTransformer = precomputed?.currentTransformer ?? new PositionOffsetTransformer(currentDoc); // Map the offset back to a position in the current document, then extract // the full line content. @@ -102,7 +103,7 @@ function diffLine(before: string, after: string): LineDiff { } /** - * Checks whether the model's cursor line output is compatible with what the user + * Checks whether the model's line output is compatible with what the user * has typed since the request started. * * Algorithm: @@ -121,9 +122,9 @@ function diffLine(before: string, after: string): LineDiff { * → user typed "x", model inserted "bonacci(n): number" * → model text does not start with "x" → incompatible ✗ */ -export function isModelCursorLineCompatible(originalCursorLine: string, currentCursorLine: string, modelCursorLine: string): boolean { - const userEdit = diffLine(originalCursorLine, currentCursorLine); - const modelEdit = diffLine(originalCursorLine, modelCursorLine); +export function isModelLineCompatible(originalLine: string, currentLine: string, modelLine: string): boolean { + const userEdit = diffLine(originalLine, currentLine); + const modelEdit = diffLine(originalLine, modelLine); // No actual user change — trivially compatible. if (userEdit.replaced.length === 0 && userEdit.inserted.length === 0) { @@ -132,7 +133,7 @@ export function isModelCursorLineCompatible(originalCursorLine: string, currentC // The user's edit range must fall within the model's edit range. // If the user edited a region the model didn't touch, we can't determine - // compatibility from the cursor line alone. + // compatibility from the line alone. const userEditWithinModelEdit = userEdit.startOffset >= modelEdit.startOffset && userEdit.endOffset <= modelEdit.endOffset; @@ -140,7 +141,7 @@ export function isModelCursorLineCompatible(originalCursorLine: string, currentC return false; } - return isUserEditCompatibleWithModelEdit(userEdit, modelEdit, currentCursorLine, modelCursorLine); + return isUserEditCompatibleWithModelEdit(userEdit, modelEdit, currentLine, modelLine); } const AUTO_CLOSE_PAIRS = new Set(['()', '[]', '{}', '<>', '""', `''`, '``']); @@ -156,9 +157,9 @@ const AUTO_CLOSE_PAIRS = new Set(['()', '[]', '{}', '<>', '""', `''`, '``']); * already matches the model, or when the model is editing the exact same range * and replacing the exact same original text with a compatible continuation. */ -function isUserEditCompatibleWithModelEdit(userEdit: LineDiff, modelEdit: LineDiff, currentCursorLine: string, modelCursorLine: string): boolean { +function isUserEditCompatibleWithModelEdit(userEdit: LineDiff, modelEdit: LineDiff, currentLine: string, modelLine: string): boolean { if (userEdit.replaced.length > 0) { - if (currentCursorLine === modelCursorLine) { + if (currentLine === modelLine) { return true; } diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index c59c660a43032..e5196bcc2669f 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -18,7 +18,7 @@ import { LanguageContextEntry, LanguageContextResponse } from '../../../platform import { LanguageId } from '../../../platform/inlineEdits/common/dataTypes/languageId'; import { NextCursorLinePrediction, NextCursorLinePredictionCursorPlacement } from '../../../platform/inlineEdits/common/dataTypes/nextCursorLinePrediction'; import * as xtabPromptOptions from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; -import { AggressivenessSetting, isAggressivenessStrategy, LanguageContextLanguages, LanguageContextOptions } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { AggressivenessSetting, EarlyDivergenceCancellationMode, isAggressivenessStrategy, LanguageContextLanguages, LanguageContextOptions } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { InlineEditRequestLogContext } from '../../../platform/inlineEdits/common/inlineEditLogContext'; import { IInlineEditsModelService } from '../../../platform/inlineEdits/common/inlineEditsModelService'; import { ResponseProcessor } from '../../../platform/inlineEdits/common/responseProcessor'; @@ -35,6 +35,7 @@ import { IExperimentationService } from '../../../platform/telemetry/common/null import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { raceFilter } from '../../../util/common/async'; import { AsyncIterUtils, AsyncIterUtilsExt } from '../../../util/common/asyncIterableUtils'; +import { backwardCompatSetting } from '../../../util/common/backwardCompatSetting'; import { ErrorUtils } from '../../../util/common/errors'; import { Result } from '../../../util/common/result'; import { assertNever } from '../../../util/vs/base/common/assert'; @@ -50,6 +51,7 @@ import { Range } from '../../../util/vs/editor/common/core/range'; import { LineRange } from '../../../util/vs/editor/common/core/ranges/lineRange'; import { OffsetRange } from '../../../util/vs/editor/common/core/ranges/offsetRange'; import { StringText } from '../../../util/vs/editor/common/core/text/abstractText'; +import { PositionOffsetTransformer } from '../../../util/vs/editor/common/core/text/positionToOffset'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { Position as VscodePosition } from '../../../vscodeTypes'; import { DelaySession } from '../../inlineEdits/common/delay'; @@ -66,7 +68,7 @@ import { nes41Miniv3SystemPrompt, simplifiedPrompt, systemPromptTemplate, unifie import { PromptTags } from '../common/tags'; import { TerminalMonitor } from '../common/terminalOutput'; import { CurrentDocument } from '../common/xtabCurrentDocument'; -import { getCurrentCursorLine, isModelCursorLineCompatible } from './cursorLineDivergence'; +import { getCurrentLine, isModelLineCompatible } from './cursorLineDivergence'; import { EditIntentParseMode } from './editIntent'; import { handleCodeBlock, handleEditWindowOnly, handleEditWindowWithEditIntent, handleUnifiedWithXml, ResponseParseResult } from './responseFormatHandlers'; import { XtabCustomDiffPatchResponseHandler } from './xtabCustomDiffPatchResponseHandler'; @@ -985,19 +987,36 @@ export class XtabProvider implements IStatelessNextEditProvider { tracer.trace(`starting to diff stream against edit window lines with latency ${fetchRequestStopWatch.elapsed()} ms`); - // Wrap the line stream to detect early cursor-line divergence. - // If the user has typed at the cursor since the request started and the cursor line - // in the model's response doesn't match what the user currently has, the response - // is stale and we can cancel early instead of waiting for the full response. + // Wrap the line stream to detect early divergence between the user's + // intermediate edits and the model's streamed output. + // In `Cursor` mode only the cursor line is checked; in `EditWindow` + // mode every line in the edit window is checked. // - // We check compatibility using `isModelCursorLineCompatible`: the user's - // cursor-line change must be contained within the model's cursor-line change range + // We check compatibility using `isModelLineCompatible`: the user's + // line change must be contained within the model's line change range // and match via the helper's `startsWith` / auto-close subsequence rules. - const earlyCursorLineDivergenceCancellation = this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, this.expService); + const earlyDivergenceMode = backwardCompatSetting( + this.configService.getExperimentBasedConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, this.expService), + (value) => { + switch (value) { + case false: + case undefined: + return EarlyDivergenceCancellationMode.Off; + case true: + return EarlyDivergenceCancellationMode.Cursor; + case EarlyDivergenceCancellationMode.Off: + case EarlyDivergenceCancellationMode.Cursor: + case EarlyDivergenceCancellationMode.EditWindow: + return value; + default: + return EarlyDivergenceCancellationMode.Off; + } + } + ); - let cursorLineDiverged = false; + let lineDiverged = false; - const divergenceCheckedStream: AsyncIterable = !earlyCursorLineDivergenceCancellation + const divergenceCheckedStream: AsyncIterable = earlyDivergenceMode === EarlyDivergenceCancellationMode.Off ? cleanedLinesStream : linesWithIntermediateEditDivergenceCheck( cleanedLinesStream, @@ -1007,14 +1026,15 @@ export class XtabProvider implements IStatelessNextEditProvider { editWindowLines, fetchCts, tracing, - (value: boolean) => { cursorLineDiverged = value; }, + (value: boolean) => { lineDiverged = value; }, + earlyDivergenceMode, ); let i = 0; let hasBeenDelayed = false; for await (const edit of ResponseProcessor.diff(editWindowLines, divergenceCheckedStream, cursorOriginalLinesOffset, diffOptions)) { - if (cursorLineDiverged) { + if (lineDiverged) { break; } @@ -1072,8 +1092,10 @@ export class XtabProvider implements IStatelessNextEditProvider { } } - if (cursorLineDiverged) { - return new NoNextEditReason.GotCancelled('cursorLineDiverged'); + if (lineDiverged) { + return new NoNextEditReason.GotCancelled( + earlyDivergenceMode === EarlyDivergenceCancellationMode.Cursor ? 'cursorLineDiverged' : 'editWindowLineDiverged' + ); } return new NoNextEditReason.NoSuggestions(request.documentBeforeEdits, editWindow); @@ -1669,27 +1691,50 @@ async function* linesWithIntermediateEditDivergenceCheck( editWindowLines: readonly string[], fetchCts: CancellationTokenSource, { tracer }: RequestTracingContext, - setCursorLineDiverged: (value: boolean) => void, + setLineDiverged: (value: boolean) => void, + mode: EarlyDivergenceCancellationMode.Cursor | EarlyDivergenceCancellationMode.EditWindow, ) { + const intermediateEdit = request.intermediateUserEdit; + if (!intermediateEdit || intermediateEdit.isEmpty()) { + yield* cleanedLinesStream; + return; + } + + const transformer = request.documentBeforeEdits.getTransformer(); + + // Precompute the post-edit document once to avoid O(lines * docSize) in EditWindow mode. + const currentDoc = intermediateEdit.apply(transformer.text); + const currentTransformer = new PositionOffsetTransformer(currentDoc); + const precomputed = { currentDoc, currentTransformer }; + + const shouldCheckLine = (lineIdx: number): boolean => { + if (lineIdx >= editWindowLines.length) { + return false; + } + switch (mode) { + case EarlyDivergenceCancellationMode.Cursor: + return lineIdx === cursorOriginalLinesOffset; + case EarlyDivergenceCancellationMode.EditWindow: + return true; + } + }; + let lineIdx = 0; for await (const line of cleanedLinesStream) { - if (lineIdx === cursorOriginalLinesOffset) { - const intermediateEdit = request.intermediateUserEdit; - if (intermediateEdit && !intermediateEdit.isEmpty()) { - const cursorDocLineIdx = editWindowLineRange.start + cursorOriginalLinesOffset; - const currentCursorLine = getCurrentCursorLine(request.documentBeforeEdits.getTransformer(), cursorDocLineIdx, intermediateEdit); - if (currentCursorLine !== undefined) { - const originalCursorLine = editWindowLines[cursorOriginalLinesOffset]; - if (currentCursorLine !== originalCursorLine // user changed the cursor line - && !isModelCursorLineCompatible(originalCursorLine, currentCursorLine, line) // model's cursor line isn't compatible with user's typing - ) { - setCursorLineDiverged(true); - tracer.trace(`Cursor line DIVERGED: model="${line}" current="${currentCursorLine}"`); - // Cancel our local fetch token so the HTTP request is - // aborted immediately. We own this token, so this is safe. - fetchCts.cancel(); - return; - } + if (shouldCheckLine(lineIdx)) { + const docLineIdx = editWindowLineRange.start + lineIdx; + const currentLine = getCurrentLine(transformer, docLineIdx, intermediateEdit, precomputed); + if (currentLine !== undefined) { + const originalLine = editWindowLines[lineIdx]; + if (currentLine !== originalLine // user changed this line + && !isModelLineCompatible(originalLine, currentLine, line) // model's line isn't compatible with user's typing + ) { + setLineDiverged(true); + tracer.trace(`Line ${lineIdx} DIVERGED (mode=${mode}): model="${line}" current="${currentLine}"`); + // Cancel our local fetch token so the HTTP request is + // aborted immediately. We own this token, so this is safe. + fetchCts.cancel(); + return; } } } diff --git a/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts b/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts index 4591dbd0b8cbe..20ad2b865d73d 100644 --- a/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/cursorLineDivergence.spec.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest'; import { StringEdit, StringReplacement } from '../../../../util/vs/editor/common/core/edits/stringEdit'; import { OffsetRange } from '../../../../util/vs/editor/common/core/ranges/offsetRange'; import { PositionOffsetTransformer } from '../../../../util/vs/editor/common/core/text/positionToOffset'; -import { getCurrentCursorLine, isModelCursorLineCompatible } from '../../node/cursorLineDivergence'; +import { getCurrentLine, isModelLineCompatible } from '../../node/cursorLineDivergence'; // ============================================================================ // isModelCursorLineCompatible — unit tests @@ -32,7 +32,7 @@ describe('isModelCursorLineCompatible', () => { // original: `function fi` // user typed `b` → current: `function fib` // model: `function fibonacci(n: number): number` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'function fi', 'function fib', 'function fibonacci(n: number): number', @@ -43,7 +43,7 @@ describe('isModelCursorLineCompatible', () => { // original: `const x` // user typed ` = 4` → current: `const x = 4` // model: `const x = 42;` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x', 'const x = 4', 'const x = 42;', @@ -54,7 +54,7 @@ describe('isModelCursorLineCompatible', () => { // original: `return` // user typed ` 0;` → current: `return 0;` // model: `return 0;` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'return', 'return 0;', 'return 0;', @@ -68,7 +68,7 @@ describe('isModelCursorLineCompatible', () => { // original: `function fi` // user typed `x` → current: `function fix` // model: `function fibonacci(n: number): number` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'function fi', 'function fix', 'function fibonacci(n: number): number', @@ -79,7 +79,7 @@ describe('isModelCursorLineCompatible', () => { // original: `const ` // user typed `bar` → current: `const bar` // model: `const foo = 1;` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const ', 'const bar', 'const foo = 1;', @@ -92,7 +92,7 @@ describe('isModelCursorLineCompatible', () => { // model: `abcz` // modelNewText = "cz", userTypedText = "z" // → "cz" does not start with "z" → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'ab', 'abz', 'abcz', @@ -106,7 +106,7 @@ describe('isModelCursorLineCompatible', () => { // original: `foo()` (cursor between the parens) // user typed `x` → current: `foo(x)` // model: `foo(x, y)` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo()', 'foo(x)', 'foo(x, y)', @@ -117,7 +117,7 @@ describe('isModelCursorLineCompatible', () => { // original: `foo()` // user typed `x` → current: `foo(x)` // model: `bar(a, b)` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo()', 'foo(x)', 'bar(a, b)', @@ -134,7 +134,7 @@ describe('isModelCursorLineCompatible', () => { // // The model's edit range is empty (no diff), so the user's edit // range cannot be "within" it → incompatible. - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x = 1;', 'const x = 12;', 'const x = 1;', @@ -148,7 +148,7 @@ describe('isModelCursorLineCompatible', () => { // original: `` // user typed `f` → current: `f` // model: `function foo() {` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( '', 'f', 'function foo() {', @@ -160,7 +160,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `z` → current: `z` // model: `let x = 1;` // → "z" is not found in "let x = 1;" → incompatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( '', 'z', 'let x = 1;', @@ -171,7 +171,7 @@ describe('isModelCursorLineCompatible', () => { // original: `foobar` // user deleted `bar` → current: `foo` // model: `foobaz` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foobar', 'foo', 'foobaz', @@ -182,7 +182,7 @@ describe('isModelCursorLineCompatible', () => { // original: `hello world` // user replaced `world` → current: `hello earth` // model: `hello earth!` (same replacement + extra) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'hello world', 'hello earth', 'hello earth!', @@ -190,7 +190,7 @@ describe('isModelCursorLineCompatible', () => { }); it('all three lines identical — trivially compatible', () => { - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'no change', 'no change', 'no change', @@ -212,7 +212,7 @@ describe('isModelCursorLineCompatible', () => { // model: `foo(x, y)` // → userTypedText="()" is an auto-close pair // → subsequence check: "(" at 0, ")" at 5 in "(x, y)" → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo', 'foo()', 'foo(x, y)', @@ -223,7 +223,7 @@ describe('isModelCursorLineCompatible', () => { // original: `if (x) ` // → current: `if (x) {}` // model: `if (x) { return 1; }` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'if (x) ', 'if (x) {}', 'if (x) { return 1; }', @@ -231,7 +231,7 @@ describe('isModelCursorLineCompatible', () => { }); it('user typed [ which auto-closed to []', () => { - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'arr', 'arr[]', 'arr[0]', @@ -239,7 +239,7 @@ describe('isModelCursorLineCompatible', () => { }); it('user typed " which auto-closed to "" — model fills string', () => { - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const s = ', 'const s = ""', 'const s = "hello"', @@ -248,7 +248,7 @@ describe('isModelCursorLineCompatible', () => { it('auto-close pair but model has no closing char — incompatible', () => { // user typed `(` auto-closed to `()`, model has `(x, y` with no `)` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo', 'foo()', 'foo(x, y', @@ -269,7 +269,7 @@ describe('isModelCursorLineCompatible', () => { // This is a false positive: the model's rename of `x`→`y` is independent // of the user's `;`, but we cancel because our range-containment check is // overly strict. The full rebase system handles disjoint edits correctly. - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x = foo()', 'const x = foo();', 'const y = foo()', @@ -284,7 +284,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `a` → current: `let a` // model: `let apple = 1;` // → modelNewText = "apple = 1;", starts with "a" → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'let ', 'let a', 'let apple = 1;', @@ -297,7 +297,7 @@ describe('isModelCursorLineCompatible', () => { // model: `let banana = 1;` // → modelNewText = "banana = 1;", does not start with "a" // → Even though "a" appears inside "banana", it's coincidental → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'let ', 'let a', 'let banana = 1;', @@ -308,7 +308,7 @@ describe('isModelCursorLineCompatible', () => { // original: `x` // user typed `y` → current: `xy` // model: `x01234567890y` ("y" appears far in, not at start) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'x', 'xy', 'x01234567890y', @@ -320,7 +320,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `ABCDEF` → current: `prefixABCDEF` // model: `prefix_ABCDEF_suffix` // → modelNewText = "_ABCDEF_suffix", does not start with "ABCDEF" → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'prefix', 'prefixABCDEF', 'prefix_ABCDEF_suffix', @@ -332,7 +332,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `ABCDEF` → current: `prefixABCDEF` // model: `prefixABCDEF_and_more` // → modelNewText = "ABCDEF_and_more", starts with "ABCDEF" → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'prefix', 'prefixABCDEF', 'prefixABCDEF_and_more', @@ -349,7 +349,7 @@ describe('isModelCursorLineCompatible', () => { // → User's edit starts at offset 3, model's edit starts at offset 5. // user's prefixLen (3) < model's prefixLen (5) → range check fails → cancels. // Correct: user deleted text, model wants different text in same area. - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foobar', 'foo', 'foobaz', @@ -360,7 +360,7 @@ describe('isModelCursorLineCompatible', () => { // original: `console.log(x);` // user deleted `console.log(` → current: `x);` // model: `x);` (same result) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'console.log(x);', 'x);', 'x);', @@ -375,7 +375,7 @@ describe('isModelCursorLineCompatible', () => { // user typed ` ` (2 chars) → current: ` return 1;` // model: ` return value;` // → " " found at position 0 in model new text → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'return 1;', ' return 1;', ' return value;', @@ -387,7 +387,7 @@ describe('isModelCursorLineCompatible', () => { // user typed ` ` → current: ` return 1;` // model: ` return 42;` // → " " found at position 0 → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'return 1;', ' return 1;', ' return 42;', @@ -403,7 +403,7 @@ describe('isModelCursorLineCompatible', () => { // model: `aaac` // → prefixLen=3, modelPrefixLen=3, userTypedText="b", modelNewText="c" // → "c".startsWith("b") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'aaa', 'aaab', 'aaac', @@ -417,7 +417,7 @@ describe('isModelCursorLineCompatible', () => { // → prefixLen=3, suffixLen=0, userTypedText="a", originalReplacedText="" // → modelPrefixLen=3, modelNewText="ab" // → "ab".startsWith("a") → true → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'aaa', 'aaaa', 'aaaab', @@ -431,7 +431,7 @@ describe('isModelCursorLineCompatible', () => { // original: `foo` // user typed `b` → current: `foob` // model: `` (empty) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo', 'foob', '', @@ -443,7 +443,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `!` → current: `const x = 1;!` // model: `const x;` (removed ` = 1`) // → user edit at col 13, model edit at col 7–12 → ranges don't overlap → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x = 1;', 'const x = 1;!', 'const x;', @@ -463,7 +463,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `F` → current: `let F` // model: `let function() {}` // → "function() {}".startsWith("F") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'let ', 'let F', 'let function() {}', @@ -474,7 +474,7 @@ describe('isModelCursorLineCompatible', () => { // original: `let ` // user typed `f` → current: `let f` // model: `let function() {}` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'let ', 'let f', 'let function() {}', @@ -491,7 +491,7 @@ describe('isModelCursorLineCompatible', () => { // user typed `🎉` → current: `const x = 🎉` // model: `const x = 42;` // → "42;".startsWith("🎉") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x = ', 'const x = 🎉', 'const x = 42;', @@ -502,7 +502,7 @@ describe('isModelCursorLineCompatible', () => { // original: `const x = ` // user typed `🎉` → current: `const x = 🎉` // model: `const x = 🎉🎊` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x = ', 'const x = 🎉', 'const x = 🎉🎊', @@ -510,7 +510,7 @@ describe('isModelCursorLineCompatible', () => { }); it('user typed CJK character, model produced different CJK — cancel', () => { - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const s = "', 'const s = "你', 'const s = "世界"', @@ -526,7 +526,7 @@ describe('isModelCursorLineCompatible', () => { // original: `abcdef` // user replaced `cd` with `x` → current: `abxef` // model: `abxyzef` (replaced `cd` with `xyz`, starts with `x`) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'abcdef', 'abxef', 'abxyzef', @@ -538,7 +538,7 @@ describe('isModelCursorLineCompatible', () => { // user replaced `cd` with `x` → current: `abxef` // model: `abYZef` (replaced `cd` with `YZ`) // → "YZ".startsWith("x") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'abcdef', 'abxef', 'abYZef', @@ -551,7 +551,7 @@ describe('isModelCursorLineCompatible', () => { // model: `abXYef` (replaced `cd` with `XY`) // → isUserEditCompatibleWithModelEdit: replaced.length > 0, currentCursorLine !== modelCursorLine, // same start/end/replaced, but userEdit.inserted.length === 0 → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'abcdef', 'abef', 'abXYef', @@ -563,7 +563,7 @@ describe('isModelCursorLineCompatible', () => { // user replaced `hello` (0..5) with `hi` → current: `hi world` // model: `hello earth` (replaced `world` at 6..11) // → user edit range [0,5) is not within model edit range [6,11) → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'hello world', 'hi world', 'hello earth', @@ -581,7 +581,7 @@ describe('isModelCursorLineCompatible', () => { // model: `div` // → userTypedText="<>", AUTO_CLOSE_PAIRS.has("<>") → true // → isSubsequenceOf("<>", "") → true → compatible - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'div', 'div<>', 'div', @@ -590,7 +590,7 @@ describe('isModelCursorLineCompatible', () => { it('user typed < auto-closed to <>, model has no > — cancel', () => { // model: `div) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'div', 'div<>', 'div { // model: `foo(a, b)` // → userTypedText = "(x)", not in AUTO_CLOSE_PAIRS // → "(a, b)".startsWith("(x)") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo', 'foo(x)', 'foo(a, b)', @@ -623,7 +623,7 @@ describe('isModelCursorLineCompatible', () => { // → "(x, y)".startsWith("(x)") → false (position 2: ')' vs ',') // → "(x)" not in AUTO_CLOSE_PAIRS → no subsequence fallback // → cancel. User closed parens but model wants different content. - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo', 'foo(x)', 'foo(x, y)', @@ -641,7 +641,7 @@ describe('isModelCursorLineCompatible', () => { // model: `let ab` (model predicted fewer chars) // → userTypedText="abc", modelNewText="ab" // → "ab".startsWith("abc") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'let ', 'let abc', 'let ab', @@ -657,7 +657,7 @@ describe('isModelCursorLineCompatible', () => { // original: `foo` // user typed `bar` → current: `foobar` // model: `foobar` - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foo', 'foobar', 'foobar', @@ -668,7 +668,7 @@ describe('isModelCursorLineCompatible', () => { // original: `aXa` // user replaced `X` with `Y` → current: `aYa` // model: `aYa` (same) - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'aXa', 'aYa', 'aYa', @@ -683,7 +683,7 @@ describe('isModelCursorLineCompatible', () => { it('user backspaced and retyped same char — no change, trivially compatible', () => { // The net result is original === current → userEdit has no diff. // Detected by: replaced.length === 0 && inserted.length === 0 → true - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'hello', 'hello', 'completely different', @@ -701,7 +701,7 @@ describe('isModelCursorLineCompatible', () => { // model: `xabcd` // → userTypedText="bc", modelNewText="abcd" // → "abcd".startsWith("bc") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'x', 'xbc', 'xabcd', @@ -714,7 +714,7 @@ describe('isModelCursorLineCompatible', () => { // model: `xabcd` // → userTypedText="cd", modelNewText="abcd" // → "abcd".startsWith("cd") → false → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'x', 'xcd', 'xabcd', @@ -732,7 +732,7 @@ describe('isModelCursorLineCompatible', () => { // model: `const y = 1;` (changed x→y at col 6-7) // → user edit at offset 13 (append), model edit at offset 6-7 // → user offset outside model range → cancel - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'const x = 1;', 'const x = 1; ', 'const y = 1;', @@ -749,7 +749,7 @@ describe('isModelCursorLineCompatible', () => { // user deleted `bar` → current: `foo` // model: `foo` // → currentCursorLine === modelCursorLine → true - expect(isModelCursorLineCompatible( + expect(isModelLineCompatible( 'foobar', 'foo', 'foo', @@ -792,13 +792,13 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb\nccc'; const edit = insertAt(4, 'X'); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('Xbbb'); + expect(getCurrentLine(t(doc), 1, edit)).toBe('Xbbb'); }); it('returns the unmodified cursor line when the edit is empty', () => { const doc = 'aaa\nbbb\nccc'; - expect(getCurrentCursorLine(t(doc), 1, StringEdit.empty)).toBe('bbb'); + expect(getCurrentLine(t(doc), 1, StringEdit.empty)).toBe('bbb'); }); }); @@ -812,7 +812,7 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb\nccc'; const edit = insertAt(3, '\nNEW'); - expect(getCurrentCursorLine(t(doc), 2, edit)).toBe('ccc'); + expect(getCurrentLine(t(doc), 2, edit)).toBe('ccc'); }); it('handles multiple lines inserted above the cursor', () => { @@ -823,7 +823,7 @@ describe('getCurrentCursorLine', () => { const doc = 'L0\nL1\nL2'; const edit = insertAt(2, '\nA\nB'); - expect(getCurrentCursorLine(t(doc), 2, edit)).toBe('L2'); + expect(getCurrentLine(t(doc), 2, edit)).toBe('L2'); }); }); @@ -836,7 +836,7 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb\nccc\nddd'; const edit = deleteAt(4, 4); // delete "bbb\n" - expect(getCurrentCursorLine(t(doc), 3, edit)).toBe('ddd'); + expect(getCurrentLine(t(doc), 3, edit)).toBe('ddd'); }); }); @@ -848,7 +848,7 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb\nccc'; const edit = StringEdit.single(new StringReplacement(new OffsetRange(8, 11), 'CCC')); - expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('aaa'); + expect(getCurrentLine(t(doc), 0, edit)).toBe('aaa'); }); }); @@ -858,20 +858,20 @@ describe('getCurrentCursorLine', () => { const doc = 'hello\nworld'; const edit = insertAt(0, 'XY'); - expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('XYhello'); + expect(getCurrentLine(t(doc), 0, edit)).toBe('XYhello'); }); it('cursor on the last line', () => { const doc = 'aaa\nbbb'; const edit = insertAt(3, '\nNEW'); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('bbb'); + expect(getCurrentLine(t(doc), 1, edit)).toBe('bbb'); }); it('returns undefined for out-of-bounds line index', () => { const doc = 'aaa\nbbb'; - expect(getCurrentCursorLine(t(doc), 5, StringEdit.empty)).toBeUndefined(); + expect(getCurrentLine(t(doc), 5, StringEdit.empty)).toBeUndefined(); }); it('returns undefined when cursor line start is inside a replacement', () => { @@ -880,14 +880,14 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb\nccc'; const edit = StringEdit.single(new StringReplacement(new OffsetRange(2, 6), 'Z')); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBeUndefined(); + expect(getCurrentLine(t(doc), 1, edit)).toBeUndefined(); }); it('single-line document, cursor on line 0', () => { const doc = 'hello'; const edit = insertAt(5, ' world'); - expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('hello world'); + expect(getCurrentLine(t(doc), 0, edit)).toBe('hello world'); }); }); @@ -907,7 +907,7 @@ describe('getCurrentCursorLine', () => { new StringReplacement(OffsetRange.emptyAt(4), 'X'), ]); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('Xbbb'); + expect(getCurrentLine(t(doc), 1, edit)).toBe('Xbbb'); }); it('handles line deleted above AND cursor line modified', () => { @@ -921,7 +921,7 @@ describe('getCurrentCursorLine', () => { new StringReplacement(OffsetRange.emptyAt(12), 'Z'), ]); - expect(getCurrentCursorLine(t(doc), 3, edit)).toBe('Zddd'); + expect(getCurrentLine(t(doc), 3, edit)).toBe('Zddd'); }); }); @@ -941,7 +941,7 @@ describe('getCurrentCursorLine', () => { new StringReplacement(new OffsetRange(4, 7), 'X\nY\nZ') ); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('X'); + expect(getCurrentLine(t(doc), 1, edit)).toBe('X'); }); }); @@ -953,7 +953,7 @@ describe('getCurrentCursorLine', () => { const doc = ''; const edit = insertAt(0, 'hello'); - expect(getCurrentCursorLine(t(doc), 0, edit)).toBe('hello'); + expect(getCurrentLine(t(doc), 0, edit)).toBe('hello'); }); }); @@ -968,7 +968,7 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb'; const edit = insertAt(7, 'XYZ'); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('bbbXYZ'); + expect(getCurrentLine(t(doc), 1, edit)).toBe('bbbXYZ'); }); it('cursor on last line, new line appended after it', () => { @@ -979,7 +979,7 @@ describe('getCurrentCursorLine', () => { const doc = 'aaa\nbbb'; const edit = insertAt(7, '\nccc'); - expect(getCurrentCursorLine(t(doc), 1, edit)).toBe('bbb'); + expect(getCurrentLine(t(doc), 1, edit)).toBe('bbb'); }); }); }); diff --git a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts index 3cb5c44fa6a81..515055796f722 100644 --- a/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/node/xtabProvider.spec.ts @@ -14,7 +14,7 @@ import { DocumentId } from '../../../../platform/inlineEdits/common/dataTypes/do import { Edits } from '../../../../platform/inlineEdits/common/dataTypes/edit'; import { LanguageId } from '../../../../platform/inlineEdits/common/dataTypes/languageId'; import { NextCursorLinePredictionCursorPlacement } from '../../../../platform/inlineEdits/common/dataTypes/nextCursorLinePrediction'; -import { DEFAULT_OPTIONS, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { DEFAULT_OPTIONS, EarlyDivergenceCancellationMode, LanguageContextLanguages, LintOptionShowCode, LintOptionWarning, ModelConfiguration, PromptingStrategy, ResponseFormat } from '../../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { InlineEditRequestLogContext } from '../../../../platform/inlineEdits/common/inlineEditLogContext'; import { IInlineEditsModelService } from '../../../../platform/inlineEdits/common/inlineEditsModelService'; import { NoNextEditReason, StatelessNextEditDocument, StatelessNextEditRequest, StreamedEdit, WithStatelessProviderTelemetry } from '../../../../platform/inlineEdits/common/statelessNextEditProvider'; @@ -2056,7 +2056,7 @@ describe('XtabProvider integration', () => { } beforeEach(async () => { - await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, true); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, EarlyDivergenceCancellationMode.Cursor); }); it('cancels when user typed a character that diverges from model output', async () => { @@ -2278,7 +2278,7 @@ describe('XtabProvider integration', () => { it('does not cancel when feature is disabled, even with divergent typing', async () => { // Explicitly disable the feature - await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, false); + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, EarlyDivergenceCancellationMode.Off); const provider = createProvider(); @@ -2438,6 +2438,935 @@ describe('XtabProvider integration', () => { expect(finalReason.v.message).not.toBe('cursorLineDiverged'); } }); + + it('backward compat: boolean true activates cursor-mode divergence', async () => { + // Old experiments set the config to `true` (boolean). This should + // be treated as EarlyDivergenceCancellationMode.Cursor. + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, true as any); + + const provider = createProvider(); + + const request = createDivergenceRequest( + ['function fi'], + { insertionOffset: 10, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'x') + ); + + streamingFetcher.setStreamingLines(['function fibonacci(n: number): number']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + + it('backward compat: boolean false disables divergence', async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, false as any); + + const provider = createProvider(); + const request = createDivergenceRequest( + ['function fi'], + { insertionOffset: 10, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'x') + ); + + streamingFetcher.setStreamingLines(['function fibonacci(n: number): number']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBeGreaterThan(0); + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + it('backward compat: undefined disables divergence', async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, undefined as any); + + const provider = createProvider(); + const request = createDivergenceRequest( + ['function fi'], + { insertionOffset: 10, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'x') + ); + + streamingFetcher.setStreamingLines(['function fibonacci(n: number): number']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBeGreaterThan(0); + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + describe('cursor mode — adversarial scenarios', () => { + + it('ignores divergence on line BEFORE the cursor', async () => { + const provider = createProvider(); + + // Doc: "line0\nline1\nline2" cursor on line 2 + // User edited line 0 divergently, but cursor mode only checks cursor line + const request = createDivergenceRequest( + ['line0', 'line1', 'line2'], + { insertionOffset: 16, insertedText: '2' }, + ); + // User inserts 'Z' at offset 4 (in "line0") → "lineZ0" + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(4), 'Z') + ); + + streamingFetcher.setStreamingLines(['line0', 'line1', 'line2']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + it('ignores divergence on line AFTER the cursor', async () => { + const provider = createProvider(); + + // Doc: "line0\nline1\nline2" cursor on line 0 + // User edited line 2 divergently + const request = createDivergenceRequest( + ['line0', 'line1', 'line2'], + { insertionOffset: 4, insertedText: '0' }, + ); + // User inserts 'Z' at offset 16 (in "line2") → "lineZ2" + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(16), 'Z') + ); + + streamingFetcher.setStreamingLines(['line0', 'line1', 'line2']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + it('detects divergence even when cursor is on the last line of the edit window', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc" cursor on line 2 = last line + // User typed 'X' on cursor line, model has different text + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 10, insertedText: 'c' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'X') + ); + + streamingFetcher.setStreamingLines(['aaa', 'bbb', 'cccY']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + + it('does not cancel when user deleted text on cursor line but model matches result', async () => { + const provider = createProvider(); + + // Doc: "foobar" cursor on line 0 + // User deleted "bar" (offsets 3..6) → "foo" + // Model also produces "foo" + const request = createDivergenceRequest( + ['foobar'], + { insertionOffset: 5, insertedText: 'r' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(3, 6), '') + ); + + streamingFetcher.setStreamingLines(['foo']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + it('cancels when user replaced text on cursor line and model disagrees', async () => { + const provider = createProvider(); + + // Doc: "hello world" cursor on line 0 + // User replaced "world" (5..11) with "earth" → "hello earth" + // Model: "hello mars" + const request = createDivergenceRequest( + ['hello world'], + { insertionOffset: 10, insertedText: 'd' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(6, 11), 'earth') + ); + + streamingFetcher.setStreamingLines(['hello mars']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + + it('does not cancel when user typed multiple compatible chars', async () => { + const provider = createProvider(); + + // Doc: "let " cursor on line 0 + // User typed "abc" → "let abc" + // Model: "let abcdef = 1;" — continues the user's text + const request = createDivergenceRequest( + ['let '], + { insertionOffset: 3, insertedText: ' ' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(4), 'abc') + ); + + streamingFetcher.setStreamingLines(['let abcdef = 1;']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + } + }); + + it('cancels when model produces empty line but user typed on cursor', async () => { + const provider = createProvider(); + + // Doc: "foo" cursor on line 0 + // User typed 'b' → "foob" + // Model: "" (empty line) + const request = createDivergenceRequest( + ['foo'], + { insertionOffset: 2, insertedText: 'o' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(3), 'b') + ); + + streamingFetcher.setStreamingLines(['']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('cursorLineDiverged'); + }); + }); + + describe('editWindow mode', () => { + + beforeEach(async () => { + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, EarlyDivergenceCancellationMode.EditWindow); + }); + + // ── Basic divergence detection ────────────────────────────────── + + it('cancels when user edited a non-cursor line that diverges from model', async () => { + const provider = createProvider(); + + // Doc: "const a = 1;\nfunction fi\n}" + // Cursor on line 1 (insertionOffset 23 = last 'i' of "function fi") + // User typed "Z" on line 0 (non-cursor line) at offset 11 (end of "const a = 1") → "const a = 1Z;" + // Model echoes the original line 0 "const a = 1;" unchanged + // → user changed line 0 but model did not → divergence + const request = createDivergenceRequest( + ['const a = 1;', 'function fi', '}'], + { insertionOffset: 23, insertedText: 'i' }, + ); + + // User edits line 0 (non-cursor) divergently + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'Z') + ); + + streamingFetcher.setStreamingLines(['const a = 1;', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + it('cancels on cursor-line divergence in editWindow mode', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['function fi'], + { insertionOffset: 10, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'x') + ); + + streamingFetcher.setStreamingLines(['function fibonacci(n: number): number']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Compatible edits — no cancellation ────────────────────────── + + it('does not cancel when user edited a non-cursor line compatibly with model', async () => { + const provider = createProvider(); + + // Doc: "const a = 1;\nfunction fi\n}" + // Cursor on line 1 + // User typed "2" at offset 11 (before ";") → "const a = 12;" + // Model: "const a = 123;" → compatible continuation + const request = createDivergenceRequest( + ['const a = 1;', 'function fi', '}'], + { insertionOffset: 23, insertedText: 'i' }, + ); + + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), '2') + ); + + streamingFetcher.setStreamingLines(['const a = 123;', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + it('does not cancel when no lines were changed by user', async () => { + const provider = createProvider(); + + // intermediateUserEdit is empty → no user typing since request + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 4, insertedText: 'b' }, + ); + // Default: StringEdit.empty (no changes) + + streamingFetcher.setStreamingLines(['COMPLETELY', 'DIFFERENT', 'LINES']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + it('does not cancel when intermediateUserEdit is undefined', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['aaa', 'bbb'], + { insertionOffset: 4, insertedText: 'b' }, + ); + request.intermediateUserEdit = undefined; + + streamingFetcher.setStreamingLines(['COMPLETELY', 'DIFFERENT']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + it('does not cancel when all user edits across multiple lines are compatible', async () => { + const provider = createProvider(); + + // Doc: "let a\nlet b\nlet c" + // offsets: l=0,e=1,t=2, =3,a=4,\n=5,l=6,e=7,t=8, =9,b=10,\n=11,l=12,e=13,t=14, =15,c=16 + // Cursor on line 1 + // User typed "1" at offset 5 (after 'a', before '\n') on line 0 → "let a1" + // User typed "2" at offset 17 (after 'c', end of doc) on line 2 → "let c2" + // Model: "let a12 = 1;\nlet b\nlet c23 = 3;" + // Both edits are compatible continuations + const request = createDivergenceRequest( + ['let a', 'let b', 'let c'], + { insertionOffset: 8, insertedText: 'b' }, + ); + request.intermediateUserEdit = StringEdit.create([ + new StringReplacement(OffsetRange.emptyAt(5), '1'), + new StringReplacement(OffsetRange.emptyAt(17), '2'), + ]); + + streamingFetcher.setStreamingLines(['let a12 = 1;', 'let b', 'let c23 = 3;']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Divergence on specific lines ──────────────────────────────── + + it('cancels on divergence at the very first line (line 0)', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc" cursor on line 1 + // User changed line 0 divergently + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 5, insertedText: 'b' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(2), 'Z') + ); + + // Model keeps "aaa" unchanged — diverges from user's "aaZa" + streamingFetcher.setStreamingLines(['aaa', 'bbb', 'ccc']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + it('cancels on divergence at the very last line of the edit window', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc" cursor on line 0 + // User changed last line (line 2) divergently + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 2, insertedText: 'a' }, + ); + // Insert 'Z' at offset 10 (in "ccc") → "ccZc" + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(10), 'Z') + ); + + // Model keeps "ccc" unchanged — diverges from user's "ccZc" + streamingFetcher.setStreamingLines(['aaa', 'bbb', 'ccc']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + it('cancels at the first divergent line when multiple lines diverge', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc" cursor on line 1 + // User edited BOTH line 0 and line 2 divergently. + // Divergence should be detected at line 0 (first streamed line). + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 5, insertedText: 'b' }, + ); + request.intermediateUserEdit = StringEdit.create([ + new StringReplacement(OffsetRange.emptyAt(2), 'X'), // line 0: "aaXa" + new StringReplacement(OffsetRange.emptyAt(10), 'Y'), // line 2: "ccYc" + ]); + + // Model keeps both lines unchanged + streamingFetcher.setStreamingLines(['aaa', 'bbb', 'ccc']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + // Cancelled at line 0 — no edits should have been yielded + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + it('cancels on second line when first line is compatible but second diverges', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc" cursor on line 2 + // User edited line 0 compatibly ("a" → "ab...") and line 1 divergently + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 10, insertedText: 'c' }, + ); + request.intermediateUserEdit = StringEdit.create([ + new StringReplacement(OffsetRange.emptyAt(3), 'b'), // line 0: "aaab" — compatible with "aaabcd" + new StringReplacement(OffsetRange.emptyAt(6), 'Z'), // line 1: "bbbZ" — diverges from model's "bbb" + ]); + + // Model: line 0 is a continuation, line 1 unchanged + streamingFetcher.setStreamingLines(['aaabcd', 'bbb', 'ccc']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Line-shift scenarios ──────────────────────────────────────── + + it('does not false-cancel when user inserted a line above, shifting lines down', async () => { + const provider = createProvider(); + + // Doc: "import foo\nfunction fi\n}" cursor on line 1 + // User inserts newline after "import foo" + types "b" on cursor line + // Lines shift down but getCurrentLine maps through the edit correctly + const request = createDivergenceRequest( + ['import foo', 'function fi', '}'], + { insertionOffset: 21, insertedText: 'i' }, + ); + request.intermediateUserEdit = StringEdit.create([ + new StringReplacement(OffsetRange.emptyAt(10), '\n'), + new StringReplacement(OffsetRange.emptyAt(22), 'b'), + ]); + + streamingFetcher.setStreamingLines(['import foo', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + it('cancels when user deleted a line above, causing content shift on other lines', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc\nddd" cursor on line 3 + // User deletes line 1 ("bbb\n", offsets 4..8) + // After deletion: "aaa\nccc\nddd" + // getCurrentLine for original line 1 now resolves to "ccc" (shifted up) + // Model streams "bbb" for line 1 → "ccc" ≠ "bbb" → divergence + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc', 'ddd'], + { insertionOffset: 14, insertedText: 'd' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(4, 8), '') + ); + + // Model produces lines matching the original (not the shifted doc) + streamingFetcher.setStreamingLines(['aaa', 'bbb', 'ccc', 'ddd']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + // In editWindow mode, the shifted content causes divergence at line 1 + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Ambiguous mapping → skip (no false cancel) ────────────────── + + it('skips divergence check for a line whose start offset falls inside a replacement', async () => { + const provider = createProvider(); + + // Doc: "aaa\nbbb\nccc" cursor on line 2 + // User replaces offsets 2..6 (spanning "a\nbb") with "ZZ" + // Result: "aaZZb\nccc" + // + // For line 1 in the original doc, lineStartOffset = 4 which falls + // inside the replacement [2,6) → getCurrentLine returns undefined + // → divergence check is skipped for that line. + // + // However, line 0 is also affected: "aaa" → "aaZZb", so model + // must be compatible with line 0's change. + const request = createDivergenceRequest( + ['aaa', 'bbb', 'ccc'], + { insertionOffset: 10, insertedText: 'c' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(2, 6), 'ZZ') + ); + + // Model's line 0 is a compatible continuation of the user's change + // (user changed "aaa" → "aaZZb", model has "aaZZb..." starting with that) + streamingFetcher.setStreamingLines(['aaZZb_more', 'ANYTHING', 'ccc']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + // Line 1 is skipped (ambiguous), line 0 is compatible → no cancel + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Model output length edge cases ────────────────────────────── + + it('does not cancel when model output has fewer lines than edit window and divergent line is not reached', async () => { + const provider = createProvider(); + + // Doc: "line0\nline1\nline2" cursor on line 1 + // User edited line 2 divergently, but model only streams 2 lines + const request = createDivergenceRequest( + ['line0', 'line1', 'line2'], + { insertionOffset: 10, insertedText: '1' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(16), 'Z') + ); + + // Model only returns 2 lines — line 2 is never streamed + streamingFetcher.setStreamingLines(['line0', 'line1']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Deletion / replacement on non-cursor lines ────────────────── + + it('cancels when user deleted text on a non-cursor line and model kept original', async () => { + const provider = createProvider(); + + // Doc: "foobar\ncursor" cursor on line 1 + // User deleted "bar" from line 0 (offsets 3..6) → "foo" + // Model echoes "foobar" unchanged + const request = createDivergenceRequest( + ['foobar', 'cursor'], + { insertionOffset: 7, insertedText: 'c' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(3, 6), '') + ); + + streamingFetcher.setStreamingLines(['foobar', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + it('does not cancel when user deleted text on non-cursor line and model matches result', async () => { + const provider = createProvider(); + + // Doc: "foobar\ncursor" cursor on line 1 + // User deleted "bar" from line 0 (offsets 3..6) → "foo" + // Model also produces "foo" → identical to current state + const request = createDivergenceRequest( + ['foobar', 'cursor'], + { insertionOffset: 7, insertedText: 'c' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(3, 6), '') + ); + + streamingFetcher.setStreamingLines(['foo', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + it('cancels when user replaced text on non-cursor line and model disagrees', async () => { + const provider = createProvider(); + + // Doc: "hello world\ncursor" cursor on line 1 + // User replaced "world" (offsets 6..11) with "earth" → "hello earth" + // Model: "hello mars" + const request = createDivergenceRequest( + ['hello world', 'cursor'], + { insertionOffset: 13, insertedText: 'u' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(new OffsetRange(6, 11), 'earth') + ); + + streamingFetcher.setStreamingLines(['hello mars', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Auto-close pairs on non-cursor lines ──────────────────────── + + it('does not cancel when user typed auto-close pair on non-cursor line and model fills it', async () => { + const provider = createProvider(); + + // Doc: "foo\ncursor" cursor on line 1 + // User typed "()" on line 0 (auto-close pair) → "foo()" + // Model: "foo(x, y)" — fills the parens + const request = createDivergenceRequest( + ['foo', 'cursor'], + { insertionOffset: 5, insertedText: 'u' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(3), '()') + ); + + streamingFetcher.setStreamingLines(['foo(x, y)', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + it('cancels when user typed auto-close pair on non-cursor line but model has no closing char', async () => { + const provider = createProvider(); + + // Doc: "foo\ncursor" cursor on line 1 + // User typed "()" on line 0 → "foo()" + // Model: "foo(x, y" — no closing paren + const request = createDivergenceRequest( + ['foo', 'cursor'], + { insertionOffset: 5, insertedText: 'u' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(3), '()') + ); + + streamingFetcher.setStreamingLines(['foo(x, y', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Single-line edit window ───────────────────────────────────── + + it('cancels on divergence in a single-line edit window', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['hello'], + { insertionOffset: 4, insertedText: 'o' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(5), 'X') + ); + + streamingFetcher.setStreamingLines(['helloY']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + it('does not cancel compatible edit in a single-line edit window', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['hello'], + { insertionOffset: 4, insertedText: 'o' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(5), ' w') + ); + + streamingFetcher.setStreamingLines(['hello world!']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Model produces empty line vs user typed ───────────────────── + + it('cancels when model produces empty line for a non-cursor line that user edited', async () => { + const provider = createProvider(); + + // Doc: "foo\ncursor" cursor on line 1 + // User typed 'b' on line 0 → "foob" + // Model: "" (empty) for line 0 + const request = createDivergenceRequest( + ['foo', 'cursor'], + { insertionOffset: 5, insertedText: 'u' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(3), 'b') + ); + + streamingFetcher.setStreamingLines(['', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { edits, finalReason } = await collectEdits(gen); + + expect(edits.length).toBe(0); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Net-zero user edit → no cancellation ──────────────────────── + + it('does not cancel when user edit is net-zero (typed then backspaced)', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['aaa', 'bbb'], + { insertionOffset: 5, insertedText: 'b' }, + ); + request.intermediateUserEdit = StringEdit.empty; + + streamingFetcher.setStreamingLines(['COMPLETELY', 'DIFFERENT']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Whitespace edits ──────────────────────────────────────────── + + it('does not cancel when user added indentation and model continues with same indent', async () => { + const provider = createProvider(); + + // Doc: "return 1;\ncursor" cursor on line 1 + // User typed " " (indent) at start of line 0 → " return 1;" + // Model: " return 42;" — same indent, different value + const request = createDivergenceRequest( + ['return 1;', 'cursor'], + { insertionOffset: 11, insertedText: 'u' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(0), ' ') + ); + + streamingFetcher.setStreamingLines([' return 42;', 'cursor']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Request token not cancelled (only internal fetch token) ────── + + it('does not cancel the request token, only the internal fetch token', async () => { + const provider = createProvider(); + + const request = createDivergenceRequest( + ['aaa', 'bbb'], + { insertionOffset: 5, insertedText: 'b' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(2), 'Z') + ); + + streamingFetcher.setStreamingLines(['aaa', 'bbb']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(request.cancellationTokenSource.token.isCancellationRequested).toBe(false); + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + expect((finalReason.v as NoNextEditReason.GotCancelled).message).toBe('editWindowLineDiverged'); + }); + + // ── Mode contrast: cursor mode ignores non-cursor lines ───────── + + it('does not cancel non-cursor line divergence in cursor mode', async () => { + // Switch to cursor mode — only cursor line should be checked + await configService.setConfig(ConfigKey.TeamInternal.InlineEditsXtabEarlyCursorLineDivergenceCancellation, EarlyDivergenceCancellationMode.Cursor); + + const provider = createProvider(); + + // Doc: "const a = 1;\nfunction fi\n}" + // Cursor on line 1 + // User typed "Z" on line 0 — does NOT match model's line 0 + // But in cursor mode, only cursor line is checked → no cancel + const request = createDivergenceRequest( + ['const a = 1;', 'function fi', '}'], + { insertionOffset: 23, insertedText: 'i' }, + ); + + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(11), 'Z') + ); + + streamingFetcher.setStreamingLines(['const a = 1;', 'function fibonacci(n): number', '}']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + // Cursor mode: only cursor line is checked, and cursor line wasn't changed + if (finalReason.v instanceof NoNextEditReason.GotCancelled) { + expect(finalReason.v.message).not.toBe('cursorLineDiverged'); + expect(finalReason.v.message).not.toBe('editWindowLineDiverged'); + } + }); + + // ── Cancellation reason string ────────────────────────────────── + + it('uses editWindowLineDiverged reason, not cursorLineDiverged', async () => { + const provider = createProvider(); + + // Same simple divergence scenario but verify the reason string specifically + const request = createDivergenceRequest( + ['abc'], + { insertionOffset: 2, insertedText: 'c' }, + ); + request.intermediateUserEdit = StringEdit.single( + new StringReplacement(OffsetRange.emptyAt(3), 'X') + ); + + streamingFetcher.setStreamingLines(['abcY']); + + const gen = provider.provideNextEdit(request, createMockLogger(), createLogContext(), CancellationToken.None); + const { finalReason } = await collectEdits(gen); + + expect(finalReason.v).toBeInstanceOf(NoNextEditReason.GotCancelled); + const msg = (finalReason.v as NoNextEditReason.GotCancelled).message; + expect(msg).toBe('editWindowLineDiverged'); + expect(msg).not.toBe('cursorLineDiverged'); + }); + }); }); }); suite('filterOutEditsWithSubstrings', () => { diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index ef4baf95cd6eb..ff59c5c344f3e 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -19,7 +19,7 @@ import { NextCursorLinePredictionCursorPlacement } from '../../inlineEdits/commo import * as triggerOptions from '../../inlineEdits/common/dataTypes/triggerOptions'; import * as xtabHistoryOptions from '../../inlineEdits/common/dataTypes/xtabHistoryOptions'; import * as xtabPromptOptions from '../../inlineEdits/common/dataTypes/xtabPromptOptions'; -import { LANGUAGE_CONTEXT_ENABLED_LANGUAGES, LanguageContextLanguages, SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsCursorPlacement, SpeculativeRequestsEnablement } from '../../inlineEdits/common/dataTypes/xtabPromptOptions'; +import { EarlyDivergenceCancellationMode, LANGUAGE_CONTEXT_ENABLED_LANGUAGES, LanguageContextLanguages, SpeculativeRequestsAutoExpandEditWindowLines, SpeculativeRequestsCursorPlacement, SpeculativeRequestsEnablement } from '../../inlineEdits/common/dataTypes/xtabPromptOptions'; import { ResponseProcessor } from '../../inlineEdits/common/responseProcessor'; import { FetcherId } from '../../networking/common/fetcherService'; import { AlternativeNotebookFormat } from '../../notebook/common/alternativeContentFormat'; @@ -815,7 +815,7 @@ export namespace ConfigKey { export const InlineEditsXtabDiffUseRelativePaths = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.diffUseRelativePaths', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.diffHistory.useRelativePaths); export const InlineEditsXtabNNonSignificantLinesToConverge = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.nNonSignificantLinesToConverge', ConfigType.ExperimentBased, ResponseProcessor.DEFAULT_DIFF_PARAMS.nLinesToConverge); export const InlineEditsXtabNSignificantLinesToConverge = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.nSignificantLinesToConverge', ConfigType.ExperimentBased, ResponseProcessor.DEFAULT_DIFF_PARAMS.nSignificantLinesToConverge); - export const InlineEditsXtabEarlyCursorLineDivergenceCancellation = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.earlyCursorLineDivergenceCancellation', ConfigType.ExperimentBased, false); + export const InlineEditsXtabEarlyCursorLineDivergenceCancellation = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.earlyCursorLineDivergenceCancellation', ConfigType.ExperimentBased, EarlyDivergenceCancellationMode.Off, EarlyDivergenceCancellationMode.VALIDATOR); export const InlineEditsXtabLanguageContextEnabled = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.enabled', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.enabled); export const InlineEditsXtabLanguageContextMaxTokens = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.languageContext.maxTokens', ConfigType.ExperimentBased, xtabPromptOptions.DEFAULT_OPTIONS.languageContext.maxTokens); export const InlineEditsXtabMaxMergeConflictLines = defineTeamInternalSetting('chat.advanced.inlineEdits.xtabProvider.maxMergeConflictLines', ConfigType.ExperimentBased, undefined); diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index 9e8eaf81022a8..7a37be682bfcb 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -96,7 +96,7 @@ export function isHiddenModelF(model: LanguageModelChat | IChatEndpoint) { export function isHiddenModelG(model: LanguageModelChat | IChatEndpoint) { const family_hash = getCachedSha256Hash(model.family); - return family_hash === '94e44d9d24608ae2161d0c56704f226dc89c2cd8be566abb8fbfbded5a507401'; + return family_hash === '3ae755cc6122a54cc873e3ba2bd8703883b4a711d1af2707ef00f2c2c963ee8d'; } export function isHiddenFamilyH(model: LanguageModelChat | IChatEndpoint) { diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index 251cb6d7cfe80..0c9220928a0fc 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -119,6 +119,23 @@ export enum AggressivenessLevel { High = 'high', } +/** + * Controls the scope of the early divergence cancellation check. + * + * - `Off`: disable early divergence cancellation checks. + * - `Cursor`: only check the cursor line for divergence (original behavior). + * - `EditWindow`: check every line in the edit window for divergence. + */ +export enum EarlyDivergenceCancellationMode { + Cursor = 'cursor', + EditWindow = 'editWindow', + Off = 'off', +} + +export namespace EarlyDivergenceCancellationMode { + export const VALIDATOR = vEnum(EarlyDivergenceCancellationMode.Cursor, EarlyDivergenceCancellationMode.EditWindow, EarlyDivergenceCancellationMode.Off); +} + export namespace AggressivenessSetting { export const VALIDATOR = vEnum(AggressivenessSetting.Default, AggressivenessSetting.Low, AggressivenessSetting.Medium, AggressivenessSetting.High); diff --git a/package-lock.json b/package-lock.json index f4be17c02c595..bf395838ca4ec 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-1", + "@vscode/codicons": "^0.0.46-5", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -3401,9 +3401,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.46-1", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-1.tgz", - "integrity": "sha512-BMOj3V0zXCGsBHQN18NyorO4wk+qdpmS4MLSyqZEDsixgKODA5570RspntWtIZOhmEkcOwlrTRtuZu+yiukBQQ==", + "version": "0.0.46-5", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-5.tgz", + "integrity": "sha512-b/j0tBkst5T27DNqL9m2rOzPf/ilybs1w+aJtnSPEQubhXW07d7k8MPOtVUHqr/kLS3phzDC9+NUtVJlDt60Qg==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { @@ -9263,9 +9263,9 @@ } }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", diff --git a/package.json b/package.json index d32bfce2e6084..9553ff406d4b6 100644 --- a/package.json +++ b/package.json @@ -99,7 +99,7 @@ "@microsoft/dev-tunnels-ssh-tcp": "^3.12.22", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.46-1", + "@vscode/codicons": "^0.0.46-5", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 7ce63d3b133fe..8bdd0256dac55 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-1", + "@vscode/codicons": "^0.0.46-5", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.46-1", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-1.tgz", - "integrity": "sha512-BMOj3V0zXCGsBHQN18NyorO4wk+qdpmS4MLSyqZEDsixgKODA5570RspntWtIZOhmEkcOwlrTRtuZu+yiukBQQ==", + "version": "0.0.46-5", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.46-5.tgz", + "integrity": "sha512-b/j0tBkst5T27DNqL9m2rOzPf/ilybs1w+aJtnSPEQubhXW07d7k8MPOtVUHqr/kLS3phzDC9+NUtVJlDt60Qg==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 59f8dc7cf45b8..211e2dd403216 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.46-1", + "@vscode/codicons": "^0.0.46-5", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.1", "@vscode/vscode-languagedetection": "1.0.23", diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 4aceb3ea3c0eb..5dc82ac562e2a 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -15,6 +15,8 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ ├── aiCustomizationManagementEditor.ts # SplitView list/editor ├── aiCustomizationManagementEditorInput.ts # Singleton input ├── aiCustomizationListWidget.ts # Search + grouped list + harness toggle +├── aiCustomizationItemSource.ts # Item pipeline: ICustomizationItem → IAICustomizationListItem view model +├── promptsServiceCustomizationItemProvider.ts # Adapts IPromptsService → ICustomizationItemProvider ├── aiCustomizationListWidgetUtils.ts # List item helpers (truncation, etc.) ├── aiCustomizationDebugPanel.ts # Debug diagnostics panel ├── aiCustomizationWorkspaceService.ts # Core VS Code workspace service impl @@ -29,7 +31,7 @@ src/vs/workbench/contrib/chat/browser/aiCustomization/ src/vs/workbench/contrib/chat/common/ ├── aiCustomizationWorkspaceService.ts # IAICustomizationWorkspaceService + IStorageSourceFilter + BUILTIN_STORAGE -└── customizationHarnessService.ts # ICustomizationHarnessService + ISectionOverride + helpers +└── customizationHarnessService.ts # ICustomizationHarnessService + ICustomizationItem + ICustomizationItemProvider + helpers ``` The tree view and overview live in `vs/sessions` (agent sessions window only): @@ -98,6 +100,8 @@ Key properties on the harness descriptor: | Property | Purpose | |----------|--------| +| `itemProvider` | `ICustomizationItemProvider` supplying items; when absent, falls back to `PromptsServiceCustomizationItemProvider` | +| `syncProvider` | `ICustomizationSyncProvider` enabling local→remote sync checkboxes | | `hiddenSections` | Sidebar sections to hide (e.g. Claude: `[Prompts, Plugins]`) | | `workspaceSubpaths` | Restrict file creation/display to directories (e.g. Claude: `['.claude']`) | | `hideGenerateButton` | Replace "Generate X" sparkle button with "New X" | @@ -157,16 +161,38 @@ Claude additionally applies: In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". -This follows the same pattern as the MCP list widget, which determines grouping at the UI layer by inspecting collection sources. The list widget uses `IProductService` to identify the chat extension and sets `groupKey: BUILTIN_STORAGE` on matching items: +`PromptsServiceCustomizationItemProvider` handles this via `applyBuiltinGroupKeys()`: it builds a URI→extension-ID lookup from prompt file metadata, then sets `groupKey: BUILTIN_STORAGE` on items whose extension matches the chat extension ID (checked via the shared `isChatExtensionItem()` utility). The underlying `storage` remains `PromptsStorage.extension` — the grouping is a `groupKey` override that keeps `applyStorageSourceFilter` working while visually distinguishing chat-extension items from third-party extension items. -- **Agents**: checks `agent.source.extensionId` against the chat extension ID -- **Skills**: builds a URI→ExtensionIdentifier lookup from `listPromptFiles(PromptsType.skill)`, then checks each skill's URI -- **Prompts**: checks `command.promptPath.extension?.identifier` -- **Instructions/Hooks**: checks `item.extension?.identifier` via `IPromptPath` +`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. -The underlying `storage` remains `PromptsStorage.extension` — the grouping is a UI-level override via `groupKey` that keeps `applyStorageSourceFilter` working with existing storage types while visually distinguishing chat-extension items from third-party extension items. +### Management Editor Item Pipeline -`BUILTIN_STORAGE` is defined in `aiCustomizationWorkspaceService.ts` (common layer) and re-exported by both `aiCustomizationManagement.ts` (browser) and `builtinPromptsStorage.ts` (sessions) for backward compatibility. +All customization sources — `IPromptsService`, extension-contributed providers, and AHP remote servers — produce items conforming to the same `ICustomizationItem` contract (defined in `customizationHarnessService.ts`). This contract carries `uri`, `type`, `name`, `description`, optional `storage`, `groupKey`, `badge`, and status fields. + +``` +promptsService ──→ PromptsServiceCustomizationItemProvider ──→ ICustomizationItem[] + │ +Extension Provider ───────────────────────────────────────→ ICustomizationItem[] + │ +AHP Remote Server ────────────────────────────────────────→ ICustomizationItem[] + │ + ▼ + CustomizationItemSource (aiCustomizationItemSource.ts) + ├── normalizes → IAICustomizationListItem[] + ├── expands hooks from file content + └── blends sync overlays when syncProvider present + │ + ▼ + List Widget renders +``` + +**Key files:** + +- **`aiCustomizationItemSource.ts`** — The browser-side pipeline: `IAICustomizationListItem` (view model), `IAICustomizationItemSource` (data contract), `AICustomizationItemNormalizer` (maps `ICustomizationItem` → view model, inferring storage/grouping from URIs when the provider doesn't supply them), `ProviderCustomizationItemSource` (orchestrates provider + sync + normalizer), and shared utilities (`expandHookFileItems`, `getFriendlyName`, `isChatExtensionItem`). + +- **`promptsServiceCustomizationItemProvider.ts`** — Adapts `IPromptsService` to `ICustomizationItemProvider`. Reads agents, skills, instructions, hooks, and prompts from the core service, expands instruction categories and hook entries, applies harness-specific filters (storage sources, workspace subpaths, instruction file patterns), and returns `ICustomizationItem[]` with `storage` set from the authoritative promptsService metadata. Used as the default item provider for harnesses that don't supply their own. + +- **`customizationHarnessService.ts`** (common layer) — Defines `ICustomizationItem`, `ICustomizationItemProvider`, `ICustomizationSyncProvider`, and `IHarnessDescriptor`. A harness descriptor optionally carries an `itemProvider`; when absent, the widget falls back to `PromptsServiceCustomizationItemProvider`. ### AgenticPromptsService (Sessions) @@ -194,15 +220,7 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Count Consistency -`customizationCounts.ts` uses the **same data sources** as the list widget's `loadItems()`: - -| Type | Data Source | Notes | -|------|-------------|-------| -| Agents | `getCustomAgents()` | Parsed agents, not raw files | -| Skills | `findAgentSkills()` | Parsed skills with frontmatter | -| Prompts | `getPromptSlashCommands()` | Filters out skill-type commands | -| Instructions | `listPromptFiles()` + `listAgentInstructions()` | Includes AGENTS.md, CLAUDE.md etc. | -| Hooks | `listPromptFiles()` | Individual hooks parsed via `parseHooksFromFile()` | +`customizationCounts.ts` uses the **same data sources** as the list widget. Both go through the active harness's `ICustomizationItemProvider` (or the `PromptsServiceCustomizationItemProvider` fallback), ensuring counts match what the list displays. ### Item Badges @@ -210,10 +228,10 @@ Skills that are directly invoked by UI elements (toolbar buttons, menu items) ar ### Debug Panel -Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a 4-stage pipeline view: +Toggle via Command Palette: "Toggle Customizations Debug Panel". Shows a diagnostic view of the item pipeline: -1. **Raw PromptsService data** — per-storage file lists + type-specific extras -2. **After applyStorageSourceFilter** — what was removed and why +1. **Provider data** — items returned by the active `ICustomizationItemProvider` +2. **After filtering** — what was removed by storage source and workspace subpath filters 3. **Widget state** — allItems vs displayEntries with group counts 4. **Source/resolved folders** — creation targets and discovery order diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index c779a34019aab..9cff0ad8c6681 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -204,6 +204,8 @@ The setting `workbench.editor.useModal` is an enum with three values: - `'some'`: Certain editors (e.g. Settings, Keyboard Shortcuts) may open in a modal overlay when requested via `MODAL_GROUP` - `'all'`: All editors open in a modal overlay (used by agent sessions window) +The sessions default configuration also sets `workbench.notifications.position` to `'top-right'` so toast notifications anchor in the top-right corner of the sessions window without changing the default notification placement in the regular workbench. + --- @@ -651,6 +653,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-14 | Updated the sessions-only default configuration so notification toasts default to the top-right corner via `workbench.notifications.position: 'top-right'`, without changing the regular workbench default. | | 2026-04-10 | Updated the sessions titlebar widget so repository/worktree metadata truncates with ellipsis before the primary AI-generated session title when the command center gets narrow. | | 2026-04-10 | Updated workspace/repository section headers in the Sessions sidebar to keep their uppercase titles visible via ellipsis truncation so the section toolbar actions remain reachable when names are long. | | 2026-04-10 | Updated the Sessions view header so the sidebar "Sessions" label stays visible and truncates with ellipsis when space is tight instead of being hidden; documented the find-widget exception in the Sessions view spec. | diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index ffab7ef782ef4..71d537c6f1b6f 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -271,18 +271,14 @@ opacity 250ms ease-out, margin-top 250ms ease-out, margin-right 250ms ease-out, - margin-bottom 250ms ease-out, - border-color 250ms ease-out, - background 250ms ease-out; + margin-bottom 250ms ease-out; } /* Auxiliary bar also transitions horizontal margin */ .agent-sessions-workbench .part.auxiliarybar { transition: opacity 250ms ease-out, - margin 250ms ease-out, - border-color 250ms ease-out, - background 250ms ease-out; + margin 250ms ease-out; } .agent-sessions-workbench .part.sidebar > .content { diff --git a/src/vs/sessions/browser/menus.ts b/src/vs/sessions/browser/menus.ts index 5ea833e94ace8..c987148f22327 100644 --- a/src/vs/sessions/browser/menus.ts +++ b/src/vs/sessions/browser/menus.ts @@ -26,9 +26,7 @@ export const Menus = { SidebarCustomizations: new MenuId('SessionsSidebarCustomizations'), AgentFeedbackEditorContent: new MenuId('AgentFeedbackEditorContent'), - // New session picker menus — providers contribute actions into these - // scoped by context keys (sessionsProviderId, sessionType, etc.) - NewSessionRepositoryConfig: new MenuId('NewSessions.RepositoryConfigMenu'), NewSessionConfig: new MenuId('NewSessions.SessionConfigMenu'), NewSessionControl: new MenuId('NewSessions.SessionControlMenu'), + NewSessionRepositoryConfig: new MenuId('NewSessions.RepositoryConfigMenu'), } as const; diff --git a/src/vs/sessions/browser/parts/chatBarPart.ts b/src/vs/sessions/browser/parts/chatBarPart.ts index daf529b8d3088..68caa801374fc 100644 --- a/src/vs/sessions/browser/parts/chatBarPart.ts +++ b/src/vs/sessions/browser/parts/chatBarPart.ts @@ -13,7 +13,7 @@ import { IStorageService } from '../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { ACTIVITY_BAR_BADGE_BACKGROUND, ACTIVITY_BAR_BADGE_FOREGROUND, PANEL_ACTIVE_TITLE_BORDER, PANEL_ACTIVE_TITLE_FOREGROUND, PANEL_BORDER, PANEL_DRAG_AND_DROP_BORDER, PANEL_INACTIVE_TITLE_FOREGROUND, SIDE_BAR_TITLE_BORDER, SIDE_BAR_FOREGROUND } from '../../../workbench/common/theme.js'; import { contrastBorder } from '../../../platform/theme/common/colorRegistry.js'; -import { sessionsChatBarBackground } from '../../common/theme.js'; +import { chatBarBackground } from '../../common/theme.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../workbench/common/views.js'; import { IExtensionService } from '../../../workbench/services/extensions/common/extensions.js'; import { IWorkbenchLayoutService, Parts } from '../../../workbench/services/layout/browser/layoutService.js'; @@ -29,7 +29,7 @@ import { IHoverService } from '../../../platform/hover/browser/hover.js'; import { Extensions } from '../../../workbench/browser/panecomposite.js'; import { Menus } from '../menus.js'; import { ActiveChatBarContext, ChatBarFocusContext } from '../../common/contextkeys.js'; -import { SessionCompositeBar } from './sessionCompositeBar.js'; +import { ChatCompositeBar } from './chatCompositeBar.js'; import { prepend } from '../../../base/browser/dom.js'; export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not be a AbstractPaneCompositePart but instead a custom Part with a CompositeBar @@ -56,7 +56,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not /** Height of the session composite bar when visible */ private static readonly SESSION_BAR_HEIGHT = 35; - private _sessionCompositeBar: SessionCompositeBar | undefined; + private _sessionCompositeBar: ChatCompositeBar | undefined; private _lastLayout: { readonly width: number; readonly height: number; readonly top: number; readonly left: number } | undefined; @@ -117,7 +117,7 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not super.create(parent); // Create the session composite bar and prepend it before the content area - this._sessionCompositeBar = this._register(this.instantiationService.createInstance(SessionCompositeBar)); + this._sessionCompositeBar = this._register(this.instantiationService.createInstance(ChatCompositeBar)); prepend(parent, this._sessionCompositeBar.element); // Relayout when session bar visibility changes @@ -134,9 +134,9 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not const container = assertReturnsDefined(this.getContainer()); // Store background and border as CSS variables for the card styling on .part - container.style.setProperty('--part-background', this.getColor(sessionsChatBarBackground) || ''); + container.style.setProperty('--part-background', this.getColor(chatBarBackground) || ''); container.style.setProperty('--part-border-color', this.getColor(PANEL_BORDER) || this.getColor(contrastBorder) || 'transparent'); - container.style.backgroundColor = this.getColor(sessionsChatBarBackground) || ''; + container.style.backgroundColor = this.getColor(chatBarBackground) || ''; container.style.color = this.getColor(SIDE_BAR_FOREGROUND) || ''; } @@ -180,8 +180,8 @@ export class ChatBarPart extends AbstractPaneCompositePart { // TODO: should not iconSize: 16, overflowActionSize: 30, colors: theme => ({ - activeBackgroundColor: theme.getColor(sessionsChatBarBackground), - inactiveBackgroundColor: theme.getColor(sessionsChatBarBackground), + activeBackgroundColor: theme.getColor(chatBarBackground), + inactiveBackgroundColor: theme.getColor(chatBarBackground), activeBorderBottomColor: theme.getColor(PANEL_ACTIVE_TITLE_BORDER), activeForegroundColor: theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND), inactiveForegroundColor: theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND), diff --git a/src/vs/sessions/browser/parts/sessionCompositeBar.ts b/src/vs/sessions/browser/parts/chatCompositeBar.ts similarity index 87% rename from src/vs/sessions/browser/parts/sessionCompositeBar.ts rename to src/vs/sessions/browser/parts/chatCompositeBar.ts index aa4eb37b1d462..34b24e44cb8ce 100644 --- a/src/vs/sessions/browser/parts/sessionCompositeBar.ts +++ b/src/vs/sessions/browser/parts/chatCompositeBar.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import './media/sessionCompositeBar.css'; +import './media/chatCompositeBar.css'; import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { $, addDisposableListener, EventType, getWindow, reset } from '../../../base/browser/dom.js'; @@ -15,11 +15,11 @@ import { IContextMenuService } from '../../../platform/contextview/browser/conte import { StandardMouseEvent } from '../../../base/browser/mouseEvent.js'; import { localize } from '../../../nls.js'; import { IQuickInputService } from '../../../platform/quickinput/common/quickInput.js'; -import { sessionsChatBarBackground } from '../../common/theme.js'; +import { chatBarBackground } from '../../common/theme.js'; import { IChat } from '../../services/sessions/common/session.js'; import { ISessionsManagementService } from '../../services/sessions/common/sessionsManagement.js'; -interface ISessionTab { +interface IChatTab { readonly chat: IChat; readonly element: HTMLElement; } @@ -30,11 +30,11 @@ interface ISessionTab { * * The bar auto-hides when there is only one chat in the active session and shows when there are multiple. */ -export class SessionCompositeBar extends Disposable { +export class ChatCompositeBar extends Disposable { private readonly _container: HTMLElement; private readonly _tabsContainer: HTMLElement; - private readonly _tabs: ISessionTab[] = []; + private readonly _tabs: IChatTab[] = []; private readonly _tabDisposables = this._register(new DisposableStore()); private readonly _onDidChangeVisibility = this._register(new Emitter()); @@ -58,8 +58,8 @@ export class SessionCompositeBar extends Disposable { ) { super(); - this._container = $('.session-composite-bar'); - this._tabsContainer = $('.session-composite-bar-tabs'); + this._container = $('.chat-composite-bar'); + this._tabsContainer = $('.chat-composite-bar-tabs'); this._container.appendChild(this._tabsContainer); // Track active session changes @@ -95,18 +95,18 @@ export class SessionCompositeBar extends Disposable { } private _createTab(chat: IChat, isMainChat: boolean): void { - const tab = $('.session-composite-bar-tab'); + const tab = $('.chat-composite-bar-tab'); tab.tabIndex = 0; tab.setAttribute('role', 'tab'); - const labelEl = $('.session-composite-bar-tab-label'); + const labelEl = $('.chat-composite-bar-tab-label'); this._tabDisposables.add(autorun(reader => { const title = chat.title.read(reader); labelEl.textContent = title; })); tab.appendChild(labelEl); - const indicator = $('.session-composite-bar-tab-indicator'); + const indicator = $('.chat-composite-bar-tab-indicator'); tab.appendChild(indicator); this._tabsContainer.appendChild(tab); @@ -186,14 +186,14 @@ export class SessionCompositeBar extends Disposable { private _updateStyles(): void { const theme = this._themeService.getColorTheme(); - const bg = theme.getColor(sessionsChatBarBackground); + const bg = theme.getColor(chatBarBackground); const activeFg = theme.getColor(PANEL_ACTIVE_TITLE_FOREGROUND); const inactiveFg = theme.getColor(PANEL_INACTIVE_TITLE_FOREGROUND); const activeBorder = theme.getColor(PANEL_ACTIVE_TITLE_BORDER); - this._container.style.setProperty('--session-bar-background', bg?.toString() ?? ''); - this._container.style.setProperty('--session-tab-active-foreground', activeFg?.toString() ?? ''); - this._container.style.setProperty('--session-tab-inactive-foreground', inactiveFg?.toString() ?? ''); - this._container.style.setProperty('--session-tab-active-border', activeBorder?.toString() ?? ''); + this._container.style.setProperty('--chat-bar-background', bg?.toString() ?? ''); + this._container.style.setProperty('--chat-tab-active-foreground', activeFg?.toString() ?? ''); + this._container.style.setProperty('--chat-tab-inactive-foreground', inactiveFg?.toString() ?? ''); + this._container.style.setProperty('--chat-tab-active-border', activeBorder?.toString() ?? ''); } } diff --git a/src/vs/sessions/browser/parts/media/sessionCompositeBar.css b/src/vs/sessions/browser/parts/media/chatCompositeBar.css similarity index 71% rename from src/vs/sessions/browser/parts/media/sessionCompositeBar.css rename to src/vs/sessions/browser/parts/media/chatCompositeBar.css index 2e923fc4b69d3..3673f7ce5561e 100644 --- a/src/vs/sessions/browser/parts/media/sessionCompositeBar.css +++ b/src/vs/sessions/browser/parts/media/chatCompositeBar.css @@ -3,19 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -/* ===== Session composite bar in the chat bar ===== */ - -.session-composite-bar { +.chat-composite-bar { display: flex; align-items: center; - background-color: var(--session-bar-background); + background-color: var(--chat-bar-background); padding: 0 4px; height: 35px; flex-shrink: 0; overflow: hidden; } -.session-composite-bar-tabs { +.chat-composite-bar-tabs { display: flex; align-items: center; height: 100%; @@ -24,19 +22,19 @@ overflow-y: hidden; } -.session-composite-bar-tabs::-webkit-scrollbar { +.chat-composite-bar-tabs::-webkit-scrollbar { display: none; } /* Base tab: capitalize text + pill padding — mirrors auxiliarybar action-label */ -.session-composite-bar-tab { +.chat-composite-bar-tab { display: flex; align-items: center; position: relative; padding: 0 8px; cursor: pointer; white-space: nowrap; - color: var(--session-tab-inactive-foreground); + color: var(--chat-tab-inactive-foreground); text-transform: capitalize; font-weight: 500; font-size: 12px; @@ -46,22 +44,22 @@ user-select: none; } -.session-composite-bar-tab:hover { - color: var(--session-tab-active-foreground); +.chat-composite-bar-tab:hover { + color: var(--chat-tab-active-foreground); } /* Active state: background container instead of underline — mirrors auxiliarybar checked */ -.session-composite-bar-tab.active { - color: var(--session-tab-active-foreground); +.chat-composite-bar-tab.active { + color: var(--chat-tab-active-foreground); background-color: var(--vscode-activityBarTop-activeBackground, color-mix(in srgb, var(--vscode-sideBarTitle-foreground) 5%, transparent)); } /* Hide the underline indicator — mirrors auxiliarybar active-item-indicator hiding */ -.session-composite-bar-tab-indicator { +.chat-composite-bar-tab-indicator { display: none; } -.session-composite-bar-tab:focus-visible { +.chat-composite-bar-tab:focus-visible { outline: 1px solid var(--vscode-focusBorder); outline-offset: -1px; } diff --git a/src/vs/sessions/common/theme.ts b/src/vs/sessions/common/theme.ts index 99a166941ace7..fa2ae6b9d78a3 100644 --- a/src/vs/sessions/common/theme.ts +++ b/src/vs/sessions/common/theme.ts @@ -47,15 +47,15 @@ export const sessionsPanelBackground = registerColor( ); // Sessions chat bar background color -export const sessionsChatBarBackground = registerColor( - 'sessionsChatBar.background', +export const chatBarBackground = registerColor( + 'chatBar.background', { dark: SIDE_BAR_BACKGROUND, light: editorBackground, hcDark: SIDE_BAR_BACKGROUND, hcLight: SIDE_BAR_BACKGROUND, }, - localize('sessionsChatBar.background', 'Background color of the chat bar in the agent sessions window.') + localize('chatBar.background', 'Background color of the chat bar in the agent sessions window.') ); // Sessions sidebar header colors diff --git a/src/vs/sessions/contrib/chat/browser/media/chatInput.css b/src/vs/sessions/contrib/chat/browser/media/chatInput.css new file mode 100644 index 0000000000000..bddd91e33f6d4 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/chatInput.css @@ -0,0 +1,350 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.new-chat-input-container { + width: 100%; + max-width: 800px; + margin-top: 12px; + box-sizing: border-box; + display: none; +} + +/* Ensure the input editor fits properly */ +.new-chat-input-container .interactive-input-part { + margin: 0; + padding: 0; + max-width: 100%; + box-sizing: border-box; +} + +.new-chat-input-container .interactive-input-part .monaco-editor { + min-height: 0; +} + +.new-chat-input-container .interactive-input-part .monaco-editor .view-lines { + min-height: 0; +} + +.new-chat-input-container .chat-input-container { + overflow: hidden; + border-color: var(--vscode-contrastBorder, var(--vscode-editorWidget-border)); +} + +.new-chat-input-area { + position: relative; + width: 100%; + max-width: 800px; + box-sizing: border-box; + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, var(--vscode-contrastBorder, transparent))); + border-radius: 8px; + background-color: var(--vscode-input-background); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.new-chat-input-area:focus-within { + border-color: var(--vscode-focusBorder); +} + +.chat-input-picker-item .action-label.disabled { + opacity: 0.5; + cursor: default; + pointer-events: none; +} + +/* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ +.sessions-chat-editor { + padding: 0 8px 6px 10px; + flex-shrink: 1; +} + +.sessions-chat-editor .monaco-editor, +.sessions-chat-editor .monaco-editor .overflow-guard, +.sessions-chat-editor .monaco-editor-background { + background-color: var(--vscode-input-background) !important; + border-radius: 8px 8px 0 0; +} + +/* Toolbar */ +.sessions-chat-toolbar { + display: flex; + align-items: center; + padding: 0 6px 6px 6px; + gap: 4px; + color: var(--vscode-icon-foreground); +} + +.sessions-chat-toolbar-spacer { + flex: 1; +} + +/* Model picker - uses workbench ModelPickerActionItem */ +/* Session config toolbar (mode, model pickers via MenuWorkbenchToolBar) */ +.sessions-chat-config-toolbar { + display: flex; + align-items: center; + min-width: 0; + overflow: hidden; +} + +.sessions-chat-config-toolbar .monaco-toolbar { + height: auto; +} + +.sessions-chat-config-toolbar .monaco-action-bar .action-item { + display: flex; + align-items: center; +} + +.sessions-chat-config-toolbar .action-label { + display: flex; + align-items: center; + height: 16px; + padding: 3px 3px 3px 6px; + background-color: transparent; + border: none; + border-radius: 4px; + font-size: 13px; + cursor: pointer; + color: var(--vscode-icon-foreground); + white-space: nowrap; + min-width: 0; + overflow: hidden; +} + +.sessions-chat-config-toolbar .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + +.sessions-chat-config-toolbar .action-label .codicon { + font-size: 14px; + flex-shrink: 0; +} + +.sessions-chat-config-toolbar .action-label .codicon-chevron-down { + font-size: 12px; + margin-left: 6px; +} + +/* Send button - wraps a Button widget */ +.sessions-chat-send-button { + display: flex; + align-items: center; +} + +.sessions-chat-send-button .monaco-button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + min-width: 22px; + padding: 0; + border-radius: 4px; + color: var(--vscode-icon-foreground); + background: transparent !important; + border: none !important; + cursor: pointer; +} + +.sessions-chat-send-button .monaco-button.disabled { + cursor: default; +} + +.sessions-chat-send-button .monaco-button:not(.disabled):hover { + background-color: var(--vscode-toolbar-hoverBackground) !important; +} + +.sessions-chat-send-button .monaco-button .codicon { + font-size: 16px; +} + +/* Loading spinner in toolbar */ +.sessions-chat-loading-spinner { + display: none; + width: 12px; + height: 12px; + margin-right: 4px; + border: 1.5px solid var(--vscode-icon-foreground); + border-top-color: transparent; + border-radius: 50%; + animation: sessions-chat-spin 0.8s linear infinite; + opacity: 0.6; + flex-shrink: 0; +} + +.sessions-chat-loading-spinner.visible { + display: block; +} + +@keyframes sessions-chat-spin { + to { + transform: rotate(360deg); + } +} + +/* Attach row (pills only, above editor, inside input area) */ +.sessions-chat-attach-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px 6px 0 6px; +} + +.sessions-chat-attach-row:has(.sessions-chat-attached-context:empty) { + display: none; +} + +/* Attach context button */ +.sessions-chat-attach-button { + display: flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 4px; + cursor: pointer; + color: var(--vscode-icon-foreground); + background: transparent; + border: none; + outline: none; +} + +.sessions-chat-attach-button:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.sessions-chat-attach-button:focus-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; +} + +.sessions-chat-attach-button .codicon { + font-size: 16px; +} + +/* Attached context container */ +.sessions-chat-attached-context { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.sessions-chat-attached-context:empty, +.sessions-chat-attached-context[style*="display: none"] { + display: none !important; +} + +/* Attachment pills */ +.sessions-chat-attachment-pill { + display: inline-flex; + align-items: center; + overflow: hidden; + font-size: 11px; + padding: 0 4px 0 0; + border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); + border-radius: 4px; + height: 18px; + max-width: 200px; + width: fit-content; +} + +.sessions-chat-attachment-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.sessions-chat-attachment-pill .codicon { + font-size: 14px; + flex-shrink: 0; + padding: 0 3px; +} + +.sessions-chat-attachment-pill .monaco-icon-label { + gap: 4px; +} + +.sessions-chat-attachment-pill .monaco-icon-label::before { + height: auto; + padding: 0 0 0 2px; + line-height: 100% !important; + align-self: center; +} + +.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container { + display: flex; +} + +.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container .monaco-highlighted-label { + display: inline-flex; + align-items: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.sessions-chat-attachment-remove { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + flex-shrink: 0; + color: var(--vscode-descriptionForeground); + margin-left: 4px; +} + +.sessions-chat-attachment-pill:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.sessions-chat-attachment-remove .codicon { + font-size: 12px; + padding: 0; +} + +.sessions-chat-attachment-remove:hover { + background-color: var(--vscode-toolbar-hoverBackground); +} + +/* Drag and drop */ +.sessions-chat-dnd-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + box-sizing: border-box; + display: none; + z-index: 10; + background-color: var(--vscode-sideBar-dropBackground, var(--vscode-list-dropBackground)); +} + +.sessions-chat-dnd-overlay.visible { + display: flex; + align-items: center; + justify-content: center; +} + +.sessions-chat-dnd-overlay .attach-context-overlay-text { + padding: 0.6em; + margin: 0.2em; + line-height: 12px; + height: 12px; + display: flex; + align-items: center; + text-align: center; + background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); +} + +.sessions-chat-dnd-overlay .attach-context-overlay-text .codicon { + height: 12px; + font-size: 12px; + margin-right: 3px; +} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css b/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css deleted file mode 100644 index 9ed72a28310a9..0000000000000 --- a/src/vs/sessions/contrib/chat/browser/media/chatWelcomePart.css +++ /dev/null @@ -1,337 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -.chat-full-welcome { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - box-sizing: border-box; - overflow: hidden; - padding: 16px 16px 20px 16px; - container-type: size; - position: relative; -} - -.chat-full-welcome.revealed { - justify-content: center; -} - -/* Input slot */ -.chat-full-welcome-inputSlot { - width: 100%; - max-width: 800px; - margin-top: 12px; - box-sizing: border-box; - display: none; -} - -.chat-full-welcome.revealed .chat-full-welcome-inputSlot { - display: block; - animation: chat-full-welcome-fade-in 0.35s ease 0.15s both; -} - -/* Option group pickers container (above the input) */ -.chat-full-welcome-pickers-container { - display: none; - width: 100%; - max-width: 800px; - margin: 0 0 8px 0; - padding: 0; - box-sizing: border-box; -} - -.chat-full-welcome.revealed .chat-full-welcome-pickers-container { - display: block; - animation: chat-full-welcome-fade-in 0.35s ease 0.1s both; -} - -@keyframes chat-full-welcome-fade-in { - from { - opacity: 0; - transform: translateY(12px); - } - - to { - opacity: 1; - transform: translateY(0); - } -} - -.chat-full-welcome-pickers-container:empty { - display: none; - margin-bottom: 0; -} - -.chat-full-welcome-content { - width: 100%; - max-width: 800px; - display: flex; - flex-direction: column; - align-items: stretch; -} - -/* Local mode picker (Workspace / Worktree) below input */ -.chat-full-welcome-local-mode { - width: 100%; - max-width: 800px; - margin-top: 8px; - box-sizing: border-box; - display: none; - flex-direction: row; - align-items: center; - gap: 4px; - min-height: 28px; -} - -.chat-full-welcome.revealed .chat-full-welcome-local-mode { - display: flex; -} - -.sessions-chat-local-mode-left { - display: flex; - align-items: center; - min-width: 0; -} - -.sessions-chat-local-mode-spacer { - flex: 1; -} - -.sessions-chat-local-mode-right { - display: flex; - align-items: center; - gap: 2px; - min-width: 0; -} - -/* Ensure the input editor fits properly */ -.chat-full-welcome-inputSlot .interactive-input-part { - margin: 0; - padding: 0; - max-width: 100%; - box-sizing: border-box; -} - -.chat-full-welcome-inputSlot .interactive-input-part .monaco-editor { - min-height: 0; -} - -.chat-full-welcome-inputSlot .interactive-input-part .monaco-editor .view-lines { - min-height: 0; -} - -.chat-full-welcome-inputSlot .chat-input-container { - overflow: hidden; - border-color: var(--vscode-contrastBorder, var(--vscode-editorWidget-border)); -} - -.chat-controls-container .monaco-editor-background { - background-color: var(--vscode-input-background) !important; -} - -/* Pickers row - two equal halves */ -.chat-full-welcome-pickers { - display: flex; - flex-direction: row; - flex-wrap: nowrap; - align-items: center; - justify-content: flex-start; - gap: 6px; - width: 100%; - box-sizing: border-box; - padding: 0; -} - -.chat-full-welcome-pickers:empty { - display: none; -} - -.chat-full-welcome-pickers-label { - font-size: 18px; - line-height: 1.25; - color: var(--vscode-descriptionForeground); - white-space: nowrap; -} - -/* Project picker in inline title row */ -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { - height: auto; - padding: 4px; - font-size: 18px; - line-height: 1.25; - border: none; - background-color: transparent; - color: var(--vscode-foreground); - border-radius: 4px; -} - -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { - font-size: 18px; -} - -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label > .codicon:not(.sessions-chat-dropdown-chevron) { - font-size: 16px; - margin: 0; -} - -.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-chevron { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 16px; - margin-left: 6px; - line-height: 1; - transform: translateY(1px); -} - -.sessions-chat-dropdown-label { - margin-left: 4px; -} - -/* Extension picker slots (rendered inline in the row) */ -.sessions-chat-picker-slot { - display: flex; - align-items: center; - min-width: 0; - overflow: hidden; -} - -.sessions-chat-picker-slot .action-label { - display: flex; - align-items: center; - height: 16px; - padding: 3px 3px 3px 6px; - background-color: transparent; - border: none; - color: var(--vscode-icon-foreground); - font-size: 13px; - cursor: pointer; - white-space: nowrap; - border-radius: 4px; - min-width: 0; - overflow: hidden; -} - -.sessions-chat-picker-slot .action-label.hidden { - display: none; -} - -.sessions-chat-picker-slot .action-label:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); -} - -.sessions-chat-picker-slot.disabled .action-label { - opacity: 0.5; - cursor: default; - pointer-events: none; -} - -.sessions-chat-picker-slot.disabled .action-label:hover { - background-color: transparent; - color: var(--vscode-icon-foreground); -} - -.chat-input-picker-item .action-label.disabled { - opacity: 0.5; - cursor: default; - pointer-events: none; -} - -.sessions-chat-picker-slot.loading .action-label { - opacity: 0.5; - cursor: default; - pointer-events: none; -} - -.sessions-chat-picker-slot.loading .action-label .codicon-chevron-down { - display: none; -} - -.sessions-chat-picker-slot.loading .action-label::after { - content: ''; - display: inline-block; - width: 12px; - height: 12px; - margin-left: 4px; - border: 1.5px solid var(--vscode-descriptionForeground); - border-top-color: transparent; - border-radius: 50%; - animation: sessions-chat-picker-spin 0.8s linear infinite; - flex-shrink: 0; -} - -@keyframes sessions-chat-picker-spin { - to { transform: rotate(360deg); } -} - -.sessions-chat-picker-slot .action-label .codicon { - font-size: 14px; - flex-shrink: 0; -} - -.sessions-chat-picker-slot .action-label .codicon-chevron-down { - display: inline-flex; - align-items: center; - justify-content: center; - font-size: 12px; - margin-left: 6px; - line-height: 1; - transform: translateY(1px); -} - -.sessions-chat-picker-slot .action-label .chat-session-option-label { - overflow: hidden; - text-overflow: ellipsis; -} - -.sessions-chat-picker-slot .action-label span + .chat-session-option-label { - margin-left: 2px; -} - -.sessions-chat-picker-slot .action-label.warning { - color: var(--vscode-problemsWarningIcon-foreground); - opacity: 0.75; -} - -.sessions-chat-picker-slot .action-label.warning .codicon { - color: var(--vscode-problemsWarningIcon-foreground) !important; -} - -.sessions-chat-picker-slot .action-label.warning:hover { - color: var(--vscode-problemsWarningIcon-foreground); - opacity: 1; -} - -.sessions-chat-picker-slot .action-label.info { - color: var(--vscode-problemsInfoIcon-foreground); - opacity: 0.75; -} - -.sessions-chat-picker-slot .action-label.info .codicon { - color: var(--vscode-problemsInfoIcon-foreground) !important; -} - -.sessions-chat-picker-slot .action-label.info:hover { - color: var(--vscode-problemsInfoIcon-foreground); - opacity: 1; -} - -/* Sync indicator: a slim non-interactive-looking separator before the button */ -.sessions-chat-sync-indicator { - margin-left: 4px; -} - -.sessions-chat-sync-indicator .action-label .sessions-chat-dropdown-label { - margin-left: 3px; -} diff --git a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css index 32cba87667b30..8e47a3a302c1f 100644 --- a/src/vs/sessions/contrib/chat/browser/media/chatWidget.css +++ b/src/vs/sessions/contrib/chat/browser/media/chatWidget.css @@ -11,330 +11,285 @@ position: relative; } -/* Welcome container fills available space and centers content */ -.sessions-chat-widget .agent-chat-welcome-container { - flex: 1; +.new-chat-widget-container { display: flex; flex-direction: column; align-items: center; justify-content: center; -} - -/* Input area */ -.sessions-chat-input-area { - position: relative; width: 100%; - max-width: 800px; + height: 100%; box-sizing: border-box; - border: 1px solid var(--vscode-editorWidget-border, var(--vscode-widget-border, var(--vscode-contrastBorder, transparent))); - border-radius: 8px; - background-color: var(--vscode-input-background); overflow: hidden; - display: flex; - flex-direction: column; + padding: 16px 16px 20px 16px; + container-type: size; + position: relative; } -.sessions-chat-input-area:focus-within { - border-color: var(--vscode-focusBorder); +.new-chat-widget-container.revealed { + justify-content: center; } -/* Editor */ -/* Height constraints are driven by MIN_EDITOR_HEIGHT / MAX_EDITOR_HEIGHT in newChatViewPane.ts */ -.sessions-chat-editor { - padding: 0 8px 6px 10px; - flex-shrink: 1; +.new-chat-widget-container.revealed .new-chat-input-container { + display: block; + animation: chat-full-welcome-fade-in 0.35s ease 0.15s both; } -.sessions-chat-editor .monaco-editor, -.sessions-chat-editor .monaco-editor .overflow-guard, -.sessions-chat-editor .monaco-editor-background { - background-color: var(--vscode-input-background) !important; - border-radius: 8px 8px 0 0; +/* Option group pickers container (above the input) */ +.new-session-workspace-picker-container { + display: none; + width: 100%; + max-width: 800px; + margin: 0 0 8px 0; + padding: 0; + box-sizing: border-box; } -/* Toolbar */ -.sessions-chat-toolbar { - display: flex; - align-items: center; - padding: 0 6px 6px 6px; - gap: 4px; - color: var(--vscode-icon-foreground); +.new-chat-widget-container.revealed .new-session-workspace-picker-container { + display: block; + animation: chat-full-welcome-fade-in 0.35s ease 0.1s both; +} + +@keyframes chat-full-welcome-fade-in { + from { + opacity: 0; + transform: translateY(12px); + } + + to { + opacity: 1; + transform: translateY(0); + } } -.sessions-chat-toolbar-spacer { - flex: 1; +.new-session-workspace-picker-container:empty { + display: none; + margin-bottom: 0; } -.sessions-chat-toolbar-pickers { +.new-chat-widget-content { + width: 100%; + max-width: 800px; display: flex; - align-items: center; - gap: 4px; + flex-direction: column; + align-items: stretch; } -/* Model picker - uses workbench ModelPickerActionItem */ -/* Session config toolbar (mode, model pickers via MenuWorkbenchToolBar) */ -.sessions-chat-config-toolbar { - display: flex; +.new-chat-widget-container .new-chat-bottom-container { + width: 100%; + max-width: 800px; + margin-top: 8px; + box-sizing: border-box; + display: none; + flex-direction: row; align-items: center; - min-width: 0; - overflow: hidden; + gap: 4px; + min-height: 28px; + justify-content: space-between; } -.sessions-chat-config-toolbar .monaco-toolbar { - height: auto; +.new-chat-widget-container.revealed .new-chat-bottom-container { + display: flex; } -.sessions-chat-config-toolbar .monaco-action-bar .action-item { +.new-chat-widget-container .new-chat-bottom-container .new-chat-controls-container { display: flex; - align-items: center; } -.sessions-chat-config-toolbar .action-label { +.new-chat-widget-container .new-chat-bottom-container .new-chat-repo-config-container { display: flex; align-items: center; - height: 16px; - padding: 3px 3px 3px 6px; - background-color: transparent; - border: none; - border-radius: 4px; - font-size: 13px; - cursor: pointer; - color: var(--vscode-icon-foreground); - white-space: nowrap; + gap: 2px; min-width: 0; - overflow: hidden; } -.sessions-chat-config-toolbar .action-label:hover { - background-color: var(--vscode-toolbar-hoverBackground); - color: var(--vscode-foreground); +.chat-controls-container .monaco-editor-background { + background-color: var(--vscode-input-background) !important; } -.sessions-chat-config-toolbar .action-label .codicon { - font-size: 14px; - flex-shrink: 0; +/* Pickers row - two equal halves */ +.session-workspace-picker { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + justify-content: flex-start; + gap: 6px; + width: 100%; + box-sizing: border-box; + padding: 0; } -.sessions-chat-config-toolbar .action-label .codicon-chevron-down { - font-size: 12px; - margin-left: 6px; +.session-workspace-picker:empty { + display: none; } -/* Send button - wraps a Button widget */ -.sessions-chat-send-button { - display: flex; - align-items: center; +.session-workspace-picker-label { + font-size: 18px; + line-height: 1.25; + color: var(--vscode-descriptionForeground); + white-space: nowrap; } -.sessions-chat-send-button .monaco-button { - display: flex; - align-items: center; - justify-content: center; - width: 22px; - height: 22px; - min-width: 22px; - padding: 0; +/* Project picker in inline title row */ +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label { + height: auto; + padding: 4px; + font-size: 18px; + line-height: 1.25; + border: none; + background-color: transparent; + color: var(--vscode-foreground); border-radius: 4px; - color: var(--vscode-icon-foreground); - background: transparent !important; - border: none !important; - cursor: pointer; } -.sessions-chat-send-button .monaco-button.disabled { - cursor: default; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); } -.sessions-chat-send-button .monaco-button:not(.disabled):hover { - background-color: var(--vscode-toolbar-hoverBackground) !important; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-label { + font-size: 18px; } -.sessions-chat-send-button .monaco-button .codicon { +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label > .codicon:not(.sessions-chat-dropdown-chevron) { font-size: 16px; + margin: 0; } -/* Loading spinner in toolbar */ -.sessions-chat-loading-spinner { - display: none; - width: 12px; - height: 12px; - margin-right: 4px; - border: 1.5px solid var(--vscode-icon-foreground); - border-top-color: transparent; - border-radius: 50%; - animation: sessions-chat-spin 0.8s linear infinite; - opacity: 0.6; - flex-shrink: 0; -} - -.sessions-chat-loading-spinner.visible { - display: block; +.sessions-chat-picker-slot.sessions-chat-workspace-picker .action-label .sessions-chat-dropdown-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + margin-left: 6px; + line-height: 1; + transform: translateY(1px); } -@keyframes sessions-chat-spin { - to { - transform: rotate(360deg); - } +.sessions-chat-dropdown-label { + margin-left: 4px; } -/* Attach row (pills only, above editor, inside input area) */ -.sessions-chat-attach-row { +.sessions-chat-picker-slot { display: flex; - flex-wrap: wrap; align-items: center; - gap: 4px; - padding: 4px 6px 0 6px; -} - -.sessions-chat-attach-row:has(.sessions-chat-attached-context:empty) { - display: none; + min-width: 0; + overflow: hidden; } -/* Attach context button */ -.sessions-chat-attach-button { +.sessions-chat-picker-slot .action-label { display: flex; align-items: center; - justify-content: center; - width: 22px; - height: 22px; - border-radius: 4px; - cursor: pointer; - color: var(--vscode-icon-foreground); - background: transparent; + height: 16px; + padding: 3px 3px 3px 6px; + background-color: transparent; border: none; - outline: none; -} - -.sessions-chat-attach-button:hover { - background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-icon-foreground); + font-size: 13px; + cursor: pointer; + white-space: nowrap; + border-radius: 4px; + min-width: 0; + overflow: hidden; } -.sessions-chat-attach-button:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; +.sessions-chat-picker-slot .action-label.hidden { + display: none; } -.sessions-chat-attach-button .codicon { - font-size: 16px; +.sessions-chat-picker-slot .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); } -/* Attached context container */ -.sessions-chat-attached-context { - display: flex; - flex-wrap: wrap; - gap: 4px; - align-items: center; +.sessions-chat-picker-slot.disabled .action-label { + opacity: 0.5; + cursor: default; + pointer-events: none; } -.sessions-chat-attached-context:empty, -.sessions-chat-attached-context[style*="display: none"] { - display: none !important; +.sessions-chat-picker-slot.disabled .action-label:hover { + background-color: transparent; + color: var(--vscode-icon-foreground); } -/* Attachment pills */ -.sessions-chat-attachment-pill { - display: inline-flex; - align-items: center; - overflow: hidden; - font-size: 11px; - padding: 0 4px 0 0; - border: 1px solid var(--vscode-chat-requestBorder, var(--vscode-input-background, transparent)); - border-radius: 4px; - height: 18px; - max-width: 200px; - width: fit-content; +.sessions-chat-picker-slot.loading .action-label { + opacity: 0.5; + cursor: default; + pointer-events: none; } -.sessions-chat-attachment-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; +.sessions-chat-picker-slot.loading .action-label .codicon-chevron-down { + display: none; } -.sessions-chat-attachment-pill .codicon { - font-size: 14px; +.sessions-chat-picker-slot.loading .action-label::after { + content: ''; + display: inline-block; + width: 12px; + height: 12px; + margin-left: 4px; + border: 1.5px solid var(--vscode-descriptionForeground); + border-top-color: transparent; + border-radius: 50%; + animation: sessions-chat-picker-spin 0.8s linear infinite; flex-shrink: 0; - padding: 0 3px; } -.sessions-chat-attachment-pill .monaco-icon-label { - gap: 4px; -} - -.sessions-chat-attachment-pill .monaco-icon-label::before { - height: auto; - padding: 0 0 0 2px; - line-height: 100% !important; - align-self: center; +@keyframes sessions-chat-picker-spin { + to { + transform: rotate(360deg); + } } -.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container { - display: flex; +.sessions-chat-picker-slot .action-label .codicon { + font-size: 14px; + flex-shrink: 0; } -.sessions-chat-attachment-pill .monaco-icon-label .monaco-icon-label-container .monaco-highlighted-label { +.sessions-chat-picker-slot .action-label .codicon-chevron-down { display: inline-flex; align-items: center; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; + justify-content: center; + font-size: 12px; + margin-left: 6px; + line-height: 1; + transform: translateY(1px); } -.sessions-chat-attachment-remove { - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - flex-shrink: 0; - color: var(--vscode-descriptionForeground); - margin-left: 4px; +.sessions-chat-picker-slot .action-label .chat-session-option-label { + overflow: hidden; + text-overflow: ellipsis; } -.sessions-chat-attachment-pill:hover { - background-color: var(--vscode-toolbar-hoverBackground); +.sessions-chat-picker-slot .action-label span + .chat-session-option-label { + margin-left: 2px; } -.sessions-chat-attachment-remove .codicon { - font-size: 12px; - padding: 0; +.sessions-chat-picker-slot .action-label.warning { + color: var(--vscode-problemsWarningIcon-foreground); + opacity: 0.75; } -.sessions-chat-attachment-remove:hover { - background-color: var(--vscode-toolbar-hoverBackground); +.sessions-chat-picker-slot .action-label.warning .codicon { + color: var(--vscode-problemsWarningIcon-foreground) !important; } -/* Drag and drop */ -.sessions-chat-dnd-overlay { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - box-sizing: border-box; - display: none; - z-index: 10; - background-color: var(--vscode-sideBar-dropBackground, var(--vscode-list-dropBackground)); +.sessions-chat-picker-slot .action-label.warning:hover { + color: var(--vscode-problemsWarningIcon-foreground); + opacity: 1; } -.sessions-chat-dnd-overlay.visible { - display: flex; - align-items: center; - justify-content: center; +.sessions-chat-picker-slot .action-label.info { + color: var(--vscode-problemsInfoIcon-foreground); + opacity: 0.75; } -.sessions-chat-dnd-overlay .attach-context-overlay-text { - padding: 0.6em; - margin: 0.2em; - line-height: 12px; - height: 12px; - display: flex; - align-items: center; - text-align: center; - background-color: var(--vscode-sideBar-background, var(--vscode-editor-background)); +.sessions-chat-picker-slot .action-label.info .codicon { + color: var(--vscode-problemsInfoIcon-foreground) !important; } -.sessions-chat-dnd-overlay .attach-context-overlay-text .codicon { - height: 12px; - font-size: 12px; - margin-right: 3px; +.sessions-chat-picker-slot .action-label.info:hover { + color: var(--vscode-problemsInfoIcon-foreground); + opacity: 1; } diff --git a/src/vs/sessions/contrib/chat/browser/newChatInput.ts b/src/vs/sessions/contrib/chat/browser/newChatInput.ts new file mode 100644 index 0000000000000..c36e8a6868b06 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/newChatInput.ts @@ -0,0 +1,567 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './media/chatInput.css'; +import * as dom from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Emitter } from '../../../../base/common/event.js'; +import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; +import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; +import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; +import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; +import { IModelService } from '../../../../editor/common/services/model.js'; +import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; +import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; +import { AccessibilityCommandId } from '../../../../workbench/contrib/accessibility/common/accessibilityCommands.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IHoverService } from '../../../../platform/hover/browser/hover.js'; +import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; +import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; +import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; +import { localize } from '../../../../nls.js'; +import * as aria from '../../../../base/browser/ui/aria/aria.js'; +import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; +import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; +import { NewChatContextAttachments } from './newChatContextAttachments.js'; +import { SessionTypePicker } from './sessionTypePicker.js'; +import { Menus } from '../../../browser/menus.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { SlashCommandHandler } from './slashCommands.js'; +import { VariableCompletionHandler } from './variableCompletions.js'; +import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; +import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; +import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; +import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; +import { autorun, IObservable } from '../../../../base/common/observable.js'; + + +const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; +const MIN_EDITOR_HEIGHT = 50; +const MAX_EDITOR_HEIGHT = 200; + +interface IDraftState { + inputText: string; + attachments: readonly IChatRequestVariableEntry[]; +} + +// #region --- New Chat Widget --- + +export class NewChatInputWidget extends Disposable implements IHistoryNavigationWidget { + + readonly sessionTypePicker: SessionTypePicker; + + // IHistoryNavigationWidget + private readonly _onDidFocus = this._register(new Emitter()); + readonly onDidFocus = this._onDidFocus.event; + private readonly _onDidBlur = this._register(new Emitter()); + readonly onDidBlur = this._onDidBlur.event; + get element(): HTMLElement { return this._editorContainer; } + + // Input + private _editor!: CodeEditorWidget; + private _editorContainer!: HTMLElement; + + // Send button + private _sendButton: Button | undefined; + private _sending = false; + + // Loading state + private _loadingSpinner: HTMLElement | undefined; + private readonly _loadingDelayDisposable = this._register(new MutableDisposable()); + + // Attached context + private readonly _contextAttachments: NewChatContextAttachments; + + // Slash commands + private _slashCommandHandler: SlashCommandHandler | undefined; + + // Input state + private _draftState: IDraftState | undefined = { + inputText: '', + attachments: [], + }; + + // Input history + private readonly _history: ChatHistoryNavigator; + private _historyNavigationBackwardsEnablement!: IHistoryNavigationContext['historyNavigationBackwardsEnablement']; + private _historyNavigationForwardsEnablement!: IHistoryNavigationContext['historyNavigationForwardsEnablement']; + + constructor( + private readonly options: { + getContextFolderUri: () => URI | undefined; + sendRequest: (query: string, attachments?: IChatRequestVariableEntry[]) => Promise; + canSendRequest: IObservable; + loading: IObservable; + }, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IModelService private readonly modelService: IModelService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @ILogService private readonly logService: ILogService, + @IHoverService private readonly hoverService: IHoverService, + @IStorageService private readonly storageService: IStorageService, + @IKeybindingService private readonly keybindingService: IKeybindingService, + ) { + super(); + this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); + this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); + this.sessionTypePicker = this._register(this.instantiationService.createInstance(SessionTypePicker)); + this._register(this._contextAttachments.onDidChangeContext(() => { + this._updateDraftState(); + this.focus(); + })); + this._register(autorun(reader => { + this.options.canSendRequest.read(reader); + const isLoading = this.options.loading.read(reader); + this._loadingSpinner?.classList.toggle('visible', isLoading); + this._updateSendButtonState(); + })); + } + + // --- Rendering --- + + render(parent: HTMLElement, root: HTMLElement): void { + // Input slot + const chatInputContainer = dom.append(parent, dom.$('.new-chat-input-container')); + + // Overflow widget DOM node at the top level so the suggest widget + // is not clipped by any overflow:hidden ancestor. + const editorOverflowWidgetsDomNode = dom.append(root, dom.$('.sessions-chat-editor-overflow.monaco-editor')); + this._register({ dispose: () => editorOverflowWidgetsDomNode.remove() }); + + // Input area inside the input slot + const inputArea = dom.append(chatInputContainer, dom.$('.new-chat-input-area')); + + // Attachments row (pills only) inside input area, above editor + const attachRow = dom.append(inputArea, dom.$('.sessions-chat-attach-row')); + const attachedContextContainer = dom.append(attachRow, dom.$('.sessions-chat-attached-context')); + this._contextAttachments.renderAttachedContext(attachedContextContainer); + this._contextAttachments.registerDropTarget(root); + this._contextAttachments.registerPasteHandler(inputArea); + + this._createEditor(inputArea, editorOverflowWidgetsDomNode); + this._createInputToolbar(inputArea); + + const newChatBottomContainer = dom.append(parent, dom.$('.new-chat-bottom-container')); + const newChatControlsContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-controls-container')); + this.sessionTypePicker.render(newChatControlsContainer); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, dom.append(newChatControlsContainer, dom.$('')), Menus.NewSessionControl, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + })); + + const repoConfigContainer = dom.append(newChatBottomContainer, dom.$('.new-chat-repo-config-container')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, repoConfigContainer, Menus.NewSessionRepositoryConfig, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + })); + + // Restore draft input state from storage + this._restoreState(); + + // Layout editor after the input slot fade-in animation completes + this._register(dom.addDisposableListener(chatInputContainer, 'animationend', () => { + this._editor?.layout(); + }, { once: true })); + } + + private _updateInputLoadingState(): void { + const loading = this._sending; + if (loading) { + if (!this._loadingDelayDisposable.value) { + const timer = setTimeout(() => { + this._loadingDelayDisposable.clear(); + if (this._sending) { + this._loadingSpinner?.classList.add('visible'); + } + }, 500); + this._loadingDelayDisposable.value = toDisposable(() => clearTimeout(timer)); + } + } else { + this._loadingDelayDisposable.clear(); + this._loadingSpinner?.classList.remove('visible'); + } + } + + // --- Editor --- + + private _getAriaLabel(): string { + const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.SessionsChat); + if (verbose) { + const kbLabel = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); + return kbLabel + ? localize('chatInput.accessibilityHelp', "Chat input. Press Enter to send out the request. Use {0} for Chat Accessibility Help.", kbLabel) + : localize('chatInput.accessibilityHelpNoKb', "Chat input. Press Enter to send out the request. Use the Chat Accessibility Help command for more information."); + } + return localize('chatInput', "Chat input"); + } + + private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { + const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); + editorContainer.style.height = `${MIN_EDITOR_HEIGHT}px`; + + // Create scoped context key service and register history navigation + // BEFORE creating the editor, so the editor's context key scope is a child + const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(container)); + const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); + this._historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; + this._historyNavigationForwardsEnablement = historyNavigationForwardsEnablement; + + const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); + + const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); + const textModel = this._register(this.modelService.createModel('', null, uri, true)); + + const editorOptions: IEditorConstructionOptions = { + ...getSimpleEditorOptions(this.configurationService), + readOnly: false, + ariaLabel: this._getAriaLabel(), + placeholder: localize('chatPlaceholder', "Run tasks in the background, type '#' for adding context"), + fontFamily: 'system-ui, -apple-system, sans-serif', + fontSize: 13, + lineHeight: 20, + cursorWidth: 1, + padding: { top: 8, bottom: 2 }, + wrappingStrategy: 'advanced', + stickyScroll: { enabled: false }, + renderWhitespace: 'none', + overflowWidgetsDomNode, + suggest: { + showIcons: true, + showSnippets: false, + showWords: true, + showStatusBar: false, + insertMode: 'insert', + }, + }; + + const widgetOptions: ICodeEditorWidgetOptions = { + isSimpleWidget: true, + contributions: EditorExtensionsRegistry.getSomeEditorContributions([ + ContextMenuController.ID, + SuggestController.ID, + SnippetController2.ID, + ]), + }; + + this._editor = this._register(scopedInstantiationService.createInstance( + CodeEditorWidget, editorContainer, editorOptions, widgetOptions, + )); + this._editor.setModel(textModel); + + // Ensure suggest widget renders above the input (not clipped by container) + SuggestController.get(this._editor)?.forceRenderingAbove(); + + // Update aria label when accessibility verbosity setting changes + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(AccessibilityVerbositySettingId.SessionsChat)) { + this._editor.updateOptions({ ariaLabel: this._getAriaLabel() }); + } + })); + + this._register(this._editor.onDidFocusEditorWidget(() => this._onDidFocus.fire())); + this._register(this._editor.onDidBlurEditorWidget(() => this._onDidBlur.fire())); + + this._register(this._editor.onKeyDown(e => { + if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { + // Don't send if the suggest widget is visible (let it accept the completion) + if (this._editor.contextKeyService.getContextKeyValue('suggestWidgetVisible')) { + return; + } + e.preventDefault(); + e.stopPropagation(); + this._send(); + } + if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && e.altKey) { + e.preventDefault(); + e.stopPropagation(); + this._send(); + } + // Cmd+/ / Ctrl+/ — open the context picker (same as the attach button) + if (e.equals(KeyMod.CtrlCmd | KeyCode.Slash)) { + e.preventDefault(); + e.stopPropagation(); + this._contextAttachments.showPicker(this.options.getContextFolderUri()); + } + })); + + // Update history navigation enablement based on cursor position + const updateHistoryNavigationEnablement = () => { + const model = this._editor.getModel(); + const position = this._editor.getPosition(); + if (!model || !position) { + return; + } + this._historyNavigationBackwardsEnablement.set(position.lineNumber === 1 && position.column === 1); + this._historyNavigationForwardsEnablement.set(position.lineNumber === model.getLineCount() && position.column === model.getLineMaxColumn(position.lineNumber)); + }; + this._register(this._editor.onDidChangeCursorPosition(() => updateHistoryNavigationEnablement())); + updateHistoryNavigationEnablement(); + + let previousHeight = -1; + this._register(this._editor.onDidContentSizeChange(e => { + if (!e.contentHeightChanged) { + return; + } + const contentHeight = this._editor.getContentHeight(); + const clampedHeight = Math.min(MAX_EDITOR_HEIGHT, Math.max(MIN_EDITOR_HEIGHT, contentHeight)); + if (clampedHeight === previousHeight) { + return; + } + previousHeight = clampedHeight; + this._editorContainer.style.height = `${clampedHeight}px`; + this._editor.layout(); + })); + + // Slash commands + this._slashCommandHandler = this._register(this.instantiationService.createInstance(SlashCommandHandler, this._editor)); + + // Variable completions (#file, #folder) + this._register(this.instantiationService.createInstance( + VariableCompletionHandler, this._editor, this._contextAttachments, () => this.options.getContextFolderUri(), + )); + + this._register(this._editor.onDidChangeModelContent(() => { + this._updateDraftState(); + this._updateSendButtonState(); + })); + } + + private _createAttachButton(container: HTMLElement): void { + const attachButton = dom.append(container, dom.$('.sessions-chat-attach-button')); + const attachButtonLabel = localize('addContext', "Add Context..."); + attachButton.tabIndex = 0; + attachButton.role = 'button'; + attachButton.ariaLabel = attachButtonLabel; + this._register(this.hoverService.setupDelayedHover(attachButton, { + content: attachButtonLabel, + position: { hoverPosition: HoverPosition.BELOW }, + appearance: { showPointer: true } + })); + dom.append(attachButton, renderIcon(Codicon.add)); + this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { + this._contextAttachments.showPicker(this.options.getContextFolderUri()); + })); + } + + private _createInputToolbar(container: HTMLElement): void { + const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); + + this._createAttachButton(toolbar); + + // Session config pickers (mode, model) — rendered via MenuWorkbenchToolBar + // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) + const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + })); + + dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); + + this._loadingSpinner = dom.append(toolbar, dom.$('.sessions-chat-loading-spinner')); + this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._loadingSpinner, localize('loading', "Loading..."))); + + const sendButtonContainer = dom.append(toolbar, dom.$('.sessions-chat-send-button')); + const sendButton = this._sendButton = this._register(new Button(sendButtonContainer, { + secondary: true, + title: localize('send', "Send"), + ariaLabel: localize('send', "Send"), + })); + sendButton.icon = Codicon.send; + this._register(sendButton.onDidClick(() => this._send())); + } + + // --- Input History (IHistoryNavigationWidget) --- + + showPreviousValue(): void { + if (this._history.isAtStart()) { + return; + } + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._toHistoryEntry(this._draftState)); + } + this._navigateHistory(true); + } + + showNextValue(): void { + if (this._history.isAtEnd()) { + return; + } + if (this._draftState?.inputText || this._draftState?.attachments.length) { + this._history.overlay(this._toHistoryEntry(this._draftState)); + } + this._navigateHistory(false); + } + + private _updateDraftState(): void { + this._draftState = { + inputText: this._editor?.getModel()?.getValue() ?? '', + attachments: [...this._contextAttachments.attachments], + }; + } + + private _toHistoryEntry(draft: IDraftState): IChatModelInputState { + return { + ...draft, + mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, + selectedModel: undefined, + selections: [], + contrib: {}, + }; + } + + private _navigateHistory(previous: boolean): void { + const entry = previous ? this._history.previous() : this._history.next(); + const inputText = entry?.inputText ?? ''; + if (entry) { + this._editor?.getModel()?.setValue(inputText); + this._contextAttachments.setAttachments(entry.attachments); + } + aria.status(inputText); + if (previous) { + this._editor.setPosition({ lineNumber: 1, column: 1 }); + } else { + const model = this._editor.getModel(); + if (model) { + const lastLine = model.getLineCount(); + this._editor.setPosition({ lineNumber: lastLine, column: model.getLineMaxColumn(lastLine) }); + } + } + } + + // --- Send --- + + + private async _send(): Promise { + let query = this._editor.getModel()?.getValue().trim(); + if (!query || this._sending) { + return; + } + + // Check for slash commands first + if (this._slashCommandHandler?.tryExecuteSlashCommand(query)) { + this._editor.getModel()?.setValue(''); + return; + } + + // Expand prompt/skill slash commands into a CLI-friendly reference + const expanded = this._slashCommandHandler?.tryExpandPromptSlashCommand(query); + if (expanded) { + query = expanded; + } + + const attachedContext = this._contextAttachments.attachments.length > 0 + ? [...this._contextAttachments.attachments] + : undefined; + + if (this._draftState) { + this._history.append(this._toHistoryEntry(this._draftState)); + } + this._clearDraftState(); + + this._sending = true; + this._editor.updateOptions({ readOnly: true }); + this._updateSendButtonState(); + this._updateInputLoadingState(); + + try { + await this.options.sendRequest(query, attachedContext); + this._contextAttachments.clear(); + this._editor.getModel()?.setValue(''); + } catch (e) { + this.logService.error('Failed to send request:', e); + } + + this._sending = false; + this._editor.updateOptions({ readOnly: false }); + this._updateSendButtonState(); + this._updateInputLoadingState(); + } + + private _updateSendButtonState(): void { + if (!this._sendButton) { + return; + } + const hasText = !!this._editor?.getModel()?.getValue().trim(); + this._sendButton.enabled = !this._sending && hasText && this.options.canSendRequest.get(); + } + + private _restoreState(): void { + const draft = this._getDraftState(); + if (draft) { + this._editor?.getModel()?.setValue(draft.inputText); + if (draft.attachments?.length) { + this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); + } + } + } + + private _getDraftState(): IDraftState | undefined { + const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); + if (!raw) { + return undefined; + } + try { + return JSON.parse(raw); + } catch { + return undefined; + } + } + + private _clearDraftState(): void { + this._draftState = { inputText: '', attachments: [] }; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(this._draftState), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + + saveState(): void { + if (this._draftState) { + const state = { + ...this._draftState, + attachments: this._draftState.attachments.map(IChatRequestVariableEntry.toExport), + }; + this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + } + + layout(_height: number, _width: number): void { + this._editor?.layout(); + } + + focus(): void { + this._editor?.focus(); + } + + prefillInput(text: string): void { + const editor = this._editor; + const model = editor?.getModel(); + if (editor && model) { + model.setValue(text); + const lastLine = model.getLineCount(); + const maxColumn = model.getLineMaxColumn(lastLine); + editor.setPosition({ lineNumber: lastLine, column: maxColumn }); + editor.focus(); + } + } + + sendQuery(text: string): void { + const model = this._editor?.getModel(); + if (model) { + model.setValue(text); + this._send(); + } + } +} + +// #endregion diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 61273f59c71df..244a3a109dfae 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -4,435 +4,110 @@ *--------------------------------------------------------------------------------------------*/ import './media/chatWidget.css'; -import './media/chatWelcomePart.css'; import * as dom from '../../../../base/browser/dom.js'; -import { Codicon } from '../../../../base/common/codicons.js'; -import { Emitter } from '../../../../base/common/event.js'; -import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { Disposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; -import { autorun } from '../../../../base/common/observable.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; +import { derived } from '../../../../base/common/observable.js'; import { URI } from '../../../../base/common/uri.js'; -import { Button } from '../../../../base/browser/ui/button/button.js'; -import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js'; -import { EditorExtensionsRegistry } from '../../../../editor/browser/editorExtensions.js'; -import { IEditorConstructionOptions } from '../../../../editor/browser/config/editorConfiguration.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; -import { SuggestController } from '../../../../editor/contrib/suggest/browser/suggestController.js'; -import { SnippetController2 } from '../../../../editor/contrib/snippet/browser/snippetController2.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { AccessibilityVerbositySettingId } from '../../../../workbench/contrib/accessibility/browser/accessibilityConfiguration.js'; -import { AccessibilityCommandId } from '../../../../workbench/contrib/accessibility/common/accessibilityCommands.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; -import { getDefaultHoverDelegate } from '../../../../base/browser/ui/hover/hoverDelegateFactory.js'; -import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; -import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; import { localize } from '../../../../nls.js'; -import * as aria from '../../../../base/browser/ui/aria/aria.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsProvidersService } from '../../../services/sessions/browser/sessionsProvidersService.js'; import { IViewDescriptorService } from '../../../../workbench/common/views.js'; import { IWorkspaceTrustRequestService } from '../../../../platform/workspace/common/workspaceTrust.js'; import { IViewPaneOptions, ViewPane } from '../../../../workbench/browser/parts/views/viewPane.js'; -import { ContextMenuController } from '../../../../editor/contrib/contextmenu/browser/contextmenu.js'; -import { getSimpleEditorOptions } from '../../../../workbench/contrib/codeEditor/browser/simpleEditorOptions.js'; -import { NewChatContextAttachments } from './newChatContextAttachments.js'; -import { SessionTypePicker } from './sessionTypePicker.js'; import { WorkspacePicker, IWorkspaceSelection } from './sessionWorkspacePicker.js'; -import { Menus } from '../../../browser/menus.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { SlashCommandHandler } from './slashCommands.js'; -import { VariableCompletionHandler } from './variableCompletions.js'; -import { IChatModelInputState } from '../../../../workbench/contrib/chat/common/model/chatModel.js'; +import { NewChatInputWidget } from './newChatInput.js'; import { IChatRequestVariableEntry } from '../../../../workbench/contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatAgentLocation, ChatModeKind } from '../../../../workbench/contrib/chat/common/constants.js'; -import { ChatHistoryNavigator } from '../../../../workbench/contrib/chat/common/widget/chatWidgetHistoryService.js'; -import { IHistoryNavigationWidget } from '../../../../base/browser/history.js'; -import { registerAndCreateHistoryNavigationContext, IHistoryNavigationContext } from '../../../../platform/history/browser/contextScopedHistoryWidget.js'; +// #region --- New Chat Widget --- -const STORAGE_KEY_DRAFT_STATE = 'sessions.draftState'; -const MIN_EDITOR_HEIGHT = 50; -const MAX_EDITOR_HEIGHT = 200; - -interface IDraftState { - inputText: string; - attachments: readonly IChatRequestVariableEntry[]; -} - -// #region --- Chat Welcome Widget --- - -/** - * A self-contained new-session chat widget with a welcome view (mascot, target - * buttons, option pickers), an input editor, model picker, and send button. - * - * This widget is shown only in the empty/welcome state. Once the user sends - * a message, a session is created and the workbench ChatViewPane takes over. - */ -class NewChatWidget extends Disposable implements IHistoryNavigationWidget { +class NewChatWidget extends Disposable { private readonly _workspacePicker: WorkspacePicker; - private readonly _sessionTypePicker: SessionTypePicker; - - // IHistoryNavigationWidget - private readonly _onDidFocus = this._register(new Emitter()); - readonly onDidFocus = this._onDidFocus.event; - private readonly _onDidBlur = this._register(new Emitter()); - readonly onDidBlur = this._onDidBlur.event; - get element(): HTMLElement { return this._editorContainer; } - - // Input - private _editor!: CodeEditorWidget; - private _editorContainer!: HTMLElement; - - // Send button - private _sendButton: Button | undefined; - private _sending = false; - - // Loading state - private _loadingSpinner: HTMLElement | undefined; - private readonly _loadingDelayDisposable = this._register(new MutableDisposable()); - - // Welcome part - private _pickersContainer: HTMLElement | undefined; - private _inputSlot: HTMLElement | undefined; - - // Attached context - private readonly _contextAttachments: NewChatContextAttachments; - - // Slash commands - private _slashCommandHandler: SlashCommandHandler | undefined; - - // Input state - private _draftState: IDraftState | undefined = { - inputText: '', - attachments: [], - }; - - // Input history - private readonly _history: ChatHistoryNavigator; - private _historyNavigationBackwardsEnablement!: IHistoryNavigationContext['historyNavigationBackwardsEnablement']; - private _historyNavigationForwardsEnablement!: IHistoryNavigationContext['historyNavigationForwardsEnablement']; + private readonly _newChatInput: NewChatInputWidget; constructor( @IInstantiationService private readonly instantiationService: IInstantiationService, - @IModelService private readonly modelService: IModelService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, @ILogService private readonly logService: ILogService, - @IHoverService private readonly hoverService: IHoverService, @ISessionsManagementService private readonly sessionsManagementService: ISessionsManagementService, @ISessionsProvidersService private readonly sessionsProvidersService: ISessionsProvidersService, - @IStorageService private readonly storageService: IStorageService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, - @IKeybindingService private readonly keybindingService: IKeybindingService, ) { super(); - this._history = this._register(this.instantiationService.createInstance(ChatHistoryNavigator, ChatAgentLocation.Chat)); - this._contextAttachments = this._register(this.instantiationService.createInstance(NewChatContextAttachments)); this._workspacePicker = this._register(this.instantiationService.createInstance(WorkspacePicker)); - this._sessionTypePicker = this._register(this.instantiationService.createInstance(SessionTypePicker)); - // When a workspace is selected, create a new session - this._register(this._workspacePicker.onDidChangeSelection(() => { - this._renderOptionGroupPickers(); + const canSendRequest = derived(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + if (!session) { + return false; + } + return !session.loading.read(reader) && session.ready.read(reader); + }); + + const loading = derived(reader => { + const session = this.sessionsManagementService.activeSession.read(reader); + return session?.loading.read(reader) ?? false; + }); + + this._newChatInput = this._register(this.instantiationService.createInstance(NewChatInputWidget, { + getContextFolderUri: () => this._getContextFolderUri(), + sendRequest: async (text: string, attachedContext?: IChatRequestVariableEntry[]) => this._send(text, attachedContext), + canSendRequest, + loading, })); + this._register(this._workspacePicker.onDidSelectWorkspace(async workspace => { - await this._onWorkspaceSelected(workspace, this._sessionTypePicker.selectedType); - this._focusEditor(); + await this._onWorkspaceSelected(workspace, this._newChatInput.sessionTypePicker.selectedType); + this._newChatInput.focus(); })); - this._register(this._sessionTypePicker.onDidSelectSessionType(async sessionType => { + this._register(this._newChatInput.sessionTypePicker.onDidSelectSessionType(async sessionType => { await this._onWorkspaceSelected(this._workspacePicker.selectedProject, sessionType); - this._focusEditor(); - })); - - // Update send button and loading state when active session changes or loads - this._register(autorun(reader => { - const session = this.sessionsManagementService.activeSession.read(reader); - const isLoading = session?.loading.read(reader) ?? false; - session?.ready.read(reader); - this._loadingSpinner?.classList.toggle('visible', isLoading); - this._updateSendButtonState(); - })); - this._register(this._contextAttachments.onDidChangeContext(() => { - this._updateDraftState(); - this._focusEditor(); + this._newChatInput.focus(); })); } // --- Rendering --- - render(container: HTMLElement): void { - const wrapper = dom.append(container, dom.$('.sessions-chat-widget')); - - // Overflow widget DOM node at the top level so the suggest widget - // is not clipped by any overflow:hidden ancestor. - const editorOverflowWidgetsDomNode = dom.append(container, dom.$('.sessions-chat-editor-overflow.monaco-editor')); - this._register({ dispose: () => editorOverflowWidgetsDomNode.remove() }); - - const welcomeElement = dom.append(wrapper, dom.$('.chat-full-welcome')); - - // Main empty-state content area (folder picker, input, local mode controls) - const welcomeContent = dom.append(welcomeElement, dom.$('.chat-full-welcome-content')); - - // Option group pickers (above the input) - this._pickersContainer = dom.append(welcomeContent, dom.$('.chat-full-welcome-pickers-container')); + render(parent: HTMLElement): void { + const element = dom.append(parent, dom.$('.sessions-chat-widget')); + const chatWidgetContainer = dom.append(element, dom.$('.new-chat-widget-container')); + const chatWidgetContent = dom.append(chatWidgetContainer, dom.$('.new-chat-widget-content')); - // Input slot - this._inputSlot = dom.append(welcomeContent, dom.$('.chat-full-welcome-inputSlot')); + const workspacePickerContainer = dom.append(chatWidgetContent, dom.$('.new-session-workspace-picker-container')); + this._renderWorkspacePicker(workspacePickerContainer); - // Input area inside the input slot - const inputArea = dom.$('.sessions-chat-input-area'); - this._contextAttachments.registerDropTarget(wrapper); - this._contextAttachments.registerPasteHandler(inputArea); - - // Attachments row (pills only) inside input area, above editor - const attachRow = dom.append(inputArea, dom.$('.sessions-chat-attach-row')); - const attachedContextContainer = dom.append(attachRow, dom.$('.sessions-chat-attached-context')); - this._contextAttachments.renderAttachedContext(attachedContextContainer); - - this._createEditor(inputArea, editorOverflowWidgetsDomNode); - this._createBottomToolbar(inputArea); - this._inputSlot.appendChild(inputArea); - - // Below-input row: session type picker, permission control, spacer, repository config (right) - const belowInputRow = dom.append(welcomeContent, dom.$('.chat-full-welcome-local-mode')); - this._sessionTypePicker.render(belowInputRow); - const controlContainer = dom.append(belowInputRow, dom.$('.sessions-chat-control-toolbar')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, controlContainer, Menus.NewSessionControl, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - })); - dom.append(belowInputRow, dom.$('.sessions-chat-local-mode-spacer')); - const repoConfigContainer = dom.append(belowInputRow, dom.$('.sessions-chat-local-mode-right')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, repoConfigContainer, Menus.NewSessionRepositoryConfig, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - })); - - // Render project picker & extension pickers - this._renderOptionGroupPickers(); - - // Restore draft input state from storage - this._restoreState(); + this._newChatInput.render(chatWidgetContent, parent); // Create initial session — wait for providers if none registered yet const restoredProject = this._workspacePicker.selectedProject; if (restoredProject) { if (this.sessionsProvidersService.getProviders().length > 0) { - this._createNewSession(restoredProject, this._sessionTypePicker.selectedType); + this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); } else { // Providers not yet registered (startup race) — wait for first registration const sub = this.sessionsProvidersService.onDidChangeProviders(() => { sub.dispose(); - this._createNewSession(restoredProject, this._sessionTypePicker.selectedType); + this._createNewSession(restoredProject, this._newChatInput.sessionTypePicker.selectedType); }); this._register(sub); } } - // Reveal - welcomeElement.classList.add('revealed'); - - // Layout editor after the input slot fade-in animation completes - this._register(dom.addDisposableListener(this._inputSlot, 'animationend', () => { - this._editor?.layout(); - }, { once: true })); + chatWidgetContainer.classList.add('revealed'); } private _createNewSession(selection: IWorkspaceSelection, sessionTypeId: string | undefined): void { this.sessionsManagementService.createNewSession(selection.providerId, selection.workspace.repositories[0].uri, sessionTypeId); } - private _updateInputLoadingState(): void { - const loading = this._sending; - if (loading) { - if (!this._loadingDelayDisposable.value) { - const timer = setTimeout(() => { - this._loadingDelayDisposable.clear(); - if (this._sending) { - this._loadingSpinner?.classList.add('visible'); - } - }, 500); - this._loadingDelayDisposable.value = toDisposable(() => clearTimeout(timer)); - } - } else { - this._loadingDelayDisposable.clear(); - this._loadingSpinner?.classList.remove('visible'); - } - } - - // --- Editor --- - - private _getAriaLabel(): string { - const verbose = this.configurationService.getValue(AccessibilityVerbositySettingId.SessionsChat); - if (verbose) { - const kbLabel = this.keybindingService.lookupKeybinding(AccessibilityCommandId.OpenAccessibilityHelp)?.getLabel(); - return kbLabel - ? localize('chatInput.accessibilityHelp', "Chat input. Press Enter to send out the request. Use {0} for Chat Accessibility Help.", kbLabel) - : localize('chatInput.accessibilityHelpNoKb', "Chat input. Press Enter to send out the request. Use the Chat Accessibility Help command for more information."); - } - return localize('chatInput', "Chat input"); - } - - private _createEditor(container: HTMLElement, overflowWidgetsDomNode: HTMLElement): void { - const editorContainer = this._editorContainer = dom.append(container, dom.$('.sessions-chat-editor')); - editorContainer.style.height = `${MIN_EDITOR_HEIGHT}px`; - - // Create scoped context key service and register history navigation - // BEFORE creating the editor, so the editor's context key scope is a child - const inputScopedContextKeyService = this._register(this.contextKeyService.createScoped(container)); - const { historyNavigationBackwardsEnablement, historyNavigationForwardsEnablement } = this._register(registerAndCreateHistoryNavigationContext(inputScopedContextKeyService, this)); - this._historyNavigationBackwardsEnablement = historyNavigationBackwardsEnablement; - this._historyNavigationForwardsEnablement = historyNavigationForwardsEnablement; - - const scopedInstantiationService = this._register(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, inputScopedContextKeyService]))); - - const uri = URI.from({ scheme: 'sessions-chat', path: `input-${Date.now()}` }); - const textModel = this._register(this.modelService.createModel('', null, uri, true)); - - const editorOptions: IEditorConstructionOptions = { - ...getSimpleEditorOptions(this.configurationService), - readOnly: false, - ariaLabel: this._getAriaLabel(), - placeholder: localize('chatPlaceholder', "Run tasks in the background, type '#' for adding context"), - fontFamily: 'system-ui, -apple-system, sans-serif', - fontSize: 13, - lineHeight: 20, - cursorWidth: 1, - padding: { top: 8, bottom: 2 }, - wrappingStrategy: 'advanced', - stickyScroll: { enabled: false }, - renderWhitespace: 'none', - overflowWidgetsDomNode, - suggest: { - showIcons: true, - showSnippets: false, - showWords: true, - showStatusBar: false, - insertMode: 'insert', - }, - }; - - const widgetOptions: ICodeEditorWidgetOptions = { - isSimpleWidget: true, - contributions: EditorExtensionsRegistry.getSomeEditorContributions([ - ContextMenuController.ID, - SuggestController.ID, - SnippetController2.ID, - ]), - }; - - this._editor = this._register(scopedInstantiationService.createInstance( - CodeEditorWidget, editorContainer, editorOptions, widgetOptions, - )); - this._editor.setModel(textModel); - - // Ensure suggest widget renders above the input (not clipped by container) - SuggestController.get(this._editor)?.forceRenderingAbove(); - - // Update aria label when accessibility verbosity setting changes - this._register(this.configurationService.onDidChangeConfiguration(e => { - if (e.affectsConfiguration(AccessibilityVerbositySettingId.SessionsChat)) { - this._editor.updateOptions({ ariaLabel: this._getAriaLabel() }); - } - })); - - this._register(this._editor.onDidFocusEditorWidget(() => this._onDidFocus.fire())); - this._register(this._editor.onDidBlurEditorWidget(() => this._onDidBlur.fire())); - - this._register(this._editor.onKeyDown(e => { - if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && !e.altKey) { - // Don't send if the suggest widget is visible (let it accept the completion) - if (this._editor.contextKeyService.getContextKeyValue('suggestWidgetVisible')) { - return; - } - e.preventDefault(); - e.stopPropagation(); - this._send(); - } - if (e.keyCode === KeyCode.Enter && !e.shiftKey && !e.ctrlKey && e.altKey) { - e.preventDefault(); - e.stopPropagation(); - this._send(); - } - // Cmd+/ / Ctrl+/ — open the context picker (same as the attach button) - if (e.equals(KeyMod.CtrlCmd | KeyCode.Slash)) { - e.preventDefault(); - e.stopPropagation(); - this._contextAttachments.showPicker(this._getContextFolderUri()); - } - })); - - // Update history navigation enablement based on cursor position - const updateHistoryNavigationEnablement = () => { - const model = this._editor.getModel(); - const position = this._editor.getPosition(); - if (!model || !position) { - return; - } - this._historyNavigationBackwardsEnablement.set(position.lineNumber === 1 && position.column === 1); - this._historyNavigationForwardsEnablement.set(position.lineNumber === model.getLineCount() && position.column === model.getLineMaxColumn(position.lineNumber)); - }; - this._register(this._editor.onDidChangeCursorPosition(() => updateHistoryNavigationEnablement())); - updateHistoryNavigationEnablement(); - - let previousHeight = -1; - this._register(this._editor.onDidContentSizeChange(e => { - if (!e.contentHeightChanged) { - return; - } - const contentHeight = this._editor.getContentHeight(); - const clampedHeight = Math.min(MAX_EDITOR_HEIGHT, Math.max(MIN_EDITOR_HEIGHT, contentHeight)); - if (clampedHeight === previousHeight) { - return; - } - previousHeight = clampedHeight; - this._editorContainer.style.height = `${clampedHeight}px`; - this._editor.layout(); - })); - - // Slash commands - this._slashCommandHandler = this._register(this.instantiationService.createInstance(SlashCommandHandler, this._editor)); - - // Variable completions (#file, #folder) - this._register(this.instantiationService.createInstance( - VariableCompletionHandler, this._editor, this._contextAttachments, () => this._getContextFolderUri(), - )); - - this._register(this._editor.onDidChangeModelContent(() => { - this._updateDraftState(); - this._updateSendButtonState(); - })); - } - - private _focusEditor(): void { - this._editor?.focus(); - } - - private _createAttachButton(container: HTMLElement): void { - const attachButton = dom.append(container, dom.$('.sessions-chat-attach-button')); - const attachButtonLabel = localize('addContext', "Add Context..."); - attachButton.tabIndex = 0; - attachButton.role = 'button'; - attachButton.ariaLabel = attachButtonLabel; - this._register(this.hoverService.setupDelayedHover(attachButton, { - content: attachButtonLabel, - position: { hoverPosition: HoverPosition.BELOW }, - appearance: { showPointer: true } - })); - dom.append(attachButton, renderIcon(Codicon.add)); - this._register(dom.addDisposableListener(attachButton, dom.EventType.CLICK, () => { - this._contextAttachments.showPicker(this._getContextFolderUri()); - })); - } - /** * Returns the workspace URI for the context picker based on the current workspace selection. */ @@ -440,197 +115,33 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { return this._workspacePicker.selectedProject?.workspace.repositories[0]?.uri; } - private _createBottomToolbar(container: HTMLElement): void { - const toolbar = dom.append(container, dom.$('.sessions-chat-toolbar')); - - this._createAttachButton(toolbar); - - // Session config pickers (mode, model) — rendered via MenuWorkbenchToolBar - // Visibility controlled by context keys (isActiveSessionBackgroundProvider, isNewChatSession) - const configContainer = dom.append(toolbar, dom.$('.sessions-chat-config-toolbar')); - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, configContainer, Menus.NewSessionConfig, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - })); - - dom.append(toolbar, dom.$('.sessions-chat-toolbar-spacer')); - - this._loadingSpinner = dom.append(toolbar, dom.$('.sessions-chat-loading-spinner')); - this._register(this.hoverService.setupManagedHover(getDefaultHoverDelegate('mouse'), this._loadingSpinner, localize('loading', "Loading..."))); - - const sendButtonContainer = dom.append(toolbar, dom.$('.sessions-chat-send-button')); - const sendButton = this._sendButton = this._register(new Button(sendButtonContainer, { - secondary: true, - title: localize('send', "Send"), - ariaLabel: localize('send', "Send"), - })); - sendButton.icon = Codicon.send; - this._register(sendButton.onDidClick(() => this._send())); - this._updateSendButtonState(); - } - - // --- Welcome: Target & option pickers (dropdown row below input) --- - - private _renderOptionGroupPickers(): void { - if (!this._pickersContainer) { - return; - } - - dom.clearNode(this._pickersContainer); - - const pickersRow = dom.append(this._pickersContainer, dom.$('.chat-full-welcome-pickers')); - const pickersLabel = dom.append(pickersRow, dom.$('.chat-full-welcome-pickers-label')); + private _renderWorkspacePicker(container: HTMLElement): IDisposable { + const pickersRow = dom.append(container, dom.$('.session-workspace-picker')); + const pickersLabel = dom.append(pickersRow, dom.$('.session-workspace-picker-label')); pickersLabel.textContent = this._workspacePicker.selectedProject ? localize('newSessionIn', "New session in") : localize('newSessionChooseWorkspace', "Start by picking a"); - // Project picker (unified folder + repo picker) this._workspacePicker.render(pickersRow); - } - - // --- Input History (IHistoryNavigationWidget) --- - - showPreviousValue(): void { - if (this._history.isAtStart()) { - return; - } - if (this._draftState?.inputText || this._draftState?.attachments.length) { - this._history.overlay(this._toHistoryEntry(this._draftState)); - } - this._navigateHistory(true); - } - - showNextValue(): void { - if (this._history.isAtEnd()) { - return; - } - if (this._draftState?.inputText || this._draftState?.attachments.length) { - this._history.overlay(this._toHistoryEntry(this._draftState)); - } - this._navigateHistory(false); - } - - private _updateDraftState(): void { - this._draftState = { - inputText: this._editor?.getModel()?.getValue() ?? '', - attachments: [...this._contextAttachments.attachments], - }; - } - - private _toHistoryEntry(draft: IDraftState): IChatModelInputState { - return { - ...draft, - mode: { id: ChatModeKind.Agent, kind: ChatModeKind.Agent }, - selectedModel: undefined, - selections: [], - contrib: {}, - }; - } - - private _navigateHistory(previous: boolean): void { - const entry = previous ? this._history.previous() : this._history.next(); - const inputText = entry?.inputText ?? ''; - if (entry) { - this._editor?.getModel()?.setValue(inputText); - this._contextAttachments.setAttachments(entry.attachments); - } - aria.status(inputText); - if (previous) { - this._editor.setPosition({ lineNumber: 1, column: 1 }); - } else { - const model = this._editor.getModel(); - if (model) { - const lastLine = model.getLineCount(); - this._editor.setPosition({ lineNumber: lastLine, column: model.getLineMaxColumn(lastLine) }); - } - } + return this._workspacePicker.onDidSelectWorkspace(() => { + const workspace = this._workspacePicker.selectedProject; + pickersLabel.textContent = workspace ? localize('newSessionIn', "New session in") : localize('newSessionChooseWorkspace', "Start by picking a"); + }); } // --- Send --- - private _updateSendButtonState(): void { - if (!this._sendButton) { - return; - } - const hasText = !!this._editor?.getModel()?.getValue().trim(); + private async _send(query: string, attachedContext?: IChatRequestVariableEntry[]): Promise { const session = this.sessionsManagementService.activeSession.get(); - const hasActiveSession = !!session; - const isLoading = session?.loading.get() ?? false; - const isReady = session?.ready.get() ?? false; - this._sendButton.enabled = !this._sending && hasText && hasActiveSession && !isLoading && isReady; - } - - private async _send(): Promise { - let query = this._editor.getModel()?.getValue().trim(); - if (!query || this._sending) { - return; - } - - // If no workspace is selected, open the picker - if (!this._hasRequiredRepoOrFolderSelection()) { - this._openRepoOrFolderPicker(); - return; - } - - const activeSession = this.sessionsManagementService.activeSession.get(); - if (!activeSession || !activeSession.ready.get()) { - return; - } - - // Check for slash commands first - if (this._slashCommandHandler?.tryExecuteSlashCommand(query)) { - this._editor.getModel()?.setValue(''); + if (!session) { + this._workspacePicker.showPicker(); return; } - - // Expand prompt/skill slash commands into a CLI-friendly reference - const expanded = this._slashCommandHandler?.tryExpandPromptSlashCommand(query); - if (expanded) { - query = expanded; - } - - const attachedContext = this._contextAttachments.attachments.length > 0 - ? [...this._contextAttachments.attachments] - : undefined; - - if (this._draftState) { - this._history.append(this._toHistoryEntry(this._draftState)); - } - this._clearDraftState(); - - this._sending = true; - this._editor.updateOptions({ readOnly: true }); - this._updateSendButtonState(); - this._updateInputLoadingState(); - try { - const session = this.sessionsManagementService.activeSession.get(); - if (!session) { - return; - } await this.sessionsManagementService.sendAndCreateChat(session, { query, attachedContext }); - this._contextAttachments.clear(); - this._editor.getModel()?.setValue(''); } catch (e) { this.logService.error('Failed to send request:', e); } - - this._sending = false; - this._editor.updateOptions({ readOnly: false }); - this._updateSendButtonState(); - this._updateInputLoadingState(); - } - - /** - * Checks whether the required folder/repo selection exists for the given session type. - * For Local/Background targets, checks the folder picker. - * For other targets, checks extension-contributed repo/folder option groups. - */ - private _hasRequiredRepoOrFolderSelection(): boolean { - return !!this._workspacePicker.selectedProject; - } - - private _openRepoOrFolderPicker(): void { - this._workspacePicker.showPicker(); } private async _requestFolderTrust(folderUri: URI): Promise { @@ -644,50 +155,16 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { return !!trusted; } - - private _restoreState(): void { - const draft = this._getDraftState(); - if (draft) { - this._editor?.getModel()?.setValue(draft.inputText); - if (draft.attachments?.length) { - this._contextAttachments.setAttachments(draft.attachments.map(IChatRequestVariableEntry.fromExport)); - } - } - } - - private _getDraftState(): IDraftState | undefined { - const raw = this.storageService.get(STORAGE_KEY_DRAFT_STATE, StorageScope.WORKSPACE); - if (!raw) { - return undefined; - } - try { - return JSON.parse(raw); - } catch { - return undefined; - } - } - - private _clearDraftState(): void { - this._draftState = { inputText: '', attachments: [] }; - this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(this._draftState), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } - saveState(): void { - if (this._draftState) { - const state = { - ...this._draftState, - attachments: this._draftState.attachments.map(IChatRequestVariableEntry.toExport), - }; - this.storageService.store(STORAGE_KEY_DRAFT_STATE, JSON.stringify(state), StorageScope.WORKSPACE, StorageTarget.MACHINE); - } + this._newChatInput.saveState(); } layout(_height: number, _width: number): void { - this._editor?.layout(); + this._newChatInput.layout(_height, _width); } focusInput(): void { - this._editor?.focus(); + this._newChatInput.focus(); } /** @@ -711,23 +188,11 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { } prefillInput(text: string): void { - const editor = this._editor; - const model = editor?.getModel(); - if (editor && model) { - model.setValue(text); - const lastLine = model.getLineCount(); - const maxColumn = model.getLineMaxColumn(lastLine); - editor.setPosition({ lineNumber: lastLine, column: maxColumn }); - editor.focus(); - } + this._newChatInput.prefillInput(text); } sendQuery(text: string): void { - const model = this._editor?.getModel(); - if (model) { - model.setValue(text); - this._send(); - } + this._newChatInput.sendQuery(text); } selectWorkspace(workspace: IWorkspaceSelection): void { @@ -741,9 +206,6 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { export const SessionsViewId = 'workbench.view.sessions.chat'; -/** - * A view pane that hosts the new-session welcome widget. - */ export class NewChatViewPane extends ViewPane { private _widget: NewChatWidget | undefined; diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index 0b6f9e77b9040..5d05396bfb118 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -11,6 +11,7 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { URI, UriComponents } from '../../../../base/common/uri.js'; import { Schemas } from '../../../../base/common/network.js'; +import { isNative } from '../../../../base/common/platform.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; @@ -65,8 +66,8 @@ interface IWorkspacePickerItem { readonly checked?: boolean; /** Remote provider reference for gear menu actions. */ readonly remoteProvider?: IAgentHostSessionsProvider; - /** When true, clicking this item triggers the tunnel connection command. */ - readonly tunnelAction?: boolean; + /** Command to execute when this item is selected. */ + readonly commandId?: string; } /** @@ -189,8 +190,8 @@ export class WorkspacePicker extends Disposable { const delegate: IActionListDelegate = { onSelect: (item) => { this.actionWidgetService.hide(); - if (item.tunnelAction) { - this.commandService.executeCommand('workbench.action.sessions.connectViaTunnel'); + if (item.commandId) { + this.commandService.executeCommand(item.commandId); } else if (item.selection && this._isProviderUnavailable(item.selection.providerId)) { // Workspace belongs to an unavailable remote — ignore selection return; @@ -469,7 +470,7 @@ export class WorkspacePicker extends Disposable { }); } - // "Tunnels..." entry — shown when remote agent hosts are enabled + // "Tunnels..." and "SSH..." entries — shown when remote agent hosts are enabled if (this.configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { if (items.length > 0 && items[items.length - 1].kind !== ActionListItemKind.Separator) { items.push({ kind: ActionListItemKind.Separator, label: '' }); @@ -478,8 +479,16 @@ export class WorkspacePicker extends Disposable { kind: ActionListItemKind.Action, label: localize('workspacePicker.tunnels', "Tunnels..."), group: { title: '', icon: Codicon.cloud }, - item: { tunnelAction: true }, + item: { commandId: 'workbench.action.sessions.connectViaTunnel' }, }); + if (isNative) { + items.push({ + kind: ActionListItemKind.Action, + label: localize('workspacePicker.ssh', "SSH..."), + group: { title: '', icon: Codicon.remote }, + item: { commandId: 'workbench.action.sessions.connectViaSSH' }, + }); + } } return items; diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 1528a47cdd417..77ee9d463573d 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -60,6 +60,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'workbench.editor.doubleClickTabToToggleEditorGroupSizes': 'maximize', 'workbench.editor.restoreEditors': false, 'update.showReleaseNotes': false, + 'workbench.notifications.position': 'top-right', 'workbench.startupEditor': 'none', 'workbench.tips.enabled': false, 'workbench.layoutControl.type': 'toggles', diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 2bfc0053dfec5..e1a6fb35289e8 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -32,7 +32,7 @@ import { IsolationPicker } from './isolationPicker.js'; import { BranchPicker } from './branchPicker.js'; import { ModePicker } from './modePicker.js'; import { CloudModelPicker } from './modelPicker.js'; -import { NewChatPermissionPicker } from '../../chat/browser/newChatPermissionPicker.js'; +import { PermissionPicker } from './permissionPicker.js'; const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLI_SESSION_TYPE); const IsActiveSessionCopilotCloud = ContextKeyExpr.equals(ActiveSessionTypeContext.key, COPILOT_CLOUD_SESSION_TYPE); @@ -276,7 +276,7 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor this._register(actionViewItemService.register( Menus.NewSessionControl, 'sessions.defaultCopilot.permissionPicker', () => { - const picker = instantiationService.createInstance(NewChatPermissionPicker); + const picker = instantiationService.createInstance(PermissionPicker); return new PickerActionViewItem(picker); }, )); diff --git a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts similarity index 98% rename from src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts rename to src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts index 63d38b7637f79..5a079f79b434c 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatPermissionPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/permissionPicker.ts @@ -33,11 +33,7 @@ interface IPermissionItem { readonly checked: boolean; } -/** - * A permission picker for the new-session welcome view. - * Shows Default Approvals, Bypass Approvals, and Autopilot options. - */ -export class NewChatPermissionPicker extends Disposable { +export class PermissionPicker extends Disposable { private readonly _onDidChangeLevel = this._register(new Emitter()); readonly onDidChangeLevel: Event = this._onDidChangeLevel.event; diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts index f651084001751..10bed14b9f989 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostCustomizationHarness.ts @@ -12,7 +12,7 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { AICustomizationManagementSection, type IStorageSourceFilter } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { PromptsStorage } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; -import { type IHarnessDescriptor, type IExternalCustomizationItem, type IExternalCustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; +import { type IHarnessDescriptor, type ICustomizationItem, type ICustomizationItemProvider } from '../../../../workbench/contrib/chat/common/customizationHarnessService.js'; import type { IAgentConnection } from '../../../../platform/agentHost/common/agentService.js'; import { ActionType } from '../../../../platform/agentHost/common/state/sessionActions.js'; import { type IAgentInfo, type ICustomizationRef, type ISessionCustomization, CustomizationStatus } from '../../../../platform/agentHost/common/state/sessionState.js'; @@ -23,7 +23,7 @@ export { AgentCustomizationSyncProvider as RemoteAgentSyncProvider } from '../.. /** * Maps a {@link CustomizationStatus} enum value to the string literal - * expected by {@link IExternalCustomizationItem.status}. + * expected by {@link ICustomizationItem.status}. */ function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'loaded' | 'degraded' | 'error' | undefined { switch (status) { @@ -37,14 +37,14 @@ function toStatusString(status: CustomizationStatus | undefined): 'loading' | 'l /** * Provider that exposes a remote agent's customizations as - * {@link IExternalCustomizationItem} entries for the list widget. + * {@link ICustomizationItem} entries for the list widget. * * Baseline items come from {@link IAgentInfo.customizations} (available * without an active session). When a session is active, the provider * overlays {@link ISessionCustomization} data, which includes loading * status and enabled state. */ -export class RemoteAgentCustomizationItemProvider extends Disposable implements IExternalCustomizationItemProvider { +export class RemoteAgentCustomizationItemProvider extends Disposable implements ICustomizationItemProvider { private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; @@ -79,7 +79,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements this._onDidChange.fire(); } - async provideChatSessionCustomizations(_token: CancellationToken): Promise { + async provideChatSessionCustomizations(_token: CancellationToken): Promise { // When a session is active, prefer session-level data (includes status) if (this._sessionCustomizations) { return this._sessionCustomizations.map(sc => ({ @@ -108,7 +108,7 @@ export class RemoteAgentCustomizationItemProvider extends Disposable implements * the agent host protocol. * * The descriptor exposes the agent's server-provided customizations through - * an {@link IExternalCustomizationItemProvider} and allows the user to + * an {@link ICustomizationItemProvider} and allows the user to * select local customizations for syncing via an {@link ICustomizationSyncProvider}. */ export function createRemoteAgentHarnessDescriptor( diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a2f6359afeca7..95656a46876d1 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -46,7 +46,7 @@ import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; import { ExtHostChatAgentsShape2, ExtHostContext, IChatAgentInvokeResult, IChatSessionCustomizationItemDto, IChatSessionCustomizationProviderMetadataDto, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IHookDto, IInstructionDto, IPluginDto, ISkillDto, ISlashCommandDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; 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 { ICustomizationHarnessService, ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IAgentPluginService } from '../../contrib/chat/common/plugins/agentPluginService.js'; import { IWorkbenchEnvironmentService } from '../../services/environment/common/environmentService.js'; @@ -762,14 +762,14 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._customizationProviderEmitters.set(handle, emitter); // Build the item provider that calls back to the ExtHost - const itemProvider: IExternalCustomizationItemProvider = { + const itemProvider: ICustomizationItemProvider = { onDidChange: emitter.event, provideChatSessionCustomizations: async (token) => { const items = await this._proxy.$provideChatSessionCustomizations(handle, token); if (!items) { return undefined; } - return items.map((item: IChatSessionCustomizationItemDto): IExternalCustomizationItem => ({ + return items.map((item: IChatSessionCustomizationItemDto): ICustomizationItem => ({ uri: URI.revive(item.uri), type: item.type, name: item.name, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 0a3a9bc976e21..5b39d22ee90f5 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -2132,7 +2132,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I InteractiveSessionVoteDirection: extHostTypes.InteractiveSessionVoteDirection, ChatCopyKind: extHostTypes.ChatCopyKind, ChatSessionChangedFile: extHostTypes.ChatSessionChangedFile, - ChatSessionChangedFile2: extHostTypes.ChatSessionChangedFile2, ChatEditingSessionActionOutcome: extHostTypes.ChatEditingSessionActionOutcome, InteractiveEditorResponseFeedbackKind: extHostTypes.InteractiveEditorResponseFeedbackKind, DebugStackFrame: extHostTypes.DebugStackFrame, diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 94f2a91c1b563..4ae3d4222bbb4 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -44,6 +44,8 @@ class ChatSessionInputStateImpl implements vscode.ChatSessionInputState { readonly #onDidChangeEmitter = new Emitter(); readonly onDidChange = this.#onDidChangeEmitter.event; + sessionResource: vscode.Uri | undefined; + constructor(groups: readonly vscode.ChatSessionProviderOptionGroup[], onChangedDelegate?: () => void) { this.#groups = groups; this.#onChangedDelegate = onChangedDelegate; @@ -605,6 +607,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio controllerData?.optionGroups ?? [], context.initialSessionOptions ); + if (inputState instanceof ChatSessionInputStateImpl) { + inputState.sessionResource = isUntitledChatSession(sessionResource) ? undefined : sessionResource; + } + const session = await provider.provider.provideChatSessionContent(sessionResource, token, { sessionOptions: context?.initialSessionOptions ?? [], inputState, @@ -862,17 +868,23 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio ): Promise { const scheme = sessionResource?.scheme; const controllerData = scheme ? this.getChatSessionItemController(scheme) : undefined; + const resolvedResource = sessionResource && !isUntitledChatSession(sessionResource) ? sessionResource : undefined; if (controllerData?.controller.getChatSessionInputState) { const result = await controllerData.controller.getChatSessionInputState( - sessionResource && !isUntitledChatSession(sessionResource) ? sessionResource : undefined, + resolvedResource, { previousInputState: this._createInputStateFromOptions(controllerData.optionGroups ?? [], initialSessionOptions) }, token, ); if (result) { + if (result instanceof ChatSessionInputStateImpl) { + result.sessionResource = resolvedResource; + } return result; } } - return this._createInputStateFromOptions(controllerData?.optionGroups ?? [], initialSessionOptions); + const fallback = this._createInputStateFromOptions(controllerData?.optionGroups ?? [], initialSessionOptions); + fallback.sessionResource = resolvedResource; + return fallback; } /** @@ -1039,11 +1051,12 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return undefined; } + const previousInputState = this._createInputStateFromOptions(controllerData.optionGroups ?? [], request.initialSessionOptions); let inputState: vscode.ChatSessionInputState; if (controllerData.controller.getChatSessionInputState) { - inputState = await controllerData.controller.getChatSessionInputState(undefined, { previousInputState: this._createInputStateFromOptions(controllerData.optionGroups ?? [], request.initialSessionOptions) }, token); + inputState = await controllerData.controller.getChatSessionInputState(undefined, { previousInputState }, token); } else { - inputState = new ChatSessionInputStateImpl([]); + inputState = previousInputState; } const item = await handler({ @@ -1099,6 +1112,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return undefined; } + if (inputState instanceof ChatSessionInputStateImpl) { + inputState.sessionResource = sessionResource; + } + // Store the option groups for onSearch callbacks controllerData.optionGroups = inputState.groups; diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 347d80f9be090..8f2008735ebbe 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3791,10 +3791,6 @@ export class ChatDebugEventHookContent { } export class ChatSessionChangedFile { - constructor(public readonly modifiedUri: vscode.Uri, public readonly insertions: number, public readonly deletions: number, public readonly originalUri?: vscode.Uri) { } -} - -export class ChatSessionChangedFile2 { constructor(public readonly uri: vscode.Uri, public readonly originalUri: vscode.Uri | undefined, public readonly modifiedUri: vscode.Uri | undefined, public readonly insertions: number, public readonly deletions: number) { } } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index 0ebc6817f71fc..a2910a1a9159d 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -3,19 +3,144 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { status } from '../../../../../base/browser/ui/aria/aria.js'; import * as dom from '../../../../../base/browser/dom.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { IActionRunner } from '../../../../../base/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; +import { Disposable, markAsSingleton, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; -import { localize2 } from '../../../../../nls.js'; -import { Action2, MenuId, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { MenuEntryActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, MenuId, MenuItemAction, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { katexContainerClassName, katexContainerLatexAttributeName } from '../../../markdown/common/markedKatexExtension.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatRequestViewModel, IChatResponseViewModel, isChatTreeItem, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatTreeItem, IChatWidgetService } from '../chat.js'; import { CHAT_CATEGORY, stringifyItem } from './chatActions.js'; +const CopyItemActionId = 'workbench.action.chat.copyItem'; +const copyFeedbackDuration = 1200; +const copyIconClasses = ThemeIcon.asClassNameArray(Codicon.copy); +const copiedIconClasses = ThemeIcon.asClassNameArray(Codicon.check); + +class ChatCopyActionViewItem extends MenuEntryActionViewItem { + + private readonly copiedStateReset = this._register(new MutableDisposable()); + private readonly actionRunnerListener = this._register(new MutableDisposable()); + private copied = false; + + override get actionRunner(): IActionRunner { + return super.actionRunner; + } + + override set actionRunner(actionRunner: IActionRunner) { + super.actionRunner = actionRunner; + this.bindActionRunner(actionRunner); + } + + override render(container: HTMLElement): void { + super.render(container); + this.bindActionRunner(super.actionRunner); + + if (!this.element || !this.label) { + return; + } + + this.element.classList.add('chat-copy-action'); + this.clearLabelIconClasses(); + this.label.style.backgroundImage = ''; + this.label.classList.remove('icon'); + this.label.textContent = ''; + this.label.setAttribute('aria-hidden', 'true'); + + const iconContainer = dom.append(this.label, dom.$('.chat-copy-action-icons')); + const copyIcon = dom.append(iconContainer, dom.$('.chat-copy-action-icon.chat-copy-action-icon-copy')); + copyIcon.classList.add(...copyIconClasses); + copyIcon.setAttribute('aria-hidden', 'true'); + + const copiedIcon = dom.append(iconContainer, dom.$('.chat-copy-action-icon.chat-copy-action-icon-copied')); + copiedIcon.classList.add(...copiedIconClasses); + copiedIcon.setAttribute('aria-hidden', 'true'); + + this.renderCopiedState(); + } + + protected override getTooltip(): string { + return this.copied + ? localize('interactive.copyItem.copied', "Copied") + : super.getTooltip(); + } + + protected override updateAriaLabel(): void { + this.element?.setAttribute('aria-label', this.copied + ? localize('interactive.copyItem.copiedAriaLabel', "Copied") + : localize('interactive.copyItem.ariaLabel', "Copy")); + } + + protected override updateClass(): void { + super.updateClass(); + this.clearLabelIconClasses(); + if (this.label) { + this.label.style.backgroundImage = ''; + this.label.classList.remove('icon'); + } + } + + private clearLabelIconClasses(): void { + this.label?.classList.remove(...copyIconClasses, ...copiedIconClasses); + } + + private renderCopiedState(): void { + this.element?.classList.toggle('copied', this.copied); + this.updateTooltip(); + } + + private bindActionRunner(actionRunner: IActionRunner): void { + this.actionRunnerListener.value = actionRunner.onDidRun(e => { + if (e.action !== this.action || e.error) { + return; + } + + this.copied = true; + this.renderCopiedState(); + this.copiedStateReset.value = disposableTimeout(() => { + this.copied = false; + this.renderCopiedState(); + }, copyFeedbackDuration); + status(localize('interactive.copyItem.status', "Copied to clipboard")); + }); + } +} + +export class ChatCopyActionRendering extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.copyActionRendering'; + + constructor( + @IActionViewItemService actionViewItemService: IActionViewItemService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const disposable = this._register(actionViewItemService.register(MenuId.ChatMessageFooter, CopyItemActionId, (action, options) => { + if (!(action instanceof MenuItemAction)) { + return undefined; + } + + return instantiationService.createInstance(ChatCopyActionViewItem, action, options); + })); + + markAsSingleton(disposable); + } +} + export function registerChatCopyActions() { registerAction2(class CopyAllAction extends Action2 { constructor() { @@ -52,7 +177,7 @@ export function registerChatCopyActions() { registerAction2(class CopyItemAction extends Action2 { constructor() { super({ - id: 'workbench.action.chat.copyItem', + id: CopyItemActionId, title: localize2('interactive.copyItem.label', "Copy"), f1: false, category: CHAT_CATEGORY, 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 236840ad85a08..819357ecd58f0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -147,14 +147,12 @@ class AgentHostChatSession extends Disposable implements IChatSession { private readonly _onDidStartServerRequest = this._register(new Emitter<{ prompt: string }>()); readonly onDidStartServerRequest = this._onDidStartServerRequest.event; - readonly requestHandler: IChatSession['requestHandler']; interruptActiveResponseCallback: IChatSession['interruptActiveResponseCallback']; readonly forkSession: IChatSession['forkSession']; constructor( readonly sessionResource: URI, readonly history: readonly IChatSessionHistoryItem[], - private readonly _sendRequest: (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => Promise, private readonly _forkSession: ((request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => Promise), initialProgress: IChatProgress[] | undefined, onDispose: () => void, @@ -171,13 +169,6 @@ class AgentHostChatSession extends Disposable implements IChatSession { this._register(toDisposable(() => this._onWillDispose.fire())); this._register(toDisposable(onDispose)); - this.requestHandler = async (request, progress, _history, cancellationToken) => { - this._logService.info('[AgentHost] requestHandler called'); - this.isCompleteObs.set(false, undefined); - await this._sendRequest(request, progress, cancellationToken); - this.isCompleteObs.set(true, undefined); - }; - // Provide interrupt callback when reconnecting to an active turn or // when this is a brand-new session (no history yet). this.interruptActiveResponseCallback = (hasActiveTurn || history.length === 0) ? async () => { @@ -475,20 +466,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC AgentHostChatSession, sessionResource, history, - async (request: IChatAgentRequest, progress: (parts: IChatProgress[]) => void, token: CancellationToken) => { - // todo@connor4312, I think IChatSession.requestHandler is actually - // dead code and I don't believe this is ever called. - const backendSession = resolvedSession ?? await this._createAndSubscribe(sessionResource, request.userSelectedModelId, undefined, request.agentHostSessionConfig); - if (!resolvedSession) { - resolvedSession = backendSession; - this._sessionToBackend.set(sessionResource, backendSession); - } - // For existing sessions, set up pending message sync on the first turn - // (after the ChatModel becomes available in the ChatService). - this._ensurePendingMessageSubscription(sessionResource, backendSession); - return this._handleTurn(backendSession, request, progress, token); - }, - (request, token) => { + (request: IChatSessionRequestHistoryItem | undefined, token: CancellationToken) => { resolvedSession ??= this._sessionToBackend.get(sessionResource); if (!resolvedSession) { throw new BugIndicatingError('Cannot fork session before the initial request'); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index d255da3cfcd90..cab4800610779 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, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; +import { ICustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; /** * Maps section ID to prompt type. Duplicated from aiCustomizationListWidget @@ -100,7 +100,7 @@ export async function generateCustomizationDebugReport( return lines.join('\n'); } -async function appendExternalProviderData(lines: string[], provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { +async function appendExternalProviderData(lines: string[], provider: ICustomizationItemProvider, promptType: PromptsType): Promise { lines.push('--- External Provider Data ---'); const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts index 2eb829a3e0364..a3d71e823171c 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationIcons.ts @@ -4,8 +4,10 @@ *--------------------------------------------------------------------------------------------*/ import { Codicon } from '../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; import { localize } from '../../../../../nls.js'; import { registerIcon } from '../../../../../platform/theme/common/iconRegistry.js'; +import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; /** * Icon for the AI Customization view container (sidebar). @@ -76,3 +78,16 @@ export const builtinIcon = registerIcon('ai-customization-builtin', Codicon.star * Icon for MCP servers. */ export const mcpServerIcon = registerIcon('ai-customization-mcp-server', Codicon.server, localize('aiCustomizationMcpServerIcon', "Icon for MCP servers.")); + +/** + * Returns the icon for a given storage type. + */ +export function storageToIcon(storage: PromptsStorage): ThemeIcon { + switch (storage) { + case PromptsStorage.local: return workspaceIcon; + case PromptsStorage.user: return userIcon; + case PromptsStorage.extension: return extensionIcon; + case PromptsStorage.plugin: return pluginIcon; + default: return instructionsIcon; + } +} diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts new file mode 100644 index 0000000000000..9035727f7d69e --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationItemSource.ts @@ -0,0 +1,402 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { IMatch } from '../../../../../base/common/filters.js'; +import { parse as parseJSONC } from '../../../../../base/common/json.js'; +import { ResourceMap } from '../../../../../base/common/map.js'; +import { Schemas } from '../../../../../base/common/network.js'; +import { OS } from '../../../../../base/common/platform.js'; +import { basename, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize } from '../../../../../nls.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; +import { ICustomizationSyncProvider, ICustomizationItem, ICustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { IAgentPluginService } from '../../common/plugins/agentPluginService.js'; +import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { storageToIcon } from './aiCustomizationIcons.js'; +import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { extractExtensionIdFromPath } from './aiCustomizationListWidgetUtils.js'; + +// #region Interfaces + +/** + * Represents an AI customization item in the list widget. + */ +export interface IAICustomizationListItem { + readonly id: string; + readonly uri: URI; + readonly name: string; + readonly filename: string; + readonly description?: string; + /** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */ + readonly storage?: PromptsStorage; + readonly promptType: PromptsType; + readonly disabled: boolean; + /** When set, overrides `storage` for display grouping purposes. */ + readonly groupKey?: string; + /** URI of the parent plugin, when this item comes from an installed plugin. */ + readonly pluginUri?: URI; + /** When set, overrides the formatted name for display. */ + readonly displayName?: string; + /** When set, shows a small inline badge next to the item name. */ + readonly badge?: string; + /** Tooltip shown when hovering the badge. */ + readonly badgeTooltip?: string; + /** When set, overrides the default prompt-type icon. */ + readonly typeIcon?: ThemeIcon; + /** True when item comes from the default chat extension (grouped under Built-in). */ + readonly isBuiltin?: boolean; + /** Display name of the contributing extension (for non-built-in extension items). */ + readonly extensionLabel?: string; + /** Server-reported loading/sync status for remote customizations. */ + readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; + /** Human-readable status detail (e.g. error message or warning). */ + readonly statusMessage?: string; + /** When true, this item can be selected for syncing to a remote harness. */ + readonly syncable?: boolean; + /** When true, this syncable item is currently selected for syncing. */ + readonly synced?: boolean; + nameMatches?: IMatch[]; + descriptionMatches?: IMatch[]; +} + +/** + * Browser-internal item source consumed by the list widget. + * + * Item sources fetch provider-shaped customization rows, normalize them into + * the browser-only list item shape, and add view-only overlays such as sync state. + */ +export interface IAICustomizationItemSource { + readonly onDidChange: Event; + fetchItems(promptType: PromptsType): Promise; +} + +// #endregion + +// #region Utilities + +/** + * Returns true if the given extension identifier matches the default + * chat extension (e.g. GitHub Copilot Chat). Used to group items from + * the chat extension under "Built-in" instead of "Extensions". + */ +export function isChatExtensionItem(extensionId: ExtensionIdentifier, productService: IProductService): boolean { + const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; + return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); +} + +/** + * Derives a friendly name from a filename by removing extension suffixes. + */ +export function getFriendlyName(filename: string): string { + let name = filename + .replace(/\.instructions\.md$/i, '') + .replace(/\.prompt\.md$/i, '') + .replace(/\.agent\.md$/i, '') + .replace(/\.md$/i, ''); + + name = name + .replace(/[-_]/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + + return name || filename; +} + +/** + * Expands hook file items into individual hook entries by parsing hook + * definitions from the file content. Falls back to the original item + * when parsing fails. + */ +export async function expandHookFileItems( + hookFileItems: readonly ICustomizationItem[], + workspaceService: IAICustomizationWorkspaceService, + fileService: IFileService, + pathService: IPathService, +): Promise { + const items: ICustomizationItem[] = []; + const activeRoot = workspaceService.getActiveProjectRoot(); + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + for (const item of hookFileItems) { + let parsedHooks = false; + try { + const content = await fileService.readFile(item.uri); + const json = parseJSONC(content.value.toString()); + const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); + + if (hooks.size > 0) { + parsedHooks = true; + for (const [hookType, entry] of hooks) { + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < entry.hooks.length; i++) { + const hook = entry.hooks[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: item.uri, + type: PromptsType.hook, + name: hookMeta?.label ?? entry.originalId, + description: truncatedCmd || localize('hookUnset', "(unset)"), + enabled: item.enabled, + groupKey: item.groupKey, + }); + } + } + } + } catch { + // Parse failed — fall through to show raw file. + } + + if (!parsedHooks) { + items.push(item); + } + } + + return items; +} + +// #endregion + +// #region Normalizer + +/** + * Converts provider-shaped customization rows into the rich list model used by the management UI. + */ +export class AICustomizationItemNormalizer { + constructor( + private readonly workspaceContextService: IWorkspaceContextService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly labelService: ILabelService, + private readonly agentPluginService: IAgentPluginService, + private readonly productService: IProductService, + ) { } + + normalizeItems(items: readonly ICustomizationItem[], promptType: PromptsType): IAICustomizationListItem[] { + const uriUseCounts = new ResourceMap(); + return items + .filter(item => item.type === promptType) + .map(item => this.normalizeItem(item, promptType, uriUseCounts)) + .sort((a, b) => a.name.localeCompare(b.name)); + } + + normalizeItem(item: ICustomizationItem, promptType: PromptsType, uriUseCounts = new ResourceMap()): IAICustomizationListItem { + const { storage, groupKey, isBuiltin, extensionLabel } = this.resolveSource(item); + const seenCount = uriUseCounts.get(item.uri) ?? 0; + uriUseCounts.set(item.uri, seenCount + 1); + const duplicateSuffix = seenCount === 0 ? '' : `#${seenCount}`; + const isWorkspaceItem = storage === PromptsStorage.local; + + return { + id: `${item.uri.toString()}${duplicateSuffix}`, + uri: item.uri, + name: item.name, + filename: item.uri.scheme === Schemas.file + ? this.labelService.getUriLabel(item.uri, { relative: isWorkspaceItem }) + : basename(item.uri), + description: item.description, + storage, + promptType, + disabled: item.enabled === false, + groupKey, + pluginUri: storage === PromptsStorage.plugin ? this.findPluginUri(item.uri) : undefined, + displayName: item.name, + badge: item.badge, + badgeTooltip: item.badgeTooltip, + typeIcon: promptType === PromptsType.instructions && storage ? storageToIcon(storage) : undefined, + isBuiltin, + extensionLabel, + status: item.status, + statusMessage: item.statusMessage, + }; + } + + private resolveSource(item: ICustomizationItem): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { + const inferred = this.inferStorageAndGroup(item.uri); + + // Use provider-supplied storage when available; otherwise fall back to URI inference. + const storage = item.storage ?? inferred.storage; + const extensionLabel = inferred.extensionLabel; + + if (!item.groupKey) { + return { ...inferred, storage }; + } + + switch (item.groupKey) { + case BUILTIN_STORAGE: + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true, extensionLabel }; + default: + return { storage, groupKey: item.groupKey, extensionLabel }; + } + } + + private inferStorageAndGroup(uri: URI): { storage?: PromptsStorage; groupKey?: string; isBuiltin?: boolean; extensionLabel?: string } { + if (uri.scheme !== Schemas.file) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + } + + const activeProjectRoot = this.workspaceService.getActiveProjectRoot(); + if (activeProjectRoot && isEqualOrParent(uri, activeProjectRoot)) { + return { storage: PromptsStorage.local }; + } + + for (const folder of this.workspaceContextService.getWorkspace().folders) { + if (isEqualOrParent(uri, folder.uri)) { + return { storage: PromptsStorage.local }; + } + } + + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(uri, plugin.uri)) { + return { storage: PromptsStorage.plugin }; + } + } + + const extensionId = extractExtensionIdFromPath(uri.path); + if (extensionId) { + const extensionIdentifier = new ExtensionIdentifier(extensionId); + if (isChatExtensionItem(extensionIdentifier, this.productService)) { + return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE, isBuiltin: true }; + } + return { storage: PromptsStorage.extension, extensionLabel: extensionIdentifier.value }; + } + + return { storage: PromptsStorage.user }; + } + + private findPluginUri(itemUri: URI): URI | undefined { + for (const plugin of this.agentPluginService.plugins.get()) { + if (isEqualOrParent(itemUri, plugin.uri)) { + return plugin.uri; + } + } + return undefined; + } +} + +// #endregion + +// #region Item Source + +/** + * Unified item source that fetches items from a provider (extension-contributed + * or the promptsService adapter), normalizes them into list items, and optionally + * blends in local syncable items when a sync provider is present. + */ +export class ProviderCustomizationItemSource implements IAICustomizationItemSource { + + readonly onDidChange: Event; + + constructor( + private readonly itemProvider: ICustomizationItemProvider | undefined, + private readonly syncProvider: ICustomizationSyncProvider | undefined, + private readonly promptsService: IPromptsService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly fileService: IFileService, + private readonly pathService: IPathService, + private readonly itemNormalizer: AICustomizationItemNormalizer, + ) { + const onDidChangeSyncableCustomizations = this.syncProvider + ? Event.any( + this.promptsService.onDidChangeCustomAgents, + this.promptsService.onDidChangeSlashCommands, + this.promptsService.onDidChangeSkills, + this.promptsService.onDidChangeHooks, + this.promptsService.onDidChangeInstructions, + ) + : Event.None; + + this.onDidChange = Event.any( + this.itemProvider?.onDidChange ?? Event.None, + this.syncProvider?.onDidChange ?? Event.None, + onDidChangeSyncableCustomizations, + ); + } + + async fetchItems(promptType: PromptsType): Promise { + const remoteItems = this.itemProvider + ? await this.fetchItemsFromProvider(this.itemProvider, promptType) + : []; + if (!this.syncProvider) { + return remoteItems; + } + const localItems = await this.fetchLocalSyncableItems(promptType, this.syncProvider); + return [...remoteItems, ...localItems]; + } + + private async fetchItemsFromProvider(provider: ICustomizationItemProvider, promptType: PromptsType): Promise { + const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); + if (!allItems) { + return []; + } + + let providerItems: readonly ICustomizationItem[] = promptType === PromptsType.hook + ? await expandHookFileItems( + allItems.filter(item => item.type === PromptsType.hook), + this.workspaceService, this.fileService, this.pathService, + ) + : allItems.filter(item => item.type === promptType); + + if (promptType === PromptsType.skill) { + providerItems = await this.addSkillDescriptionFallbacks(providerItems); + } + + return this.itemNormalizer.normalizeItems(providerItems, promptType); + } + + private async addSkillDescriptionFallbacks(items: readonly ICustomizationItem[]): Promise { + const descriptionsByUri = new Map(); + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const skill of skills ?? []) { + if (skill.description) { + descriptionsByUri.set(skill.uri.toString(), skill.description); + } + } + + return items.map(item => item.description + ? item + : { ...item, description: descriptionsByUri.get(item.uri.toString()) }); + } + + private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { + const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); + if (!files.length) { + return []; + } + + const providerItems: ICustomizationItem[] = files + .filter(file => file.storage === PromptsStorage.local || file.storage === PromptsStorage.user) + .map(file => ({ + uri: file.uri, + type: promptType, + name: getFriendlyName(basename(file.uri)), + groupKey: 'sync-local', + enabled: true, + })); + + return this.itemNormalizer.normalizeItems(providerItems, promptType) + .map(item => ({ + ...item, + id: `sync-${item.id}`, + syncable: true, + synced: syncProvider.isSelected(item.uri), + })); + } +} + +// #endregion diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index cdbbda2a20522..21a1063961ed1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -10,12 +10,10 @@ 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'; -import { basename, dirname, isEqual, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { isEqual } from '../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { localize } from '../../../../../nls.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -39,7 +37,7 @@ import { IContextKeyService } from '../../../../../platform/contextkey/common/co import { createActionViewItem, getContextMenuActions } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; -import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { IAICustomizationWorkspaceService } from '../../common/aiCustomizationWorkspaceService.js'; import { Action, Separator } from '../../../../../base/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { IHoverService } from '../../../../../platform/hover/browser/hover.js'; @@ -47,18 +45,13 @@ import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/ho import { IFileService } from '../../../../../platform/files/common/files.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; import { generateCustomizationDebugReport } from './aiCustomizationDebugPanel.js'; -import { extractExtensionIdFromPath, getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; -import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; -import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; -import { parse as parseJSONC } from '../../../../../base/common/json.js'; -import { Schemas } from '../../../../../base/common/network.js'; -import { OS } from '../../../../../base/common/platform.js'; +import { getCustomizationSecondaryText } from './aiCustomizationListWidgetUtils.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, matchesWorkspaceSubpath, matchesInstructionFileFilter, ICustomizationSyncProvider } from '../../common/customizationHarnessService.js'; -import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ICustomizationHarnessService } from '../../common/customizationHarnessService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { AICustomizationItemNormalizer, IAICustomizationItemSource, IAICustomizationListItem, ProviderCustomizationItemSource } from './aiCustomizationItemSource.js'; +import { PromptsServiceCustomizationItemProvider } from './promptsServiceCustomizationItemProvider.js'; export { truncateToFirstLine } from './aiCustomizationListWidgetUtils.js'; @@ -84,47 +77,6 @@ const ITEM_HEIGHT = 44; const GROUP_HEADER_HEIGHT = 36; const GROUP_HEADER_HEIGHT_WITH_SEPARATOR = 40; -/** - * Represents an AI customization item in the list. - */ -export interface IAICustomizationListItem { - readonly id: string; - readonly uri: URI; - readonly name: string; - readonly filename: string; - readonly description?: string; - /** Storage origin. Set by core when items come from promptsService; omitted for external provider items. */ - readonly storage?: PromptsStorage; - readonly promptType: PromptsType; - readonly disabled: boolean; - /** When set, overrides `storage` for display grouping purposes. */ - readonly groupKey?: string; - /** URI of the parent plugin, when this item comes from an installed plugin. */ - readonly pluginUri?: URI; - /** When set, overrides the formatted name for display. */ - readonly displayName?: string; - /** When set, shows a small inline badge next to the item name. */ - readonly badge?: string; - /** Tooltip shown when hovering the badge. */ - readonly badgeTooltip?: string; - /** When set, overrides the default prompt-type icon. */ - readonly typeIcon?: ThemeIcon; - /** True when item comes from the default chat extension (grouped under Built-in). */ - readonly isBuiltin?: boolean; - /** Display name of the contributing extension (for non-built-in extension items). */ - readonly extensionLabel?: string; - /** Server-reported loading/sync status for remote customizations. */ - readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; - /** Human-readable status detail (e.g. error message or warning). */ - readonly statusMessage?: string; - /** When true, this item can be selected for syncing to a remote harness. */ - readonly syncable?: boolean; - /** When true, this syncable item is currently selected for syncing. */ - readonly synced?: boolean; - nameMatches?: IMatch[]; - descriptionMatches?: IMatch[]; -} - /** * Represents a collapsible group header in the list. */ @@ -267,19 +219,6 @@ function promptTypeToIcon(type: PromptsType): ThemeIcon { } } -/** - * Returns the icon for a given storage type. - */ -function storageToIcon(storage: PromptsStorage): ThemeIcon { - switch (storage) { - case PromptsStorage.local: return workspaceIcon; - case PromptsStorage.user: return userIcon; - case PromptsStorage.extension: return extensionIcon; - case PromptsStorage.plugin: return pluginIcon; - default: return instructionsIcon; - } -} - /** * Formats a name for display by stripping a trailing .md extension. * Names from frontmatter headers are shown as-is to stay consistent @@ -590,6 +529,9 @@ export class AICustomizationListWidget extends Disposable { private _loadItemsSeq = 0; private readonly delayedFilter = new Delayer(200); + private readonly itemNormalizer: AICustomizationItemNormalizer; + private readonly promptsServiceItemProvider: PromptsServiceCustomizationItemProvider; + private cachedItemSource: { descriptorId: string; source: IAICustomizationItemSource } | undefined; private readonly _onDidSelectItem = this._register(new Emitter()); readonly onDidSelectItem: Event = this._onDidSelectItem.event; @@ -625,6 +567,21 @@ export class AICustomizationListWidget extends Disposable { @IProductService private readonly productService: IProductService, ) { super(); + this.itemNormalizer = new AICustomizationItemNormalizer( + this.workspaceContextService, + this.workspaceService, + this.labelService, + this.agentPluginService, + this.productService, + ); + this.promptsServiceItemProvider = new PromptsServiceCustomizationItemProvider( + () => this.harnessService.getActiveDescriptor(), + this.promptsService, + this.workspaceService, + this.fileService, + this.pathService, + this.productService, + ); this.element = $('.ai-customization-list-widget'); this.create(); @@ -648,27 +605,18 @@ export class AICustomizationListWidget extends Disposable { this.refresh(); })); - // Subscribe to the active provider's onDidChange event. + // Subscribe to the active item source's onDidChange event. // Read both activeHarness and availableHarnesses so that the // subscription is re-established when a new provider harness // registers (availableHarnesses changes) even if activeHarness // was already set to the harness id from persisted state. - const providerChangeDisposable = this._register(new MutableDisposable()); - const syncChangeDisposable = this._register(new MutableDisposable()); + const itemSourceChangeDisposable = this._register(new MutableDisposable()); this._register(autorun(reader => { this.harnessService.activeHarness.read(reader); this.harnessService.availableHarnesses.read(reader); + this.cachedItemSource = undefined; const activeDescriptor = this.harnessService.getActiveDescriptor(); - if (activeDescriptor.itemProvider) { - providerChangeDisposable.value = activeDescriptor.itemProvider.onDidChange(() => this.refresh()); - } else { - providerChangeDisposable.clear(); - } - if (activeDescriptor.syncProvider) { - syncChangeDisposable.value = activeDescriptor.syncProvider.onDidChange(() => this.refresh()); - } else { - syncChangeDisposable.clear(); - } + itemSourceChangeDisposable.value = this.getItemSource(activeDescriptor).onDidChange(() => this.refresh()); })); } @@ -781,12 +729,6 @@ export class AICustomizationListWidget extends Disposable { // Handle context menu this._register(this.list.onContextMenu(e => this.onContextMenu(e))); - // Subscribe to prompt service changes - this._register(this.promptsService.onDidChangeCustomAgents(() => this.refresh())); - this._register(this.promptsService.onDidChangeSlashCommands(() => this.refresh())); - this._register(this.promptsService.onDidChangeSkills(() => this.refresh())); - this._register(this.promptsService.onDidChangeInstructions(() => this.refresh())); - // Refresh on file deletions so the list updates after inline delete actions this._register(this.fileService.onDidFilesChange(e => { if (e.gotDeleted()) { @@ -1200,653 +1142,36 @@ export class AICustomizationListWidget extends Disposable { return items.length; } - /** - * Returns true if the given extension identifier matches the default - * chat extension (e.g. GitHub Copilot Chat). Used to group items from - * the chat extension under "Built-in" instead of "Extensions", similar - * to how MCP categorizes built-in servers. - */ - private isChatExtensionItem(extensionId: ExtensionIdentifier): boolean { - const chatExtensionId = this.productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && ExtensionIdentifier.equals(extensionId, chatExtensionId); - } - - /** - * Post-processes items to assign groupKey overrides for extension-sourced - * items. Applies the built-in grouping consistently across all item types. - * - * Items that already have an explicit groupKey (e.g. instruction categories, - * agent hooks) are left untouched — groupKey overrides are only applied to - * items whose current groupKey is `undefined`. - */ - private applyBuiltinGroupKeys(items: IAICustomizationListItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): IAICustomizationListItem[] { - return items.map(item => { - if (item.storage !== PromptsStorage.extension) { - return item; - } - const extInfo = extensionInfoByUri.get(item.uri); - if (!extInfo) { - return item; - } - const isBuiltin = this.isChatExtensionItem(extInfo.id); - if (isBuiltin) { - return { - ...item, - isBuiltin: true, - groupKey: item.groupKey ?? BUILTIN_STORAGE, - }; - } - return { - ...item, - extensionLabel: extInfo.displayName || extInfo.id.value, - }; - }); - } - /** * Fetches and filters items for a given section. - * Delegates to the provider path or core path based on the active harness. + * Delegates to the item source selected by the active harness. */ private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { const promptType = sectionToPromptType(section); - const activeDescriptor = this.harnessService.getActiveDescriptor(); - - if (activeDescriptor.itemProvider && promptType) { - 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 }>(); - - - if (promptType === PromptsType.agent) { - // Use getCustomAgents which has parsed name/description from frontmatter - const agents = await this.promptsService.getCustomAgents(CancellationToken.None); - // Build extension display name lookup from raw file list - const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None); - for (const file of allAgentFiles) { - if (file.extension) { - extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); - } - } - for (const agent of agents) { - const filename = basename(agent.uri); - items.push({ - id: agent.uri.toString(), - uri: agent.uri, - name: agent.name, - filename, - description: agent.description, - storage: agent.source.storage, - promptType, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, - disabled: disabledUris.has(agent.uri), - }); - // Track extension ID for built-in grouping (if not already set from file list) - if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { - extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); - } - } - } else if (promptType === PromptsType.skill) { - // Use findAgentSkills for enabled skills (has parsed name/description from frontmatter) - const skills = await this.promptsService.findAgentSkills(CancellationToken.None); - // Build extension ID lookup from raw file list (like MCP builds collectionSources) - const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None); - for (const file of allSkillFiles) { - if (file.extension) { - extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); - } - } - const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); - const seenUris = new ResourceSet(); - for (const skill of skills || []) { - const filename = basename(skill.uri); - const skillName = skill.name || basename(dirname(skill.uri)) || filename; - seenUris.add(skill.uri); - const skillFolderName = basename(dirname(skill.uri)); - const uiTooltip = uiIntegrations.get(skillFolderName); - items.push({ - id: skill.uri.toString(), - uri: skill.uri, - name: skillName, - filename, - description: skill.description, - storage: skill.storage, - promptType, - pluginUri: skill.storage === PromptsStorage.plugin ? this.findPluginUri(skill.uri) : undefined, - disabled: false, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, - }); - } - // Also include disabled skills from the raw file list - if (disabledUris.size > 0) { - for (const file of allSkillFiles) { - if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { - const filename = basename(file.uri); - const disabledName = file.name || basename(dirname(file.uri)) || filename; - const disabledFolderName = basename(dirname(file.uri)); - const uiTooltip = uiIntegrations.get(disabledFolderName); - items.push({ - id: file.uri.toString(), - uri: file.uri, - name: disabledName, - filename, - description: file.description, - storage: file.storage, - promptType, - disabled: true, - badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, - badgeTooltip: uiTooltip, - }); - } - } - } - } else if (promptType === PromptsType.prompt) { - // Use getPromptSlashCommands which has parsed name/description from frontmatter - // Filter out skills since they have their own section - const commands = await this.promptsService.getPromptSlashCommands(CancellationToken.None); - for (const command of commands) { - if (command.type === PromptsType.skill) { - continue; - } - const filename = basename(command.uri); - items.push({ - id: command.uri.toString(), - uri: command.uri, - name: command.name, - filename, - description: command.description, - storage: command.storage, - promptType, - pluginUri: command.storage === PromptsStorage.plugin ? command.pluginUri : undefined, - disabled: disabledUris.has(command.uri), - }); - if (command.extension) { - extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); - } - } - } else if (promptType === PromptsType.hook) { - // Try to parse individual hooks from each file; fall back to showing the file itself - const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - const userHomeUri = await this.pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - - for (const hookFile of hookFiles) { - // Plugins parse their own hooks and emit them individually because they can - // be embedded with interpolations in the plugin manifests; don't re-parse them - if (hookFile.storage === PromptsStorage.plugin) { - const filename = basename(hookFile.uri); - items.push({ - id: hookFile.uri.toString() + ':' + hookFile.name, - uri: hookFile.uri, - name: hookFile.name || this.getFriendlyName(filename), - filename, - storage: hookFile.storage, - promptType, - pluginUri: hookFile.pluginUri, - disabled: disabledUris.has(hookFile.uri), - }); - continue; - } - - let parsedHooks = false; - try { - const content = await this.fileService.readFile(hookFile.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(hookFile.uri, json, activeRoot, userHome); - - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - id: `${hookFile.uri.toString()}#${entry.originalId}[${i}]`, - uri: hookFile.uri, - name: hookMeta?.label ?? entry.originalId, - filename: basename(hookFile.uri), - description: truncatedCmd || localize('hookUnset', "(unset)"), - storage: hookFile.storage, - promptType, - disabled: disabledUris.has(hookFile.uri), - }); - } - } - } - } catch { - // Parse failed — fall through to show raw file - } - - if (!parsedHooks) { - const filename = basename(hookFile.uri); - items.push({ - id: hookFile.uri.toString(), - uri: hookFile.uri, - name: hookFile.name || this.getFriendlyName(filename), - filename, - storage: hookFile.storage, - promptType, - disabled: disabledUris.has(hookFile.uri), - }); - } - } - - // Also include hooks defined in agent frontmatter (not in sessions window) - // TODO: add this back when Copilot CLI supports this - const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; - for (const agent of agents) { - if (!agent.hooks) { - continue; - } - for (const hookType of Object.values(HookType)) { - const hookCommands = agent.hooks[hookType]; - if (!hookCommands || hookCommands.length === 0) { - continue; - } - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < hookCommands.length; i++) { - const hook = hookCommands[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - id: `${agent.uri.toString()}#hook:${hookType}[${i}]`, - uri: agent.uri, - name: hookMeta?.label ?? hookType, - filename: basename(agent.uri), - description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, - storage: agent.source.storage, - groupKey: 'agents', - promptType, - pluginUri: agent.source.storage === PromptsStorage.plugin ? agent.source.pluginUri : undefined, - disabled: disabledUris.has(agent.uri), - }); - } - } - } - } else { - // For instructions, group by category: agent instructions, context instructions, on-demand instructions - const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None); - for (const file of instructionFiles) { - if (file.extension) { - extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); - } - } - const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); - const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); - - // Add agent instruction items - const workspaceFolderUris = this.workspaceContextService.getWorkspace().folders.map(f => f.uri); - const activeRoot = this.workspaceService.getActiveProjectRoot(); - if (activeRoot) { - workspaceFolderUris.push(activeRoot); - } - for (const file of agentInstructionFiles) { - const storage = PromptsStorage.local; - const filename = basename(file.uri); - items.push({ - id: file.uri.toString(), - uri: file.uri, - name: filename, - filename: this.labelService.getUriLabel(file.uri, { relative: true }), - displayName: filename, - storage, - promptType, - typeIcon: storageToIcon(storage), - groupKey: 'agent-instructions', - disabled: disabledUris.has(file.uri), - }); - } - - // Parse prompt files to separate into context vs on-demand - - for (const { uri, pattern, name, description, storage, pluginUri } of instructionFiles) { - if (agentInstructionUris.has(uri)) { - continue; // already added as agent instruction - } - - const friendlyName = this.getFriendlyName(name); - - if (pattern !== undefined) { - // Context instruction - const badge = pattern === '**' - ? localize('alwaysAdded', "always added") - : pattern; - const badgeTooltip = pattern === '**' - ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") - : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", pattern); - items.push({ - id: uri.toString(), - uri, - name: friendlyName, - filename: this.labelService.getUriLabel(uri, { relative: true }), - displayName: friendlyName, - badge, - badgeTooltip, - description, - storage, - promptType, - typeIcon: storageToIcon(storage), - groupKey: 'context-instructions', - pluginUri, - disabled: disabledUris.has(uri), - }); - } else { - // On-demand instruction - items.push({ - id: uri.toString(), - uri, - name: friendlyName, - filename: basename(uri), - displayName: friendlyName, - description, - storage, - promptType, - typeIcon: storageToIcon(storage), - groupKey: 'on-demand-instructions', - pluginUri, - disabled: disabledUris.has(uri), - }); - } - } - } - - // Assign built-in groupKeys — items from the default chat extension - // are re-grouped under "Built-in" instead of "Extensions". - // This is a single-pass transformation applied after all items are - // collected, keeping the item-building code free of grouping logic. - const groupedItems = this.applyBuiltinGroupKeys(items, extensionInfoByUri); - - // Apply storage source filter (removes items not in visible sources or excluded user roots) - const filter = this.workspaceService.getStorageSourceFilter(promptType); - const withStorage = groupedItems.filter((item): item is IAICustomizationListItem & { readonly storage: PromptsStorage } => item.storage !== undefined); - const withoutStorage = groupedItems.filter(item => item.storage === undefined); - const filteredItems = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; - items.length = 0; - items.push(...filteredItems); - - // Apply workspace subpath filter — when the active harness specifies - // workspaceSubpaths, hide workspace-local items that aren't under one - // of the recognized sub-paths (e.g. Claude only shows .claude/ items). - // Exception: instruction files matched by the harness's instructionFileFilter - // are exempt (e.g. CLAUDE.md at workspace root is a Claude-native file - // even though it's not under .claude/). - const descriptor = this.harnessService.getActiveDescriptor(); - const subpaths = descriptor.workspaceSubpaths; - const instrFilter = descriptor.instructionFileFilter; - if (subpaths) { - const projectRoot = this.workspaceService.getActiveProjectRoot(); - for (let i = items.length - 1; i >= 0; i--) { - const item = items[i]; - if (item.storage === PromptsStorage.local && projectRoot && isEqualOrParent(item.uri, projectRoot)) { - if (!matchesWorkspaceSubpath(item.uri.path, subpaths)) { - // Keep instruction files that match the harness's native patterns - if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { - continue; - } - // Keep agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) - // — these live at the workspace root by design and should not be - // filtered out by workspace subpath restrictions. - if (item.groupKey === 'agent-instructions') { - continue; - } - items.splice(i, 1); - } - } - } - } - - // Apply instruction file filter — when the active harness specifies - // instructionFileFilter, hide instruction files that don't match the - // recognized patterns (e.g. Claude doesn't support *.instructions.md). - if (instrFilter && promptType === PromptsType.instructions) { - for (let i = items.length - 1; i >= 0; i--) { - if (!matchesInstructionFileFilter(items[i].uri.path, instrFilter)) { - items.splice(i, 1); - } - } - } - - // Sort items by name - items.sort((a, b) => a.name.localeCompare(b.name)); - - return items; - } - - /** - * Fetches items from an external customization provider, converting - * the provider's items into the list widget format. - */ - private async fetchItemsFromProvider(provider: IExternalCustomizationItemProvider, promptType: PromptsType): Promise { - const allItems = await provider.provideChatSessionCustomizations(CancellationToken.None); - if (!allItems) { - 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); - } - } - } - - // Hooks: expand file-level items into individual hook entries (matching core path display) - if (promptType === PromptsType.hook) { - return this._expandProviderHookItems(allItems, workspaceFolders); - } - - return allItems - .filter(item => item.type === promptType) - .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: item.uri.scheme === Schemas.file - ? this.labelService.getUriLabel(item.uri, { relative: true }) - : 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)); - } - - /** - * Expands provider hook items (file-level) into individual hook entries - * with hook type labels and command descriptions, matching the core path display. - */ - private async _expandProviderHookItems(allItems: readonly IExternalCustomizationItem[], workspaceFolders: readonly { uri: URI }[]): Promise { - const hookFileItems = allItems.filter(item => item.type === PromptsType.hook); - const items: IAICustomizationListItem[] = []; - const activeRoot = this.workspaceService.getActiveProjectRoot(); - const userHomeUri = await this.pathService.userHome(); - const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; - - for (const item of hookFileItems) { - const { storage } = item.groupKey - ? { storage: undefined } - : this._inferStorageAndGroup(item.uri, workspaceFolders); - - let parsedHooks = false; - try { - const content = await this.fileService.readFile(item.uri); - const json = parseJSONC(content.value.toString()); - const { hooks } = parseHooksFromFile(item.uri, json, activeRoot, userHome); - - if (hooks.size > 0) { - parsedHooks = true; - for (const [hookType, entry] of hooks) { - const hookMeta = HOOK_METADATA[hookType]; - for (let i = 0; i < entry.hooks.length; i++) { - const hook = entry.hooks[i]; - const cmdLabel = formatHookCommandLabel(hook, OS); - const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; - items.push({ - id: `${item.uri.toString()}#${entry.originalId}[${i}]`, - uri: item.uri, - name: hookMeta?.label ?? entry.originalId, - filename: basename(item.uri), - description: truncatedCmd || localize('hookUnset', "(unset)"), - storage, - promptType: PromptsType.hook, - disabled: item.enabled === false, - }); - } - } - } - } catch { - // Parse failed — fall through to show raw file - } - - if (!parsedHooks) { - items.push({ - id: item.uri.toString(), - uri: item.uri, - name: item.name, - filename: basename(item.uri), - description: item.description, - storage, - promptType: PromptsType.hook, - disabled: item.enabled === false, - }); - } - } - - return items; + return this.getItemSource(this.harnessService.getActiveDescriptor()).fetchItems(promptType); } /** - * 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) + * Returns the rich, browser-internal item source for a harness descriptor. + * The source is cached per descriptor id and reused across fetch and + * subscription calls to avoid redundant event composition. */ - 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 { storage: PromptsStorage.extension, 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 under an installed plugin = plugin - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(uri, plugin.uri)) { - return { storage: PromptsStorage.plugin }; - } - } - - // file: URI inside an extension install directory = extension or built-in. - // At this point we've already checked workspace folders and plugins, so - // a path containing /extensions/-/ is an extension directory. - const extensionId = extractExtensionIdFromPath(uri.path); - if (extensionId) { - if (this.isChatExtensionItem(new ExtensionIdentifier(extensionId))) { - return { storage: PromptsStorage.extension, groupKey: BUILTIN_STORAGE }; - } - return { storage: PromptsStorage.extension }; + private getItemSource(descriptor: ReturnType): IAICustomizationItemSource { + if (this.cachedItemSource && this.cachedItemSource.descriptorId === descriptor.id) { + return this.cachedItemSource.source; } - - // 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. - * These items appear alongside remote items with sync checkboxes. - */ - private async fetchLocalSyncableItems(promptType: PromptsType, syncProvider: ICustomizationSyncProvider): Promise { - const files = await this.promptsService.listPromptFiles(promptType, CancellationToken.None); - if (!files.length) { - return []; - } - - return files - .filter(f => f.storage === PromptsStorage.local || f.storage === PromptsStorage.user) - .map(f => ({ - id: `sync-${f.uri.toString()}`, - uri: f.uri, - name: this.getFriendlyName(basename(f.uri)), - filename: basename(f.uri), - promptType, - disabled: false, - storage: f.storage, - groupKey: 'sync-local', - syncable: true, - synced: syncProvider.isSelected(f.uri), - })) - .sort((a, b) => a.name.localeCompare(b.name)); - } - - /** - * Derives a friendly name from a filename by removing extension suffixes. - */ - private getFriendlyName(filename: string): string { - // Remove common prompt file extensions like .instructions.md, .prompt.md, etc. - let name = filename - .replace(/\.instructions\.md$/i, '') - .replace(/\.prompt\.md$/i, '') - .replace(/\.agent\.md$/i, '') - .replace(/\.md$/i, ''); - - // Convert kebab-case or snake_case to Title Case - name = name - .replace(/[-_]/g, ' ') - .replace(/\b\w/g, c => c.toUpperCase()); - - return name || filename; + const itemProvider = descriptor.itemProvider ?? (descriptor.syncProvider ? undefined : this.promptsServiceItemProvider); + const source = new ProviderCustomizationItemSource( + itemProvider, + descriptor.syncProvider, + this.promptsService, + this.workspaceService, + this.fileService, + this.pathService, + this.itemNormalizer, + ); + this.cachedItemSource = { descriptorId: descriptor.id, source }; + return source; } /** @@ -1931,11 +1256,11 @@ export class AICustomizationListWidget extends Disposable { } /** - * Filters and groups items from an external provider. + * Groups normalized list items for display. * When a syncProvider is present, shows remote items + local sync items. - * Otherwise, groups items by inferred storage/groupKey. + * Otherwise, groups items by normalized storage/groupKey. */ - private filterItemsForProvider(matchedItems: IAICustomizationListItem[]): void { + private groupMatchedItems(matchedItems: IAICustomizationListItem[]): void { const activeDescriptor = this.harnessService.getActiveDescriptor(); if (activeDescriptor.syncProvider) { @@ -2014,54 +1339,12 @@ export class AICustomizationListWidget extends Disposable { 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); - } + this.groupMatchedItems(matchedItems); return matchedItems.length; } @@ -2121,18 +1404,6 @@ export class AICustomizationListWidget extends Disposable { } } - /** - * Finds the plugin URI for an item URI by checking the known plugins. - */ - private findPluginUri(itemUri: URI): URI | undefined { - for (const plugin of this.agentPluginService.plugins.get()) { - if (isEqualOrParent(itemUri, plugin.uri)) { - return plugin.uri; - } - } - return undefined; - } - private getEmptyStateInfo(): { title: string; description: string } { switch (this.currentSection) { case AICustomizationManagementSection.Agents: diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts new file mode 100644 index 0000000000000..2ee50a453a1d6 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/promptsServiceCustomizationItemProvider.ts @@ -0,0 +1,343 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Event } from '../../../../../base/common/event.js'; +import { ResourceMap, ResourceSet } from '../../../../../base/common/map.js'; +import { OS } from '../../../../../base/common/platform.js'; +import { basename, dirname, isEqualOrParent } from '../../../../../base/common/resources.js'; +import { localize } from '../../../../../nls.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; +import { IAICustomizationWorkspaceService, applyStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; +import { HookType, HOOK_METADATA } from '../../common/promptSyntax/hookTypes.js'; +import { formatHookCommandLabel } from '../../common/promptSyntax/hookSchema.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { ICustomizationItem, ICustomizationItemProvider, IHarnessDescriptor, matchesInstructionFileFilter, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { BUILTIN_STORAGE } from './aiCustomizationManagement.js'; +import { expandHookFileItems, getFriendlyName, isChatExtensionItem } from './aiCustomizationItemSource.js'; + +/** + * Adapts the rich promptsService model to the same provider-shaped items + * contributed by external customization providers. + */ +export class PromptsServiceCustomizationItemProvider implements ICustomizationItemProvider { + + readonly onDidChange: Event; + + constructor( + private readonly getActiveDescriptor: () => IHarnessDescriptor, + private readonly promptsService: IPromptsService, + private readonly workspaceService: IAICustomizationWorkspaceService, + private readonly fileService: IFileService, + private readonly pathService: IPathService, + private readonly productService: IProductService, + ) { + this.onDidChange = Event.any( + this.promptsService.onDidChangeCustomAgents, + this.promptsService.onDidChangeSlashCommands, + this.promptsService.onDidChangeSkills, + this.promptsService.onDidChangeHooks, + this.promptsService.onDidChangeInstructions, + ); + } + + async provideChatSessionCustomizations(token: CancellationToken): Promise { + const itemSets = await Promise.all([ + this.provideCustomizations(PromptsType.agent, token), + this.provideCustomizations(PromptsType.skill, token), + this.provideCustomizations(PromptsType.instructions, token), + this.provideCustomizations(PromptsType.hook, token), + this.provideCustomizations(PromptsType.prompt, token), + ]); + return itemSets.flat(); + } + + private async provideCustomizations(promptType: PromptsType, token: CancellationToken = CancellationToken.None): Promise { + const items: ICustomizationItem[] = []; + const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); + const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); + + if (promptType === PromptsType.agent) { + const agents = await this.promptsService.getCustomAgents(token); + const allAgentFiles = await this.promptsService.listPromptFiles(PromptsType.agent, token); + for (const file of allAgentFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } + for (const agent of agents) { + items.push({ + uri: agent.uri, + type: promptType, + name: agent.name, + description: agent.description, + storage: agent.source.storage, + enabled: !disabledUris.has(agent.uri), + }); + if (agent.source.storage === PromptsStorage.extension && !extensionInfoByUri.has(agent.uri)) { + extensionInfoByUri.set(agent.uri, { id: agent.source.extensionId }); + } + } + } else if (promptType === PromptsType.skill) { + const skills = await this.promptsService.findAgentSkills(token); + const allSkillFiles = await this.promptsService.listPromptFiles(PromptsType.skill, token); + for (const file of allSkillFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } + const uiIntegrations = this.workspaceService.getSkillUIIntegrations(); + const seenUris = new ResourceSet(); + for (const skill of skills || []) { + const skillName = skill.name || basename(dirname(skill.uri)) || basename(skill.uri); + seenUris.add(skill.uri); + const skillFolderName = basename(dirname(skill.uri)); + const uiTooltip = uiIntegrations.get(skillFolderName); + items.push({ + uri: skill.uri, + type: promptType, + name: skillName, + description: skill.description, + storage: skill.storage, + enabled: true, + badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, + badgeTooltip: uiTooltip, + }); + } + if (disabledUris.size > 0) { + for (const file of allSkillFiles) { + if (!seenUris.has(file.uri) && disabledUris.has(file.uri)) { + const disabledName = file.name || basename(dirname(file.uri)) || basename(file.uri); + const disabledFolderName = basename(dirname(file.uri)); + const uiTooltip = uiIntegrations.get(disabledFolderName); + items.push({ + uri: file.uri, + type: promptType, + name: disabledName, + description: file.description, + storage: file.storage, + enabled: false, + badge: uiTooltip ? localize('uiIntegrationBadge', "UI Integration") : undefined, + badgeTooltip: uiTooltip, + }); + } + } + } + } else if (promptType === PromptsType.prompt) { + const commands = await this.promptsService.getPromptSlashCommands(token); + for (const command of commands) { + if (command.type === PromptsType.skill) { + continue; + } + items.push({ + uri: command.uri, + type: promptType, + name: command.name, + description: command.description, + storage: command.storage, + enabled: !disabledUris.has(command.uri), + }); + if (command.extension) { + extensionInfoByUri.set(command.uri, { id: command.extension.identifier, displayName: command.extension.displayName }); + } + } + } else if (promptType === PromptsType.hook) { + await this.fetchPromptServiceHooks(items, disabledUris, promptType); + } else { + await this.fetchPromptServiceInstructions(items, extensionInfoByUri, disabledUris, promptType); + } + + return this.applyLocalFilters(this.applyBuiltinGroupKeys(items, extensionInfoByUri), promptType); + } + + private async fetchPromptServiceHooks(items: ICustomizationItem[], disabledUris: ResourceSet, promptType: PromptsType): Promise { + const hookFiles = await this.promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + + // Convert hook files to provider-shaped items for shared expansion. + // Plugin hooks are pre-expanded by plugin manifests and kept as-is. + const hookFileItems: ICustomizationItem[] = hookFiles + .filter(f => f.storage !== PromptsStorage.plugin) + .map(f => ({ + uri: f.uri, + type: promptType, + name: f.name || getFriendlyName(basename(f.uri)), + enabled: !disabledUris.has(f.uri), + })); + + const expanded = await expandHookFileItems( + hookFileItems, this.workspaceService, this.fileService, this.pathService, + ); + const storageByUri = new Map(hookFiles.map(f => [f.uri.toString(), f.storage])); + for (const item of expanded) { + items.push({ ...item, storage: storageByUri.get(item.uri.toString()) }); + } + + // Plugin hooks are pre-expanded; add them directly. + for (const hookFile of hookFiles) { + if (hookFile.storage === PromptsStorage.plugin) { + items.push({ + uri: hookFile.uri, + type: promptType, + name: hookFile.name || getFriendlyName(basename(hookFile.uri)), + storage: hookFile.storage, + enabled: !disabledUris.has(hookFile.uri), + }); + } + } + + // Agent-embedded hooks (not in sessions window). + const agents = !this.workspaceService.isSessionsWindow ? await this.promptsService.getCustomAgents(CancellationToken.None) : []; + for (const agent of agents) { + if (!agent.hooks) { + continue; + } + for (const hookType of Object.values(HookType)) { + const hookCommands = agent.hooks[hookType]; + if (!hookCommands || hookCommands.length === 0) { + continue; + } + const hookMeta = HOOK_METADATA[hookType]; + for (let i = 0; i < hookCommands.length; i++) { + const hook = hookCommands[i]; + const cmdLabel = formatHookCommandLabel(hook, OS); + const truncatedCmd = cmdLabel.length > 60 ? cmdLabel.substring(0, 57) + '...' : cmdLabel; + items.push({ + uri: agent.uri, + type: promptType, + name: hookMeta?.label ?? hookType, + description: `${agent.name}: ${truncatedCmd || localize('hookUnset', "(unset)")}`, + storage: agent.source.storage, + groupKey: 'agents', + enabled: !disabledUris.has(agent.uri), + }); + } + } + } + } + + private async fetchPromptServiceInstructions(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>, disabledUris: ResourceSet, promptType: PromptsType): Promise { + const instructionFiles = await this.promptsService.getInstructionFiles(CancellationToken.None); + for (const file of instructionFiles) { + if (file.extension) { + extensionInfoByUri.set(file.uri, { id: file.extension.identifier, displayName: file.extension.displayName }); + } + } + const agentInstructionFiles = await this.promptsService.listAgentInstructions(CancellationToken.None, undefined); + const agentInstructionUris = new ResourceSet(agentInstructionFiles.map(f => f.uri)); + + for (const file of agentInstructionFiles) { + const storage = PromptsStorage.local; + const filename = basename(file.uri); + items.push({ + uri: file.uri, + type: promptType, + name: filename, + storage, + groupKey: 'agent-instructions', + enabled: !disabledUris.has(file.uri), + }); + } + + for (const { uri, pattern, name, description, storage } of instructionFiles) { + if (agentInstructionUris.has(uri)) { + continue; + } + + const friendlyName = getFriendlyName(name); + + if (pattern !== undefined) { + const badge = pattern === '**' + ? localize('alwaysAdded', "always added") + : pattern; + const badgeTooltip = pattern === '**' + ? localize('alwaysAddedTooltip', "This instruction is automatically included in every interaction.") + : localize('onContextTooltip', "This instruction is automatically included when files matching '{0}' are in context.", pattern); + items.push({ + uri, + type: promptType, + name: friendlyName, + badge, + badgeTooltip, + description, + storage, + groupKey: 'context-instructions', + enabled: !disabledUris.has(uri), + }); + } else { + items.push({ + uri, + type: promptType, + name: friendlyName, + description, + storage, + groupKey: 'on-demand-instructions', + enabled: !disabledUris.has(uri), + }); + } + } + } + + private applyBuiltinGroupKeys(items: ICustomizationItem[], extensionInfoByUri: ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>): ICustomizationItem[] { + return items.map(item => { + if (item.storage !== PromptsStorage.extension) { + return item; + } + const extInfo = extensionInfoByUri.get(item.uri); + if (!extInfo) { + return item; + } + if (isChatExtensionItem(extInfo.id, this.productService)) { + return { + ...item, + groupKey: item.groupKey ?? BUILTIN_STORAGE, + }; + } + return item; + }); + } + + private applyLocalFilters(groupedItems: ICustomizationItem[], promptType: PromptsType): ICustomizationItem[] { + const filter = this.workspaceService.getStorageSourceFilter(promptType); + const withStorage = groupedItems.filter((item): item is ICustomizationItem & { readonly storage: PromptsStorage } => item.storage !== undefined); + const withoutStorage = groupedItems.filter(item => item.storage === undefined); + let items = [...applyStorageSourceFilter(withStorage, filter), ...withoutStorage]; + + const descriptor = this.getActiveDescriptor(); + const subpaths = descriptor.workspaceSubpaths; + const instrFilter = descriptor.instructionFileFilter; + + if (subpaths) { + const projectRoot = this.workspaceService.getActiveProjectRoot(); + items = items.filter(item => { + if (item.storage !== PromptsStorage.local || !projectRoot || !isEqualOrParent(item.uri, projectRoot)) { + return true; + } + if (matchesWorkspaceSubpath(item.uri.path, subpaths)) { + return true; + } + // Keep instruction files matching the harness's native patterns + if (instrFilter && promptType === PromptsType.instructions && matchesInstructionFileFilter(item.uri.path, instrFilter)) { + return true; + } + // Keep agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md) + if (item.groupKey === 'agent-instructions') { + return true; + } + return false; + }); + } + + if (instrFilter && promptType === PromptsType.instructions) { + items = items.filter(item => matchesInstructionFileFilter(item.uri.path, instrFilter)); + } + + return items; + } + +} diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 0764147143c8c..f374ed43467d0 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,7 +52,7 @@ import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatT import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { IChatVariablesService } from '../common/attachments/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatNotificationMode } from '../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatNotificationMode, ChatPermissionLevel } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; @@ -81,7 +81,7 @@ import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatAct import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; import { ChatContextContributions } from './actions/chatContext.js'; import { registerChatContextActions } from './actions/chatContextActions.js'; -import { registerChatCopyActions } from './actions/chatCopyActions.js'; +import { ChatCopyActionRendering, registerChatCopyActions } from './actions/chatCopyActions.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; @@ -410,6 +410,23 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, + [ChatConfiguration.DefaultPermissionLevel]: { + type: 'string', + enum: [ChatPermissionLevel.Default, ChatPermissionLevel.AutoApprove, ChatPermissionLevel.Autopilot], + enumItemLabels: [ + nls.localize('chat.permissions.default.default.label', "Default Approvals"), + nls.localize('chat.permissions.default.autoApprove.label', "Bypass Approvals"), + nls.localize('chat.permissions.default.autopilot.label', "Autopilot (Preview)"), + ], + enumDescriptions: [ + nls.localize('chat.permissions.default.default.description', "Start new chat sessions with Default Approvals."), + nls.localize('chat.permissions.default.autoApprove.description', "Start new chat sessions in Bypass Approvals mode."), + nls.localize('chat.permissions.default.autopilot.description', "Start new chat sessions in Autopilot mode."), + ], + description: nls.localize('chat.permissions.default.settingDescription', "Controls the default permissions picker mode for new chat sessions. You can still change the permission mode per session, and each session remembers the permission mode that was used. If enterprise policy disables auto approval, new sessions use Default Approvals."), + default: ChatPermissionLevel.Default, + tags: ['experimental', 'advanced'], + }, [ChatConfiguration.GlobalAutoApprove]: { default: false, markdownDescription: globalAutoApproveDescription.value, @@ -2067,6 +2084,7 @@ registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, Langu registerWorkbenchContribution2(ChatPromptFilesExtensionPointHandler.ID, ChatPromptFilesExtensionPointHandler, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatCompatibilityNotifier.ID, ChatCompatibilityNotifier, WorkbenchPhase.Eventually); registerWorkbenchContribution2(CodeBlockActionRendering.ID, CodeBlockActionRendering, WorkbenchPhase.BlockRestore); +registerWorkbenchContribution2(ChatCopyActionRendering.ID, ChatCopyActionRendering, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatImplicitContextContribution.ID, ChatImplicitContextContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatViewsWelcomeHandler.ID, ChatViewsWelcomeHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatGettingStartedContribution.ID, ChatGettingStartedContribution, WorkbenchPhase.Eventually); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts index a19465f272163..dbd1e354d7b41 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/chatStatusDashboard.ts @@ -162,10 +162,11 @@ export class ChatStatusDashboard extends DomWidget { private render(): void { const token = cancelOnDispose(this._store); - const hasQuotas = !!(this.chatEntitlementService.quotas.chat || this.chatEntitlementService.quotas.completions || this.chatEntitlementService.quotas.premiumChat); + const hasQuotas = !!(this.chatEntitlementService.quotas.chat || this.chatEntitlementService.quotas.premiumChat); const isAnonymousWithSentiment = this.chatEntitlementService.anonymous && this.chatEntitlementService.sentiment.completed; const hasUsageSection = hasQuotas || isAnonymousWithSentiment; - const hasInlineSuggestionsSection = !this.options?.disableInlineSuggestionsSettings || + const hasInlineSuggestionsSection = !!(this.chatEntitlementService.quotas.completions) || + !this.options?.disableInlineSuggestionsSettings || !this.options?.disableModelSelection || !this.options?.disableProviderOptions || !this.options?.disableCompletionsSnooze; @@ -247,18 +248,19 @@ export class ChatStatusDashboard extends DomWidget { tabContentContainer.appendChild(usageContent); tabContentContainer.appendChild(inlineSuggestionsContent); - this.renderUsageContent(usageContent, token); - this.renderInlineSuggestionsContent(inlineSuggestionsContent); + const updatePromise = this.chatEntitlementService.update(token); + this.renderUsageContent(usageContent, token, updatePromise); + this.renderInlineSuggestionsContent(inlineSuggestionsContent, token, updatePromise); } else if (hasUsageSection) { this.renderUsageContent(this.element, token); } else if (hasInlineSuggestionsSection) { - this.renderInlineSuggestionsContent(this.element); + this.renderInlineSuggestionsContent(this.element, token); } // Chat sessions (below tabs) { - const inProgress = this.chatSessionsService.getInProgress(); - if (inProgress.some(item => item.count > 0)) { + const inProgress = this.chatSessionsService.getInProgress().filter(item => item.count > 0); + if (inProgress.length > 0) { this.element.appendChild($('hr')); this.renderHeader(this.element, this._store, localize('chatAgentSessionsTitle', "Agent Sessions"), toAction({ @@ -272,15 +274,13 @@ export class ChatStatusDashboard extends DomWidget { } })); - for (const { chatSessionType, count } of inProgress) { - if (count > 0) { - const displayName = this.getDisplayNameForChatSessionType(chatSessionType); - if (displayName) { - const text = '$(loading~spin) ' + localize('inProgressChatSession', "{0} in progress", displayName); - const chatSessionsElement = this.element.appendChild($('div.description')); - const parts = renderLabelWithIcons(text); - chatSessionsElement.append(...parts); - } + for (const { chatSessionType } of inProgress) { + const displayName = this.getDisplayNameForChatSessionType(chatSessionType); + if (displayName) { + const text = '$(loading~spin) ' + localize('inProgressChatSession', "{0} in progress", displayName); + const chatSessionsElement = this.element.appendChild($('div.description')); + const parts = renderLabelWithIcons(text); + chatSessionsElement.append(...parts); } } } @@ -341,11 +341,10 @@ export class ChatStatusDashboard extends DomWidget { } } - private renderUsageContent(container: HTMLElement, token: CancellationToken): void { + private renderUsageContent(container: HTMLElement, token: CancellationToken, updatePromise?: Promise): void { const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota, resetDate, resetDateHasTime } = this.chatEntitlementService.quotas; - if (chatQuota || completionsQuota || premiumChatQuota) { - const completionsQuotaIndicator = completionsQuota && (completionsQuota.total > 0 || completionsQuota.unlimited) ? this.createQuotaIndicator(container, this._store, completionsQuota, localize('completionsLabel', "Inline Suggestions"), false) : undefined; + if (chatQuota || premiumChatQuota) { const chatQuotaIndicator = chatQuota && (chatQuota.total > 0 || chatQuota.unlimited) ? this.createQuotaIndicator(container, this._store, chatQuota, localize('chatsLabel', "Chat messages"), false) : undefined; const premiumChatLabel = premiumChatQuota?.overageEnabled && !premiumChatQuota?.unlimited ? localize('includedPremiumChatsLabel', "Included premium requests") : localize('premiumChatsLabel', "Premium requests"); const premiumChatQuotaIndicator = premiumChatQuota && (premiumChatQuota.total > 0 || premiumChatQuota.unlimited) ? this.createQuotaIndicator(container, this._store, premiumChatQuota, premiumChatLabel, true) : undefined; @@ -361,15 +360,12 @@ export class ChatStatusDashboard extends DomWidget { } (async () => { - await this.chatEntitlementService.update(token); + await (updatePromise ?? this.chatEntitlementService.update(token)); if (token.isCancellationRequested) { return; } - const { chat: chatQuota, completions: completionsQuota, premiumChat: premiumChatQuota } = this.chatEntitlementService.quotas; - if (completionsQuota) { - completionsQuotaIndicator?.(completionsQuota); - } + const { chat: chatQuota, premiumChat: premiumChatQuota } = this.chatEntitlementService.quotas; if (chatQuota) { chatQuotaIndicator?.(chatQuota); } @@ -381,20 +377,44 @@ export class ChatStatusDashboard extends DomWidget { // Anonymous Indicator else if (this.chatEntitlementService.anonymous && this.chatEntitlementService.sentiment.completed) { - this.createQuotaIndicator(container, this._store, localize('quotaLimited', "Limited"), localize('completionsLabel', "Inline Suggestions"), false); this.createQuotaIndicator(container, this._store, localize('quotaLimited', "Limited"), localize('chatsLabel', "Chat messages"), false); } } - private renderInlineSuggestionsContent(container: HTMLElement): void { + private renderInlineSuggestionsContent(container: HTMLElement, token: CancellationToken, updatePromise?: Promise): void { + // Completions quota + { + const { completions: completionsQuota } = this.chatEntitlementService.quotas; + if (completionsQuota && (completionsQuota.total > 0 || completionsQuota.unlimited)) { + if (completionsQuota.unlimited) { + this.createIncludedIndicator(container, localize('completionsLabel', "Inline Suggestions")); + } else { + const completionsQuotaIndicator = this.createQuotaIndicator(container, this._store, completionsQuota, localize('completionsLabel', "Inline Suggestions"), false); + (async () => { + await (updatePromise ?? this.chatEntitlementService.update(token)); + if (token.isCancellationRequested) { + return; + } + const { completions } = this.chatEntitlementService.quotas; + if (completions) { + completionsQuotaIndicator(completions); + } + })(); + } + } else if (this.chatEntitlementService.anonymous && this.chatEntitlementService.sentiment.completed) { + this.createQuotaIndicator(container, this._store, localize('quotaLimited', "Limited"), localize('completionsLabel', "Inline Suggestions"), false); + } + } + // Settings (editor-specific) if (!this.options?.disableInlineSuggestionsSettings) { this.createSettings(container, this._store); } + const providers = (!this.options?.disableModelSelection || !this.options?.disableProviderOptions) ? this.languageFeaturesService.inlineCompletionsProvider.allNoModel() : undefined; + // Model Selection (editor-specific) - if (!this.options?.disableModelSelection) { - const providers = this.languageFeaturesService.inlineCompletionsProvider.allNoModel(); + if (!this.options?.disableModelSelection && providers) { const provider = providers.find(p => p.modelInfo && p.modelInfo.models.length > 0); if (provider) { @@ -422,8 +442,7 @@ export class ChatStatusDashboard extends DomWidget { } // Provider Options (editor-specific) - if (!this.options?.disableProviderOptions) { - const providers = this.languageFeaturesService.inlineCompletionsProvider.allNoModel(); + if (!this.options?.disableProviderOptions && providers) { for (const provider of providers) { if (provider.providerOptions && provider.providerOptions.length > 0) { for (const option of provider.providerOptions) { @@ -493,7 +512,7 @@ export class ChatStatusDashboard extends DomWidget { } } - private runCommandAndClose(commandOrFn: string | Function, ...args: unknown[]): void { + private runCommandAndClose(commandOrFn: string | ((...args: unknown[]) => void), ...args: unknown[]): void { if (typeof commandOrFn === 'function') { commandOrFn(...args); } else { @@ -580,6 +599,13 @@ export class ChatStatusDashboard extends DomWidget { return update; } + private createIncludedIndicator(container: HTMLElement, label: string): void { + const includedContainer = container.appendChild($('div.included-indicator')); + includedContainer.appendChild($('span.included-label', undefined, label)); + includedContainer.appendChild($('span.included-separator', { 'aria-hidden': 'true' }, '\u00B7')); + includedContainer.appendChild($('span.included-value', undefined, localize('includedWithPlan', "Included with your plan."))); + } + private createSettings(container: HTMLElement, disposables: DisposableStore): HTMLElement { const modeId = this.editorService.activeTextEditorLanguageId; const settings = container.appendChild($('div.settings')); diff --git a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css index 7e822e05e6b19..cd1ea0950129f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css +++ b/src/vs/workbench/contrib/chat/browser/chatStatus/media/chatStatus.css @@ -112,6 +112,27 @@ margin-bottom: 6px; } +/* Included Indicator */ + +.chat-status-bar-entry-tooltip .included-indicator { + display: flex; + align-items: center; + gap: 4px; + padding: 6px 10px; + margin-bottom: 8px; + border: 1px solid var(--vscode-editorWidget-border); + border-radius: 6px; +} + +.chat-status-bar-entry-tooltip .included-indicator .included-label { + font-weight: 600; +} + +.chat-status-bar-entry-tooltip .included-indicator .included-separator, +.chat-status-bar-entry-tooltip .included-indicator .included-value { + color: var(--vscode-descriptionForeground); +} + .chat-status-bar-entry-tooltip .quota-indicator .quota-label { display: flex; justify-content: space-between; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 3e7d31350f777..a5f66faa11455 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -86,7 +86,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEnt import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatQuestionCarousel, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, isChatPermissionLevel } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; @@ -559,7 +559,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._contextResourceLabels = this._register(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this._onDidChangeVisibility.event })); this._currentModeObservable = observableValue('currentMode', this.options.defaultMode ?? ChatMode.Agent); - this._currentPermissionLevel = observableValue('permissionLevel', ChatPermissionLevel.Default); + this._currentPermissionLevel = observableValue('permissionLevel', this.getDefaultPermissionLevel()); this._register(this.editorService.onDidActiveEditorChange(() => { this._indexOfLastOpenedContext = -1; this.refreshChatSessionPickers(); @@ -613,6 +613,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatModeNameKey = ChatContextKeys.chatModeName.bindTo(contextKeyService); this.chatModelIdKey = ChatContextKeys.chatModelId.bindTo(contextKeyService); this.permissionLevelKey = ChatContextKeys.chatPermissionLevel.bindTo(contextKeyService); + this.permissionLevelKey.set(this._currentPermissionLevel.get()); this.withinEditSessionKey = ChatContextKeys.withinEditSessionDiff.bindTo(contextKeyService); this.filePartOfEditSessionKey = ChatContextKeys.filePartOfEditSession.bindTo(contextKeyService); this.chatSessionHasOptions = ChatContextKeys.chatSessionHasModels.bindTo(contextKeyService); @@ -635,6 +636,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._register(this.configurationService.onDidChangeConfiguration(e => { const newOptions: IEditorOptions = {}; + if (e.affectsConfiguration(ChatConfiguration.GlobalAutoApprove)) { + this.setPermissionLevel(this._currentPermissionLevel.get()); + } + if (e.affectsConfiguration(ChatConfiguration.DefaultPermissionLevel)) { + if (this._chatSessionIsEmpty) { + this.setPermissionLevel(this.getDefaultPermissionLevel()); + } + } if (e.affectsConfiguration(AccessibilityVerbositySettingId.Chat)) { newOptions.ariaLabel = this._getAriaLabel(); } @@ -804,12 +813,25 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } public setPermissionLevel(level: ChatPermissionLevel): void { + level = this.getPermittedPermissionLevel(level); this._currentPermissionLevel.set(level, undefined); this.permissionLevelKey.set(level); this.permissionWidget?.refresh(); this._syncInputStateToModel(); } + private getDefaultPermissionLevel(): ChatPermissionLevel { + const level = this.configurationService.getValue(ChatConfiguration.DefaultPermissionLevel); + return isChatPermissionLevel(level) ? level : ChatPermissionLevel.Default; + } + + private getPermittedPermissionLevel(level: ChatPermissionLevel): ChatPermissionLevel { + if (this.configurationService.inspect(ChatConfiguration.GlobalAutoApprove).policyValue === false && level !== ChatPermissionLevel.Default) { + return ChatPermissionLevel.Default; + } + return level; + } + public openSessionTargetPicker(): void { this.sessionTargetWidget?.show(); } @@ -901,6 +923,20 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (chatSessionIsEmpty) { this._setEmptyModelState(); + + // The default mode setting may be registered asynchronously by TAS, + // and custom modes (like Plan) load asynchronously from prompt files. + // Re-apply when either becomes available. + this._modelSyncDisposables.add(this.configurationService.onDidChangeConfiguration(e => { + if (this._chatSessionIsEmpty && e.affectsConfiguration(ChatConfiguration.DefaultNewSessionMode)) { + this._setEmptyModelState(); + } + })); + this._modelSyncDisposables.add(this.chatModeService.onDidChangeChatModes(() => { + if (this._chatSessionIsEmpty) { + this._setEmptyModelState(); + } + })); } // Observe changes from model and sync to view @@ -915,6 +951,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _setEmptyModelState() { + this.setPermissionLevel(this.getDefaultPermissionLevel()); + if (this.entitlementService.anonymous) { // Be deterministic for anonymous users to support // agentic flows with default model. @@ -927,8 +965,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge 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) @@ -937,8 +973,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge 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`); } } } @@ -999,7 +1033,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Sync permission level (skip if global auto-approve is on, so the picker stays unchanged) if (!this.configurationService.getValue(ChatConfiguration.GlobalAutoApprove)) { - const targetLevel = state?.permissionLevel ?? ChatPermissionLevel.Default; + const targetLevel = this.getPermittedPermissionLevel(state?.permissionLevel ?? ChatPermissionLevel.Default); if (this._currentPermissionLevel.get() !== targetLevel) { this._currentPermissionLevel.set(targetLevel, undefined); this.permissionLevelKey.set(targetLevel); 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 1b2dafb100c1f..a8957c286210a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -217,6 +217,45 @@ gap: 4px; } +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .action-label { + position: relative; + overflow: hidden; +} + +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icons { + display: grid; + place-items: center; + width: 16px; + height: 16px; +} + +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon { + grid-area: 1 / 1; + display: flex; + align-items: center; + justify-content: center; + line-height: 1; + opacity: 0; + transform: scale(0.92); + transition: + opacity 140ms cubic-bezier(0.2, 0, 0, 1), + transform 140ms cubic-bezier(0.2, 0, 0, 1); + will-change: opacity, transform; +} + +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action:not(.copied) .chat-copy-action-icon-copy, +.interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action.copied .chat-copy-action-icon-copied { + opacity: 1; + transform: scale(1); +} + +@media (prefers-reduced-motion: reduce) { + .interactive-item-container .chat-footer-toolbar .menu-entry.chat-copy-action .chat-copy-action-icon { + transform: none; + transition: none; + } +} + .interactive-item-container .chat-footer-toolbar .checked.action-label, .interactive-item-container .chat-footer-toolbar .checked.action-label:hover { color: var(--vscode-inputOption-activeForeground) !important; diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts index a3bb64cffb8a6..f2a7a77ec9d42 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -231,6 +231,20 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { // Agent changes this._register(this.chatAgentService.onDidChangeAgents(() => this.onDidChangeAgents())); + // Session changes + this._register(this.chatSessionsService.onDidCommitSession(async (e) => { + if (!this.modelRef.value) { + return; + } + + if (!isEqual(e.original, this.modelRef.value.object.sessionResource)) { + return; + } + + const modelRef = await this.chatService.acquireOrLoadSession(e.committed, ChatAgentLocation.Chat, CancellationToken.None, 'ChatViewPane#onDidCommitSession'); + await this.showModel(CancellationToken.None, modelRef); + })); + // Layout changes this._register(Event.any( Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration('workbench.sideBar.location')), diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 472e7556fd6a5..1707237fd4b8f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -849,7 +849,6 @@ export class ChatService extends Disposable implements IChatService { async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); - if (!request.trim() && !options?.slashCommand && !options?.agentId && !options?.agentIdSilent) { this.trace('sendRequest', 'Rejected empty message'); return { kind: 'rejected', reason: 'Empty message' }; @@ -860,87 +859,94 @@ export class ChatService extends Disposable implements IChatService { throw new Error(`Unknown session: ${sessionResource}`); } + let tempRef: IChatModelReference | undefined; let newSessionResource: URI | undefined; + try { + // Workaround for the contributed chat sessions + // + // Internally blank widgets uses special sessions with an untitled- path. We do not want these leaking out + // to the rest of code. Instead use `createNewChatSessionItem` to make sure the session gets properly initialized with a real resource before processing the first request. + if (!model.hasRequests && isUntitledChatSession(sessionResource) && getChatSessionType(sessionResource) !== localChatSessionType) { + + const parsedRequest = this.parseChatRequest(sessionResource, request, options?.location ?? model.initialLocation, options); + const commandPart = parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart); + const requestText = getPromptText(parsedRequest).message; + + // Capture session options before loading the remote session, + // since the alias registration below may change the lookup. + const initialSessionOptions = this.chatSessionService.getSessionOptions(sessionResource); + + const newItem = await this.chatSessionService.createNewChatSessionItem(getChatSessionType(sessionResource), { prompt: requestText, command: commandPart?.text, initialSessionOptions }, CancellationToken.None); + if (newItem) { + tempRef = await this.loadRemoteSession(newItem.resource, model.initialLocation, CancellationToken.None); + model = tempRef?.object as ChatModel | undefined; + if (!model) { + throw new Error(`Failed to load session for resource: ${newItem.resource}`); + } - // Workaround for the contributed chat sessions - // - // Internally blank widgets uses special sessions with an untitled- path. We do not want these leaking out - // to the rest of code. Instead use `createNewChatSessionItem` to make sure the session gets properly initialized with a real resource before processing the first request. - if (!model.hasRequests && isUntitledChatSession(sessionResource) && getChatSessionType(sessionResource) !== localChatSessionType) { - - const parsedRequest = this.parseChatRequest(sessionResource, request, options?.location ?? model.initialLocation, options); - const commandPart = parsedRequest.parts.find((r): r is ChatRequestSlashCommandPart => r instanceof ChatRequestSlashCommandPart); - const requestText = getPromptText(parsedRequest).message; - - // Capture session options before loading the remote session, - // since the alias registration below may change the lookup. - const initialSessionOptions = this.chatSessionService.getSessionOptions(sessionResource); - - const newItem = await this.chatSessionService.createNewChatSessionItem(getChatSessionType(sessionResource), { prompt: requestText, command: commandPart?.text, initialSessionOptions }, CancellationToken.None); - if (newItem) { - model = (await this.loadRemoteSession(newItem.resource, model.initialLocation, CancellationToken.None))?.object as ChatModel | undefined; - if (!model) { - throw new Error(`Failed to load session for resource: ${newItem.resource}`); - } + // Register alias so session-option lookups work with the new resource + this.chatSessionService.registerSessionResourceAlias(sessionResource, newItem.resource); - // Register alias so session-option lookups work with the new resource - this.chatSessionService.registerSessionResourceAlias(sessionResource, newItem.resource); + // Update the new model's contributed session with initialSessionOptions + // so that the agent receives them when invoked. + if (initialSessionOptions) { + this.chatSessionService.updateSessionOptions(model.sessionResource, initialSessionOptions); + } - // Update the new model's contributed session with initialSessionOptions - // so that the agent receives them when invoked. - if (initialSessionOptions) { - this.chatSessionService.updateSessionOptions(model.sessionResource, initialSessionOptions); - } + this.chatSessionService.fireSessionCommitted(sessionResource, newItem.resource); - sessionResource = newItem.resource; - newSessionResource = newItem.resource; + sessionResource = newItem.resource; + newSessionResource = newItem.resource; + } } - } - const hasPendingRequest = this._pendingRequests.has(sessionResource); + const hasPendingRequest = this._pendingRequests.has(sessionResource); - if (options?.queue) { - const queued = this.queuePendingRequest(model, sessionResource, request, options); - if (!options.pauseQueue) { - this.processPendingRequests(sessionResource); + if (options?.queue) { + const queued = this.queuePendingRequest(model, sessionResource, request, options); + if (!options.pauseQueue) { + this.processPendingRequests(sessionResource); + } + return queued; + } else if (hasPendingRequest) { + this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); + return { kind: 'rejected', reason: 'Request already in progress' }; } - return queued; - } else if (hasPendingRequest) { - this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); - return { kind: 'rejected', reason: 'Request already in progress' }; - } - - const requests = model.getRequests(); - for (let i = requests.length - 1; i >= 0; i -= 1) { - const request = requests[i]; - if (request.shouldBeRemovedOnSend) { - if (request.shouldBeRemovedOnSend.afterUndoStop) { - request.response?.finalizeUndoState(); - } else { - await this.removeRequest(sessionResource, request.id); + + const requests = model.getRequests(); + for (let i = requests.length - 1; i >= 0; i -= 1) { + const request = requests[i]; + if (request.shouldBeRemovedOnSend) { + if (request.shouldBeRemovedOnSend.afterUndoStop) { + request.response?.finalizeUndoState(); + } else { + await this.removeRequest(sessionResource, request.id); + } } } - } - const location = options?.location ?? model.initialLocation; - const attempt = options?.attempt ?? 0; - const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.modeInfo?.kind)!; + const location = options?.location ?? model.initialLocation; + const attempt = options?.attempt ?? 0; + const defaultAgent = this.chatAgentService.getDefaultAgent(location, options?.modeInfo?.kind)!; - const parsedRequest = this.parseChatRequest(sessionResource, request, location, options); - const silentAgent = options?.agentIdSilent ? this.chatAgentService.getAgent(options.agentIdSilent) : undefined; - const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; - const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + const parsedRequest = this.parseChatRequest(sessionResource, request, location, options); + const silentAgent = options?.agentIdSilent ? this.chatAgentService.getAgent(options.agentIdSilent) : undefined; + const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; + const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); - // This method is only returning whether the request was accepted - don't block on the actual request - return { - kind: 'sent', - newSessionResource, - data: { - ...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options), - agent, - slashCommand: agentSlashCommandPart?.command, - }, - }; + // This method is only returning whether the request was accepted - don't block on the actual request + return { + kind: 'sent', + newSessionResource, + data: { + ...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options), + agent, + slashCommand: agentSlashCommandPart?.command, + }, + }; + } finally { + tempRef?.dispose(); + } } private parseChatRequest(sessionResource: URI, request: string, location: ChatAgentLocation, options: IChatSendRequestOptions | undefined): IParsedChatRequest { diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 37735f9b0586a..4e721a84a071b 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -60,6 +60,7 @@ export enum ChatConfiguration { ChatCustomizationHarnessSelectorEnabled = 'chat.customizations.harnessSelector.enabled', AutopilotEnabled = 'chat.autopilot.enabled', + DefaultPermissionLevel = 'chat.permissions.default', ImageCarouselEnabled = 'imageCarousel.chat.enabled', ArtifactsEnabled = 'chat.artifacts.enabled', ArtifactsRulesByMimeType = 'chat.artifacts.rules.byMimeType', @@ -91,6 +92,12 @@ export enum ChatPermissionLevel { Autopilot = 'autopilot' } +const chatPermissionLevels = new Set(Object.values(ChatPermissionLevel)); + +export function isChatPermissionLevel(level: string | undefined): level is ChatPermissionLevel { + return level !== undefined && chatPermissionLevels.has(level); +} + /** * Returns true if the permission level enables auto-approval of all tool calls. * Both {@link ChatPermissionLevel.AutoApprove} and {@link ChatPermissionLevel.Autopilot} enable auto-approval. diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 7f1ae9aae444b..d4c407a3e846f 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -125,7 +125,7 @@ export interface IHarnessDescriptor { * that can supply customization items directly (bypassing promptsService * discovery and filtering). */ - readonly itemProvider?: IExternalCustomizationItemProvider; + readonly itemProvider?: ICustomizationItemProvider; /** * When set, this harness supports syncing local customizations to a * remote target. The UI shows local items with sync checkboxes when @@ -137,11 +137,13 @@ export interface IHarnessDescriptor { /** * Represents a customization item provided by an external extension. */ -export interface IExternalCustomizationItem { +export interface ICustomizationItem { readonly uri: URI; readonly type: string; readonly name: string; readonly description?: string; + /** Storage origin (local, user, extension, plugin). Set by providers that know the source. */ + readonly storage?: PromptsStorage; /** Server-reported loading status for this customization. */ readonly status?: 'loading' | 'loaded' | 'degraded' | 'error'; /** Human-readable status detail (e.g. error message or warning). */ @@ -160,7 +162,7 @@ export interface IExternalCustomizationItem { * Provider interface for extension-contributed harnesses that supply * customization items directly from their SDK. */ -export interface IExternalCustomizationItemProvider { +export interface ICustomizationItemProvider { /** * Event that fires when the provider's customizations change. */ @@ -168,7 +170,7 @@ export interface IExternalCustomizationItemProvider { /** * Provide the customization items this harness supports. */ - provideChatSessionCustomizations(token: CancellationToken): Promise; + provideChatSessionCustomizations(token: CancellationToken): Promise; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index aef15e151943f..d824c13006c46 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -287,13 +287,14 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv registerLanguageModelProvider: () => toDisposable(() => { }), }); instantiationService.stub(IConfigurationService, { - getValue: (...args: unknown[]) => { - if (args[0] === 'chat.agentHost.clientTools') { - return []; - } - return true; - }, onDidChangeConfiguration: Event.None, + getValue: (...args: any[]) => typeof args[0] === 'string' && args[0] === 'chat.agentHost.clientTools' ? [] : true, + }); + instantiationService.stub(ILanguageModelToolsService, { + observeTools: () => observableValue('tools', []), + onDidChangeTools: Event.None, + getTools: () => [], + _serviceBrand: undefined, }); instantiationService.stub(IOutputService, { getChannel: () => undefined }); instantiationService.stub(IWorkspaceContextService, { getWorkspace: () => ({ id: '', folders: [] }), getWorkspaceFolder: () => null }); @@ -327,14 +328,6 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv registerResolver: () => toDisposable(() => { }), resolve: sessionResource => workingDirectoryResolver?.resolve(sessionResource), }); - instantiationService.stub(ILanguageModelToolsService, { - observeTools: () => observableValue('tools', []), - onDidChangeTools: Event.None, - getToolByName: () => undefined, - invokeTool: async () => ({ content: [] }), - onDidPrepareToolCallBecomeUnresponsive: Event.None, - onDidInvokeTool: Event.None, - }); return { instantiationService, agentHostService, chatAgentService }; } @@ -357,11 +350,11 @@ function createContribution(disposables: DisposableStore) { return { contribution, listController, sessionHandler, agentHostService, chatAgentService }; } -function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; agentHostSessionConfig: Record }> = {}): IChatAgentRequest { +function makeRequest(overrides: Partial<{ message: string; sessionResource: URI; variables: IChatAgentRequest['variables']; userSelectedModelId: string; agentHostSessionConfig: Record; agentId: string }> = {}): IChatAgentRequest { return upcastPartial({ sessionResource: overrides.sessionResource ?? URI.from({ scheme: 'untitled', path: '/chat-1' }), requestId: 'req-1', - agentId: 'agent-host-copilot', + agentId: overrides.agentId ?? 'agent-host-copilot', message: overrides.message ?? 'Hello', variables: overrides.variables ?? { variables: [] }, location: ChatAgentLocation.Chat, @@ -380,12 +373,13 @@ function textOf(value: string | IMarkdownString | undefined): string | undefined /** * Start a turn through the state-driven flow. Creates a chat session, - * starts the requestHandler (non-blocking), and waits for the first action + * invokes the agent (non-blocking), and waits for the first action * to be dispatched. Returns helpers to fire server action envelopes. */ async function startTurn( sessionHandler: AgentHostSessionHandler, agentHostService: MockAgentHostService, + chatAgentService: MockChatAgentService, ds: DisposableStore, overrides?: Partial<{ message: string; @@ -394,9 +388,11 @@ async function startTurn( userSelectedModelId: string; agentHostSessionConfig: Record; cancellationToken: CancellationToken; + agentId: string; }>, ) { - const sessionResource = overrides?.sessionResource ?? URI.from({ scheme: 'agent-host-copilot', path: '/untitled-turntest' }); + const agentId = overrides?.agentId ?? 'agent-host-copilot'; + const sessionResource = overrides?.sessionResource ?? URI.from({ scheme: agentId, path: '/untitled-turntest' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); ds.add(toDisposable(() => chatSession.dispose())); @@ -407,13 +403,17 @@ async function startTurn( const collected: IChatProgress[][] = []; const seq = { v: 1 }; - const turnPromise = chatSession.requestHandler!( + const registered = chatAgentService.registeredAgents.get(agentId); + assert.ok(registered, `${agentId} agent should be registered`); + + const turnPromise = registered.impl.invoke( makeRequest({ message: overrides?.message ?? 'Hello', sessionResource, variables: overrides?.variables, userSelectedModelId: overrides?.userSelectedModelId, agentHostSessionConfig: overrides?.agentHostSessionConfig, + agentId, }), (parts) => collected.push(parts), [], @@ -473,6 +473,7 @@ async function startDynamicAgentTurn( variables: overrides?.variables, userSelectedModelId: overrides?.userSelectedModelId, agentHostSessionConfig: overrides?.agentHostSessionConfig, + agentId, }), parts => collected.push(parts), [], @@ -565,9 +566,9 @@ suite('AgentHostChatContribution', () => { suite('session ID resolution', () => { test('creates new SDK session for untitled resource', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { message: 'Hello' }); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello' }); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -578,7 +579,7 @@ suite('AgentHostChatContribution', () => { })); test('reuses SDK session for same resource on second message', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const resource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-reuse' }); const chatSession = await sessionHandler.provideChatSessionContent(resource, CancellationToken.None); @@ -587,8 +588,10 @@ suite('AgentHostChatContribution', () => { // Clear lifecycle actions so only turn dispatches are counted agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + // First turn - const turn1Promise = chatSession.requestHandler!( + const turn1Promise = registered.impl.invoke( makeRequest({ message: 'First', sessionResource: resource }), () => { }, [], CancellationToken.None, ); @@ -601,7 +604,7 @@ suite('AgentHostChatContribution', () => { await turn1Promise; // Second turn - const turn2Promise = chatSession.requestHandler!( + const turn2Promise = registered.impl.invoke( makeRequest({ message: 'Second', sessionResource: resource }), () => { }, [], CancellationToken.None, ); @@ -620,9 +623,9 @@ suite('AgentHostChatContribution', () => { })); test('uses sessionId from agent-host scheme resource', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi', sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/existing-session-42' }), }); @@ -633,9 +636,9 @@ suite('AgentHostChatContribution', () => { })); test('agent-host scheme with untitled path creates new session via mapping', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi', sessionResource: URI.from({ scheme: 'agent-host-copilot', path: '/untitled-abc123' }), }); @@ -646,9 +649,9 @@ suite('AgentHostChatContribution', () => { assert.ok(AgentSession.id(URI.parse(session)).startsWith('sdk-session-')); })); test('passes raw model id extracted from language model identifier', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi', userSelectedModelId: 'agent-host-copilot:claude-sonnet-4-20250514', }); @@ -660,9 +663,9 @@ suite('AgentHostChatContribution', () => { })); test('passes model id as-is when no vendor prefix', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hi', userSelectedModelId: 'gpt-4o', }); @@ -690,9 +693,9 @@ suite('AgentHostChatContribution', () => { suite('progress routing', () => { test('delta events become markdownContent progress', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/responsePart', session, turnId, part: { kind: 'markdown', id: 'md-1', content: 'hello ' } } as ISessionAction); fire({ type: 'session/delta', session, turnId, partId: 'md-1', content: 'world' } as ISessionAction); @@ -707,9 +710,9 @@ suite('AgentHostChatContribution', () => { })); test('tool_start events become toolInvocation progress', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-1', toolName: 'read_file', displayName: 'Read File' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-1', invocationMessage: 'Reading file', confirmed: 'not-needed' } as ISessionAction); @@ -722,9 +725,9 @@ suite('AgentHostChatContribution', () => { })); test('tool_complete event transitions toolInvocation to completed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-2', toolName: 'bash', displayName: 'Bash' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-2', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); @@ -744,9 +747,9 @@ suite('AgentHostChatContribution', () => { })); test('tool_complete with failure sets error state', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-3', toolName: 'bash', displayName: 'Bash' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-3', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); @@ -765,9 +768,9 @@ suite('AgentHostChatContribution', () => { })); test('malformed toolArguments does not throw', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-bad', toolName: 'bash', displayName: 'Bash' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-bad', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); @@ -780,9 +783,9 @@ suite('AgentHostChatContribution', () => { })); test('outstanding tool invocations are completed on idle', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); // tool_start without tool_complete fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-orphan', toolName: 'bash', displayName: 'Bash' } as ISessionAction); @@ -798,9 +801,9 @@ suite('AgentHostChatContribution', () => { })); test('events from other sessions are ignored', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); // Delta from a different session — will be ignored (session not subscribed) agentHostService.fireAction({ @@ -823,12 +826,12 @@ suite('AgentHostChatContribution', () => { suite('cancellation', () => { test('cancellation resolves the agent invoke', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const cts = new CancellationTokenSource(); disposables.add(cts); - const { turnPromise } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { cancellationToken: cts.token, }); @@ -839,12 +842,12 @@ suite('AgentHostChatContribution', () => { })); test('cancellation force-completes outstanding tool invocations', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const cts = new CancellationTokenSource(); disposables.add(cts); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { cancellationToken: cts.token, }); @@ -863,12 +866,12 @@ suite('AgentHostChatContribution', () => { })); test('cancellation calls abortSession on the agent host service', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const cts = new CancellationTokenSource(); disposables.add(cts); - const { turnPromise } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { cancellationToken: cts.token, }); @@ -885,9 +888,9 @@ suite('AgentHostChatContribution', () => { suite('error events', () => { test('error event renders error message and finishes the request', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); agentHostService.fireAction({ action: { @@ -914,9 +917,9 @@ suite('AgentHostChatContribution', () => { suite('permission requests', () => { test('permission_request event shows confirmation and responds when confirmed', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); // Simulate a tool call requiring confirmation via toolCallStart + toolCallReady fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-1', toolName: 'shell', displayName: 'Shell' } as ISessionAction); @@ -956,9 +959,9 @@ suite('AgentHostChatContribution', () => { })); test('permission_request denied when user skips', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-2', toolName: 'write', displayName: 'Write File' } as ISessionAction); fire({ @@ -990,9 +993,9 @@ suite('AgentHostChatContribution', () => { })); test('shell permission shows input-style confirmation data with toolInput', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-shell', toolName: 'shell', displayName: 'Shell' } as ISessionAction); fire({ @@ -1014,9 +1017,9 @@ suite('AgentHostChatContribution', () => { })); test('read permission shows input-style confirmation data', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-perm-read', toolName: 'read_file', displayName: 'Read File' } as ISessionAction); fire({ @@ -1090,9 +1093,9 @@ suite('AgentHostChatContribution', () => { suite('tool invocation rendering', () => { test('bash tool renders as terminal command block with output', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-shell', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-shell', invocationMessage: 'Running `echo hello`', toolInput: 'echo hello', confirmed: 'not-needed' } as ISessionAction); @@ -1128,9 +1131,9 @@ suite('AgentHostChatContribution', () => { })); test('bash tool failure sets exit code 1 and error output', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-fail', toolName: 'bash', displayName: 'Bash', _meta: { toolKind: 'terminal', language: 'shellscript' } } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-fail', invocationMessage: 'Running `bad_cmd`', toolInput: 'bad_cmd', confirmed: 'not-needed' } as ISessionAction); @@ -1156,9 +1159,9 @@ suite('AgentHostChatContribution', () => { })); test('generic tool has invocation message and no toolSpecificData', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-gen', toolName: 'custom_tool', displayName: 'custom_tool' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-gen', invocationMessage: 'Using "custom_tool"', confirmed: 'not-needed' } as ISessionAction); @@ -1183,9 +1186,9 @@ suite('AgentHostChatContribution', () => { })); test('bash tool without arguments has no terminal data', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-noargs', toolName: 'bash', displayName: 'Bash', toolKind: 'terminal' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-noargs', invocationMessage: 'Running Bash command', confirmed: 'not-needed' } as ISessionAction); @@ -1210,9 +1213,9 @@ suite('AgentHostChatContribution', () => { })); test('view tool shows file path in messages', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, collected, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/toolCallStart', session, turnId, toolCallId: 'tc-view', toolName: 'view', displayName: 'View File' } as ISessionAction); fire({ type: 'session/toolCallReady', session, turnId, toolCallId: 'tc-view', invocationMessage: 'Reading /tmp/test.txt', confirmed: 'not-needed' } as ISessionAction); @@ -1368,9 +1371,9 @@ suite('AgentHostChatContribution', () => { suite('server error handling', () => { test('server-side error resolves the agent invoke without throwing', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, session, turnId } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); // Simulate a server-side error (e.g. sendMessage failure on the server) agentHostService.fireAction({ @@ -1461,9 +1464,9 @@ suite('AgentHostChatContribution', () => { suite('attachment context', () => { test('file variable with file:// URI becomes file attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'check this file', variables: { variables: [ @@ -1482,9 +1485,9 @@ suite('AgentHostChatContribution', () => { })); test('directory variable with file:// URI becomes directory attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'check this dir', variables: { variables: [ @@ -1503,9 +1506,9 @@ suite('AgentHostChatContribution', () => { })); test('implicit selection variable becomes selection attachment', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'explain this', variables: { variables: [ @@ -1524,9 +1527,9 @@ suite('AgentHostChatContribution', () => { })); test('non-file URIs are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'check this', variables: { variables: [ @@ -1544,9 +1547,9 @@ suite('AgentHostChatContribution', () => { })); test('tool variables are skipped', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'use tools', variables: { variables: [ @@ -1563,9 +1566,9 @@ suite('AgentHostChatContribution', () => { })); test('mixed variables extracts only supported types', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'mixed', variables: { variables: [ @@ -1588,9 +1591,9 @@ suite('AgentHostChatContribution', () => { })); test('no variables results in no attachments argument', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { message: 'Hello', }); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); @@ -1663,7 +1666,7 @@ suite('AgentHostChatContribution', () => { }); test('handler uses resolveWorkingDirectory callback', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { instantiationService, agentHostService } = createTestServices(disposables); + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); const handler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { provider: 'copilot' as const, @@ -1676,7 +1679,7 @@ suite('AgentHostChatContribution', () => { resolveWorkingDirectory: () => URI.file('/custom/working/dir'), })); - const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, disposables); + const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-test' }); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -1720,7 +1723,7 @@ suite('AgentHostChatContribution', () => { test('handler uses registered working directory resolver', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const resolvedWorkingDirectory = URI.file('/resolved/working/dir'); - const { instantiationService, agentHostService } = createTestServices(disposables, { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, { resolve: () => resolvedWorkingDirectory, }); @@ -1734,7 +1737,7 @@ suite('AgentHostChatContribution', () => { connectionAuthority: 'local', })); - const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, disposables); + const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-resolver-test' }); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -1743,7 +1746,7 @@ suite('AgentHostChatContribution', () => { })); test('handler passes vscode-agent-host URI as-is to createSession', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { instantiationService, agentHostService } = createTestServices(disposables); + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); // The workspace repository URI in the Sessions app is a // vscode-agent-host:// URI. It must be passed through unchanged @@ -1766,7 +1769,7 @@ suite('AgentHostChatContribution', () => { resolveWorkingDirectory: () => agentHostUri, })); - const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, disposables); + const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { agentId: 'workdir-agenthost-test' }); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -1818,8 +1821,9 @@ suite('AgentHostChatContribution', () => { assert.ok(chatAgentService.registeredAgents.has('connection-test')); // Verify it can run a turn through the IAgentConnection path - const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, disposables, { + const { turnPromise, session, turnId, fire } = await startTurn(handler, agentHostService, chatAgentService, disposables, { message: 'Test message', + agentId: 'connection-test', }); fire({ type: 'session/delta', session, turnId, content: 'Response' } as ISessionAction); @@ -2117,7 +2121,7 @@ suite('AgentHostChatContribution', () => { suite('server-initiated turns', () => { test('detects server-initiated turn and fires onDidStartServerRequest', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); // Create and subscribe a session const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-server-turn' }); @@ -2127,8 +2131,10 @@ suite('AgentHostChatContribution', () => { // Clear lifecycle actions so only turn dispatches are counted agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + // First, do a normal turn so the backend session is created - const turn1Promise = chatSession.requestHandler!( + const turn1Promise = registered.impl.invoke( makeRequest({ message: 'Hello', sessionResource }), () => { }, [], CancellationToken.None, ); @@ -2168,7 +2174,7 @@ suite('AgentHostChatContribution', () => { })); test('server-initiated turn streams progress through progressObs', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-server-progress' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -2177,8 +2183,10 @@ suite('AgentHostChatContribution', () => { // Clear lifecycle actions so only turn dispatches are counted agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + // Normal turn to create backend session - const turn1Promise = chatSession.requestHandler!( + const turn1Promise = registered.impl.invoke( makeRequest({ message: 'Init', sessionResource }), () => { }, [], CancellationToken.None, ); @@ -2240,7 +2248,7 @@ suite('AgentHostChatContribution', () => { }); test('client-dispatched turns are not treated as server-initiated', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-no-dupe' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -2252,8 +2260,10 @@ suite('AgentHostChatContribution', () => { // Clear lifecycle actions so only turn dispatches are counted agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + // Normal client turn — should NOT fire onDidStartServerRequest - const turnPromise = chatSession.requestHandler!( + const turnPromise = registered.impl.invoke( makeRequest({ message: 'Client turn', sessionResource }), () => { }, [], CancellationToken.None, ); @@ -2268,7 +2278,7 @@ suite('AgentHostChatContribution', () => { })); test('server-initiated turn does not duplicate tool calls on repeated state changes', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-server-tool-dedup' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -2277,8 +2287,10 @@ suite('AgentHostChatContribution', () => { // Clear lifecycle actions so only turn dispatches are counted agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + // First, do a normal turn so the backend session is created - const turn1Promise = chatSession.requestHandler!( + const turn1Promise = registered.impl.invoke( makeRequest({ message: 'Init', sessionResource }), () => { }, [], CancellationToken.None, ); @@ -2334,7 +2346,7 @@ suite('AgentHostChatContribution', () => { })); test('server-initiated turn picks up markdown arriving with turnStarted', () => runWithFakedTimers({ useFakeTimers: true }, async () => { - const { sessionHandler, agentHostService } = createContribution(disposables); + const { sessionHandler, agentHostService, chatAgentService } = createContribution(disposables); const sessionResource = URI.from({ scheme: 'agent-host-copilot', path: '/untitled-server-md-initial' }); const chatSession = await sessionHandler.provideChatSessionContent(sessionResource, CancellationToken.None); @@ -2343,8 +2355,10 @@ suite('AgentHostChatContribution', () => { // Clear lifecycle actions so only turn dispatches are counted agentHostService.dispatchedActions.length = 0; + const registered = chatAgentService.registeredAgents.get('agent-host-copilot')!; + // First, do a normal turn so the backend session is created - const turn1Promise = chatSession.requestHandler!( + const turn1Promise = registered.impl.invoke( makeRequest({ message: 'Init', sessionResource }), () => { }, [], CancellationToken.None, ); @@ -2394,7 +2408,7 @@ suite('AgentHostChatContribution', () => { suite('customizations', () => { test('dispatches activeClientChanged when a new session is created', async () => { - const { instantiationService, agentHostService } = createTestServices(disposables); + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); const customizations = observableValue('customizations', [ { uri: 'file:///plugin-a', displayName: 'Plugin A' }, @@ -2411,7 +2425,7 @@ suite('AgentHostChatContribution', () => { customizations, })); - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; @@ -2425,7 +2439,7 @@ suite('AgentHostChatContribution', () => { }); test('re-dispatches activeClientChanged when customizations observable changes', async () => { - const { instantiationService, agentHostService } = createTestServices(disposables); + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); const customizations = observableValue('customizations', []); @@ -2441,7 +2455,7 @@ suite('AgentHostChatContribution', () => { })); // Create a session first - const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, disposables); + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables); fire({ type: 'session/turnComplete', session, turnId } as ISessionAction); await turnPromise; diff --git a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts index f9016d832647b..71a39402d31d6 100644 --- a/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts @@ -8,7 +8,7 @@ import { Emitter } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CustomizationHarness, CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, IExternalCustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; +import { CustomizationHarness, CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; @@ -147,7 +147,7 @@ suite('CustomizationHarnessService', () => { { uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill' }, ]; - const itemProvider: IExternalCustomizationItemProvider = { + const itemProvider: ICustomizationItemProvider = { onDidChange: emitter.event, provideChatSessionCustomizations: async () => testItems, }; diff --git a/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts b/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts index feca14ac94b25..152c6e4fa2c38 100644 --- a/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts +++ b/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts @@ -606,10 +606,15 @@ abstract class AbstractLaunch implements ILaunch { private getDeduplicatedConfig(): IGlobalConfig | undefined { const original = this.getConfig(); - return original && { + if (!original) { + return undefined; + } + const compounds = original.compounds?.filter((compound): compound is ICompound => !!compound && typeof compound.name === 'string') ?? []; + const configurations = original.configurations?.filter((configuration): configuration is IConfig => !!configuration && typeof configuration.name === 'string') ?? []; + return { version: original.version, - compounds: original.compounds && distinguishConfigsByName(original.compounds), - configurations: original.configurations && distinguishConfigsByName(original.configurations), + compounds: distinguishConfigsByName(compounds), + configurations: distinguishConfigsByName(configurations), }; } } diff --git a/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts index ecbb3b1ba4468..3101c1f4c128a 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugConfigurationManager.test.ts @@ -179,5 +179,20 @@ suite('debugConfigurationManager', () => { assert.deepStrictEqual(_debugConfigurationManager.getAllConfigurations().map(({ name }) => name), ['visible']); }); + test('ignores null entries in launch configurations', () => { + configurationService.setUserConfiguration('launch', { + version: '0.2.0', + configurations: [ + { type: 'node', request: 'launch', name: 'valid' }, + null + ] + }); + + disposables.delete(_debugConfigurationManager); + _debugConfigurationManager = createConfigurationManager(); + + assert.deepStrictEqual(_debugConfigurationManager.getAllConfigurations().map(({ name }) => name), ['valid']); + }); + teardown(() => disposables.clear()); }); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts index f71143c24f575..91a30d512e526 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWidgets.ts @@ -843,7 +843,7 @@ export class ExtensionHoverWidget extends ExtensionWidget { } const markdown = new MarkdownString('', { isTrusted: true, supportThemeIcons: true }); - markdown.appendMarkdown(`**${this.extension.displayName}**`); + markdown.appendMarkdown(`**`).appendText(this.extension.displayName).appendMarkdown(`**`); if (semver.valid(this.extension.version)) { markdown.appendMarkdown(` ** _v${this.extension.version}${(this.extension.isPreReleaseVersion ? ' (pre-release)' : '')}_** `); } @@ -894,7 +894,7 @@ export class ExtensionHoverWidget extends ExtensionWidget { } if (this.extension.description) { - markdown.appendMarkdown(`${this.extension.description}`); + markdown.appendText(this.extension.description); markdown.appendText(`\n`); } diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 5842288693be3..49b3ec6a6ebfa 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -1611,10 +1611,14 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension await this.editorService.openEditor(this.instantiationService.createInstance(ExtensionsInput, extension), options, options?.sideByside ? SIDE_GROUP : useModal ? MODAL_GROUP : ACTIVE_GROUP); } - async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { - const viewPaneContainer = (await this.viewsService.openViewContainer(VIEWLET_ID, true))?.getViewPaneContainer() as IExtensionsViewPaneContainer; + async openSearch(searchValue: string, preserveFocus?: boolean): Promise { + const viewPaneContainer = (await this.viewsService.openViewContainer(VIEWLET_ID, true))?.getViewPaneContainer() as IExtensionsViewPaneContainer | undefined; + if (!viewPaneContainer) { + this.logService.trace('ExtensionsWorkbenchService#openSearch: extension view pane container was not available'); + return; + } viewPaneContainer.search(searchValue); - if (!preserveFoucs) { + if (!preserveFocus) { viewPaneContainer.focus(); } } diff --git a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts index 342092dbd7299..7f079dd4caf19 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpWorkbenchService.ts @@ -725,8 +725,8 @@ export class McpWorkbenchService extends Disposable implements IMcpWorkbenchServ return true; } - async openSearch(searchValue: string, preserveFoucs?: boolean): Promise { - await this.extensionsWorkbenchService.openSearch(`@mcp ${searchValue}`, preserveFoucs); + async openSearch(searchValue: string, preserveFocus?: boolean): Promise { + await this.extensionsWorkbenchService.openSearch(`@mcp ${searchValue}`, preserveFocus); } async open(extension: IWorkbenchMcpServer, options?: IEditorOptions): Promise { diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 35b4dacf823e7..8868b1de415a9 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -822,7 +822,7 @@ export interface IMcpWorkbenchService { uninstall(mcpServer: IWorkbenchMcpServer): Promise; getMcpConfigPath(arg: IWorkbenchLocalMcpServer): IMcpConfigPath | undefined; getMcpConfigPath(arg: URI): Promise; - openSearch(searchValue: string, preserveFoucs?: boolean): Promise; + openSearch(searchValue: string, preserveFocus?: boolean): Promise; open(extension: IWorkbenchMcpServer | string, options?: IMcpServerEditorOptions): Promise; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/outputHelpers.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/outputHelpers.ts index 184e49eb75a3c..b5aa7e49d13ec 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/outputHelpers.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/outputHelpers.ts @@ -9,10 +9,16 @@ import { truncateOutputKeepingTail } from './runInTerminalHelpers.js'; const MAX_OUTPUT_LENGTH = 16000; -export function getOutput(instance: ITerminalInstance, startMarker?: IXtermMarker): string { +export interface IGetOutputOptions { + /** When set, only return the last N non-empty lines from the bottom of the buffer. */ + lastNLines?: number; +} + +export function getOutput(instance: ITerminalInstance, startMarker?: IXtermMarker, options?: IGetOutputOptions): string { if (!instance.xterm || !instance.xterm.raw) { return ''; } + const buffer = instance.xterm.raw.buffer.active; let startLine = Math.max(startMarker?.line ?? 0, 0); while (startLine > 0 && buffer.getLine(startLine)?.isWrapped) { @@ -39,6 +45,11 @@ export function getOutput(instance: ITerminalInstance, startMarker?: IXtermMarke lines.push(currentLine); } + if (options?.lastNLines !== undefined) { + const nonEmpty = lines.filter(l => l.trim().length > 0); + return nonEmpty.slice(-options.lastNLines).join('\n'); + } + let output = lines.join('\n'); if (output.length > MAX_OUTPUT_LENGTH) { output = truncateOutputKeepingTail(output, MAX_OUTPUT_LENGTH); 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 f0f6bdda85a46..a10fcf864576f 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 @@ -208,8 +208,12 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { */ continueMonitoringAsync(token: CancellationToken): void { this._asyncMode = true; - // Cancel and dispose any in-progress monitoring run to avoid two concurrent loops - this._currentMonitoringCts?.dispose(); + // Cancel and dispose any in-progress monitoring run to avoid two concurrent loops. + // Cancel before dispose so that onCancellationRequested handlers fire and pending + // promises (e.g. _waitForNewData) resolve properly. + const currentMonitoringCts = this._currentMonitoringCts; + currentMonitoringCts?.cancel(); + currentMonitoringCts?.dispose(); this._currentMonitoringCts = new CancellationTokenSource(token); this._state = OutputMonitorState.PollingForIdle; this._startMonitoring(this._command, this._invocationContext, this._currentMonitoringCts.token); @@ -468,8 +472,9 @@ export function matchTerminalPromptOption(options: readonly string[], suggestedO export function detectsInputRequiredPattern(cursorLine: string): boolean { return [ // PowerShell-style multi-option line (supports [?] Help and optional default suffix) ending - // in whitespace - /\s*(?:\[[^\]]\]\s+[^\[\s][^\[]*\s*)+(?:\(default is\s+"[^"]+"\):)?\s+$/, + // in whitespace. The label part uses [^\[\s]+(?:\s+[^\[\s]+)* to support multi-word + // labels (e.g. "Yes to All") while avoiding overlap with \s* that caused ReDoS. + /\s*(?:\[[^\]]\]\s+[^\[\s]+(?:\s+[^\[\s]+)*\s*)+(?:\(default is\s+"[^"]+"\):)?\s+$/, // Bracketed/parenthesized yes/no pairs at end of line: (y/n), [Y/n], (yes/no), [no/yes] /(?:\(|\[)\s*(?:y(?:es)?\s*\/\s*n(?:o)?|n(?:o)?\s*\/\s*y(?:es)?)\s*(?:\]|\))\s+$/i, // Same as above but allows a preceding '?' or ':' and optional wrappers e.g. diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 0e92d061c5139..ae560581441da 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { localize } from '../../../../../../nls.js'; +import { IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; import { TerminalToolId } from './toolIds.js'; @@ -76,11 +77,24 @@ export class ConfirmTerminalCommandTool extends RunInTerminalTool { return preparedInvocation; } override async invoke(invocation: IToolInvocation, countTokens: CountTokensCallback, progress: ToolProgress, token: CancellationToken): Promise { - // This is a confirmation-only tool - just return success + // Check if the user edited the command during confirmation + const toolSpecificData = invocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; + const userEdited = toolSpecificData?.commandLine?.userEdited; + const original = toolSpecificData?.commandLine?.original; + + if (userEdited !== undefined && userEdited !== original) { + return { + content: [{ + kind: 'text', + value: `The user approved the command but edited it.\nYou MUST use this edited command exactly as written when executing it.\n\n${userEdited}\n` + }] + }; + } + return { content: [{ kind: 'text', - value: 'yes' + value: 'The user approved the command.' }] }; } 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 8731e1a9bd832..87d83447017b8 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -9,7 +9,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../../ba import { Codicon } from '../../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Event } from '../../../../../../base/common/event.js'; -import { MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { escapeMarkdownSyntaxTokens, MarkdownString, type IMarkdownString } from '../../../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { getMediaMime } from '../../../../../../base/common/mime.js'; @@ -28,7 +28,7 @@ import { ITerminalLogService, ITerminalProfile } from '../../../../../../platfor import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js'; import { IChatService, ChatRequestQueueKind, ElicitationState, type IChatExternalToolInvocationUpdate, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; -import { constObservable, type IObservable } from '../../../../../../base/common/observable.js'; +import { autorun, constObservable, type IObservable } from '../../../../../../base/common/observable.js'; import { ChatModel, 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'; @@ -621,7 +621,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const partialInput = context.rawInput as Partial | undefined; if (partialInput && typeof partialInput === 'object' && partialInput.command) { const truncatedCommand = buildCommandDisplayText(partialInput.command); - const invocationMessage = new MarkdownString(localize('runInTerminal.streaming', "Running `{0}`", truncatedCommand)); + const invocationMessage = new MarkdownString(localize('runInTerminal.streaming', "Running `{0}`", escapeMarkdownSyntaxTokens(truncatedCommand))); return { invocationMessage }; } return { invocationMessage: localize('runInTerminal.streaming.default', "Running command") }; @@ -896,8 +896,8 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ? rawDisplayCommand.substring(0, 77) + '...' : rawDisplayCommand; const invocationMessage = toolSpecificData.commandLine.isSandboxWrapped - ? new MarkdownString(localize('runInTerminal.invocation.sandbox', "Running `{0}` in sandbox", displayCommand)) - : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", displayCommand)); + ? new MarkdownString(localize('runInTerminal.invocation.sandbox', "Running `{0}` in sandbox", escapeMarkdownSyntaxTokens(displayCommand))) + : new MarkdownString(localize('runInTerminal.invocation', "Running `{0}`", escapeMarkdownSyntaxTokens(displayCommand))); return { invocationMessage, @@ -1029,7 +1029,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { new MarkdownString(localize( 'runInTerminal.unsandboxed.autoRetry.confirmationMessage', "`{0}`", - buildCommandDisplayText(command) + escapeMarkdownSyntaxTokens(buildCommandDisplayText(command)) )), '', localize('allow', 'Allow'), @@ -1078,7 +1078,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { toolCallId, toolName: localize('runInTerminalTool.displayName', 'Run in Terminal'), isComplete, - invocationMessage: new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry.invocation', "Running `{0}` outside the sandbox", displayCommand)), + invocationMessage: new MarkdownString(localize('runInTerminal.unsandboxed.autoRetry.invocation', "Running `{0}` outside the sandbox", escapeMarkdownSyntaxTokens(displayCommand))), pastTenseMessage: toolResultMessage, toolSpecificData, }; @@ -2116,6 +2116,33 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // stops asking questions and lets the user finish interacting with the terminal. let userIsReplyingDirectly = false; + const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(termId); + + // If the user manually stopped the agent, suppress background + // steering requests and tear down the notification listeners. + const handleSessionCancelled = (): boolean => { + if (sessionRef.object.lastRequest?.response?.isCanceled) { + disposeNotification(); + return true; + } + return false; + }; + + // Proactively detect session cancellation so that all background + // listeners are torn down immediately, rather than waiting for the + // next terminal event to fire and discover the cancelled state. + store.add(autorun(reader => { + const request = sessionRef.object.lastRequestObs.read(reader); + if (!request?.response) { + return; + } + reader.store.add(request.response.onDidChange(ev => { + if (ev.reason === 'completedRequest' && request.response!.isCanceled) { + disposeNotification(); + } + })); + })); + if (outputMonitor) { let lastInputNeededOutput = ''; let lastInputNeededNotificationTime = 0; @@ -2137,6 +2164,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return; } + if (handleSessionCancelled()) { + return; + } + const execution = RunInTerminalTool._activeExecutions.get(termId); if (!execution) { return; @@ -2159,7 +2190,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, - systemInitiatedLabel: localize('backgroundTaskNeedsInput', "Background task `{0}` needs input", commandName), + systemInitiatedLabel: localize('terminalNeedsInput', "`{0}` needs input", commandName), terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send input-needed notification for terminal ${termId}`, e); @@ -2181,8 +2212,6 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { store.add(sessionRef); - const disposeNotification = () => this._backgroundNotifications.deleteAndDispose(termId); - store.add(commandDetection.onCommandFinished(command => { const execution = RunInTerminalTool._activeExecutions.get(termId); if (!execution) { @@ -2190,6 +2219,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { return; } + if (handleSessionCancelled()) { + return; + } + // Dispose after first notification to avoid chatty repeated messages // if the user runs additional commands via send_to_terminal. disposeNotification(); @@ -2205,7 +2238,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { ...sendOptions, queue: ChatRequestQueueKind.Steering, isSystemInitiated: true, - systemInitiatedLabel: localize('backgroundTaskCompleted', "Background task `{0}` completed", commandName), + systemInitiatedLabel: localize('terminalCommandCompleted', "`{0}` completed", commandName), terminalExecutionId: termId, }).catch(e => { this._logService.warn(`RunInTerminalTool: Failed to send completion notification for terminal ${termId}`, e); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts index 99a9e6c634443..d970eaf235977 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/sendToTerminalTool.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { timeout } from '../../../../../../base/common/async.js'; import type { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; import { createCommandUri, isMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; @@ -16,6 +17,7 @@ import { IChatService, IChatMultiSelectAnswer, IChatQuestionAnswerValue, IChatQu 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 { URI } from '../../../../../../base/common/uri.js'; import { ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { getOutput } from '../outputHelpers.js'; import { buildCommandDisplayText, normalizeCommandForExecution } from '../runInTerminalHelpers.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; import { isSessionAutoApproveLevel } from './terminalToolAutoApprove.js'; @@ -25,7 +27,7 @@ export const SendToTerminalToolData: IToolData = { id: TerminalToolId.SendToTerminal, toolReferenceName: 'sendToTerminal', displayName: localize('sendToTerminalTool.displayName', 'Send to Terminal'), - modelDescription: `Send input text to a terminal session. This can target either a persistent terminal started with ${TerminalToolId.RunInTerminal} in async mode (using 'id') or any foreground terminal visible in the terminal panel (using 'terminalId'). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). After sending, use ${TerminalToolId.GetTerminalOutput} to check updated output for persistent terminals.`, + modelDescription: `Send input text to a terminal session. This can target either a persistent terminal started with ${TerminalToolId.RunInTerminal} in async mode (using 'id') or any foreground terminal visible in the terminal panel (using 'terminalId'). The 'command' field may be empty or whitespace to press Enter (useful for interactive prompts). The result includes the last few lines of terminal output captured shortly after sending.`, icon: Codicon.terminal, source: ToolDataSource.Internal, inputSchema: { @@ -361,10 +363,13 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { await instance.sendText(normalizeCommandForExecution(args.command), true); + await timeout(100); + const recentOutput = getOutput(instance, undefined, { lastNLines: 5 }); + return { content: [{ kind: 'text', - value: `Successfully sent command to foreground terminal ${args.terminalId}. Use ${TerminalToolId.GetTerminalOutput} with terminalId ${args.terminalId} to check for updated output.` + value: `Successfully sent command to foreground terminal ${args.terminalId}.${recentOutput ? `\n\nTerminal output (last 5 lines):\n${recentOutput}` : ''}` }] }; } @@ -382,10 +387,13 @@ export class SendToTerminalTool extends Disposable implements IToolImpl { await execution.instance.sendText(normalizeCommandForExecution(args.command), true); + await timeout(100); + const recentOutput = getOutput(execution.instance, undefined, { lastNLines: 5 }); + return { content: [{ kind: 'text', - value: `Successfully sent command to terminal ${args.id}. Use ${TerminalToolId.GetTerminalOutput} to check for updated output.` + value: `Successfully sent command to terminal ${args.id}.${recentOutput ? `\n\nTerminal output (last 5 lines):\n${recentOutput}` : ''}` }] }; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index 9c4bfd155bff1..0430a4d4d1111 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -271,6 +271,15 @@ suite('OutputMonitor', () => { false ); }); + test('PowerShell regex does not cause catastrophic backtracking (ReDoS)', () => { + // Pathological input: many spaces not followed by a bracket. + // With the old overlapping regex this would hang; it must return promptly. + const start = performance.now(); + const pathological = '[Y] Yes' + ' '.repeat(200) + 'x'; + detectsInputRequiredPattern(pathological); + const elapsed = performance.now() - start; + assert.ok(elapsed < 500, `Regex took ${elapsed}ms on pathological input, expected < 500ms`); + }); test('Line ends with colon', () => { assert.strictEqual(detectsInputRequiredPattern('Enter your name: '), true); assert.strictEqual(detectsInputRequiredPattern('Password: '), true); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts index 93dd7ccfa2bc4..131abd1c75386 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/sendToTerminalTool.test.ts @@ -111,7 +111,6 @@ suite('SendToTerminalTool', () => { const value = (result.content[0] as { value: string }).value; assert.ok(value.includes('Successfully sent command')); assert.ok(value.includes(KNOWN_TERMINAL_ID)); - assert.ok(value.includes('get_terminal_output'), 'should direct agent to use get_terminal_output'); // Verify sendText was called with shouldExecute=true assert.strictEqual(mockExecution.sentTexts.length, 1); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 3dc146a4eac5a..e1f1a20c65446 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -7,6 +7,7 @@ import { ok, strictEqual } from 'assert'; import { Separator } from '../../../../../../base/common/actions.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { constObservable } from '../../../../../../base/common/observable.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { isLinux, isWindows, OperatingSystem } from '../../../../../../base/common/platform.js'; import { count } from '../../../../../../base/common/strings.js'; @@ -36,7 +37,7 @@ import { IChatWidgetService } from '../../../../chat/browser/chat.js'; import { ChatPermissionLevel } from '../../../../chat/common/constants.js'; import { LocalChatSessionUri } from '../../../../chat/common/model/chatUri.js'; import { ITerminalSandboxService, TerminalSandboxPrerequisiteCheck, type ITerminalSandboxPrerequisiteCheckResult } from '../../common/terminalSandboxService.js'; -import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocationPreparationContext, ToolDataSource, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, ToolDataSource, ToolSet, type ToolConfirmationAction } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; import type { ICommandLinePresenter } from '../../browser/tools/commandLinePresenter/commandLinePresenter.js'; @@ -150,6 +151,7 @@ suite('RunInTerminalTool', () => { acquireExistingSession: () => ({ object: { lastRequest: undefined, + lastRequestObs: constObservable(undefined), onDidChange: Event.None, }, dispose: () => { }, @@ -2214,6 +2216,24 @@ suite('RunInTerminalTool', () => { }); suite('ConfirmTerminalCommandTool', () => { + async function invokeConfirmTool(tool: RunInTerminalTool, original: string, userEdited?: string) { + const invocation = { + callId: 'test-call-id', + toolId: 'confirmTerminalCommand', + parameters: {}, + context: undefined, + toolSpecificData: { + kind: 'terminal', + commandLine: { + original, + userEdited, + }, + language: 'bash', + } as IChatTerminalToolInvocationData + } as IToolInvocation; + return tool.invoke(invocation, () => Promise.resolve(0), { report: () => { } }, CancellationToken.None); + } + test('should require confirmation when sandbox is enabled but sandbox rewriting is disabled', async () => { sandboxEnabled = true; @@ -2254,6 +2274,36 @@ suite('RunInTerminalTool', () => { const result = await confirmTool.prepareToolInvocation(context, CancellationToken.None); assertConfirmationRequired(result); }); + + test('invoke should return approved message when user does not edit command', async () => { + const { ConfirmTerminalCommandTool } = await import('../../browser/tools/runInTerminalConfirmationTool.js'); + const confirmTool = store.add(instantiationService.createInstance(ConfirmTerminalCommandTool)); + + const result = await invokeConfirmTool(confirmTool, 'echo hello'); + strictEqual(result.content[0].kind, 'text'); + strictEqual((result.content[0] as { kind: 'text'; value: string }).value, 'The user approved the command.'); + }); + + test('invoke should return edited command when user edits the command', async () => { + const { ConfirmTerminalCommandTool } = await import('../../browser/tools/runInTerminalConfirmationTool.js'); + const confirmTool = store.add(instantiationService.createInstance(ConfirmTerminalCommandTool)); + + const result = await invokeConfirmTool(confirmTool, 'echo hello', 'echo stop'); + strictEqual(result.content[0].kind, 'text'); + const textValue = (result.content[0] as { kind: 'text'; value: string }).value; + ok(textValue.includes(''), 'Result should contain editedCommand tags'); + ok(textValue.includes('echo stop'), 'Result should contain the edited command'); + ok(textValue.includes('edited'), 'Result should indicate the command was edited'); + }); + + test('invoke should return approved message when userEdited equals original', async () => { + const { ConfirmTerminalCommandTool } = await import('../../browser/tools/runInTerminalConfirmationTool.js'); + const confirmTool = store.add(instantiationService.createInstance(ConfirmTerminalCommandTool)); + + const result = await invokeConfirmTool(confirmTool, 'echo hello', 'echo hello'); + strictEqual(result.content[0].kind, 'text'); + strictEqual((result.content[0] as { kind: 'text'; value: string }).value, 'The user approved the command.'); + }); }); }); diff --git a/src/vs/workbench/contrib/terminalContrib/inlineHint/common/terminalInitialHintConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/inlineHint/common/terminalInitialHintConfiguration.ts index a66d4c70f2354..54a2ed8bbbab1 100644 --- a/src/vs/workbench/contrib/terminalContrib/inlineHint/common/terminalInitialHintConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/inlineHint/common/terminalInitialHintConfiguration.ts @@ -22,7 +22,7 @@ export const terminalInitialHintConfiguration: IStringDictionary('commandCenter.chatInstall', { installResult: 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); + // Run chat setup in the background (sign-up, extension install, entitlement resolution) + this.commandService.executeCommand('workbench.action.chat.triggerSetup', undefined, { + disableChatViewReveal: true, + setupStrategy: ChatSetupStrategy.DefaultSetup, + }); this._nextStep(); } } catch (error) { @@ -592,6 +603,10 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi if (account) { this._userSignedIn = true; this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'installed', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); + this.commandService.executeCommand('workbench.action.chat.triggerSetup', undefined, { + disableChatViewReveal: true, + setupStrategy: ChatSetupStrategy.DefaultSetup, + }); this._nextStep(); } } catch (error) { @@ -743,6 +758,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } pill.classList.add('selected'); pill.setAttribute('aria-checked', 'true'); + this.accessibilityService.alert(localize('onboarding.keymap.selected.alert', "{0} keyboard mapping selected", keymap.label)); })); } const selectedKeymapIndex = keymapOptions.findIndex(k => k.id === this.selectedKeymapId); @@ -765,6 +781,20 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi ); } + private _renderExtensionsSubtitle(container: HTMLElement): void { + clearNode(container); + const modifier = isMacintosh ? 'Cmd' : 'Ctrl'; + container.append( + localize('onboarding.extensions.subtitle.prefix', "Install extensions to enhance your workflow. Press "), + this._createKbd(localize({ key: 'onboarding.extensions.subtitle.modifier', comment: ['Keyboard modifier key'] }, "{0}", modifier)), + '+', + this._createKbd(localize('onboarding.extensions.subtitle.shift', "Shift")), + '+', + this._createKbd(localize('onboarding.extensions.subtitle.x', "X")), + localize('onboarding.extensions.subtitle.suffix', " to browse the Extension Marketplace."), + ); + } + private _createThemeCard(parent: HTMLElement, theme: IOnboardingThemeOption, allCards: HTMLElement[]): void { const card = this._registerStepFocusable(append(parent, $('div.onboarding-a-theme-card'))); allCards.push(card); @@ -795,6 +825,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } card.classList.add('selected'); card.setAttribute('aria-checked', 'true'); + this.accessibilityService.alert(localize('onboarding.theme.selected.alert', "{0} theme selected", theme.label)); })); this.stepDisposables.add(addDisposableListener(card, EventType.KEY_DOWN, (e: KeyboardEvent) => { @@ -841,6 +872,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi const installBtn = this._registerStepFocusable(append(row, $('button.onboarding-a-ext-install'))); installBtn.type = 'button'; installBtn.textContent = localize('onboarding.ext.install', "Install"); + installBtn.setAttribute('aria-label', localize('onboarding.ext.install.aria', "Install {0}", ext.name)); this.stepDisposables.add(addDisposableListener(installBtn, EventType.CLICK, () => { this._logAction('installExtension', undefined, ext.id); @@ -850,6 +882,8 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi () => { installBtn.textContent = localize('onboarding.ext.installed', "Installed"); installBtn.classList.add('installed'); + installBtn.setAttribute('aria-label', localize('onboarding.ext.installed.aria', "{0} installed", ext.name)); + this.accessibilityService.alert(localize('onboarding.ext.installed.alert', "{0} has been installed", ext.name)); }, () => { installBtn.textContent = localize('onboarding.ext.install', "Install"); @@ -1068,6 +1102,7 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi c.setAttribute('aria-checked', c.dataset.id === option.id ? 'true' : 'false'); } this._applyAiPreference(option.id); + this.accessibilityService.alert(localize('onboarding.aiPref.selected.alert', "{0} selected", option.label)); })); } const selectedAiIndex = ONBOARDING_AI_PREFERENCE_OPTIONS.findIndex(o => o.id === this.selectedAiMode); @@ -1148,7 +1183,10 @@ export class OnboardingVariationA extends Disposable implements IOnboardingServi } private _createFeatureCard(parent: HTMLElement, icon: ThemeIcon, title: string, description?: string): HTMLElement { - const card = append(parent, $('div.onboarding-a-feature-card')); + const card = this._registerStepFocusable(append(parent, $('div.onboarding-a-feature-card'))); + card.setAttribute('tabindex', '0'); + card.setAttribute('role', 'group'); + card.setAttribute('aria-label', title); const iconCol = append(card, $('div.onboarding-a-feature-icon')); iconCol.appendChild(renderIcon(icon)); const textCol = append(card, $('div.onboarding-a-feature-text')); diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index 6a394c158b2ae..58e75115de047 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -343,7 +343,7 @@ declare module 'vscode' { /** * Statistics about the chat session. */ - changes?: readonly ChatSessionChangedFile[] | readonly ChatSessionChangedFile2[]; + changes?: readonly ChatSessionChangedFile[]; /** * Arbitrary metadata for the chat session. Can be anything, but must be JSON-stringifyable. @@ -353,34 +353,7 @@ declare module 'vscode' { metadata?: { readonly [key: string]: any }; } - /** - * @deprecated Use `ChatSessionChangedFile2` instead - */ export class ChatSessionChangedFile { - /** - * URI of the file. - */ - modifiedUri: Uri; - - /** - * File opened when the user takes the 'compare' action. - */ - originalUri?: Uri; - - /** - * Number of insertions made during the session. - */ - insertions: number; - - /** - * Number of deletions made during the session. - */ - deletions: number; - - constructor(modifiedUri: Uri, insertions: number, deletions: number, originalUri?: Uri); - } - - export class ChatSessionChangedFile2 { /** * URI of the file. */ @@ -728,9 +701,18 @@ declare module 'vscode' { export interface ChatSessionInputState { /** * Fired when the input state is changed by the user. + * + * Move to controller? */ readonly onDidChange: Event; + /** + * The resource associated with this chat session. + * + * This is `undefined` for chat sessions that have not yet started. + */ + readonly sessionResource: Uri | undefined; + /** * The groups of options to show in the UI for user input. * diff --git a/test/sanity/package-lock.json b/test/sanity/package-lock.json index c441eb7cf188c..6a37be3bec39a 100644 --- a/test/sanity/package-lock.json +++ b/test/sanity/package-lock.json @@ -113,9 +113,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -941,9 +941,9 @@ } }, "node_modules/serialize-javascript": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.4.tgz", - "integrity": "sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "license": "BSD-3-Clause", "engines": { "node": ">=20.0.0"