diff --git a/.github/CODENOTIFY b/.github/CODENOTIFY index 9b07c27526f..7aba51a470b 100644 --- a/.github/CODENOTIFY +++ b/.github/CODENOTIFY @@ -65,7 +65,6 @@ src/vs/code/** @bpasero @deepak1556 src/vs/workbench/services/activity/** @bpasero src/vs/workbench/services/authentication/** @TylerLeonhardt src/vs/workbench/services/auxiliaryWindow/** @bpasero -src/vs/workbench/services/chat/** @bpasero src/vs/workbench/services/contextmenu/** @bpasero src/vs/workbench/services/dialogs/** @alexr00 @bpasero src/vs/workbench/services/editor/** @bpasero @@ -100,15 +99,6 @@ src/vs/workbench/electron-browser/** @bpasero src/vs/workbench/contrib/authentication/** @TylerLeonhardt src/vs/workbench/contrib/files/** @bpasero src/vs/workbench/contrib/chat/browser/chatListRenderer.ts @roblourens -src/vs/workbench/contrib/chat/browser/chatSetup/** @bpasero -src/vs/workbench/contrib/chat/browser/chatStatus/** @bpasero -src/vs/workbench/contrib/chat/browser/chatViewPane.ts @bpasero -src/vs/workbench/contrib/chat/browser/media/chatViewPane.css @bpasero -src/vs/workbench/contrib/chat/browser/chatViewTitleControl.ts @bpasero -src/vs/workbench/contrib/chat/browser/media/chatViewTitleControl.css @bpasero -src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts @bpasero -src/vs/workbench/contrib/chat/browser/chatManagement/media/chatUsageWidget.css @bpasero -src/vs/workbench/contrib/chat/browser/agentSessions/** @bpasero src/vs/workbench/contrib/localization/** @TylerLeonhardt src/vs/workbench/contrib/quickaccess/browser/commandsQuickAccess.ts @TylerLeonhardt src/vs/workbench/contrib/scm/** @lszomoru diff --git a/.github/prompts/fix-error.prompt.md b/.github/prompts/fix-error.prompt.md new file mode 100644 index 00000000000..1cdc8fa90b4 --- /dev/null +++ b/.github/prompts/fix-error.prompt.md @@ -0,0 +1,17 @@ +--- +agent: agent +description: 'Fix an unhandled error from the VS Code error telemetry dashboard' +argument-hint: Paste the GitHub issue URL for the error-telemetry issue +tools: ['edit', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'execute/createAndRunTask', 'execute/runTask', 'read/getTaskOutput', 'search/usages', 'read/problems', 'search/changes', 'execute/testFailure', 'todo', 'execute/runTests', 'web/fetch', 'web/githubRepo'] +--- + +The user has given you a GitHub issue URL for an unhandled error from the VS Code error telemetry dashboard. Fetch the issue to retrieve its details (error message, stack trace, hit count, affected users). + +Follow the `fix-errors` skill guidelines to fix this error. Key principles: + +1. **Do NOT fix at the crash site.** Do not add guards, try/catch, or fallback values at the bottom of the stack trace. That only masks the problem. +2. **Trace the data flow upward** through the call stack to find the producer of invalid data. +3. **If the producer is cross-process** (e.g., IPC) and cannot be identified from the stack alone, **enrich the error message** with diagnostic context (data type, truncated value, operation name) so the next telemetry cycle reveals the source. Do NOT silently swallow the error. +4. **If the producer is identifiable**, fix it directly. + +After making changes, check for compilation errors via the build task and run relevant unit tests. diff --git a/.github/skills/fix-errors/SKILL.md b/.github/skills/fix-errors/SKILL.md new file mode 100644 index 00000000000..7a03d11ee0c --- /dev/null +++ b/.github/skills/fix-errors/SKILL.md @@ -0,0 +1,71 @@ +--- +name: fix-errors +description: Guidelines for fixing unhandled errors from the VS Code error telemetry dashboard. Use when investigating error-telemetry issues with stack traces, error messages, and hit/user counts. Covers tracing data flow through call stacks, identifying producers of invalid data vs. consumers that crash, enriching error messages for telemetry diagnosis, and avoiding common anti-patterns like silently swallowing errors. +--- + +When fixing an unhandled error from the telemetry dashboard, the issue typically contains an error message, a stack trace, hit count, and affected user count. + +## Approach + +### 1. Do NOT fix at the crash site + +The error manifests at a specific line in the stack trace, but **the fix almost never belongs there**. Fixing at the crash site (e.g., adding a `typeof` guard in a `revive()` function, swallowing the error with a try/catch, or returning a fallback value) only masks the real problem. The invalid data still flows through the system and will cause failures elsewhere. + +### 2. Trace the data flow upward through the call stack + +Read each frame in the stack trace from bottom to top. For each frame, understand: +- What data is being passed and what is expected +- Where that data originated (IPC message, extension API call, storage, user input, etc.) +- Whether the data could have been corrupted or malformed at that point + +The goal is to find the **producer of invalid data**, not the consumer that crashes on it. + +### 3. When the producer cannot be identified from the stack alone + +Sometimes the stack trace only shows the receiving/consuming side (e.g., an IPC server handler). The sending side is in a different process and not in the stack. In this case: + +- **Enrich the error message** at the consuming site with diagnostic context: the type of the invalid data, a truncated representation of its value, and which operation/command received it. This information flows into the error telemetry dashboard automatically via the unhandled error pipeline. +- **Do NOT silently swallow the error** — let it still throw so it remains visible in telemetry, but with enough context to identify the sender in the next telemetry cycle. +- Consider adding the same enrichment to the low-level validation function that throws (e.g., include the invalid value in the error message) so the telemetry captures it regardless of call site. + +### 4. When the producer IS identifiable + +Fix the producer directly: +- Validate or sanitize data before sending it over IPC / storing it / passing it to APIs +- Ensure serialization/deserialization preserves types correctly (e.g., URI objects should serialize as `UriComponents` objects, not as strings) + +## Example + +Given a stack trace like: +``` +at _validateUri (uri.ts) ← validation throws +at new Uri (uri.ts) ← constructor +at URI.revive (uri.ts) ← revive assumes valid UriComponents +at SomeChannel.call (ipc.ts) ← IPC handler receives arg from another process +``` + +**Wrong fix**: Add a `typeof` guard in `URI.revive` to return `undefined` for non-object input. This silences the error but the caller still expects a valid URI and will fail later. + +**Right fix (when producer is unknown)**: Enrich the error at the IPC handler level and in `_validateUri` itself to include the actual invalid value, so telemetry reveals what data is being sent and from where. Example: +```typescript +// In the IPC handler — validate before revive +function reviveUri(data: UriComponents | URI | undefined | null, context: string): URI { + if (data && typeof data !== 'object') { + throw new Error(`[Channel] Invalid URI data for '${context}': type=${typeof data}, value=${String(data).substring(0, 100)}`); + } + // ... +} + +// In _validateUri — include the scheme value +throw new Error(`[UriError]: Scheme contains illegal characters. scheme:"${ret.scheme.substring(0, 50)}" (len:${ret.scheme.length})`); +``` + +**Right fix (when producer is known)**: Fix the code that sends malformed data. For example, if an authentication provider passes a stringified URI instead of a `UriComponents` object to a logger creation call, fix that call site to pass the proper object. + +## Guidelines + +- Prefer enriching error messages over adding try/catch guards +- Truncate any user-controlled values included in error messages (to avoid PII and keep messages bounded) +- Do not change the behavior of shared utility functions (like `URI.revive`) in ways that affect all callers — fix at the specific call site or producer +- Run the relevant unit tests after making changes +- Check for compilation errors via the build task before declaring work complete diff --git a/.vscode/launch.json b/.vscode/launch.json index 9dbed82ee94..74dfd6a3da6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -602,6 +602,17 @@ "order": 4 } }, + { + "name": "Component Explorer", + "type": "chrome", + "request": "launch", + "url": "http://localhost:5199/___explorer", + "preLaunchTask": "Launch Monaco Editor Vite", + "presentation": { + "group": "monaco", + "order": 4 + } + }, { "name": "Monaco Editor - Self Contained Diff Editor", "type": "chrome", diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f601633b570..14e637aaf2d 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,10 +1,40 @@ { "version": "2.0.0", "tasks": [ + { + "type": "npm", + "script": "watch-client-transpiled", + "label": "Core - Transpile", + "isBackground": true, + "presentation": { + "reveal": "never", + "group": "buildWatchers", + "close": false + }, + "problemMatcher": { + "owner": "esbuild", + "applyTo": "closedDocuments", + "fileLocation": [ + "relative", + "${workspaceFolder}/src" + ], + "pattern": { + "regexp": "^(.+?):(\\d+):(\\d+): ERROR: (.+)$", + "file": 1, + "line": 2, + "column": 3, + "message": 4 + }, + "background": { + "beginsPattern": "Starting transpilation...", + "endsPattern": "Finished transpilation with" + } + } + }, { "type": "npm", "script": "watch-clientd", - "label": "Core - Build", + "label": "Core - Typecheck", "isBackground": true, "presentation": { "reveal": "never", @@ -60,7 +90,8 @@ { "label": "VS Code - Build", "dependsOn": [ - "Core - Build", + "Core - Transpile", + "Core - Typecheck", "Ext - Build" ], "group": { @@ -69,10 +100,22 @@ }, "problemMatcher": [] }, + { + "type": "npm", + "script": "kill-watch-client-transpiled", + "label": "Kill Core - Transpile", + "group": "build", + "presentation": { + "reveal": "never", + "group": "buildKillers", + "close": true + }, + "problemMatcher": "$tsc" + }, { "type": "npm", "script": "kill-watch-clientd", - "label": "Kill Core - Build", + "label": "Kill Core - Typecheck", "group": "build", "presentation": { "reveal": "never", @@ -96,7 +139,8 @@ { "label": "Kill VS Code - Build", "dependsOn": [ - "Kill Core - Build", + "Kill Core - Transpile", + "Kill Core - Typecheck", "Kill Ext - Build" ], "group": "build", diff --git a/build/azure-pipelines/product-build.yml b/build/azure-pipelines/product-build.yml index 897c27e680d..b440fe34058 100644 --- a/build/azure-pipelines/product-build.yml +++ b/build/azure-pipelines/product-build.yml @@ -158,10 +158,6 @@ variables: name: "$(Date:yyyyMMdd).$(Rev:r) (${{ parameters.VSCODE_QUALITY }})" resources: - pipelines: - - pipeline: vscode-7pm-kick-off - source: 'VS Code 7PM Kick-Off' - trigger: true repositories: - repository: 1esPipelines type: git diff --git a/extensions/media-preview/extension.webpack.config.js b/build/buildConfig.ts similarity index 57% rename from extensions/media-preview/extension.webpack.config.js rename to build/buildConfig.ts index 4928186ae55..a4299d2647f 100644 --- a/extensions/media-preview/extension.webpack.config.js +++ b/build/buildConfig.ts @@ -2,15 +2,11 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - } -}); +/** + * When `true`, self-hosting uses esbuild for fast transpilation (build/next) + * and gulp-tsb only for type-checking (`noEmit`). + * + * When `false`, gulp-tsb does both transpilation and type-checking (old behavior). + */ +export const useEsbuildTranspile = true; diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index d77b2931d9b..f48738be53a 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -166,7 +166,7 @@ const tasks = compilations.map(function (tsconfigFile) { const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, async () => { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const copyNonTs = util.streamToPromise(nonts.pipe(gulp.dest(out))); - const tsgo = spawnTsgo(absolutePath, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); + const tsgo = spawnTsgo(absolutePath, { reporterId: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); await Promise.all([copyNonTs, tsgo]); })); @@ -175,7 +175,7 @@ const tasks = compilations.map(function (tsconfigFile) { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); - const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { reporterId: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); return watchStream; @@ -273,11 +273,33 @@ gulp.task(compileWebExtensionsTask); export const watchWebExtensionsTask = task.define('watch-web', () => buildWebExtensions(true)); gulp.task(watchWebExtensionsTask); -async function buildWebExtensions(isWatch: boolean) { +async function buildWebExtensions(isWatch: boolean): Promise { const extensionsPath = path.join(root, 'extensions'); - const webpackConfigLocations = await nodeUtil.promisify(glob)( - path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), + + // Find all esbuild-browser.ts files + const esbuildConfigLocations = await nodeUtil.promisify(glob)( + path.join(extensionsPath, '**', 'esbuild-browser.ts'), { ignore: ['**/node_modules'] } ); - return ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath }))); + + // Find all webpack configs, excluding those that will be esbuilt + const esbuildExtensionDirs = new Set(esbuildConfigLocations.map(p => path.dirname(p))); + const webpackConfigLocations = (await nodeUtil.promisify(glob)( + path.join(extensionsPath, '**', 'extension-browser.webpack.config.js'), + { ignore: ['**/node_modules'] } + )).filter(configPath => !esbuildExtensionDirs.has(path.dirname(configPath))); + + const promises: Promise[] = []; + + // Esbuild for extensions + if (esbuildConfigLocations.length > 0) { + promises.push(ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script })))); + } + + // Run webpack for remaining extensions + if (webpackConfigLocations.length > 0) { + promises.push(ext.webpackExtensions('packaging web extension', isWatch, webpackConfigLocations.map(configPath => ({ configPath })))); + } + + await Promise.all(promises); } diff --git a/build/gulpfile.ts b/build/gulpfile.ts index a57218b8445..1c21618ca60 100644 --- a/build/gulpfile.ts +++ b/build/gulpfile.ts @@ -13,6 +13,7 @@ import { compileExtensionMediaTask, compileExtensionsTask, watchExtensionsTask } import * as compilation from './lib/compilation.ts'; import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; +import { useEsbuildTranspile } from './buildConfig.ts'; const require = createRequire(import.meta.url); @@ -32,7 +33,9 @@ gulp.task(transpileClientTask); const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compilation.compileApiProposalNamesTask, compilation.compileTask('src', 'out', false))); gulp.task(compileClientTask); -const watchClientTask = task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask))); +const watchClientTask = useEsbuildTranspile + ? task.define('watch-client', task.parallel(compilation.watchTask('out', false, 'src', { noEmit: true }), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask)) + : task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask))); gulp.task(watchClientTask); // All diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 614f2ee7d1c..19504aaf7c7 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -15,7 +15,7 @@ import electron from '@vscode/gulp-electron'; import jsonEditor from 'gulp-json-editor'; import * as util from './lib/util.ts'; import { getVersion } from './lib/getVersion.ts'; -import { readISODate } from './lib/date.ts'; +import { readISODate, writeISODate } from './lib/date.ts'; import * as task from './lib/task.ts'; import buildfile from './buildfile.ts'; import * as optimize from './lib/optimize.ts'; @@ -30,9 +30,12 @@ import { createAsar } from './lib/asar.ts'; import minimist from 'minimist'; import { compileBuildWithoutManglingTask, compileBuildWithManglingTask } from './gulpfile.compile.ts'; import { compileNonNativeExtensionsBuildTask, compileNativeExtensionsBuildTask, compileAllExtensionsBuildTask, compileExtensionMediaBuildTask, cleanExtensionsBuildTask } from './gulpfile.extensions.ts'; +import { copyCodiconsTask } from './lib/compilation.ts'; +import { useEsbuildTranspile } from './buildConfig.ts'; import { promisify } from 'util'; import globCallback from 'glob'; import rceditCallback from 'rcedit'; +import * as cp from 'child_process'; const glob = promisify(globCallback); @@ -152,6 +155,81 @@ const bundleVSCodeTask = task.define('bundle-vscode', task.series( )); gulp.task(bundleVSCodeTask); +// esbuild-based bundle tasks (drop-in replacement for bundle-vscode / minify-vscode) +function runEsbuildTranspile(outDir: string, excludeTests: boolean): Promise { + return new Promise((resolve, reject) => { + const scriptPath = path.join(root, 'build/next/index.ts'); + const args = [scriptPath, 'transpile', '--out', outDir]; + if (excludeTests) { + args.push('--exclude-tests'); + } + + const proc = cp.spawn(process.execPath, args, { + cwd: root, + stdio: 'inherit' + }); + + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`esbuild transpile failed with exit code ${code} (outDir: ${outDir})`)); + } + }); + }); +} + +function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, target: 'desktop' | 'server' | 'server-web' = 'desktop', sourceMapBaseUrl?: string): Promise { + return new Promise((resolve, reject) => { + // const tsxPath = path.join(root, 'build/node_modules/tsx/dist/cli.mjs'); + const scriptPath = path.join(root, 'build/next/index.ts'); + const args = [scriptPath, 'bundle', '--out', outDir, '--target', target]; + if (minify) { + args.push('--minify'); + } + if (nls) { + args.push('--nls'); + } + if (sourceMapBaseUrl) { + args.push('--source-map-base-url', sourceMapBaseUrl); + } + + const proc = cp.spawn(process.execPath, args, { + cwd: root, + stdio: 'inherit' + }); + + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`esbuild bundle failed with exit code ${code} (outDir: ${outDir}, minify: ${minify}, nls: ${nls}, target: ${target})`)); + } + }); + }); +} + +function runTsGoTypeCheck(): Promise { + return new Promise((resolve, reject) => { + const proc = cp.spawn('tsgo', ['--project', 'src/tsconfig.json', '--noEmit', '--skipLibCheck'], { + cwd: root, + stdio: 'inherit', + shell: true + }); + + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`tsgo typecheck failed with exit code ${code}`)); + } + }); + }); +} + const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; const minifyVSCodeTask = task.define('minify-vscode', task.series( bundleVSCodeTask, @@ -160,7 +238,7 @@ const minifyVSCodeTask = task.define('minify-vscode', task.series( )); gulp.task(minifyVSCodeTask); -const coreCI = task.define('core-ci', task.series( +const coreCIOld = task.define('core-ci-old', task.series( gulp.task('compile-build-with-mangling') as task.Task, task.parallel( gulp.task('minify-vscode') as task.Task, @@ -168,7 +246,28 @@ const coreCI = task.define('core-ci', task.series( gulp.task('minify-vscode-reh-web') as task.Task, ) )); -gulp.task(coreCI); +gulp.task(coreCIOld); + +const coreCIEsbuild = task.define('core-ci-esbuild', task.series( + copyCodiconsTask, + cleanExtensionsBuildTask, + compileNonNativeExtensionsBuildTask, + compileExtensionMediaBuildTask, + writeISODate('out-build'), + // Type-check with tsgo (no emit) + task.define('tsgo-typecheck', () => runTsGoTypeCheck()), + // Transpile individual files to out-build first (for unit tests) + task.define('esbuild-out-build', () => runEsbuildTranspile('out-build', false)), + // Then bundle for shipping (bundles also write NLS files to out-build) + task.parallel( + task.define('esbuild-vscode-min', () => runEsbuildBundle('out-vscode-min', true, true, 'desktop', `${sourceMappingURLBase}/core`)), + task.define('esbuild-vscode-reh-min', () => runEsbuildBundle('out-vscode-reh-min', true, true, 'server', `${sourceMappingURLBase}/core`)), + task.define('esbuild-vscode-reh-web-min', () => runEsbuildBundle('out-vscode-reh-web-min', true, true, 'server-web', `${sourceMappingURLBase}/core`)), + ) +)); +gulp.task(coreCIEsbuild); + +gulp.task(task.define('core-ci', useEsbuildTranspile ? coreCIEsbuild : coreCIOld)); const coreCIPR = task.define('core-ci-pr', task.series( gulp.task('compile-build-without-mangling') as task.Task, @@ -516,27 +615,44 @@ BUILD_TARGETS.forEach(buildTarget => { const sourceFolderName = `out-vscode${dashed(minified)}`; const destinationFolderName = `VSCode${dashed(platform)}${dashed(arch)}`; - const tasks = [ + const packageTasks: task.Task[] = [ compileNativeExtensionsBuildTask, util.rimraf(path.join(buildRoot, destinationFolderName)), packageTask(platform, arch, sourceFolderName, destinationFolderName, opts) ]; if (platform === 'win32') { - tasks.push(patchWin32DependenciesTask(destinationFolderName)); + packageTasks.push(patchWin32DependenciesTask(destinationFolderName)); } - const vscodeTaskCI = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}-ci`, task.series(...tasks)); + const vscodeTaskCI = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}-ci`, task.series(...packageTasks)); gulp.task(vscodeTaskCI); - const vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( - minified ? compileBuildWithManglingTask : compileBuildWithoutManglingTask, - cleanExtensionsBuildTask, - compileNonNativeExtensionsBuildTask, - compileExtensionMediaBuildTask, - minified ? minifyVSCodeTask : bundleVSCodeTask, - vscodeTaskCI - )); + let vscodeTask: task.Task; + if (useEsbuildTranspile) { + const esbuildBundleTask = task.define( + `esbuild-bundle${dashed(platform)}${dashed(arch)}${dashed(minified)}`, + () => runEsbuildBundle(sourceFolderName, !!minified, true, 'desktop', minified ? `${sourceMappingURLBase}/core` : undefined) + ); + vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( + copyCodiconsTask, + cleanExtensionsBuildTask, + compileNonNativeExtensionsBuildTask, + compileExtensionMediaBuildTask, + writeISODate('out-build'), + esbuildBundleTask, + vscodeTaskCI + )); + } else { + vscodeTask = task.define(`vscode${dashed(platform)}${dashed(arch)}${dashed(minified)}`, task.series( + minified ? compileBuildWithManglingTask : compileBuildWithoutManglingTask, + cleanExtensionsBuildTask, + compileNonNativeExtensionsBuildTask, + compileExtensionMediaBuildTask, + minified ? minifyVSCodeTask : bundleVSCodeTask, + vscodeTaskCI + )); + } gulp.task(vscodeTask); return vscodeTask; @@ -568,7 +684,7 @@ const innoSetupConfig: Record { + return new Promise((resolve, reject) => { + const scriptPath = path.join(REPO_ROOT, 'build/next/index.ts'); + const args = [scriptPath, 'bundle', '--out', outDir, '--target', 'web']; + if (minify) { + args.push('--minify'); + } + if (nls) { + args.push('--nls'); + } + + const proc = cp.spawn(process.execPath, args, { + cwd: REPO_ROOT, + stdio: 'inherit' + }); + + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`esbuild web bundle failed with exit code ${code} (outDir: ${outDir}, minify: ${minify}, nls: ${nls})`)); + } + }); + }); +} + export const vscodeWebResourceIncludes = [ // NLS @@ -110,7 +140,7 @@ export const createVSCodeWebFileContentMapper = (extensionsRoot: string, product }; }; -const bundleVSCodeWebTask = task.define('bundle-vscode-web', task.series( +const bundleVSCodeWebTask = task.define('bundle-vscode-web-OLD', task.series( util.rimraf('out-vscode-web'), optimize.bundleTask( { @@ -125,13 +155,17 @@ const bundleVSCodeWebTask = task.define('bundle-vscode-web', task.series( ) )); -const minifyVSCodeWebTask = task.define('minify-vscode-web', task.series( +const minifyVSCodeWebTask = task.define('minify-vscode-web-OLD', task.series( bundleVSCodeWebTask, util.rimraf('out-vscode-web-min'), optimize.minifyTask('out-vscode-web', `https://main.vscode-cdn.net/sourcemaps/${commit}/core`) )); gulp.task(minifyVSCodeWebTask); +// esbuild-based tasks (new) +const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', () => runEsbuildBundle('out-vscode-web', false, true)); +const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true)); + function packageTask(sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); @@ -197,8 +231,9 @@ const dashed = (str: string) => (str ? `-${str}` : ``); const destinationFolderName = `vscode-web`; const vscodeWebTaskCI = task.define(`vscode-web${dashed(minified)}-ci`, task.series( + copyCodiconsTask, compileWebExtensionsBuildTask, - minified ? minifyVSCodeWebTask : bundleVSCodeWebTask, + minified ? esbuildBundleVSCodeWebMinTask : esbuildBundleVSCodeWebTask, util.rimraf(path.join(BUILD_ROOT, destinationFolderName)), packageTask(sourceFolderName, destinationFolderName) )); diff --git a/build/lib/compilation.ts b/build/lib/compilation.ts index f440dc28dd0..2484e884a55 100644 --- a/build/lib/compilation.ts +++ b/build/lib/compilation.ts @@ -49,14 +49,18 @@ interface ICompileTaskOptions { readonly emitError: boolean; readonly transpileOnly: boolean | { esbuild: boolean }; readonly preserveEnglish: boolean; + readonly noEmit?: boolean; } -export function createCompile(src: string, { build, emitError, transpileOnly, preserveEnglish }: ICompileTaskOptions) { +export function createCompile(src: string, { build, emitError, transpileOnly, preserveEnglish, noEmit }: ICompileTaskOptions) { const projectPath = path.join(import.meta.dirname, '../../', src, 'tsconfig.json'); const overrideOptions = { ...getTypeScriptCompilerOptions(src), inlineSources: Boolean(build) }; if (!build) { overrideOptions.inlineSourceMap = true; } + if (noEmit) { + overrideOptions.noEmit = true; + } const compilation = tsb.create(projectPath, overrideOptions, { verbose: false, @@ -163,10 +167,10 @@ export function compileTask(src: string, out: string, build: boolean, options: { return task; } -export function watchTask(out: string, build: boolean, srcPath: string = 'src'): task.StreamTask { +export function watchTask(out: string, build: boolean, srcPath: string = 'src', options?: { noEmit?: boolean }): task.StreamTask { const task = () => { - const compile = createCompile(srcPath, { build, emitError: false, transpileOnly: false, preserveEnglish: false }); + const compile = createCompile(srcPath, { build, emitError: false, transpileOnly: false, preserveEnglish: false, noEmit: options?.noEmit }); const src = gulp.src(`${srcPath}/**`, { base: srcPath }); const watchSrc = watch(`${srcPath}/**`, { base: srcPath, readDelay: 200 }); diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index e06f1510a66..cea54bff8b9 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -66,16 +66,31 @@ function updateExtensionPackageJSON(input: Stream, update: (data: any) => any): function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolean): Stream { + const esbuildConfigFileName = forWeb + ? 'esbuild-browser.ts' + : 'esbuild.ts'; + const webpackConfigFileName = forWeb ? `extension-browser.webpack.config.js` : `extension.webpack.config.js`; + const hasEsbuild = fs.existsSync(path.join(extensionPath, esbuildConfigFileName)); const isWebPacked = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); - let input = isWebPacked - ? fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle) - : fromLocalNormal(extensionPath); - if (isWebPacked) { + let input: Stream; + let isBundled = false; + + if (hasEsbuild) { + input = fromLocalEsbuild(extensionPath, esbuildConfigFileName); + isBundled = true; + } else if (isWebPacked) { + input = fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle); + isBundled = true; + } else { + input = fromLocalNormal(extensionPath); + } + + if (isBundled) { input = updateExtensionPackageJSON(input, (data: any) => { delete data.scripts; delete data.dependencies; @@ -240,6 +255,51 @@ function fromLocalNormal(extensionPath: string): Stream { return result.pipe(createStatsStream(path.basename(extensionPath))); } +function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): Stream { + const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); + const result = es.through(); + + const esbuildScript = path.join(extensionPath, esbuildConfigFileName); + + // Run esbuild, then collect the files + new Promise((resolve, reject) => { + const proc = cp.execFile(process.argv[0], [esbuildScript], {}, (error, _stdout, stderr) => { + if (error) { + return reject(error); + } + const matches = (stderr || '').match(/\> (.+): error: (.+)?/g); + fancyLog(`Bundled extension: ${ansiColors.yellow(path.join(path.basename(extensionPath), esbuildConfigFileName))} with ${matches ? matches.length : 0} errors.`); + for (const match of matches || []) { + fancyLog.error(match); + } + return resolve(); + }); + + proc.stdout!.on('data', (data) => { + fancyLog(`${ansiColors.green('esbuilding')}: ${data.toString('utf8')}`); + }); + }).then(() => { + // After esbuild completes, collect all files using vsce + return vsce.listFiles({ cwd: extensionPath, packageManager: vsce.PackageManager.None }); + }).then(fileNames => { + const files = fileNames + .map(fileName => path.join(extensionPath, fileName)) + .map(filePath => new File({ + path: filePath, + stat: fs.statSync(filePath), + base: extensionPath, + contents: fs.createReadStream(filePath) + })); + + es.readArray(files).pipe(result); + }).catch(err => { + console.error(extensionPath); + result.emit('error', err); + }); + + return result.pipe(createStatsStream(path.basename(extensionPath))); +} + const userAgent = 'VSCode Build'; const baseHeaders = { 'X-Market-Client-Id': 'VSCode Build', @@ -647,7 +707,7 @@ export async function webpackExtensions(taskName: string, isWatch: boolean, webp }); } -async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]) { +export async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { script: string; outputRoot?: string }[]): Promise { function reporter(stdError: string, script: string) { const matches = (stdError || '').match(/\> (.+): error: (.+)?/g); fancyLog(`Finished ${ansiColors.green(taskName)} ${script} with ${matches ? matches.length : 0} errors.`); @@ -678,10 +738,11 @@ async function esbuildExtensions(taskName: string, isWatch: boolean, scripts: { }); }); }); - return Promise.all(tasks); + + await Promise.all(tasks); } -export async function buildExtensionMedia(isWatch: boolean, outputRoot?: string) { +export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ script: path.join(extensionsPath, p), outputRoot: outputRoot ? path.join(root, outputRoot, path.dirname(p)) : undefined diff --git a/build/lib/nls-analysis.ts b/build/lib/nls-analysis.ts new file mode 100644 index 00000000000..2b00da68c7a --- /dev/null +++ b/build/lib/nls-analysis.ts @@ -0,0 +1,317 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as ts from 'typescript'; + +// ============================================================================ +// Types +// ============================================================================ + +export interface ISpan { + start: ts.LineAndCharacter; + end: ts.LineAndCharacter; +} + +export interface ILocalizeCall { + keySpan: ISpan; + key: string; + valueSpan: ISpan; + value: string; +} + +// ============================================================================ +// AST Collection +// ============================================================================ + +export const CollectStepResult = Object.freeze({ + Yes: 'Yes', + YesAndRecurse: 'YesAndRecurse', + No: 'No', + NoAndRecurse: 'NoAndRecurse' +}); + +export type CollectStepResult = typeof CollectStepResult[keyof typeof CollectStepResult]; + +export function collect(node: ts.Node, fn: (node: ts.Node) => CollectStepResult): ts.Node[] { + const result: ts.Node[] = []; + + function loop(node: ts.Node) { + const stepResult = fn(node); + + if (stepResult === CollectStepResult.Yes || stepResult === CollectStepResult.YesAndRecurse) { + result.push(node); + } + + if (stepResult === CollectStepResult.YesAndRecurse || stepResult === CollectStepResult.NoAndRecurse) { + ts.forEachChild(node, loop); + } + } + + loop(node); + return result; +} + +export function isImportNode(node: ts.Node): boolean { + return node.kind === ts.SyntaxKind.ImportDeclaration || node.kind === ts.SyntaxKind.ImportEqualsDeclaration; +} + +export function isCallExpressionWithinTextSpanCollectStep(textSpan: ts.TextSpan, node: ts.Node): CollectStepResult { + if (!ts.textSpanContainsTextSpan({ start: node.pos, length: node.end - node.pos }, textSpan)) { + return CollectStepResult.No; + } + + return node.kind === ts.SyntaxKind.CallExpression ? CollectStepResult.YesAndRecurse : CollectStepResult.NoAndRecurse; +} + +// ============================================================================ +// Language Service Host +// ============================================================================ + +export class SingleFileServiceHost implements ts.LanguageServiceHost { + private file: ts.IScriptSnapshot; + private lib: ts.IScriptSnapshot; + private options: ts.CompilerOptions; + private filename: string; + + constructor(options: ts.CompilerOptions, filename: string, contents: string) { + this.options = options; + this.filename = filename; + this.file = ts.ScriptSnapshot.fromString(contents); + this.lib = ts.ScriptSnapshot.fromString(''); + } + + getCompilationSettings = () => this.options; + getScriptFileNames = () => [this.filename]; + getScriptVersion = () => '1'; + getScriptSnapshot = (name: string) => name === this.filename ? this.file : this.lib; + getCurrentDirectory = () => ''; + getDefaultLibFileName = () => 'lib.d.ts'; + + readFile(path: string): string | undefined { + if (path === this.filename) { + return this.file.getText(0, this.file.getLength()); + } + return undefined; + } + + fileExists(path: string): boolean { + return path === this.filename; + } +} + +// ============================================================================ +// Analysis +// ============================================================================ + +/** + * Analyzes TypeScript source code to find localize() or localize2() calls. + */ +export function analyzeLocalizeCalls( + contents: string, + functionName: 'localize' | 'localize2' +): ILocalizeCall[] { + const filename = 'file.ts'; + const options: ts.CompilerOptions = { noResolve: true }; + const serviceHost = new SingleFileServiceHost(options, filename, contents); + const service = ts.createLanguageService(serviceHost); + const sourceFile = ts.createSourceFile(filename, contents, ts.ScriptTarget.ES5, true); + + // Find all imports + const imports = collect(sourceFile, n => isImportNode(n) ? CollectStepResult.YesAndRecurse : CollectStepResult.NoAndRecurse); + + // import nls = require('vs/nls'); + const importEqualsDeclarations = imports + .filter(n => n.kind === ts.SyntaxKind.ImportEqualsDeclaration) + .map(n => n as ts.ImportEqualsDeclaration) + .filter(d => d.moduleReference.kind === ts.SyntaxKind.ExternalModuleReference) + .filter(d => { + const text = (d.moduleReference as ts.ExternalModuleReference).expression.getText(); + return text.endsWith(`/nls'`) || text.endsWith(`/nls"`) || text.endsWith(`/nls.js'`) || text.endsWith(`/nls.js"`); + }); + + // import ... from 'vs/nls'; + const importDeclarations = imports + .filter(n => n.kind === ts.SyntaxKind.ImportDeclaration) + .map(n => n as ts.ImportDeclaration) + .filter(d => d.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral) + .filter(d => { + const text = d.moduleSpecifier.getText(); + return text.endsWith(`/nls'`) || text.endsWith(`/nls"`) || text.endsWith(`/nls.js'`) || text.endsWith(`/nls.js"`); + }) + .filter(d => !!d.importClause && !!d.importClause.namedBindings); + + // `nls.localize(...)` calls via namespace import + const nlsLocalizeCallExpressions: ts.CallExpression[] = []; + + const namespaceImports = importDeclarations + .filter(d => d.importClause?.namedBindings?.kind === ts.SyntaxKind.NamespaceImport) + .map(d => (d.importClause!.namedBindings as ts.NamespaceImport).name); + + const importEqualsNames = importEqualsDeclarations.map(d => d.name); + + for (const name of [...namespaceImports, ...importEqualsNames]) { + const refs = service.getReferencesAtPosition(filename, name.pos + 1) ?? []; + for (const ref of refs) { + if (ref.isWriteAccess) { + continue; + } + const calls = collect(sourceFile, n => isCallExpressionWithinTextSpanCollectStep(ref.textSpan, n)); + const lastCall = calls[calls.length - 1] as ts.CallExpression | undefined; + if (lastCall && + lastCall.expression.kind === ts.SyntaxKind.PropertyAccessExpression && + (lastCall.expression as ts.PropertyAccessExpression).name.getText() === functionName) { + nlsLocalizeCallExpressions.push(lastCall); + } + } + } + + // `localize` named imports + const namedImports = importDeclarations + .filter(d => d.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) + .flatMap(d => Array.from((d.importClause!.namedBindings! as ts.NamedImports).elements)); + + const localizeCallExpressions: ts.CallExpression[] = []; + + // Direct named import: import { localize } from 'vs/nls' + for (const namedImport of namedImports) { + const isTarget = namedImport.name.getText() === functionName || + (namedImport.propertyName && namedImport.propertyName.getText() === functionName); + + if (!isTarget) { + continue; + } + + const searchName = namedImport.propertyName ? namedImport.name : namedImport.name; + const refs = service.getReferencesAtPosition(filename, searchName.pos + 1) ?? []; + + for (const ref of refs) { + if (ref.isWriteAccess) { + continue; + } + const calls = collect(sourceFile, n => isCallExpressionWithinTextSpanCollectStep(ref.textSpan, n)); + const lastCall = calls[calls.length - 1] as ts.CallExpression | undefined; + if (lastCall) { + localizeCallExpressions.push(lastCall); + } + } + } + + // Combine and deduplicate + const allCalls = [...nlsLocalizeCallExpressions, ...localizeCallExpressions]; + const seen = new Set(); + const uniqueCalls = allCalls.filter(call => { + const start = call.getStart(); + if (seen.has(start)) { + return false; + } + seen.add(start); + return true; + }); + + // Convert to ILocalizeCall + return uniqueCalls + .filter(e => e.arguments.length > 1) + .sort((a, b) => a.arguments[0].getStart() - b.arguments[0].getStart()) + .map(e => { + const args = e.arguments; + return { + keySpan: { + start: ts.getLineAndCharacterOfPosition(sourceFile, args[0].getStart()), + end: ts.getLineAndCharacterOfPosition(sourceFile, args[0].getEnd()) + }, + key: args[0].getText(), + valueSpan: { + start: ts.getLineAndCharacterOfPosition(sourceFile, args[1].getStart()), + end: ts.getLineAndCharacterOfPosition(sourceFile, args[1].getEnd()) + }, + value: args[1].getText() + }; + }); +} + +// ============================================================================ +// Text Model for patching +// ============================================================================ + +export class TextModel { + private lines: string[]; + private lineEndings: string[]; + + constructor(contents: string) { + const regex = /\r\n|\r|\n/g; + let index = 0; + let match: RegExpExecArray | null; + + this.lines = []; + this.lineEndings = []; + + while (match = regex.exec(contents)) { + this.lines.push(contents.substring(index, match.index)); + this.lineEndings.push(match[0]); + index = regex.lastIndex; + } + + if (contents.length > 0) { + this.lines.push(contents.substring(index, contents.length)); + this.lineEndings.push(''); + } + } + + get(index: number): string { + return this.lines[index]; + } + + set(index: number, line: string): void { + this.lines[index] = line; + } + + get lineCount(): number { + return this.lines.length; + } + + /** + * Applies patch(es) to the model. + * Multiple patches must be ordered. + * Does not support patches spanning multiple lines. + */ + apply(span: ISpan, content: string): void { + const startLineNumber = span.start.line; + const endLineNumber = span.end.line; + + const startLine = this.lines[startLineNumber] || ''; + const endLine = this.lines[endLineNumber] || ''; + + this.lines[startLineNumber] = [ + startLine.substring(0, span.start.character), + content, + endLine.substring(span.end.character) + ].join(''); + + for (let i = startLineNumber + 1; i <= endLineNumber; i++) { + this.lines[i] = ''; + } + } + + toString(): string { + let result = ''; + for (let i = 0; i < this.lines.length; i++) { + result += this.lines[i] + this.lineEndings[i]; + } + return result; + } +} + +// ============================================================================ +// Utilities +// ============================================================================ + +/** + * Parses a localize key or value expression. + * sourceExpression can be "foo", 'foo', `foo` or { key: 'foo', comment: [...] } + */ +export function parseLocalizeKeyOrValue(sourceExpression: string): string | { key: string; comment?: string[] } { + // eslint-disable-next-line no-eval + return eval(`(${sourceExpression})`); +} diff --git a/build/lib/nls.ts b/build/lib/nls.ts index 39cc07d9d01..1c054c97399 100644 --- a/build/lib/nls.ts +++ b/build/lib/nls.ts @@ -10,45 +10,10 @@ import File from 'vinyl'; import sm from 'source-map'; import path from 'path'; import sort from 'gulp-sort'; +import { type ISpan, analyzeLocalizeCalls, TextModel, parseLocalizeKeyOrValue } from './nls-analysis.ts'; type FileWithSourcemap = File & { sourceMap: sm.RawSourceMap }; -const CollectStepResult = Object.freeze({ - Yes: 'Yes', - YesAndRecurse: 'YesAndRecurse', - No: 'No', - NoAndRecurse: 'NoAndRecurse' -}); - -type CollectStepResult = typeof CollectStepResult[keyof typeof CollectStepResult]; - -function collect(ts: typeof import('typescript'), node: ts.Node, fn: (node: ts.Node) => CollectStepResult): ts.Node[] { - const result: ts.Node[] = []; - - function loop(node: ts.Node) { - const stepResult = fn(node); - - if (stepResult === CollectStepResult.Yes || stepResult === CollectStepResult.YesAndRecurse) { - result.push(node); - } - - if (stepResult === CollectStepResult.YesAndRecurse || stepResult === CollectStepResult.NoAndRecurse) { - ts.forEachChild(node, loop); - } - } - - loop(node); - return result; -} - -function clone(object: T): T { - const result: Record = {}; - for (const id in object) { - result[id] = object[id]; - } - return result as T; -} - /** * Returns a stream containing the patched JavaScript and source maps. */ @@ -117,10 +82,6 @@ globalThis._VSCODE_NLS_MESSAGES=${JSON.stringify(_nls.allNLSMessages)};`), return eventStream.duplex(input, output); } -function isImportNode(ts: typeof import('typescript'), node: ts.Node): boolean { - return node.kind === ts.SyntaxKind.ImportDeclaration || node.kind === ts.SyntaxKind.ImportEqualsDeclaration; -} - const _nls = (() => { const moduleToNLSKeys: { [name: string /* module ID */]: ILocalizeKey[] /* keys */ } = {}; @@ -138,22 +99,6 @@ const _nls = (() => { nlsKeys?: ILocalizeKey[]; } - interface ISpan { - start: ts.LineAndCharacter; - end: ts.LineAndCharacter; - } - - interface ILocalizeCall { - keySpan: ISpan; - key: string; - valueSpan: ISpan; - value: string; - } - - interface ILocalizeAnalysisResult { - localizeCalls: ILocalizeCall[]; - } - interface IPatch { span: ISpan; content: string; @@ -176,212 +121,11 @@ const _nls = (() => { return { line: position.line - 1, character: position.column }; } - class SingleFileServiceHost implements ts.LanguageServiceHost { - - private file: ts.IScriptSnapshot; - private lib: ts.IScriptSnapshot; - private options: ts.CompilerOptions; - private filename: string; - - constructor(ts: typeof import('typescript'), options: ts.CompilerOptions, filename: string, contents: string) { - this.options = options; - this.filename = filename; - this.file = ts.ScriptSnapshot.fromString(contents); - this.lib = ts.ScriptSnapshot.fromString(''); - } - - getCompilationSettings = () => this.options; - getScriptFileNames = () => [this.filename]; - getScriptVersion = () => '1'; - getScriptSnapshot = (name: string) => name === this.filename ? this.file : this.lib; - getCurrentDirectory = () => ''; - getDefaultLibFileName = () => 'lib.d.ts'; - - readFile(path: string, _encoding?: string): string | undefined { - if (path === this.filename) { - return this.file.getText(0, this.file.getLength()); - } - return undefined; - } - fileExists(path: string): boolean { - return path === this.filename; - } - } - - function isCallExpressionWithinTextSpanCollectStep(ts: typeof import('typescript'), textSpan: ts.TextSpan, node: ts.Node): CollectStepResult { - if (!ts.textSpanContainsTextSpan({ start: node.pos, length: node.end - node.pos }, textSpan)) { - return CollectStepResult.No; - } - - return node.kind === ts.SyntaxKind.CallExpression ? CollectStepResult.YesAndRecurse : CollectStepResult.NoAndRecurse; - } - - function analyze( - ts: typeof import('typescript'), - contents: string, - functionName: 'localize' | 'localize2', - options: ts.CompilerOptions = {} - ): ILocalizeAnalysisResult { - const filename = 'file.ts'; - const serviceHost = new SingleFileServiceHost(ts, Object.assign(clone(options), { noResolve: true }), filename, contents); - const service = ts.createLanguageService(serviceHost); - const sourceFile = ts.createSourceFile(filename, contents, ts.ScriptTarget.ES5, true); - - // all imports - const imports = lazy(collect(ts, sourceFile, n => isImportNode(ts, n) ? CollectStepResult.YesAndRecurse : CollectStepResult.NoAndRecurse)); - - // import nls = require('vs/nls'); - const importEqualsDeclarations = imports - .filter(n => n.kind === ts.SyntaxKind.ImportEqualsDeclaration) - .map(n => n as ts.ImportEqualsDeclaration) - .filter(d => d.moduleReference.kind === ts.SyntaxKind.ExternalModuleReference) - .filter(d => (d.moduleReference as ts.ExternalModuleReference).expression.getText().endsWith(`/nls.js'`)); - - // import ... from 'vs/nls'; - const importDeclarations = imports - .filter(n => n.kind === ts.SyntaxKind.ImportDeclaration) - .map(n => n as ts.ImportDeclaration) - .filter(d => d.moduleSpecifier.kind === ts.SyntaxKind.StringLiteral) - .filter(d => d.moduleSpecifier.getText().endsWith(`/nls.js'`)) - .filter(d => !!d.importClause && !!d.importClause.namedBindings); - - // `nls.localize(...)` calls - const nlsLocalizeCallExpressions = importDeclarations - .filter(d => !!(d.importClause && d.importClause.namedBindings && d.importClause.namedBindings.kind === ts.SyntaxKind.NamespaceImport)) - .map(d => (d.importClause!.namedBindings as ts.NamespaceImport).name) - .concat(importEqualsDeclarations.map(d => d.name)) - - // find read-only references to `nls` - .map(n => service.getReferencesAtPosition(filename, n.pos + 1) ?? []) - .flatten() - .filter(r => !r.isWriteAccess) - - // find the deepest call expressions AST nodes that contain those references - .map(r => collect(ts, sourceFile, n => isCallExpressionWithinTextSpanCollectStep(ts, r.textSpan, n))) - .map(a => lazy(a).last()) - .filter(n => !!n) - .map(n => n as ts.CallExpression) - - // only `localize` calls - .filter(n => n.expression.kind === ts.SyntaxKind.PropertyAccessExpression && (n.expression as ts.PropertyAccessExpression).name.getText() === functionName); - - // `localize` named imports - const allLocalizeImportDeclarations = importDeclarations - .filter(d => !!(d.importClause && d.importClause.namedBindings && d.importClause.namedBindings.kind === ts.SyntaxKind.NamedImports)) - .map(d => (d.importClause!.namedBindings! as ts.NamedImports).elements) - .flatten(); - - // `localize` read-only references - const localizeReferences = allLocalizeImportDeclarations - .filter(d => d.name.getText() === functionName) - .map(n => service.getReferencesAtPosition(filename, n.pos + 1) ?? []) - .flatten() - .filter(r => !r.isWriteAccess); - - // custom named `localize` read-only references - const namedLocalizeReferences = allLocalizeImportDeclarations - .filter(d => !!d.propertyName && d.propertyName.getText() === functionName) - .map(n => service.getReferencesAtPosition(filename, n.name.pos + 1) ?? []) - .flatten() - .filter(r => !r.isWriteAccess); - - // find the deepest call expressions AST nodes that contain those references - const localizeCallExpressions = localizeReferences - .concat(namedLocalizeReferences) - .map(r => collect(ts, sourceFile, n => isCallExpressionWithinTextSpanCollectStep(ts, r.textSpan, n))) - .map(a => lazy(a).last()) - .filter(n => !!n) - .map(n => n as ts.CallExpression); - - // collect everything - const localizeCalls = nlsLocalizeCallExpressions - .concat(localizeCallExpressions) - .map(e => e.arguments) - .filter(a => a.length > 1) - .sort((a, b) => a[0].getStart() - b[0].getStart()) - .map(a => ({ - keySpan: { start: ts.getLineAndCharacterOfPosition(sourceFile, a[0].getStart()), end: ts.getLineAndCharacterOfPosition(sourceFile, a[0].getEnd()) }, - key: a[0].getText(), - valueSpan: { start: ts.getLineAndCharacterOfPosition(sourceFile, a[1].getStart()), end: ts.getLineAndCharacterOfPosition(sourceFile, a[1].getEnd()) }, - value: a[1].getText() - })); - - return { - localizeCalls: localizeCalls.toArray() - }; - } - - class TextModel { - - private lines: string[]; - private lineEndings: string[]; - - constructor(contents: string) { - const regex = /\r\n|\r|\n/g; - let index = 0; - let match: RegExpExecArray | null; - - this.lines = []; - this.lineEndings = []; - - while (match = regex.exec(contents)) { - this.lines.push(contents.substring(index, match.index)); - this.lineEndings.push(match[0]); - index = regex.lastIndex; - } - - if (contents.length > 0) { - this.lines.push(contents.substring(index, contents.length)); - this.lineEndings.push(''); - } - } - - public get(index: number): string { - return this.lines[index]; - } - - public set(index: number, line: string): void { - this.lines[index] = line; - } - - public get lineCount(): number { - return this.lines.length; - } - - /** - * Applies patch(es) to the model. - * Multiple patches must be ordered. - * Does not support patches spanning multiple lines. - */ - public apply(patch: IPatch): void { - const startLineNumber = patch.span.start.line; - const endLineNumber = patch.span.end.line; - - const startLine = this.lines[startLineNumber] || ''; - const endLine = this.lines[endLineNumber] || ''; - - this.lines[startLineNumber] = [ - startLine.substring(0, patch.span.start.character), - patch.content, - endLine.substring(patch.span.end.character) - ].join(''); - - for (let i = startLineNumber + 1; i <= endLineNumber; i++) { - this.lines[i] = ''; - } - } - - public toString(): string { - return lazy(this.lines).zip(this.lineEndings) - .flatten().toArray().join(''); - } - } - function patchJavascript(patches: IPatch[], contents: string): string { const model = new TextModel(contents); // patch the localize calls - lazy(patches).reverse().each(p => model.apply(p)); + lazy(patches).reverse().each(p => model.apply(p.span, p.content)); return model.toString(); } @@ -431,24 +175,16 @@ const _nls = (() => { return JSON.parse(smg.toString()); } - function parseLocalizeKeyOrValue(sourceExpression: string) { - // sourceValue can be "foo", 'foo', `foo` or { .... } - // in its evalulated form - // we want to return either the string or the object - // eslint-disable-next-line no-eval - return eval(`(${sourceExpression})`); - } - - function patch(ts: typeof import('typescript'), typescript: string, javascript: string, sourcemap: sm.RawSourceMap, options: { preserveEnglish: boolean }): INlsPatchResult { - const { localizeCalls } = analyze(ts, typescript, 'localize'); - const { localizeCalls: localize2Calls } = analyze(ts, typescript, 'localize2'); + function patch(typescript: string, javascript: string, sourcemap: sm.RawSourceMap, options: { preserveEnglish: boolean }): INlsPatchResult { + const localizeCalls = analyzeLocalizeCalls(typescript, 'localize'); + const localize2Calls = analyzeLocalizeCalls(typescript, 'localize2'); if (localizeCalls.length === 0 && localize2Calls.length === 0) { return { javascript, sourcemap }; } const nlsKeys = localizeCalls.map(lc => parseLocalizeKeyOrValue(lc.key)).concat(localize2Calls.map(lc => parseLocalizeKeyOrValue(lc.key))); - const nlsMessages = localizeCalls.map(lc => parseLocalizeKeyOrValue(lc.value)).concat(localize2Calls.map(lc => parseLocalizeKeyOrValue(lc.value))); + const nlsMessages = localizeCalls.map(lc => parseLocalizeKeyOrValue(lc.value) as string).concat(localize2Calls.map(lc => parseLocalizeKeyOrValue(lc.value) as string)); const smc = new sm.SourceMapConsumer(sourcemap); const positionFrom = mappedPositionFrom.bind(null, sourcemap.sources[0]); @@ -505,7 +241,6 @@ const _nls = (() => { .replace(/\\/g, '/'); const { javascript, sourcemap, nlsKeys, nlsMessages } = patch( - ts, typescript, javascriptFile.contents!.toString(), javascriptFile.sourceMap, diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index c6422deded4..3a245fe5cb6 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -12,8 +12,8 @@ const root = path.dirname(path.dirname(import.meta.dirname)); const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; -export function spawnTsgo(projectPath: string, onComplete?: () => Promise | void): Promise { - const reporter = createReporter('extensions'); +export function spawnTsgo(projectPath: string, config: { reporterId: string }, onComplete?: () => Promise | void): Promise { + const reporter = createReporter(config.reporterId); let report: NodeJS.ReadWriteStream | undefined; const beginReport = (emitError: boolean) => { @@ -31,10 +31,9 @@ export function spawnTsgo(projectPath: string, onComplete?: () => Promise report = undefined; }; - const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--sourceMap', '--inlineSources']; - beginReport(false); + const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--sourceMap', '--inlineSources']; const child = cp.spawn(npx, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], @@ -79,7 +78,7 @@ export function spawnTsgo(projectPath: string, onComplete?: () => Promise child.stdout?.on('data', handleData); child.stderr?.on('data', handleData); - const done = new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { child.on('exit', code => { if (buffer.trim()) { handleLine(buffer); @@ -88,23 +87,22 @@ export function spawnTsgo(projectPath: string, onComplete?: () => Promise endReport(); if (code === 0) { Promise.resolve(onComplete?.()).then(() => resolve(), reject); - return; + } else { + reject(new Error(`tsgo exited with code ${code ?? 'unknown'}`)); } - reject(new Error(`tsgo exited with code ${code ?? 'unknown'}`)); }); + child.on('error', err => { endReport(); reject(err); }); }); - - return done; } -export function createTsgoStream(projectPath: string, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { +export function createTsgoStream(projectPath: string, config: { reporterId: string }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { const stream = es.through(); - spawnTsgo(projectPath, onComplete).then(() => { + spawnTsgo(projectPath, config, onComplete).then(() => { stream.emit('end'); }).catch(() => { // Errors are already reported by spawnTsgo via the reporter. diff --git a/build/lib/watch/watch-win32.ts b/build/lib/watch/watch-win32.ts index 12b8ffc0ac3..5b5a1197762 100644 --- a/build/lib/watch/watch-win32.ts +++ b/build/lib/watch/watch-win32.ts @@ -10,8 +10,9 @@ import File from 'vinyl'; import es from 'event-stream'; import filter from 'gulp-filter'; import { Stream } from 'stream'; +import { fileURLToPath } from 'url'; -const watcherPath = path.join(import.meta.dirname, 'watcher.exe'); +const watcherPath = path.join(typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url)), 'watcher.exe'); function toChangeType(type: '0' | '1' | '2'): 'change' | 'add' | 'unlink' { switch (type) { diff --git a/build/next/index.ts b/build/next/index.ts new file mode 100644 index 00000000000..8d8429012de --- /dev/null +++ b/build/next/index.ts @@ -0,0 +1,1134 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as esbuild from 'esbuild'; +import * as fs from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; +import glob from 'glob'; +import gulpWatch from '../lib/watch/index.ts'; +import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from './nls-plugin.ts'; +import { getVersion } from '../lib/getVersion.ts'; +import product from '../../product.json' with { type: 'json' }; +import packageJson from '../../package.json' with { type: 'json' }; +import { useEsbuildTranspile } from '../buildConfig.ts'; + +const globAsync = promisify(glob); + +// ============================================================================ +// Configuration +// ============================================================================ + +const REPO_ROOT = path.dirname(path.dirname(import.meta.dirname)); +const commit = getVersion(REPO_ROOT); +const quality = (product as { quality?: string }).quality; +const version = (quality && quality !== 'stable') ? `${packageJson.version}-${quality}` : packageJson.version; + +// CLI: transpile [--watch] | bundle [--minify] [--nls] [--out ] +const command = process.argv[2]; // 'transpile' or 'bundle' + +function getArgValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + if (index !== -1 && index + 1 < process.argv.length) { + return process.argv[index + 1]; + } + return undefined; +} + +const options = { + watch: process.argv.includes('--watch'), + minify: process.argv.includes('--minify'), + nls: process.argv.includes('--nls'), + excludeTests: process.argv.includes('--exclude-tests'), + out: getArgValue('--out'), + target: getArgValue('--target') ?? 'desktop', // 'desktop' | 'server' | 'server-web' | 'web' + sourceMapBaseUrl: getArgValue('--source-map-base-url'), +}; + +// Build targets +type BuildTarget = 'desktop' | 'server' | 'server-web' | 'web'; + +const SRC_DIR = 'src'; +const OUT_DIR = 'out'; +const OUT_VSCODE_DIR = 'out-vscode'; + +// UTF-8 BOM - added to test files with 'utf8' in the path (matches gulp build behavior) +const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); + +// ============================================================================ +// Entry Points (from build/buildfile.ts) +// ============================================================================ + +// Workers - shared between targets +const workerEntryPoints = [ + 'vs/editor/common/services/editorWebWorkerMain', + 'vs/workbench/api/worker/extensionHostWorkerMain', + 'vs/workbench/contrib/notebook/common/services/notebookWebWorkerMain', + 'vs/workbench/services/languageDetection/browser/languageDetectionWebWorkerMain', + 'vs/workbench/services/search/worker/localFileSearchMain', + 'vs/workbench/contrib/output/common/outputLinkComputerMain', + 'vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateTokenizationWorker.workerMain', +]; + +// Desktop-only workers (use electron-browser) +const desktopWorkerEntryPoints = [ + 'vs/platform/profiling/electron-browser/profileAnalysisWorkerMain', +]; + +// Desktop workbench and code entry points +const desktopEntryPoints = [ + 'vs/workbench/workbench.desktop.main', + 'vs/workbench/contrib/debug/node/telemetryApp', + 'vs/platform/files/node/watcher/watcherMain', + 'vs/platform/terminal/node/ptyHostMain', + 'vs/workbench/api/node/extensionHostProcess', +]; + +const codeEntryPoints = [ + 'vs/code/node/cliProcessMain', + 'vs/code/electron-utility/sharedProcess/sharedProcessMain', + 'vs/code/electron-browser/workbench/workbench', +]; + +// Web entry points (used in server-web and vscode-web) +const webEntryPoints = [ + 'vs/workbench/workbench.web.main.internal', + 'vs/code/browser/workbench/workbench', +]; + +const keyboardMapEntryPoints = [ + 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.linux', + 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.darwin', + 'vs/workbench/services/keybinding/browser/keyboardLayouts/layout.contribution.win', +]; + +// Server entry points (reh) +const serverEntryPoints = [ + 'vs/workbench/api/node/extensionHostProcess', + 'vs/platform/files/node/watcher/watcherMain', + 'vs/platform/terminal/node/ptyHostMain', +]; + +// Bootstrap files per target +const bootstrapEntryPointsDesktop = [ + 'main', + 'cli', + 'bootstrap-fork', +]; + +const bootstrapEntryPointsServer = [ + 'server-main', + 'server-cli', + 'bootstrap-fork', +]; + +/** + * Get entry points for a build target. + */ +function getEntryPointsForTarget(target: BuildTarget): string[] { + switch (target) { + case 'desktop': + return [ + ...workerEntryPoints, + ...desktopWorkerEntryPoints, + ...desktopEntryPoints, + ...codeEntryPoints, + ]; + case 'server': + return [ + ...serverEntryPoints, + ]; + case 'server-web': + return [ + ...serverEntryPoints, + ...workerEntryPoints, + ...webEntryPoints, + ...keyboardMapEntryPoints, + ]; + case 'web': + return [ + ...workerEntryPoints, + 'vs/workbench/workbench.web.main.internal', // web workbench only (no browser shell) + ...keyboardMapEntryPoints, + ]; + default: + throw new Error(`Unknown target: ${target}`); + } +} + +/** + * Get bootstrap entry points for a build target. + */ +function getBootstrapEntryPointsForTarget(target: BuildTarget): string[] { + switch (target) { + case 'desktop': + return bootstrapEntryPointsDesktop; + case 'server': + case 'server-web': + return bootstrapEntryPointsServer; + case 'web': + return []; // Web has no bootstrap files (served by external server) + default: + throw new Error(`Unknown target: ${target}`); + } +} + +/** + * Get entry points that should bundle CSS (workbench mains). + */ +function getCssBundleEntryPointsForTarget(target: BuildTarget): Set { + switch (target) { + case 'desktop': + return new Set([ + 'vs/workbench/workbench.desktop.main', + 'vs/code/electron-browser/workbench/workbench', + ]); + case 'server': + return new Set(); // Server has no UI + case 'server-web': + return new Set([ + 'vs/workbench/workbench.web.main.internal', + 'vs/code/browser/workbench/workbench', + ]); + case 'web': + return new Set([ + 'vs/workbench/workbench.web.main.internal', + ]); + default: + throw new Error(`Unknown target: ${target}`); + } +} + +// ============================================================================ +// Resource Patterns (files to copy, not transpile/bundle) +// ============================================================================ + +// Common resources needed by all targets +const commonResourcePatterns = [ + // Tree-sitter queries + 'vs/editor/common/languages/highlights/*.scm', + 'vs/editor/common/languages/injections/*.scm', +]; + +// Resources only needed for dev/transpile builds (these get bundled into the main +// JS/CSS bundles for production, so separate copies are redundant) +const devOnlyResourcePatterns = [ + // Fonts (esbuild file loader copies to media/codicon.ttf for production) + 'vs/base/browser/ui/codicons/codicon/codicon.ttf', + + // Vendor JavaScript libraries (bundled into workbench main JS for production) + 'vs/base/common/marked/marked.js', + 'vs/base/common/semver/semver.js', + 'vs/base/browser/dompurify/dompurify.js', +]; + +// Resources for desktop target +const desktopResourcePatterns = [ + ...commonResourcePatterns, + + // HTML + 'vs/code/electron-browser/workbench/workbench.html', + 'vs/code/electron-browser/workbench/workbench-dev.html', + 'vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', + 'vs/workbench/contrib/webview/browser/pre/*.html', + + // Webview pre scripts + 'vs/workbench/contrib/webview/browser/pre/*.js', + + // Shell scripts + 'vs/base/node/*.sh', + 'vs/workbench/contrib/terminal/common/scripts/*.sh', + 'vs/workbench/contrib/terminal/common/scripts/*.ps1', + 'vs/workbench/contrib/terminal/common/scripts/*.psm1', + 'vs/workbench/contrib/terminal/common/scripts/*.fish', + 'vs/workbench/contrib/terminal/common/scripts/*.zsh', + 'vs/workbench/contrib/externalTerminal/**/*.scpt', + + // Media - audio + 'vs/platform/accessibilitySignal/browser/media/*.mp3', + + // Media - images + 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg', + 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.png', + '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', + 'vs/workbench/browser/parts/editor/media/*.png', + 'vs/workbench/contrib/debug/browser/media/*.png', +]; + +// Resources for server target (minimal - no UI) +const serverResourcePatterns = [ + // Shell scripts for process monitoring + 'vs/base/node/cpuUsage.sh', + 'vs/base/node/ps.sh', + + // External Terminal + 'vs/workbench/contrib/externalTerminal/**/*.scpt', + + // Terminal shell integration + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1', + 'vs/workbench/contrib/terminal/common/scripts/CodeTabExpansion.psm1', + 'vs/workbench/contrib/terminal/common/scripts/GitTabExpansion.psm1', + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh', + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-env.zsh', + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-profile.zsh', + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh', + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration-login.zsh', + 'vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish', +]; + +// Resources for server-web target (server + web UI) +const serverWebResourcePatterns = [ + ...serverResourcePatterns, + ...commonResourcePatterns, + + // Web HTML + 'vs/code/browser/workbench/workbench.html', + 'vs/code/browser/workbench/workbench-dev.html', + 'vs/code/browser/workbench/callback.html', + 'vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', + 'vs/workbench/contrib/webview/browser/pre/*.html', + + // Webview pre scripts + 'vs/workbench/contrib/webview/browser/pre/*.js', + + // Media - audio + 'vs/platform/accessibilitySignal/browser/media/*.mp3', + + // Media - images + 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg', + 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.png', + 'vs/workbench/contrib/extensions/browser/media/*.svg', + 'vs/workbench/contrib/extensions/browser/media/*.png', + 'vs/workbench/services/extensionManagement/common/media/*.svg', + 'vs/workbench/services/extensionManagement/common/media/*.png', +]; + +// Resources for standalone web target (browser-only, no server) +const webResourcePatterns = [ + ...commonResourcePatterns, + + // Web HTML + 'vs/code/browser/workbench/workbench.html', + 'vs/code/browser/workbench/workbench-dev.html', + 'vs/code/browser/workbench/callback.html', + 'vs/workbench/services/extensions/worker/webWorkerExtensionHostIframe.html', + 'vs/workbench/contrib/webview/browser/pre/*.html', + + // Webview pre scripts + 'vs/workbench/contrib/webview/browser/pre/*.js', + + // Media - audio + 'vs/platform/accessibilitySignal/browser/media/*.mp3', + + // Media - images + 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.svg', + 'vs/workbench/contrib/welcomeGettingStarted/common/media/**/*.png', + 'vs/workbench/contrib/extensions/browser/media/*.svg', + 'vs/workbench/contrib/extensions/browser/media/*.png', + 'vs/workbench/services/extensionManagement/common/media/*.svg', + 'vs/workbench/services/extensionManagement/common/media/*.png', +]; + +/** + * Get resource patterns for a build target. + */ +function getResourcePatternsForTarget(target: BuildTarget): string[] { + switch (target) { + case 'desktop': + return desktopResourcePatterns; + case 'server': + return serverResourcePatterns; + case 'server-web': + return serverWebResourcePatterns; + case 'web': + return webResourcePatterns; + default: + throw new Error(`Unknown target: ${target}`); + } +} + +// Test fixtures (only copied for development builds, not production) +const testFixturePatterns = [ + '**/test/**/*.json', + '**/test/**/*.txt', + '**/test/**/*.snap', + '**/test/**/*.tst', + '**/test/**/*.html', + '**/test/**/*.js', + '**/test/**/*.jxs', + '**/test/**/*.tsx', + '**/test/**/*.css', + '**/test/**/*.png', + '**/test/**/*.md', + '**/test/**/*.zip', + '**/test/**/*.pdf', + '**/test/**/*.qwoff', + '**/test/**/*.wuff', + '**/test/**/*.less', + // Files without extensions (executables, etc.) + '**/test/**/fixtures/executable/*', +]; + +// ============================================================================ +// Utilities +// ============================================================================ + +async function cleanDir(dir: string): Promise { + const fullPath = path.join(REPO_ROOT, dir); + console.log(`[clean] ${dir}`); + await fs.promises.rm(fullPath, { recursive: true, force: true }); + await fs.promises.mkdir(fullPath, { recursive: true }); +} + +/** + * Scan for built-in extensions in the given directory. + * Returns an array of extension entries for the builtinExtensionsScannerService. + */ +function scanBuiltinExtensions(extensionsRoot: string): Array<{ extensionPath: string; packageJSON: unknown }> { + const result: Array<{ extensionPath: string; packageJSON: unknown }> = []; + const extensionsPath = path.join(REPO_ROOT, extensionsRoot); + + if (!fs.existsSync(extensionsPath)) { + return result; + } + + for (const entry of fs.readdirSync(extensionsPath, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + const packageJsonPath = path.join(extensionsPath, entry.name, 'package.json'); + if (fs.existsSync(packageJsonPath)) { + try { + const packageJSON = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + result.push({ + extensionPath: entry.name, + packageJSON + }); + } catch (e) { + // Skip invalid extensions + } + } + } + + return result; +} + +/** + * Get the date from the out directory date file, or return current date. + */ +function readISODate(outDir: string): string { + try { + return fs.readFileSync(path.join(REPO_ROOT, outDir, 'date'), 'utf8'); + } catch { + return new Date().toISOString(); + } +} + +/** + * Only used to make encoding tests happy. The source files don't have a BOM but the + * tests expect one... so we add it here. + */ +function needsBomAdded(filePath: string): boolean { + return /([\/\\])test\1.*utf8/.test(filePath); +} + +async function copyFile(srcPath: string, destPath: string): Promise { + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + + if (needsBomAdded(srcPath)) { + const content = await fs.promises.readFile(srcPath); + if (content[0] !== 0xef || content[1] !== 0xbb || content[2] !== 0xbf) { + await fs.promises.writeFile(destPath, Buffer.concat([UTF8_BOM, content])); + return; + } + } + await fs.promises.copyFile(srcPath, destPath); +} + +/** + * Standalone TypeScript files that need to be compiled separately (not bundled). + * These run in special contexts (e.g., Electron preload) where bundling isn't appropriate. + * Only needed for desktop target. + */ +const desktopStandaloneFiles = [ + 'vs/base/parts/sandbox/electron-browser/preload.ts', + 'vs/base/parts/sandbox/electron-browser/preload-aux.ts', + 'vs/platform/browserView/electron-browser/preload-browserView.ts', +]; + +async function compileStandaloneFiles(outDir: string, doMinify: boolean, target: BuildTarget): Promise { + // Only desktop needs preload scripts + if (target !== 'desktop') { + return; + } + + console.log(`[standalone] Compiling ${desktopStandaloneFiles.length} standalone files...`); + + const banner = `/*!-------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/`; + + await Promise.all(desktopStandaloneFiles.map(async (file) => { + const entryPath = path.join(REPO_ROOT, SRC_DIR, file); + const outPath = path.join(REPO_ROOT, outDir, file.replace(/\.ts$/, '.js')); + + await esbuild.build({ + entryPoints: [entryPath], + outfile: outPath, + bundle: false, // Don't bundle - these are standalone scripts + format: 'cjs', // CommonJS for Electron preload + platform: 'node', + target: ['es2024'], + sourcemap: 'external', + sourcesContent: false, + minify: doMinify, + banner: { js: banner }, + logLevel: 'warning', + }); + })); + + console.log(`[standalone] Done`); +} + +async function copyCssFiles(outDir: string, excludeTests = false): Promise { + // Copy all CSS files from src to output (they're imported by JS) + const cssFiles = await globAsync('**/*.css', { + cwd: path.join(REPO_ROOT, SRC_DIR), + ignore: excludeTests ? ['**/test/**'] : [], + }); + + for (const file of cssFiles) { + const srcPath = path.join(REPO_ROOT, SRC_DIR, file); + const destPath = path.join(REPO_ROOT, outDir, file); + + await copyFile(srcPath, destPath); + } + + return cssFiles.length; +} + +async function copyResources(outDir: string, target: BuildTarget, excludeDevFiles = false, excludeTests = false): Promise { + console.log(`[resources] Copying to ${outDir} for target '${target}'...`); + let copied = 0; + + const ignorePatterns: string[] = []; + if (excludeTests) { + ignorePatterns.push('**/test/**'); + } + if (excludeDevFiles) { + ignorePatterns.push('**/*-dev.html'); + } + + const resourcePatterns = getResourcePatternsForTarget(target); + for (const pattern of resourcePatterns) { + const files = await globAsync(pattern, { + cwd: path.join(REPO_ROOT, SRC_DIR), + ignore: ignorePatterns, + }); + + for (const file of files) { + const srcPath = path.join(REPO_ROOT, SRC_DIR, file); + const destPath = path.join(REPO_ROOT, outDir, file); + + await copyFile(srcPath, destPath); + copied++; + } + } + + // Copy test fixtures (only for development builds) + if (!excludeTests) { + for (const pattern of testFixturePatterns) { + const files = await globAsync(pattern, { + cwd: path.join(REPO_ROOT, SRC_DIR), + }); + + for (const file of files) { + const srcPath = path.join(REPO_ROOT, SRC_DIR, file); + const destPath = path.join(REPO_ROOT, outDir, file); + + await copyFile(srcPath, destPath); + copied++; + } + } + } + + // Copy dev-only resources (vendor JS, codicon font) - only for development/transpile + // builds. In production bundles these are inlined by esbuild. + if (!excludeDevFiles) { + for (const pattern of devOnlyResourcePatterns) { + const files = await globAsync(pattern, { + cwd: path.join(REPO_ROOT, SRC_DIR), + ignore: ignorePatterns, + }); + for (const file of files) { + await copyFile(path.join(REPO_ROOT, SRC_DIR, file), path.join(REPO_ROOT, outDir, file)); + copied++; + } + } + } + + // Copy CSS files (only for development/transpile builds, not production bundles + // where CSS is already bundled into combined files like workbench.desktop.main.css) + if (!excludeDevFiles) { + const cssCount = await copyCssFiles(outDir, excludeTests); + copied += cssCount; + console.log(`[resources] Copied ${copied} files (${cssCount} CSS)`); + } else { + console.log(`[resources] Copied ${copied} files (CSS skipped - bundled)`); + } +} + +// ============================================================================ +// Plugins +// ============================================================================ + +function inlineMinimistPlugin(): esbuild.Plugin { + return { + name: 'inline-minimist', + setup(build) { + build.onResolve({ filter: /^minimist$/ }, () => ({ + path: path.join(REPO_ROOT, 'node_modules/minimist/index.js'), + external: false, + })); + }, + }; +} + +function cssExternalPlugin(): esbuild.Plugin { + // Mark CSS imports as external so they stay as import statements + // The CSS files are copied separately and loaded by the browser at runtime + return { + name: 'css-external', + setup(build) { + build.onResolve({ filter: /\.css$/ }, (args) => ({ + path: args.path, + external: true, + })); + }, + }; +} + +/** + * esbuild plugin that transforms source files to inject build-time configuration. + * This runs during onLoad so the transformation happens before esbuild processes the content, + * ensuring placeholders like `/*BUILD->INSERT_PRODUCT_CONFIGURATION* /` are replaced + * before esbuild strips them as non-legal comments. + */ +function fileContentMapperPlugin(outDir: string, target: BuildTarget): esbuild.Plugin { + // Cache the replacement strings (computed once) + let productConfigReplacement: string | undefined; + let builtinExtensionsReplacement: string | undefined; + + return { + name: 'file-content-mapper', + setup(build) { + build.onLoad({ filter: /\.ts$/ }, async (args) => { + // Skip .d.ts files + if (args.path.endsWith('.d.ts')) { + return undefined; + } + + let contents = await fs.promises.readFile(args.path, 'utf-8'); + let modified = false; + + // Inject product configuration + if (contents.includes('/*BUILD->INSERT_PRODUCT_CONFIGURATION*/')) { + if (productConfigReplacement === undefined) { + // For server-web, remove webEndpointUrlTemplate + const productForTarget = target === 'server-web' + ? { ...product, webEndpointUrlTemplate: undefined } + : product; + const productConfiguration = JSON.stringify({ + ...productForTarget, + version, + commit, + date: readISODate(outDir) + }); + // Remove the outer braces since the placeholder is inside an object literal + productConfigReplacement = productConfiguration.substring(1, productConfiguration.length - 1); + } + contents = contents.replace('/*BUILD->INSERT_PRODUCT_CONFIGURATION*/', () => productConfigReplacement!); + modified = true; + } + + // Inject built-in extensions list + if (contents.includes('/*BUILD->INSERT_BUILTIN_EXTENSIONS*/')) { + if (builtinExtensionsReplacement === undefined) { + // Web target uses .build/web/extensions (from compileWebExtensionsBuildTask) + // Other targets use .build/extensions + const extensionsRoot = target === 'web' ? '.build/web/extensions' : '.build/extensions'; + const builtinExtensions = JSON.stringify(scanBuiltinExtensions(extensionsRoot)); + // Remove the outer brackets since the placeholder is inside an array literal + builtinExtensionsReplacement = builtinExtensions.substring(1, builtinExtensions.length - 1); + } + contents = contents.replace('/*BUILD->INSERT_BUILTIN_EXTENSIONS*/', () => builtinExtensionsReplacement!); + modified = true; + } + + if (modified) { + return { contents, loader: 'ts' }; + } + + // No modifications, let esbuild handle normally + return undefined; + }); + }, + }; +} + +// ============================================================================ +// Transpile (Goal 1: TS → JS using esbuild.transform for maximum speed) +// ============================================================================ + +// Shared transform options for single-file transpilation +const transformOptions: esbuild.TransformOptions = { + loader: 'ts', + format: 'esm', + target: 'es2024', + sourcemap: 'inline', + sourcesContent: false, + tsconfigRaw: JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + useDefineForClassFields: false + } + }), +}; + +async function transpileFile(srcPath: string, destPath: string, relativePath: string): Promise { + const source = await fs.promises.readFile(srcPath, 'utf-8'); + const result = await esbuild.transform(source, { + ...transformOptions, + sourcefile: relativePath, + }); + + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.writeFile(destPath, result.code); +} + +async function transpile(outDir: string, excludeTests: boolean): Promise { + // Find all .ts files + const ignorePatterns = ['**/*.d.ts']; + if (excludeTests) { + ignorePatterns.push('**/test/**'); + } + + const files = await globAsync('**/*.ts', { + cwd: path.join(REPO_ROOT, SRC_DIR), + ignore: ignorePatterns, + }); + + console.log(`[transpile] Found ${files.length} files`); + + // Transpile all files in parallel using esbuild.transform (fastest approach) + await Promise.all(files.map(file => { + const srcPath = path.join(REPO_ROOT, SRC_DIR, file); + const destPath = path.join(REPO_ROOT, outDir, file.replace(/\.ts$/, '.js')); + return transpileFile(srcPath, destPath, file); + })); +} + +// ============================================================================ +// Bundle (Goal 2: JS → bundled JS) +// ============================================================================ + +async function bundle(outDir: string, doMinify: boolean, doNls: boolean, target: BuildTarget, sourceMapBaseUrl?: string): Promise { + await cleanDir(outDir); + + // Write build date file (used by packaging to embed in product.json) + const outDirPath = path.join(REPO_ROOT, outDir); + await fs.promises.mkdir(outDirPath, { recursive: true }); + await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); + + console.log(`[bundle] ${SRC_DIR} → ${outDir} (target: ${target})${doMinify ? ' (minify)' : ''}${doNls ? ' (nls)' : ''}`); + const t1 = Date.now(); + + // Read TSLib for banner + const tslibPath = path.join(REPO_ROOT, 'node_modules/tslib/tslib.es6.js'); + const tslib = await fs.promises.readFile(tslibPath, 'utf-8'); + const banner = { + js: `/*!-------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/ +${tslib}`, + css: `/*!-------------------------------------------------------- + * Copyright (C) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------*/`, + }; + + // Shared TypeScript options for bundling directly from source + const tsconfigRaw = JSON.stringify({ + compilerOptions: { + experimentalDecorators: true, + useDefineForClassFields: false + } + }); + + // Create shared NLS collector (only used if doNls is true) + const nlsCollector = createNLSCollector(); + const preserveEnglish = false; // Production mode: replace messages with null + + // Get entry points based on target + const allEntryPoints = getEntryPointsForTarget(target); + const bootstrapEntryPoints = getBootstrapEntryPointsForTarget(target); + const bundleCssEntryPoints = getCssBundleEntryPointsForTarget(target); + + // Collect all build results (with write: false) + const buildResults: { outPath: string; result: esbuild.BuildResult }[] = []; + + // Create the file content mapper plugin (injects product config, builtin extensions) + const contentMapperPlugin = fileContentMapperPlugin(outDir, target); + + // Bundle each entry point directly from TypeScript source + await Promise.all(allEntryPoints.map(async (entryPoint) => { + const entryPath = path.join(REPO_ROOT, SRC_DIR, `${entryPoint}.ts`); + const outPath = path.join(REPO_ROOT, outDir, `${entryPoint}.js`); + + // Use CSS external plugin for entry points that don't need bundled CSS + const plugins: esbuild.Plugin[] = bundleCssEntryPoints.has(entryPoint) ? [] : [cssExternalPlugin()]; + // Add content mapper plugin to inject product config and builtin extensions + plugins.push(contentMapperPlugin); + if (doNls) { + plugins.unshift(nlsPlugin({ + baseDir: path.join(REPO_ROOT, SRC_DIR), + collector: nlsCollector, + })); + } + + // For entry points that bundle CSS, we need to use outdir instead of outfile + // because esbuild can't produce multiple output files (JS + CSS) with outfile + const needsCssBundling = bundleCssEntryPoints.has(entryPoint); + + const buildOptions: esbuild.BuildOptions = { + entryPoints: needsCssBundling + ? [{ in: entryPath, out: entryPoint }] + : [entryPath], + ...(needsCssBundling + ? { outdir: path.join(REPO_ROOT, outDir) } + : { outfile: outPath }), + bundle: true, + format: 'esm', + platform: 'neutral', + target: ['es2024'], + packages: 'external', + sourcemap: 'external', + sourcesContent: true, + minify: doMinify, + treeShaking: true, + banner, + loader: { + '.ttf': 'file', + '.svg': 'file', + '.png': 'file', + '.sh': 'file', + }, + assetNames: 'media/[name]', + plugins, + write: false, // Don't write yet, we need to post-process + logLevel: 'warning', + logOverride: { + 'unsupported-require-call': 'silent', + }, + tsconfigRaw, + }; + + const result = await esbuild.build(buildOptions); + + buildResults.push({ outPath, result }); + })); + + // Bundle bootstrap files (with minimist inlined) directly from TypeScript source + for (const entry of bootstrapEntryPoints) { + const entryPath = path.join(REPO_ROOT, SRC_DIR, `${entry}.ts`); + if (!fs.existsSync(entryPath)) { + console.log(`[bundle] Skipping ${entry} (not found)`); + continue; + } + + const outPath = path.join(REPO_ROOT, outDir, `${entry}.js`); + + const bootstrapPlugins: esbuild.Plugin[] = [inlineMinimistPlugin(), contentMapperPlugin]; + if (doNls) { + bootstrapPlugins.unshift(nlsPlugin({ + baseDir: path.join(REPO_ROOT, SRC_DIR), + collector: nlsCollector, + })); + } + + const result = await esbuild.build({ + entryPoints: [entryPath], + outfile: outPath, + bundle: true, + format: 'esm', + platform: 'node', + target: ['es2024'], + packages: 'external', + sourcemap: 'external', + sourcesContent: true, + minify: doMinify, + treeShaking: true, + banner, + plugins: bootstrapPlugins, + write: false, // Don't write yet, we need to post-process + logLevel: 'warning', + logOverride: { + 'unsupported-require-call': 'silent', + }, + tsconfigRaw, + }); + + buildResults.push({ outPath, result }); + } + + // Finalize NLS: sort entries, assign indices, write metadata files + let indexMap = new Map(); + if (doNls) { + // Also write NLS files to out-build for backwards compatibility with test runner + const nlsResult = await finalizeNLS( + nlsCollector, + path.join(REPO_ROOT, outDir), + [path.join(REPO_ROOT, 'out-build')] + ); + indexMap = nlsResult.indexMap; + } + + // Post-process and write all output files + let bundled = 0; + for (const { result } of buildResults) { + if (!result.outputFiles) { + continue; + } + + for (const file of result.outputFiles) { + await fs.promises.mkdir(path.dirname(file.path), { recursive: true }); + + if (file.path.endsWith('.js') || file.path.endsWith('.css')) { + let content = file.text; + + // Apply NLS post-processing if enabled (JS only) + if (file.path.endsWith('.js') && doNls && indexMap.size > 0) { + content = postProcessNLS(content, indexMap, preserveEnglish); + } + + // Rewrite sourceMappingURL to CDN URL if configured + if (sourceMapBaseUrl) { + const relativePath = path.relative(path.join(REPO_ROOT, outDir), file.path); + content = content.replace( + /\/\/# sourceMappingURL=.+$/m, + `//# sourceMappingURL=${sourceMapBaseUrl}/${relativePath}.map` + ); + content = content.replace( + /\/\*# sourceMappingURL=.+\*\/$/m, + `/*# sourceMappingURL=${sourceMapBaseUrl}/${relativePath}.map*/` + ); + } + + await fs.promises.writeFile(file.path, content); + } else { + // Write other files (source maps, assets) as-is + await fs.promises.writeFile(file.path, file.contents); + } + } + bundled++; + } + + // Copy resources (exclude dev files and tests for production) + await copyResources(outDir, target, true, true); + + // Compile standalone TypeScript files (like Electron preload scripts) that cannot be bundled + await compileStandaloneFiles(outDir, doMinify, target); + + console.log(`[bundle] Done in ${Date.now() - t1}ms (${bundled} bundles)`); +} + +// ============================================================================ +// Watch Mode +// ============================================================================ + +async function watch(): Promise { + if (!useEsbuildTranspile) { + console.log('Starting transpilation...'); + console.log('Finished transpilation with 0 errors after 0 ms'); + console.log('[watch] esbuild transpile disabled (useEsbuildTranspile=false). Keeping process alive as no-op.'); + await new Promise(() => { }); // keep alive + return; + } + + console.log('Starting transpilation...'); + + const outDir = OUT_DIR; + + // Initial setup + await cleanDir(outDir); + console.log(`[transpile] ${SRC_DIR} → ${outDir}`); + + // Initial full build + const t1 = Date.now(); + try { + await transpile(outDir, false); + await copyResources(outDir, 'desktop', false, false); + console.log(`Finished transpilation with 0 errors after ${Date.now() - t1} ms`); + } catch (err) { + console.error('[watch] Initial build failed:', err); + console.log(`Finished transpilation with 1 errors after ${Date.now() - t1} ms`); + // Continue watching anyway + } + + let pendingTsFiles: Set = new Set(); + let pendingCopyFiles: Set = new Set(); + + const processChanges = async () => { + console.log('Starting transpilation...'); + const t1 = Date.now(); + const tsFiles = [...pendingTsFiles]; + const filesToCopy = [...pendingCopyFiles]; + pendingTsFiles = new Set(); + pendingCopyFiles = new Set(); + + try { + // Transform changed TypeScript files in parallel + if (tsFiles.length > 0) { + console.log(`[watch] Transpiling ${tsFiles.length} file(s)...`); + await Promise.all(tsFiles.map(srcPath => { + const relativePath = path.relative(path.join(REPO_ROOT, SRC_DIR), srcPath); + const destPath = path.join(REPO_ROOT, outDir, relativePath.replace(/\.ts$/, '.js')); + return transpileFile(srcPath, destPath, relativePath); + })); + } + + // Copy changed resource files in parallel + if (filesToCopy.length > 0) { + await Promise.all(filesToCopy.map(async (srcPath) => { + const relativePath = path.relative(path.join(REPO_ROOT, SRC_DIR), srcPath); + const destPath = path.join(REPO_ROOT, outDir, relativePath); + await fs.promises.mkdir(path.dirname(destPath), { recursive: true }); + await fs.promises.copyFile(srcPath, destPath); + console.log(`[watch] Copied ${relativePath}`); + })); + } + + if (tsFiles.length > 0 || filesToCopy.length > 0) { + console.log(`Finished transpilation with 0 errors after ${Date.now() - t1} ms`); + } + } catch (err) { + console.error('[watch] Rebuild failed:', err); + console.log(`Finished transpilation with 1 errors after ${Date.now() - t1} ms`); + // Continue watching + } + }; + + // Extensions to watch and copy (non-TypeScript resources) + const copyExtensions = ['.css', '.html', '.js', '.json', '.ttf', '.svg', '.png', '.mp3', '.scm', '.sh', '.ps1', '.psm1', '.fish', '.zsh', '.scpt']; + + // Watch src directory using existing gulp-watch based watcher + let debounceTimer: ReturnType | undefined; + const srcDir = path.join(REPO_ROOT, SRC_DIR); + const watchStream = gulpWatch('src/**', { base: srcDir, readDelay: 200 }); + + watchStream.on('data', (file: { path: string }) => { + if (file.path.endsWith('.ts') && !file.path.endsWith('.d.ts')) { + pendingTsFiles.add(file.path); + } else if (copyExtensions.some(ext => file.path.endsWith(ext))) { + pendingCopyFiles.add(file.path); + } + + if (pendingTsFiles.size > 0 || pendingCopyFiles.size > 0) { + clearTimeout(debounceTimer); + debounceTimer = setTimeout(processChanges, 200); + } + }); + + console.log('[watch] Watching src/**/*.{ts,css,...} (Ctrl+C to stop)'); + + // Keep process alive + process.on('SIGINT', () => { + console.log('\n[watch] Stopping...'); + watchStream.end(); + process.exit(0); + }); +} + +// ============================================================================ +// Main +// ============================================================================ + +function printUsage(): void { + console.log(`Usage: npx tsx build/next/index.ts [options] + +Commands: + transpile Transpile TypeScript to JavaScript (single-file, fast) + bundle Bundle entry points into optimized bundles + +Options for 'transpile': + --watch Watch for changes and rebuild incrementally + --out Output directory (default: out) + --exclude-tests Exclude test files from transpilation + +Options for 'bundle': + --minify Minify the output bundles + --nls Process NLS (localization) strings + --out Output directory (default: out-vscode) + --target Build target: desktop (default), server, server-web, web + --source-map-base-url Rewrite sourceMappingURL to CDN URL + +Examples: + npx tsx build/next/index.ts transpile + npx tsx build/next/index.ts transpile --watch + npx tsx build/next/index.ts transpile --out out-build + npx tsx build/next/index.ts transpile --out out-build --exclude-tests + npx tsx build/next/index.ts bundle + npx tsx build/next/index.ts bundle --minify --nls + npx tsx build/next/index.ts bundle --nls --out out-vscode-min + npx tsx build/next/index.ts bundle --minify --nls --target server --out out-vscode-reh-min + npx tsx build/next/index.ts bundle --minify --nls --target server-web --out out-vscode-reh-web-min +`); +} + +async function main(): Promise { + const t1 = Date.now(); + + try { + switch (command) { + case 'transpile': + if (options.watch) { + await watch(); + } else { + const outDir = options.out ?? OUT_DIR; + await cleanDir(outDir); + + // Write build date file (used by packaging to embed in product.json) + const outDirPath = path.join(REPO_ROOT, outDir); + await fs.promises.mkdir(outDirPath, { recursive: true }); + await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); + + console.log(`[transpile] ${SRC_DIR} → ${outDir}${options.excludeTests ? ' (excluding tests)' : ''}`); + const t1 = Date.now(); + await transpile(outDir, options.excludeTests); + await copyResources(outDir, 'desktop', false, options.excludeTests); + console.log(`[transpile] Done in ${Date.now() - t1}ms`); + } + break; + + case 'bundle': + await bundle(options.out ?? OUT_VSCODE_DIR, options.minify, options.nls, options.target as BuildTarget, options.sourceMapBaseUrl); + break; + + default: + printUsage(); + process.exit(command ? 1 : 0); + } + + if (!options.watch) { + console.log(`\n✓ Total: ${Date.now() - t1}ms`); + } + } catch (err) { + console.error('Build failed:', err); + process.exit(1); + } +} + +main(); diff --git a/build/next/nls-plugin.ts b/build/next/nls-plugin.ts new file mode 100644 index 00000000000..56cb84fa33a --- /dev/null +++ b/build/next/nls-plugin.ts @@ -0,0 +1,319 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as esbuild from 'esbuild'; +import * as path from 'path'; +import * as fs from 'fs'; +import { + TextModel, + analyzeLocalizeCalls, + parseLocalizeKeyOrValue +} from '../lib/nls-analysis.ts'; + +// ============================================================================ +// Types +// ============================================================================ + +interface NLSEntry { + moduleId: string; + key: string | { key: string; comment: string[] }; + message: string; + placeholder: string; +} + +export interface NLSPluginOptions { + /** + * Base path for computing module IDs (e.g., 'src') + */ + baseDir: string; + + /** + * Shared collector for NLS entries across multiple builds. + * Create with createNLSCollector() and pass to multiple plugin instances. + */ + collector: NLSCollector; +} + +/** + * Collector for NLS entries across multiple esbuild builds. + */ +export interface NLSCollector { + entries: Map; + add(entry: NLSEntry): void; +} + +/** + * Creates a shared NLS collector that can be passed to multiple plugin instances. + */ +export function createNLSCollector(): NLSCollector { + const entries = new Map(); + return { + entries, + add(entry: NLSEntry) { + entries.set(entry.placeholder, entry); + } + }; +} + +/** + * Finalizes NLS collection and writes output files. + * Call this after all esbuild builds have completed. + */ +export async function finalizeNLS( + collector: NLSCollector, + outDir: string, + alsoWriteTo?: string[] +): Promise<{ indexMap: Map; messageCount: number }> { + if (collector.entries.size === 0) { + return { indexMap: new Map(), messageCount: 0 }; + } + + // Sort entries by moduleId, then by key for stable indices + const sortedEntries = [...collector.entries.values()].sort((a, b) => { + const aKey = typeof a.key === 'string' ? a.key : a.key.key; + const bKey = typeof b.key === 'string' ? b.key : b.key.key; + const moduleCompare = a.moduleId.localeCompare(b.moduleId); + if (moduleCompare !== 0) { + return moduleCompare; + } + return aKey.localeCompare(bKey); + }); + + // Create index map + const indexMap = new Map(); + sortedEntries.forEach((entry, idx) => { + indexMap.set(entry.placeholder, idx); + }); + + // Build NLS metadata + const allMessages: string[] = []; + const moduleToKeys: Map = new Map(); + const moduleToMessages: Map = new Map(); + + for (const entry of sortedEntries) { + allMessages.push(entry.message); + + if (!moduleToKeys.has(entry.moduleId)) { + moduleToKeys.set(entry.moduleId, []); + moduleToMessages.set(entry.moduleId, []); + } + moduleToKeys.get(entry.moduleId)!.push(entry.key); + moduleToMessages.get(entry.moduleId)!.push(entry.message); + } + + // nls.keys.json: [["moduleId", ["key1", "key2"]], ...] + const nlsKeysJson: [string, string[]][] = []; + for (const [moduleId, keys] of moduleToKeys) { + nlsKeysJson.push([moduleId, keys.map(k => typeof k === 'string' ? k : k.key)]); + } + + // nls.metadata.json: { keys: {...}, messages: {...} } + const nlsMetadataJson = { + keys: Object.fromEntries(moduleToKeys), + messages: Object.fromEntries(moduleToMessages) + }; + + // Write NLS files + const allOutDirs = [outDir, ...(alsoWriteTo ?? [])]; + for (const dir of allOutDirs) { + await fs.promises.mkdir(dir, { recursive: true }); + } + + await Promise.all(allOutDirs.flatMap(dir => [ + fs.promises.writeFile( + path.join(dir, 'nls.messages.json'), + JSON.stringify(allMessages) + ), + fs.promises.writeFile( + path.join(dir, 'nls.keys.json'), + JSON.stringify(nlsKeysJson) + ), + fs.promises.writeFile( + path.join(dir, 'nls.metadata.json'), + JSON.stringify(nlsMetadataJson, null, '\t') + ), + fs.promises.writeFile( + path.join(dir, 'nls.messages.js'), + `/*---------------------------------------------------------\n * Copyright (C) Microsoft Corporation. All rights reserved.\n *--------------------------------------------------------*/\nglobalThis._VSCODE_NLS_MESSAGES=${JSON.stringify(allMessages)};` + ), + ])); + + console.log(`[nls] Extracted ${allMessages.length} messages from ${moduleToKeys.size} modules`); + + return { indexMap, messageCount: allMessages.length }; +} + +/** + * Post-processes a JavaScript file to replace NLS placeholders with indices. + */ +export function postProcessNLS( + content: string, + indexMap: Map, + preserveEnglish: boolean +): string { + return replaceInOutput(content, indexMap, preserveEnglish); +} + +// ============================================================================ +// Transformation +// ============================================================================ + +function transformToPlaceholders( + source: string, + moduleId: string +): { code: string; entries: NLSEntry[] } { + const localizeCalls = analyzeLocalizeCalls(source, 'localize'); + const localize2Calls = analyzeLocalizeCalls(source, 'localize2'); + + // Tag calls with their type so we can handle them differently later + const taggedLocalize = localizeCalls.map(call => ({ call, isLocalize2: false })); + const taggedLocalize2 = localize2Calls.map(call => ({ call, isLocalize2: true })); + const allCalls = [...taggedLocalize, ...taggedLocalize2].sort( + (a, b) => a.call.keySpan.start.line - b.call.keySpan.start.line || + a.call.keySpan.start.character - b.call.keySpan.start.character + ); + + if (allCalls.length === 0) { + return { code: source, entries: [] }; + } + + const entries: NLSEntry[] = []; + const model = new TextModel(source); + + // Process in reverse order to preserve positions + for (const { call, isLocalize2 } of allCalls.reverse()) { + const keyParsed = parseLocalizeKeyOrValue(call.key) as string | { key: string; comment: string[] }; + const messageParsed = parseLocalizeKeyOrValue(call.value); + const keyString = typeof keyParsed === 'string' ? keyParsed : keyParsed.key; + + // Use different placeholder prefix for localize vs localize2 + // localize: message will be replaced with null + // localize2: message will be preserved (only key replaced) + const prefix = isLocalize2 ? 'NLS2' : 'NLS'; + const placeholder = `%%${prefix}:${moduleId}#${keyString}%%`; + + entries.push({ + moduleId, + key: keyParsed, + message: String(messageParsed), + placeholder + }); + + // Replace the key with the placeholder string + model.apply(call.keySpan, `"${placeholder}"`); + } + + // Reverse entries to match source order + entries.reverse(); + + return { code: model.toString(), entries }; +} + +function replaceInOutput( + content: string, + indexMap: Map, + preserveEnglish: boolean +): string { + // Replace all placeholders in a single pass using regex + // Two types of placeholders: + // - %%NLS:moduleId#key%% for localize() - message replaced with null + // - %%NLS2:moduleId#key%% for localize2() - message preserved + // Note: esbuild may use single or double quotes, so we handle both + + if (preserveEnglish) { + // Just replace the placeholder with the index (both NLS and NLS2) + return content.replace(/["']%%NLS2?:([^%]+)%%["']/g, (match, inner) => { + // Try NLS first, then NLS2 + let placeholder = `%%NLS:${inner}%%`; + let index = indexMap.get(placeholder); + if (index === undefined) { + placeholder = `%%NLS2:${inner}%%`; + index = indexMap.get(placeholder); + } + if (index !== undefined) { + return String(index); + } + // Placeholder not found in map, leave as-is (shouldn't happen) + return match; + }); + } else { + // For NLS (localize): replace placeholder with index AND replace message with null + // For NLS2 (localize2): replace placeholder with index, keep message + // Note: Use (?:[^"\\]|\\.)* to properly handle escaped quotes like \" or \\ + // Note: esbuild may use single or double quotes, so we handle both + + // First handle NLS (localize) - replace both key and message + content = content.replace( + /["']%%NLS:([^%]+)%%["'](\s*,\s*)(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`)/g, + (match, inner, comma) => { + const placeholder = `%%NLS:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + return `${index}${comma}null`; + } + return match; + } + ); + + // Then handle NLS2 (localize2) - replace only key, keep message + content = content.replace( + /["']%%NLS2:([^%]+)%%["']/g, + (match, inner) => { + const placeholder = `%%NLS2:${inner}%%`; + const index = indexMap.get(placeholder); + if (index !== undefined) { + return String(index); + } + return match; + } + ); + + return content; + } +} + +// ============================================================================ +// Plugin +// ============================================================================ + +export function nlsPlugin(options: NLSPluginOptions): esbuild.Plugin { + const { collector } = options; + + return { + name: 'nls', + setup(build) { + // Transform TypeScript files to replace localize() calls with placeholders + build.onLoad({ filter: /\.ts$/ }, async (args) => { + // Skip .d.ts files + if (args.path.endsWith('.d.ts')) { + return undefined; + } + + const source = await fs.promises.readFile(args.path, 'utf-8'); + + // Compute module ID (e.g., "vs/editor/editor" from "src/vs/editor/editor.ts") + const relativePath = path.relative(options.baseDir, args.path); + const moduleId = relativePath + .replace(/\\/g, '/') + .replace(/\.ts$/, ''); + + // Transform localize() calls to placeholders + const { code, entries: fileEntries } = transformToPlaceholders(source, moduleId); + + // Collect entries + for (const entry of fileEntries) { + collector.add(entry); + } + + if (fileEntries.length > 0) { + return { contents: code, loader: 'ts' }; + } + + // No NLS calls, return undefined to let esbuild handle normally + return undefined; + }); + } + }; +} diff --git a/build/next/working.md b/build/next/working.md new file mode 100644 index 00000000000..bbf23a99806 --- /dev/null +++ b/build/next/working.md @@ -0,0 +1,235 @@ +# Working Notes: New esbuild-based Build System + +> These notes are for AI agents to help with context in new or summarized sessions. + +## Important: Validating Changes + +**The `VS Code - Build` task is NOT needed to validate changes in the `build/` folder!** + +Build scripts in `build/` are TypeScript files that run directly with `tsx` (e.g., `npx tsx build/next/index.ts`). They are not compiled by the main VS Code build. + +To test changes: +```bash +# Test transpile +npx tsx build/next/index.ts transpile --out out-test + +# Test bundle (server-web target to test the auth fix) +npx tsx build/next/index.ts bundle --nls --target server-web --out out-vscode-reh-web-test + +# Verify product config was injected +grep -l "serverLicense" out-vscode-reh-web-test/vs/code/browser/workbench/workbench.js +``` + +--- + +## Architecture Overview + +### Files + +- **[index.ts](index.ts)** - Main build orchestrator + - `transpile` command: Fast TS → JS using `esbuild.transform()` + - `bundle` command: TS → bundled JS using `esbuild.build()` +- **[nls-plugin.ts](nls-plugin.ts)** - NLS (localization) esbuild plugin + +### Integration with Old Build + +In [gulpfile.vscode.ts](../gulpfile.vscode.ts#L228-L242), the `core-ci` task uses these new scripts: +- `runEsbuildTranspile()` → transpile command +- `runEsbuildBundle()` → bundle command + +Old gulp-based bundling renamed to `core-ci-OLD`. + +--- + +## Key Learnings + +### 1. Comment Stripping by esbuild + +**Problem:** esbuild strips comments like `/*BUILD->INSERT_PRODUCT_CONFIGURATION*/` during bundling. + +**Solution:** Use an `onLoad` plugin to transform source files BEFORE esbuild processes them. See `fileContentMapperPlugin()` in index.ts. + +**Why post-processing doesn't work:** By the time we post-process the bundled output, the comment placeholder has already been stripped. + +### 2. Authorization Error: "Unauthorized client refused" + +**Root cause:** Missing product configuration in browser bundle. + +**Flow:** +1. Browser loads with empty product config (placeholder was stripped) +2. `productService.serverLicense` is empty/undefined +3. Browser's `SignService.vsda()` can't decrypt vsda WASM (needs serverLicense as key) +4. Browser's `sign()` returns original challenge instead of signed value +5. Server validates signature → fails +6. Server is in built mode (no `VSCODE_DEV`) → rejects connection + +**Fix:** The `fileContentMapperPlugin` now runs during `onLoad`, replacing placeholders before esbuild strips them. + +### 3. Build-Time Placeholders + +Two placeholders that need injection: + +| Placeholder | Location | Purpose | +|-------------|----------|---------| +| `/*BUILD->INSERT_PRODUCT_CONFIGURATION*/` | `src/vs/platform/product/common/product.ts` | Product config (commit, version, serverLicense, etc.) | +| `/*BUILD->INSERT_BUILTIN_EXTENSIONS*/` | `src/vs/workbench/services/extensionManagement/browser/builtinExtensionsScannerService.ts` | List of built-in extensions | + +### 4. Server-web Target Specifics + +- Removes `webEndpointUrlTemplate` from product config (see `tweakProductForServerWeb` in old build) +- Uses `.build/extensions` for builtin extensions (not `.build/web/extensions`) + +### 5. Entry Point Parity with Old Build + +**Problem:** The desktop target had `keyboardMapEntryPoints` as separate esbuild entry points, producing `layout.contribution.darwin.js`, `layout.contribution.linux.js`, and `layout.contribution.win.js` as standalone files in the output. + +**Root cause:** In the old build (`gulpfile.vscode.ts`), `vscodeEntryPoints` does NOT include `buildfile.keyboardMaps`. These files are only separate entry points for server-web (`gulpfile.reh.ts`) and web (`gulpfile.vscode.web.ts`). For desktop, they're imported as dependencies of `workbench.desktop.main` and get bundled into it. + +**Fix:** Removed `...keyboardMapEntryPoints` from the `desktop` case in `getEntryPointsForTarget()`. Keep for `server-web` and `web`. + +**Lesson:** Always verify new build entry points against the old build's per-target definitions in `buildfile.ts` and the respective gulpfiles. + +### 6. NLS Output File Parity + +**Problem:** `finalizeNLS()` was generating `nls.messages.js` (with `globalThis._VSCODE_NLS_MESSAGES=...`) in addition to the standard `.json` files. The old build only produces `nls.messages.json`, `nls.keys.json`, and `nls.metadata.json`. + +**Fix:** Removed `nls.messages.js` generation from `finalizeNLS()` in `nls-plugin.ts`. + +**Lesson:** Don't add new output file formats that create parity differences with the old build. The old build is the reference. + +--- + +## Testing the Fix + +```bash +# Build server-web with new system +npx tsx build/next/index.ts bundle --nls --target server-web --out out-vscode-reh-web-min + +# Package it (uses gulp task) +npm run gulp vscode-reh-web-darwin-arm64-min + +# Run server +./vscode-server-darwin-arm64-web/bin/code-server-oss --connection-token dev-token + +# Open browser - should connect without "Unauthorized client refused" +``` + +--- + +## Open Items / Future Work + +1. **`BUILD_INSERT_PACKAGE_CONFIGURATION`** - Server bootstrap files ([bootstrap-meta.ts](../../src/bootstrap-meta.ts)) have this marker for package.json injection. Currently handled by [inlineMeta.ts](../lib/inlineMeta.ts) in the old build's packaging step. + +2. **Mangling** - The new build doesn't do TypeScript-based mangling yet. Old `core-ci` with mangling is now `core-ci-OLD`. + +3. **Entry point duplication** - Entry points are duplicated between [buildfile.ts](../buildfile.ts) and [index.ts](index.ts). Consider consolidating. + +--- + +## Build Comparison: OLD (gulp-tsb) vs NEW (esbuild) — Desktop Build + +### Summary + +| Metric | OLD | NEW | Delta | +|--------|-----|-----|-------| +| Total files in `out/` | 3993 | 4301 | +309 extra, 1 missing | +| Total size of `out/` | 25.8 MB | 64.6 MB | +38.8 MB (2.5×) | +| `workbench.desktop.main.js` | 13.0 MB | 15.5 MB | +2.5 MB | + +### 1 Missing File (in OLD, not in NEW) + +| File | Why Missing | Fix | +|------|-------------|-----| +| `out/vs/platform/browserView/electron-browser/preload-browserView.js` | Not listed in `desktopStandaloneFiles` in index.ts. Only `preload.ts` and `preload-aux.ts` are compiled as standalone files. | **Add** `'vs/platform/browserView/electron-browser/preload-browserView.ts'` to the `desktopStandaloneFiles` array in `index.ts`. | + +### 309 Extra Files (in NEW, not in OLD) — Breakdown + +| Category | Count | Explanation | +|----------|-------|-------------| +| **CSS files** | 291 | `copyCssFiles()` copies ALL `.css` from `src/` to the output. The old bundler inlines CSS into the main `.css` bundle (e.g., `workbench.desktop.main.css`) and never ships individual CSS files. These individual files ARE needed at runtime because the new ESM system uses `import './foo.css'` resolved by an import map. | +| **Vendor JS files** | 3 | `dompurify.js`, `marked.js`, `semver.js` — listed in `commonResourcePatterns`. The old bundler inlines these into the main bundle. The new system keeps them as separate files because they're plain JS (not TS). They're needed. | +| **Web workbench bundle** | 1 | `vs/code/browser/workbench/workbench.js` (15.4 MB). This is the web workbench entry point bundle. It should NOT be in a desktop build — the old build explicitly excludes `out-build/vs/code/browser/**`. The `desktopResourcePatterns` in index.ts includes `vs/code/browser/workbench/*.html` and `callback.html` which is correct, but the actual bundle gets written by the esbuild desktop bundle step because the desktop entry points include web entry points. | +| **Web workbench internal** | 1 | `vs/workbench/workbench.web.main.internal.js` (15.4 MB). Similar: shouldn't ship in a desktop build. It's output by the esbuild bundler. | +| **Keyboard layout contributions** | 3 | `layout.contribution.{darwin,linux,win}.js` — the old bundler inlines these into the main bundle. These are new separate files from the esbuild bundler. | +| **NLS files** | 2 | `nls.messages.js` (new) and `nls.metadata.json` (new). The old build has `nls.messages.json` and `nls.keys.json` but not a `.js` version or metadata. The `.js` version is produced by the NLS plugin. | +| **HTML files** | 2 | `vs/code/browser/workbench/workbench.html` and `callback.html` — correctly listed in `desktopResourcePatterns` (these are needed for desktop's built-in web server). | +| **SVG loading spinners** | 3 | `loading-dark.svg`, `loading-hc.svg`, `loading.svg` in `vs/workbench/contrib/extensions/browser/media/`. The old build only copies `theme-icon.png` and `language-icon.svg` from that folder; the new build's `desktopResourcePatterns` uses `*.svg` which is broader. | +| **codicon.ttf (duplicate)** | 1 | At `vs/base/browser/ui/codicons/codicon/codicon.ttf`. The old build copies this to `out/media/codicon.ttf` only. The new build has BOTH: the copy in `out/media/` (from esbuild's `file` loader) AND the original path (from `commonResourcePatterns`). Duplicate. | +| **PSReadLine.psm1** | 1 | `vs/workbench/contrib/terminal/common/scripts/psreadline/PSReadLine.psm1` — the old build uses `*.psm1` in `terminal/common/scripts/` (non-recursive?). The new build uses `**/*.psm1` (recursive), picking up this subdirectory file. Check if it's needed. | +| **date file** | 1 | `out/date` — build timestamp, produced by the new build's `bundle()` function. The old build doesn't write this; it reads `package.json.date` instead. | + +### Size Increase Breakdown by Area + +| Area | OLD | NEW | Delta | Why | +|------|-----|-----|-------|-----| +| `vs/code` | 1.5 MB | 17.4 MB | +15.9 MB | Web workbench bundle (15.4 MB) shouldn't be in desktop build | +| `vs/workbench` | 18.9 MB | 38.7 MB | +19.8 MB | `workbench.web.main.internal.js` (15.4 MB) + unmangled desktop bundle (+2.5 MB) + individual CSS files (~1 MB) | +| `vs/base` | 0 MB | 0.4 MB | +0.4 MB | Individual CSS files + vendor JS | +| `vs/editor` | 0.3 MB | 0.5 MB | +0.1 MB | Individual CSS files | +| `vs/platform` | 1.7 MB | 1.9 MB | +0.2 MB | Individual CSS files | + +### JS Files with >2× Size Change + +| File | OLD | NEW | Ratio | Reason | +|------|-----|-----|-------|--------| +| `vs/workbench/contrib/webview/browser/pre/service-worker.js` | 7 KB | 15 KB | 2.2× | Not minified / includes more inlined code | +| `vs/code/electron-browser/workbench/workbench.js` | 10 KB | 28 KB | 2.75× | OLD is minified to 6 lines; NEW is 380 lines (not compressed, includes tslib banner) | + +### Action Items + +1. **[CRITICAL] Missing `preload-browserView.ts`** — Add to `desktopStandaloneFiles` in index.ts. Without it, BrowserView (used for Simple Browser) may fail. +2. **[SIZE] Web bundles in desktop build** — `workbench.web.main.internal.js` and `vs/code/browser/workbench/workbench.js` together add ~31 MB. These are written by the esbuild bundler and not filtered out. Consider: either don't bundle web entry points for the desktop target, or ensure the packaging step excludes them (currently `packageTask` takes `out-vscode-min/**` without filtering). +3. **[SIZE] No mangling** — The desktop main bundle is 2.5 MB larger due to no property mangling. Known open item. +4. **[MINOR] Duplicate codicon.ttf** — Exists at both `out/media/codicon.ttf` (from esbuild `file` loader) and `out/vs/base/browser/ui/codicons/codicon/codicon.ttf` (from `commonResourcePatterns`). Consider removing from `commonResourcePatterns` if it's already handled by the loader. +5. **[MINOR] Extra SVGs** — `desktopResourcePatterns` uses `*.svg` for extensions media but old build only ships `language-icon.svg`. The loading spinners may be unused in the desktop build. +6. **[MINOR] Extra PSReadLine.psm1** from recursive glob — verify if needed. + +--- + +## Source Maps + +### Fixes Applied + +1. **`sourcesContent: true`** — Production bundles now embed original TypeScript source content in `.map` files, matching the old build's `includeContent: true` behavior. Without this, crash reports from CDN-hosted source maps can't show original source. + +2. **`--source-map-base-url` option** — The `bundle` command accepts an optional `--source-map-base-url ` flag. When set, post-processing rewrites `sourceMappingURL` comments in `.js` and `.css` output files to point to the CDN (e.g., `https://main.vscode-cdn.net/sourcemaps//core/vs/...`). This matches the old build's `sourceMappingURL` function in `minifyTask()`. Wired up in `gulpfile.vscode.ts` for `core-ci-esbuild` and `vscode-esbuild-min` tasks. + +### NLS Source Map Accuracy (Decision: Accept Imprecision) + +**Problem:** `postProcessNLS()` replaces `"%%NLS:moduleId#key%%"` placeholders (~40 chars) with short index values like `null` (4 chars) in the final JS output. This shifts column positions without updating the `.map` files. + +**Options considered:** + +| Option | Description | Effort | Accuracy | +|--------|-------------|--------|----------| +| A. Fixed-width placeholders | Pad placeholders to match replacement length | Hard — indices unknown until all modules are collected across parallel bundles | Perfect | +| B. Post-process source map | Parse `.map`, track replacement offsets per line, adjust VLQ mappings | Medium | Perfect | +| C. Two-pass build | Assign NLS indices during plugin phase | Not feasible with parallel bundling | N/A | +| **D. Accept imprecision** | NLS replacements only affect column positions; line-level debugging works | Zero | Line-level | + +**Decision: Option D — accept imprecision.** Rationale: + +- NLS replacements only shift **columns**, never lines — line-level stack traces and breakpoints remain correct. +- Production crash reporting (the primary consumer of CDN source maps) uses line numbers; column-level accuracy is rarely needed. +- The old gulp build had the same fundamental issue in its `nls.nls()` step and used `SourceMapConsumer`/`SourceMapGenerator` to fix it — but that approach was fragile and slow. +- If column-level precision becomes important later (e.g., for minified+NLS bundles), Option B can be implemented: after NLS replacement, re-parse the source map, walk replacement sites, and adjust column offsets. This is a localized change in the post-processing loop. + +--- + +## Self-hosting Setup + +The default `VS Code - Build` task now runs three parallel watchers: + +| Task | What it does | Script | +|------|-------------|--------| +| **Core - Transpile** | esbuild single-file TS→JS (fast, no type checking) | `watch-client-transpiled` → `npx tsx build/next/index.ts transpile --watch` | +| **Core - Typecheck** | gulp-tsb `noEmit` watch (type errors only, no output) | `watch-clientd` → `gulp watch-client` (with `noEmit: true`) | +| **Ext - Build** | Extension compilation (unchanged) | `watch-extensionsd` | + +### Key Changes + +- **`compilation.ts`**: `ICompileTaskOptions` gained `noEmit?: boolean`. When set, `overrideOptions.noEmit = true` is passed to tsb. `watchTask()` accepts an optional 4th parameter `{ noEmit?: boolean }`. +- **`gulpfile.ts`**: `watchClientTask` no longer runs `rimraf('out')` (the transpiler owns that). Passes `{ noEmit: true }` to `watchTask`. +- **`index.ts`**: Watch mode emits `Starting transpilation...` / `Finished transpilation with N errors after X ms` for VS Code problem matcher. +- **`tasks.json`**: Old "Core - Build" split into "Core - Transpile" + "Core - Typecheck" with separate problem matchers (owners: `esbuild` vs `typescript`). +- **`package.json`**: Added `watch-client-transpile`, `watch-client-transpiled`, `kill-watch-client-transpiled` scripts. diff --git a/build/package-lock.json b/build/package-lock.json index 0a99309c063..92ebc368df2 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -53,7 +53,7 @@ "ansi-colors": "^3.2.3", "byline": "^5.0.0", "debug": "^4.3.2", - "dmg-builder": "^26.7.0", + "dmg-builder": "26.5.0", "esbuild": "0.27.2", "extract-zip": "^2.0.1", "gulp-merge-json": "^2.1.1", @@ -813,13 +813,14 @@ } }, "node_modules/@electron/rebuild": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.3.tgz", - "integrity": "sha512-u9vpTHRMkOYCs/1FLiSVAFZ7FbjsXK+bQuzviJZa+lG7BHZl1nz52/IcGvwa3sk80/fc3llutBkbCq10Vh8WQA==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@electron/rebuild/-/rebuild-4.0.1.tgz", + "integrity": "sha512-iMGXb6Ib7H/Q3v+BKZJoETgF9g6KMNZVbsO4b7Dmpgb5qTFqyFTzqW9F3TOSHdybv2vKYKzSS9OiZL+dcJb+1Q==", "dev": true, "license": "MIT", "dependencies": { "@malept/cross-spawn-promise": "^2.0.0", + "chalk": "^4.0.0", "debug": "^4.1.1", "detect-libc": "^2.0.1", "got": "^11.7.0", @@ -830,7 +831,7 @@ "ora": "^5.1.0", "read-binary-file-arch": "^1.0.6", "semver": "^7.3.5", - "tar": "^7.5.6", + "tar": "^6.0.5", "yargs": "^17.0.1" }, "bin": { @@ -840,6 +841,142 @@ "node": ">=22.12.0" } }, + "node_modules/@electron/rebuild/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@electron/rebuild/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@electron/rebuild/node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/@electron/rebuild/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@electron/rebuild/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@electron/rebuild/node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@electron/rebuild/node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@electron/rebuild/node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@electron/rebuild/node_modules/node-abi": { "version": "4.26.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-4.26.0.tgz", @@ -854,9 +991,9 @@ } }, "node_modules/@electron/rebuild/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -866,6 +1003,38 @@ "node": ">=10" } }, + "node_modules/@electron/rebuild/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@electron/rebuild/node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@electron/universal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@electron/universal/-/universal-2.0.3.tgz", @@ -1402,9 +1571,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", - "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", + "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", "dev": true, "license": "MIT", "dependencies": { @@ -1623,9 +1792,9 @@ } }, "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -2967,19 +3136,18 @@ "license": "MIT" }, "node_modules/app-builder-lib": { - "version": "26.7.0", - "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.7.0.tgz", - "integrity": "sha512-/UgCD8VrO79Wv8aBNpjMfsS1pIUfIPURoRn0Ik6tMe5avdZF+vQgl/juJgipcMmH3YS0BD573lCdCHyoi84USg==", + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.5.0.tgz", + "integrity": "sha512-iRRiJhM0uFMauDeIuv8ESHZSn+LESbdDEuHi7rKdeETjrvBObecXnWJx1f3vs3KtoGcd3hCk1zURKypyvZOtFQ==", "dev": true, "license": "MIT", "dependencies": { "@develar/schema-utils": "~2.6.5", "@electron/asar": "3.4.1", "@electron/fuses": "^1.8.0", - "@electron/get": "^3.0.0", "@electron/notarize": "2.5.0", "@electron/osx-sign": "1.3.3", - "@electron/rebuild": "^4.0.3", + "@electron/rebuild": "4.0.1", "@electron/universal": "2.0.3", "@malept/flatpak-bundler": "^0.4.0", "@types/fs-extra": "9.0.13", @@ -2992,7 +3160,7 @@ "dotenv": "^16.4.5", "dotenv-expand": "^11.0.6", "ejs": "^3.1.8", - "electron-publish": "26.6.0", + "electron-publish": "26.4.1", "fs-extra": "^10.1.0", "hosted-git-info": "^4.1.0", "isbinaryfile": "^5.0.0", @@ -3002,10 +3170,9 @@ "lazy-val": "^1.0.5", "minimatch": "^10.0.3", "plist": "3.1.0", - "proper-lockfile": "^4.1.2", "resedit": "^1.7.0", "semver": "~7.7.3", - "tar": "^7.5.7", + "tar": "7.5.3", "temp-file": "^3.4.0", "tiny-async-pool": "1.3.0", "which": "^5.0.0" @@ -3014,55 +3181,8 @@ "node": ">=14.0.0" }, "peerDependencies": { - "dmg-builder": "26.7.0", - "electron-builder-squirrel-windows": "26.7.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@electron/get/-/get-3.1.0.tgz", - "integrity": "sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.1.1", - "env-paths": "^2.2.0", - "fs-extra": "^8.1.0", - "got": "^11.8.5", - "progress": "^2.0.3", - "semver": "^6.2.0", - "sumchecker": "^3.0.1" - }, - "engines": { - "node": ">=14" - }, - "optionalDependencies": { - "global-agent": "^3.0.0" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/fs-extra": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", - "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/app-builder-lib/node_modules/@electron/get/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "dmg-builder": "26.5.0", + "electron-builder-squirrel-windows": "26.5.0" } }, "node_modules/app-builder-lib/node_modules/@electron/osx-sign": { @@ -3122,37 +3242,14 @@ "node": ">=12" } }, - "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/jsonfile": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", - "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/app-builder-lib/node_modules/fs-extra/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/app-builder-lib/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "engines": { - "node": ">=20" + "node": ">=16" } }, "node_modules/app-builder-lib/node_modules/js-yaml": { @@ -3168,14 +3265,27 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/app-builder-lib/node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, "node_modules/app-builder-lib/node_modules/minimatch": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", - "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "@isaacs/brace-expansion": "^5.0.1" + "@isaacs/brace-expansion": "^5.0.0" }, "engines": { "node": "20 || >=22" @@ -3185,9 +3295,9 @@ } }, "node_modules/app-builder-lib/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -3197,6 +3307,16 @@ "node": ">=10" } }, + "node_modules/app-builder-lib/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/app-builder-lib/node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", @@ -3278,13 +3398,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", - "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", - "dev": true, - "license": "MIT" - }, "node_modules/async-done": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/async-done/-/async-done-1.3.2.tgz", @@ -3709,7 +3822,6 @@ "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", "dependencies": { @@ -4483,13 +4595,13 @@ } }, "node_modules/dmg-builder": { - "version": "26.7.0", - "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.7.0.tgz", - "integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==", + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/dmg-builder/-/dmg-builder-26.5.0.tgz", + "integrity": "sha512-AyOCzpS1TCxDkSWxAzpfw5l7jBX4C8jKCucmT/6y6/24H5VKSHpjcVJD0W8o5BrFi+skC7Z7+F4aNyHmvn4AAw==", "dev": true, "license": "MIT", "dependencies": { - "app-builder-lib": "26.7.0", + "app-builder-lib": "26.5.0", "builder-util": "26.4.1", "fs-extra": "^10.1.0", "iconv-lite": "^0.6.2", @@ -4771,9 +4883,9 @@ } }, "node_modules/electron-publish": { - "version": "26.6.0", - "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.6.0.tgz", - "integrity": "sha512-LsyHMMqbvJ2vsOvuWJ19OezgF2ANdCiHpIucDHNiLhuI+/F3eW98ouzWSRmXXi82ZOPZXC07jnIravY4YYwCLQ==", + "version": "26.4.1", + "resolved": "https://registry.npmjs.org/electron-publish/-/electron-publish-26.4.1.tgz", + "integrity": "sha512-nByal9K5Ar3BNJUfCSglXltpKUhJqpwivNpKVHnkwxTET9LKl+NxoojpGF1dSXVFcoBKVm+OhsVa28ZsoshEPA==", "dev": true, "license": "MIT", "dependencies": { @@ -4781,7 +4893,7 @@ "builder-util": "26.4.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", - "form-data": "^4.0.5", + "form-data": "^4.0.0", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "mime": "^2.5.2" @@ -5401,9 +5513,9 @@ "dev": true }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "dev": true, "license": "MIT", "dependencies": { @@ -6220,6 +6332,13 @@ "node": ">=10" } }, + "node_modules/jake/node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -6892,6 +7011,19 @@ "node": ">= 18" } }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -6982,9 +7114,9 @@ } }, "node_modules/node-api-version/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7032,19 +7164,19 @@ } }, "node_modules/node-gyp/node_modules/isexe": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.2.tgz", - "integrity": "sha512-mIcis6w+JiQf3P7t7mg/35GKB4T1FQsBOtMIvuKw4YErj5RjtbhcTd5/I30fmkmGMwvI0WlzSNN+27K0QCMkAw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "ISC", "engines": { - "node": ">=20" + "node": ">=16" } }, "node_modules/node-gyp/node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -7775,25 +7907,6 @@ "node": ">=10" } }, - "node_modules/proper-lockfile": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", - "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.4", - "retry": "^0.12.0", - "signal-exit": "^3.0.2" - } - }, - "node_modules/proper-lockfile/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8941,9 +9054,10 @@ } }, "node_modules/tar": { - "version": "7.5.7", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", - "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exhorbitant rates) by contacting i@izs.me", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { diff --git a/build/package.json b/build/package.json index b20e60c31dd..b6ccfdfc302 100644 --- a/build/package.json +++ b/build/package.json @@ -47,7 +47,7 @@ "ansi-colors": "^3.2.3", "byline": "^5.0.0", "debug": "^4.3.2", - "dmg-builder": "^26.7.0", + "dmg-builder": "26.5.0", "esbuild": "0.27.2", "extract-zip": "^2.0.1", "gulp-merge-json": "^2.1.1", diff --git a/build/vite/fixtures/aiStats.fixture.ts b/build/vite/fixtures/aiStats.fixture.ts new file mode 100644 index 00000000000..5ebe414b52b --- /dev/null +++ b/build/vite/fixtures/aiStats.fixture.ts @@ -0,0 +1,92 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { observableValue } from '../../../src/vs/base/common/observable'; +import { createAiStatsHover, IAiStatsHoverData } from '../../../src/vs/workbench/contrib/editTelemetry/browser/editStats/aiStatsStatusBar'; +import { ISessionData } from '../../../src/vs/workbench/contrib/editTelemetry/browser/editStats/aiStatsChart'; +import { Random } from '../../../src/vs/editor/test/common/core/random'; +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils'; + +export default defineThemedFixtureGroup({ + AiStatsHover: defineComponentFixture({ + render: (context) => renderAiStatsHover({ ...context, data: createSampleDataWithSessions() }), + }), + + AiStatsHoverNoData: defineComponentFixture({ + render: (context) => renderAiStatsHover({ ...context, data: createEmptyData() }), + }), +}); + +function createSampleDataWithSessions(): IAiStatsHoverData { + const random = Random.create(42); + + // Use a fixed base time for determinism (Jan 1, 2025, 12:00:00 UTC) + const baseTime = 1735732800000; + const dayMs = 24 * 60 * 60 * 1000; + const sessionLengthMs = 5 * 60 * 1000; + + // Generate fake session data for the last 7 days + const fakeSessions: ISessionData[] = []; + for (let day = 6; day >= 0; day--) { + const dayStart = baseTime - day * dayMs; + const sessionsPerDay = random.nextIntRange(3, 9); + for (let s = 0; s < sessionsPerDay; s++) { + const sessionTime = dayStart + s * sessionLengthMs * 2; + fakeSessions.push({ + startTime: sessionTime, + typedCharacters: random.nextIntRange(100, 600), + aiCharacters: random.nextIntRange(200, 1000), + acceptedInlineSuggestions: random.nextIntRange(1, 16), + chatEditCount: random.nextIntRange(0, 5), + }); + } + } + + const totalAi = fakeSessions.reduce((sum, s) => sum + s.aiCharacters, 0); + const totalTyped = fakeSessions.reduce((sum, s) => sum + s.typedCharacters, 0); + const aiRate = totalAi / (totalAi + totalTyped); + + // "Today" for the fixture is the baseTime day + const startOfToday = baseTime - (baseTime % dayMs); + const todaySessions = fakeSessions.filter(s => s.startTime >= startOfToday); + const acceptedToday = todaySessions.reduce((sum, s) => sum + (s.acceptedInlineSuggestions ?? 0), 0); + + return { + aiRate: observableValue('aiRate', aiRate), + acceptedInlineSuggestionsToday: observableValue('acceptedToday', acceptedToday), + sessions: observableValue('sessions', fakeSessions), + }; +} + +function createEmptyData(): IAiStatsHoverData { + return { + aiRate: observableValue('aiRate', 0), + acceptedInlineSuggestionsToday: observableValue('acceptedToday', 0), + sessions: observableValue('sessions', []), + }; +} + +interface RenderAiStatsOptions extends ComponentFixtureContext { + data: IAiStatsHoverData; +} + +function renderAiStatsHover({ container, disposableStore, data }: RenderAiStatsOptions): HTMLElement { + container.style.width = '320px'; + container.style.padding = '8px'; + container.style.backgroundColor = 'var(--vscode-editorHoverWidget-background)'; + container.style.border = '1px solid var(--vscode-editorHoverWidget-border)'; + container.style.borderRadius = '4px'; + container.style.color = 'var(--vscode-editorHoverWidget-foreground)'; + + const hover = createAiStatsHover({ + data, + onOpenSettings: () => console.log('Open settings clicked'), + }); + + const elem = hover.keepUpdated(disposableStore).element; + container.appendChild(elem); + + return container; +} diff --git a/build/vite/fixtures/baseUI.fixture.ts b/build/vite/fixtures/baseUI.fixture.ts new file mode 100644 index 00000000000..4bcf0be5235 --- /dev/null +++ b/build/vite/fixtures/baseUI.fixture.ts @@ -0,0 +1,531 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $ } from '../../../src/vs/base/browser/dom'; +import { Codicon } from '../../../src/vs/base/common/codicons'; +import { ThemeIcon } from '../../../src/vs/base/common/themables'; +import { Action, Separator } from '../../../src/vs/base/common/actions'; + +// UI Components +import { Button, ButtonBar, ButtonWithDescription, unthemedButtonStyles } from '../../../src/vs/base/browser/ui/button/button'; +import { Toggle, Checkbox, unthemedToggleStyles } from '../../../src/vs/base/browser/ui/toggle/toggle'; +import { InputBox, MessageType, unthemedInboxStyles } from '../../../src/vs/base/browser/ui/inputbox/inputBox'; +import { CountBadge } from '../../../src/vs/base/browser/ui/countBadge/countBadge'; +import { ActionBar } from '../../../src/vs/base/browser/ui/actionbar/actionbar'; +import { ProgressBar } from '../../../src/vs/base/browser/ui/progressbar/progressbar'; +import { HighlightedLabel } from '../../../src/vs/base/browser/ui/highlightedlabel/highlightedLabel'; + +import { ComponentFixtureContext, defineComponentFixture, defineThemedFixtureGroup } from './fixtureUtils'; + + +// ============================================================================ +// Styles (themed versions for fixture display) +// ============================================================================ + +const themedButtonStyles = { + ...unthemedButtonStyles, + buttonBackground: 'var(--vscode-button-background)', + buttonHoverBackground: 'var(--vscode-button-hoverBackground)', + buttonForeground: 'var(--vscode-button-foreground)', + buttonSecondaryBackground: 'var(--vscode-button-secondaryBackground)', + buttonSecondaryHoverBackground: 'var(--vscode-button-secondaryHoverBackground)', + buttonSecondaryForeground: 'var(--vscode-button-secondaryForeground)', + buttonBorder: 'var(--vscode-button-border)', +}; + +const themedToggleStyles = { + ...unthemedToggleStyles, + inputActiveOptionBorder: 'var(--vscode-inputOption-activeBorder)', + inputActiveOptionForeground: 'var(--vscode-inputOption-activeForeground)', + inputActiveOptionBackground: 'var(--vscode-inputOption-activeBackground)', +}; + +const themedCheckboxStyles = { + checkboxBackground: 'var(--vscode-checkbox-background)', + checkboxBorder: 'var(--vscode-checkbox-border)', + checkboxForeground: 'var(--vscode-checkbox-foreground)', + checkboxDisabledBackground: undefined, + checkboxDisabledForeground: undefined, +}; + +const themedInputBoxStyles = { + ...unthemedInboxStyles, + inputBackground: 'var(--vscode-input-background)', + inputForeground: 'var(--vscode-input-foreground)', + inputBorder: 'var(--vscode-input-border)', + inputValidationInfoBackground: 'var(--vscode-inputValidation-infoBackground)', + inputValidationInfoBorder: 'var(--vscode-inputValidation-infoBorder)', + inputValidationWarningBackground: 'var(--vscode-inputValidation-warningBackground)', + inputValidationWarningBorder: 'var(--vscode-inputValidation-warningBorder)', + inputValidationErrorBackground: 'var(--vscode-inputValidation-errorBackground)', + inputValidationErrorBorder: 'var(--vscode-inputValidation-errorBorder)', +}; + +const themedBadgeStyles = { + badgeBackground: 'var(--vscode-badge-background)', + badgeForeground: 'var(--vscode-badge-foreground)', + badgeBorder: undefined, +}; + +const themedProgressBarOptions = { + progressBarBackground: 'var(--vscode-progressBar-background)', +}; + + +// ============================================================================ +// Buttons +// ============================================================================ + +function renderButtons({ container, disposableStore }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '12px'; + + // Section: Primary Buttons + const primarySection = $('div'); + primarySection.style.display = 'flex'; + primarySection.style.gap = '8px'; + primarySection.style.alignItems = 'center'; + container.appendChild(primarySection); + + const primaryButton = disposableStore.add(new Button(primarySection, { ...themedButtonStyles, title: 'Primary button' })); + primaryButton.label = 'Primary Button'; + + const primaryIconButton = disposableStore.add(new Button(primarySection, { ...themedButtonStyles, title: 'With Icon', supportIcons: true })); + primaryIconButton.label = '$(add) Add Item'; + + const smallButton = disposableStore.add(new Button(primarySection, { ...themedButtonStyles, title: 'Small button', small: true })); + smallButton.label = 'Small'; + + // Section: Secondary Buttons + const secondarySection = $('div'); + secondarySection.style.display = 'flex'; + secondarySection.style.gap = '8px'; + secondarySection.style.alignItems = 'center'; + container.appendChild(secondarySection); + + const secondaryButton = disposableStore.add(new Button(secondarySection, { ...themedButtonStyles, secondary: true, title: 'Secondary button' })); + secondaryButton.label = 'Secondary Button'; + + const secondaryIconButton = disposableStore.add(new Button(secondarySection, { ...themedButtonStyles, secondary: true, title: 'Cancel', supportIcons: true })); + secondaryIconButton.label = '$(close) Cancel'; + + // Section: Disabled Buttons + const disabledSection = $('div'); + disabledSection.style.display = 'flex'; + disabledSection.style.gap = '8px'; + disabledSection.style.alignItems = 'center'; + container.appendChild(disabledSection); + + const disabledButton = disposableStore.add(new Button(disabledSection, { ...themedButtonStyles, title: 'Disabled', disabled: true })); + disabledButton.label = 'Disabled'; + disabledButton.enabled = false; + + const disabledSecondary = disposableStore.add(new Button(disabledSection, { ...themedButtonStyles, secondary: true, title: 'Disabled Secondary', disabled: true })); + disabledSecondary.label = 'Disabled Secondary'; + disabledSecondary.enabled = false; + + return container; +} + +function renderButtonBar({ container, disposableStore }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '16px'; + + // Button Bar + const barContainer = $('div'); + container.appendChild(barContainer); + + const buttonBar = new ButtonBar(barContainer); + disposableStore.add(buttonBar); + + const okButton = buttonBar.addButton({ ...themedButtonStyles, title: 'OK' }); + okButton.label = 'OK'; + + const cancelButton = buttonBar.addButton({ ...themedButtonStyles, secondary: true, title: 'Cancel' }); + cancelButton.label = 'Cancel'; + + // Button with Description + const descContainer = $('div'); + descContainer.style.width = '300px'; + container.appendChild(descContainer); + + const buttonWithDesc = disposableStore.add(new ButtonWithDescription(descContainer, { ...themedButtonStyles, title: 'Install Extension', supportIcons: true })); + buttonWithDesc.label = '$(extensions) Install Extension'; + buttonWithDesc.description = 'This will install the extension and enable it globally'; + + return container; +} + + +// ============================================================================ +// Toggles and Checkboxes +// ============================================================================ + +function renderToggles({ container, disposableStore }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '12px'; + + // Toggles + const toggleSection = $('div'); + toggleSection.style.display = 'flex'; + toggleSection.style.gap = '16px'; + toggleSection.style.alignItems = 'center'; + container.appendChild(toggleSection); + + const toggle1 = disposableStore.add(new Toggle({ + ...themedToggleStyles, + title: 'Case Sensitive', + isChecked: false, + icon: Codicon.caseSensitive, + })); + toggleSection.appendChild(toggle1.domNode); + + const toggle2 = disposableStore.add(new Toggle({ + ...themedToggleStyles, + title: 'Whole Word', + isChecked: true, + icon: Codicon.wholeWord, + })); + toggleSection.appendChild(toggle2.domNode); + + const toggle3 = disposableStore.add(new Toggle({ + ...themedToggleStyles, + title: 'Use Regular Expression', + isChecked: false, + icon: Codicon.regex, + })); + toggleSection.appendChild(toggle3.domNode); + + // Checkboxes + const checkboxSection = $('div'); + checkboxSection.style.display = 'flex'; + checkboxSection.style.flexDirection = 'column'; + checkboxSection.style.gap = '8px'; + container.appendChild(checkboxSection); + + const createCheckboxRow = (label: string, checked: boolean) => { + const row = $('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '8px'; + + const checkbox = disposableStore.add(new Checkbox(label, checked, themedCheckboxStyles)); + row.appendChild(checkbox.domNode); + + const labelEl = $('span'); + labelEl.textContent = label; + labelEl.style.color = 'var(--vscode-foreground)'; + row.appendChild(labelEl); + + return row; + }; + + checkboxSection.appendChild(createCheckboxRow('Enable auto-save', true)); + checkboxSection.appendChild(createCheckboxRow('Show line numbers', true)); + checkboxSection.appendChild(createCheckboxRow('Word wrap', false)); + + return container; +} + + +// ============================================================================ +// Input Boxes +// ============================================================================ + +function renderInputBoxes({ container, disposableStore }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '16px'; + container.style.width = '350px'; + + // Normal input + const normalInput = disposableStore.add(new InputBox(container, undefined, { + placeholder: 'Enter search query...', + inputBoxStyles: themedInputBoxStyles, + })); + + // Input with value + const filledInput = disposableStore.add(new InputBox(container, undefined, { + placeholder: 'File path', + inputBoxStyles: themedInputBoxStyles, + })); + filledInput.value = '/src/vs/editor/browser'; + + // Input with info validation + const infoInput = disposableStore.add(new InputBox(container, undefined, { + placeholder: 'Username', + inputBoxStyles: themedInputBoxStyles, + validationOptions: { + validation: (value) => value.length < 3 ? { content: 'Username must be at least 3 characters', type: MessageType.INFO } : null + } + })); + infoInput.value = 'ab'; + infoInput.validate(); + + // Input with warning validation + const warningInput = disposableStore.add(new InputBox(container, undefined, { + placeholder: 'Password', + inputBoxStyles: themedInputBoxStyles, + validationOptions: { + validation: (value) => value.length < 8 ? { content: 'Password should be at least 8 characters for security', type: MessageType.WARNING } : null + } + })); + warningInput.value = 'pass'; + warningInput.validate(); + + // Input with error validation + const errorInput = disposableStore.add(new InputBox(container, undefined, { + placeholder: 'Email address', + inputBoxStyles: themedInputBoxStyles, + validationOptions: { + validation: (value) => !value.includes('@') ? { content: 'Please enter a valid email address', type: MessageType.ERROR } : null + } + })); + errorInput.value = 'invalid-email'; + errorInput.validate(); + + return container; +} + + +// ============================================================================ +// Count Badges +// ============================================================================ + +function renderCountBadges({ container }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.gap = '12px'; + container.style.alignItems = 'center'; + + // Various badge counts + const counts = [1, 5, 12, 99, 999]; + + for (const count of counts) { + const badgeContainer = $('div'); + badgeContainer.style.display = 'flex'; + badgeContainer.style.alignItems = 'center'; + badgeContainer.style.gap = '8px'; + + const label = $('span'); + label.textContent = 'Issues'; + label.style.color = 'var(--vscode-foreground)'; + badgeContainer.appendChild(label); + + new CountBadge(badgeContainer, { count }, themedBadgeStyles); + container.appendChild(badgeContainer); + } + + return container; +} + + +// ============================================================================ +// Action Bar +// ============================================================================ + +function renderActionBar({ container, disposableStore }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '16px'; + + // Horizontal action bar + const horizontalLabel = $('div'); + horizontalLabel.textContent = 'Horizontal Actions:'; + horizontalLabel.style.color = 'var(--vscode-foreground)'; + horizontalLabel.style.marginBottom = '4px'; + container.appendChild(horizontalLabel); + + const horizontalContainer = $('div'); + container.appendChild(horizontalContainer); + + const horizontalBar = disposableStore.add(new ActionBar(horizontalContainer, { + ariaLabel: 'Editor Actions', + })); + + horizontalBar.push([ + new Action('editor.action.save', 'Save', ThemeIcon.asClassName(Codicon.save), true, async () => console.log('Save')), + new Action('editor.action.undo', 'Undo', ThemeIcon.asClassName(Codicon.discard), true, async () => console.log('Undo')), + new Action('editor.action.redo', 'Redo', ThemeIcon.asClassName(Codicon.redo), true, async () => console.log('Redo')), + new Separator(), + new Action('editor.action.find', 'Find', ThemeIcon.asClassName(Codicon.search), true, async () => console.log('Find')), + new Action('editor.action.replace', 'Replace', ThemeIcon.asClassName(Codicon.replaceAll), true, async () => console.log('Replace')), + ]); + + // Action bar with disabled items + const mixedLabel = $('div'); + mixedLabel.textContent = 'Mixed States:'; + mixedLabel.style.color = 'var(--vscode-foreground)'; + mixedLabel.style.marginBottom = '4px'; + container.appendChild(mixedLabel); + + const mixedContainer = $('div'); + container.appendChild(mixedContainer); + + const mixedBar = disposableStore.add(new ActionBar(mixedContainer, { + ariaLabel: 'Mixed Actions', + })); + + mixedBar.push([ + new Action('action.enabled', 'Enabled', ThemeIcon.asClassName(Codicon.play), true, async () => { }), + new Action('action.disabled', 'Disabled', ThemeIcon.asClassName(Codicon.debugPause), false, async () => { }), + new Action('action.enabled2', 'Enabled', ThemeIcon.asClassName(Codicon.debugStop), true, async () => { }), + ]); + + return container; +} + + +// ============================================================================ +// Progress Bar +// ============================================================================ + +function renderProgressBars({ container, disposableStore }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '24px'; + container.style.width = '400px'; + + const createSection = (label: string) => { + const section = $('div'); + const labelEl = $('div'); + labelEl.textContent = label; + labelEl.style.color = 'var(--vscode-foreground)'; + labelEl.style.marginBottom = '8px'; + labelEl.style.fontSize = '12px'; + section.appendChild(labelEl); + + // Progress bar container with proper constraints + const barContainer = $('div'); + barContainer.style.position = 'relative'; + barContainer.style.width = '100%'; + barContainer.style.height = '4px'; + barContainer.style.overflow = 'hidden'; + section.appendChild(barContainer); + + container.appendChild(section); + return barContainer; + }; + + // Infinite progress + const infiniteSection = createSection('Infinite Progress (loading...)'); + const infiniteBar = disposableStore.add(new ProgressBar(infiniteSection, themedProgressBarOptions)); + infiniteBar.infinite(); + + // Discrete progress - 30% + const progress30Section = createSection('Discrete Progress - 30%'); + const progress30Bar = disposableStore.add(new ProgressBar(progress30Section, themedProgressBarOptions)); + progress30Bar.total(100); + progress30Bar.worked(30); + + // Discrete progress - 60% + const progress60Section = createSection('Discrete Progress - 60%'); + const progress60Bar = disposableStore.add(new ProgressBar(progress60Section, themedProgressBarOptions)); + progress60Bar.total(100); + progress60Bar.worked(60); + + // Discrete progress - 90% + const progress90Section = createSection('Discrete Progress - 90%'); + const progress90Bar = disposableStore.add(new ProgressBar(progress90Section, themedProgressBarOptions)); + progress90Bar.total(100); + progress90Bar.worked(90); + + // Completed progress + const doneSection = createSection('Completed (100%)'); + const doneBar = disposableStore.add(new ProgressBar(doneSection, themedProgressBarOptions)); + doneBar.total(100); + doneBar.worked(100); + + return container; +} + + +// ============================================================================ +// Highlighted Label +// ============================================================================ + +function renderHighlightedLabels({ container }: ComponentFixtureContext): HTMLElement { + container.style.padding = '16px'; + container.style.display = 'flex'; + container.style.flexDirection = 'column'; + container.style.gap = '8px'; + container.style.color = 'var(--vscode-foreground)'; + + const createHighlightedLabel = (text: string, highlights: { start: number; end: number }[]) => { + const row = $('div'); + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '8px'; + + const labelContainer = $('div'); + const label = new HighlightedLabel(labelContainer); + label.set(text, highlights); + row.appendChild(labelContainer); + + const queryLabel = $('span'); + queryLabel.style.color = 'var(--vscode-descriptionForeground)'; + queryLabel.style.fontSize = '12px'; + queryLabel.textContent = `(matches highlighted)`; + row.appendChild(queryLabel); + + return row; + }; + + // File search examples + container.appendChild(createHighlightedLabel('codeEditorWidget.ts', [{ start: 0, end: 4 }])); // "code" + container.appendChild(createHighlightedLabel('inlineCompletionsController.ts', [{ start: 6, end: 10 }])); // "Comp" + container.appendChild(createHighlightedLabel('diffEditorViewModel.ts', [{ start: 0, end: 4 }, { start: 10, end: 14 }])); // "diff" and "View" + container.appendChild(createHighlightedLabel('workbenchTestServices.ts', [{ start: 9, end: 13 }])); // "Test" + + return container; +} + + +// ============================================================================ +// Export Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ + Buttons: defineComponentFixture({ + render: renderButtons, + }), + + ButtonBar: defineComponentFixture({ + render: renderButtonBar, + }), + + Toggles: defineComponentFixture({ + render: renderToggles, + }), + + InputBoxes: defineComponentFixture({ + render: renderInputBoxes, + }), + + CountBadges: defineComponentFixture({ + render: renderCountBadges, + }), + + ActionBar: defineComponentFixture({ + render: renderActionBar, + }), + + ProgressBars: defineComponentFixture({ + render: renderProgressBars, + }), + + HighlightedLabels: defineComponentFixture({ + render: renderHighlightedLabels, + }), +}); diff --git a/build/vite/fixtures/editor/codeEditor.fixture.ts b/build/vite/fixtures/editor/codeEditor.fixture.ts new file mode 100644 index 00000000000..bdaaf35bf0f --- /dev/null +++ b/build/vite/fixtures/editor/codeEditor.fixture.ts @@ -0,0 +1,76 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../src/vs/base/common/uri'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../src/vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils'; + +const SAMPLE_CODE = `// Welcome to VS Code +function greet(name: string): string { + return \`Hello, \${name}!\`; +} + +class Counter { + private _count = 0; + + increment(): void { + this._count++; + } + + get count(): number { + return this._count; + } +} + +const counter = new Counter(); +counter.increment(); +console.log(greet('World')); +console.log(\`Count: \${counter.count}\`); +`; + +function renderCodeEditor({ container, disposableStore, theme }: ComponentFixtureContext): HTMLElement { + container.style.width = '600px'; + container.style.height = '400px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + const model = disposableStore.add(createTextModel( + instantiationService, + SAMPLE_CODE, + URI.parse('inmemory://sample.ts'), + 'typescript' + )); + + const editorOptions: ICodeEditorWidgetOptions = { + contributions: [] + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: true }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + fontFamily: 'Consolas, "Courier New", monospace', + renderWhitespace: 'selection', + bracketPairColorization: { enabled: true }, + }, + editorOptions + )); + + editor.setModel(model); + + return container; +} + +export default defineThemedFixtureGroup({ + CodeEditor: defineComponentFixture({ + render: (context) => renderCodeEditor(context), + }), +}); diff --git a/build/vite/fixtures/editor/inlineCompletions.fixture.ts b/build/vite/fixtures/editor/inlineCompletions.fixture.ts new file mode 100644 index 00000000000..06abdece6b6 --- /dev/null +++ b/build/vite/fixtures/editor/inlineCompletions.fixture.ts @@ -0,0 +1,164 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { constObservable } from '../../../../src/vs/base/common/observable'; +import { URI } from '../../../../src/vs/base/common/uri'; +import { Range } from '../../../../src/vs/editor/common/core/range'; +import { IEditorOptions } from '../../../../src/vs/editor/common/config/editorOptions'; +import { CodeEditorWidget, ICodeEditorWidgetOptions } from '../../../../src/vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { EditorExtensionsRegistry } from '../../../../src/vs/editor/browser/editorExtensions'; +import { InlineCompletionsController } from '../../../../src/vs/editor/contrib/inlineCompletions/browser/controller/inlineCompletionsController'; +import { InlineCompletionsSource, InlineCompletionsState } from '../../../../src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsSource'; +import { InlineEditItem } from '../../../../src/vs/editor/contrib/inlineCompletions/browser/model/inlineSuggestionItem'; +import { TextModelValueReference } from '../../../../src/vs/editor/contrib/inlineCompletions/browser/model/textModelValueReference'; +import { ComponentFixtureContext, createEditorServices, createTextModel, defineComponentFixture, defineThemedFixtureGroup } from '../fixtureUtils'; + +// Import to register the inline completions contribution +import '../../../../src/vs/editor/contrib/inlineCompletions/browser/inlineCompletions.contribution'; + + +// ============================================================================ +// Inline Edit Fixture +// ============================================================================ + +interface InlineEditOptions extends ComponentFixtureContext { + code: string; + cursorLine: number; + range: { startLineNumber: number; startColumn: number; endLineNumber: number; endColumn: number }; + newText: string; + width?: string; + height?: string; + editorOptions?: IEditorOptions; +} + +function renderInlineEdit(options: InlineEditOptions): HTMLElement { + const { container, disposableStore, theme } = options; + container.style.width = options.width ?? '500px'; + container.style.height = options.height ?? '170px'; + container.style.border = '1px solid var(--vscode-editorWidget-border)'; + + const instantiationService = createEditorServices(disposableStore, { colorTheme: theme }); + + const textModel = disposableStore.add(createTextModel( + instantiationService, + options.code, + URI.parse('inmemory://inline-edit.ts'), + 'typescript' + )); + + // Mock the InlineCompletionsSource to provide our test completion + instantiationService.stubInstance(InlineCompletionsSource, { + cancelUpdate: () => { }, + clear: () => { }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + clearOperationOnTextModelChange: constObservable(undefined) as any, + clearSuggestWidgetInlineCompletions: () => { }, + dispose: () => { }, + fetch: async () => true, + inlineCompletions: constObservable(new InlineCompletionsState([ + InlineEditItem.createForTest( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TextModelValueReference.snapshot(textModel as any), + new Range( + options.range.startLineNumber, + options.range.startColumn, + options.range.endLineNumber, + options.range.endColumn + ), + options.newText + ) + ], undefined)), + loading: constObservable(false), + seedInlineCompletionsWithSuggestWidget: () => { }, + seedWithCompletion: () => { }, + suggestWidgetInlineCompletions: constObservable(InlineCompletionsState.createEmpty()), + }); + + const editorWidgetOptions: ICodeEditorWidgetOptions = { + contributions: EditorExtensionsRegistry.getEditorContributions() + }; + + const editor = disposableStore.add(instantiationService.createInstance( + CodeEditorWidget, + container, + { + automaticLayout: true, + minimap: { enabled: false }, + lineNumbers: 'on', + scrollBeyondLastLine: false, + fontSize: 14, + cursorBlinking: 'solid', + ...options.editorOptions, + }, + editorWidgetOptions + )); + + editor.setModel(textModel); + editor.setPosition({ lineNumber: options.cursorLine, column: 1 }); + editor.focus(); + + // Trigger inline completions + const controller = InlineCompletionsController.get(editor); + controller?.model?.get(); + + return container; +} + + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ + // Side-by-side view: Multi-line replacement + SideBySideView: defineComponentFixture({ + render: (context) => renderInlineEdit({ + ...context, + code: `function greet(name) { + console.log("Hello, " + name); +}`, + cursorLine: 2, + range: { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 100 }, + newText: ' console.log(`Hello, ${name}!`);', + }), + }), + + // Word replacement view: Single word change + WordReplacementView: defineComponentFixture({ + render: (context) => renderInlineEdit({ + ...context, + code: `class BufferData { + append(data: number[]) { + this.data.push(data); + } +}`, + cursorLine: 2, + range: { startLineNumber: 2, startColumn: 2, endLineNumber: 2, endColumn: 8 }, + newText: 'push', + height: '200px', + }), + }), + + // Insertion view: Insert new content + InsertionView: defineComponentFixture({ + render: (context) => renderInlineEdit({ + ...context, + code: `class BufferData { + append(data: number[]) {} // appends data +}`, + cursorLine: 2, + range: { startLineNumber: 2, startColumn: 26, endLineNumber: 2, endColumn: 26 }, + newText: ` + console.log(data); + `, + height: '200px', + editorOptions: { + inlineSuggest: { + edits: { allowCodeShifting: 'always' } + } + } + }), + }), +}); diff --git a/build/vite/fixtures/fixtureUtils.ts b/build/vite/fixtures/fixtureUtils.ts new file mode 100644 index 00000000000..fd90169dae7 --- /dev/null +++ b/build/vite/fixtures/fixtureUtils.ts @@ -0,0 +1,512 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { defineFixture, defineFixtureGroup, defineFixtureVariants } from '@vscode/component-explorer'; +import { DisposableStore, toDisposable } from '../../../src/vs/base/common/lifecycle'; +import { URI } from '../../../src/vs/base/common/uri'; +import '../style.css'; + +// Theme +import { COLOR_THEME_DARK_INITIAL_COLORS, COLOR_THEME_LIGHT_INITIAL_COLORS } from '../../../src/vs/workbench/services/themes/common/workbenchThemeService'; +import { ColorThemeData } from '../../../src/vs/workbench/services/themes/common/colorThemeData'; +import { ColorScheme } from '../../../src/vs/platform/theme/common/theme'; +import { generateColorThemeCSS } from '../../../src/vs/workbench/services/themes/browser/colorThemeCss'; +import { Registry } from '../../../src/vs/platform/registry/common/platform'; +import { Extensions as ThemingExtensions, IThemingRegistry } from '../../../src/vs/platform/theme/common/themeService'; +import { IEnvironmentService } from '../../../src/vs/platform/environment/common/environment'; +import { getIconsStyleSheet } from '../../../src/vs/platform/theme/browser/iconsStyleSheet'; + +// Instantiation +import { ServiceCollection } from '../../../src/vs/platform/instantiation/common/serviceCollection'; +import { SyncDescriptor } from '../../../src/vs/platform/instantiation/common/descriptors'; +import { ServiceIdentifier } from '../../../src/vs/platform/instantiation/common/instantiation'; +import { TestInstantiationService } from '../../../src/vs/platform/instantiation/test/common/instantiationServiceMock'; + +// Test service implementations +import { TestAccessibilityService } from '../../../src/vs/platform/accessibility/test/common/testAccessibilityService'; +import { MockKeybindingService, MockContextKeyService } from '../../../src/vs/platform/keybinding/test/common/mockKeybindingService'; +import { TestClipboardService } from '../../../src/vs/platform/clipboard/test/common/testClipboardService'; +import { TestEditorWorkerService } from '../../../src/vs/editor/test/common/services/testEditorWorkerService'; +import { NullOpenerService } from '../../../src/vs/platform/opener/test/common/nullOpenerService'; +import { TestNotificationService } from '../../../src/vs/platform/notification/test/common/testNotificationService'; +import { TestDialogService } from '../../../src/vs/platform/dialogs/test/common/testDialogService'; +import { TestConfigurationService } from '../../../src/vs/platform/configuration/test/common/testConfigurationService'; +import { TestTextResourcePropertiesService } from '../../../src/vs/editor/test/common/services/testTextResourcePropertiesService'; +import { TestThemeService } from '../../../src/vs/platform/theme/test/common/testThemeService'; +import { TestLanguageConfigurationService } from '../../../src/vs/editor/test/common/modes/testLanguageConfigurationService'; +import { TestCodeEditorService, TestCommandService } from '../../../src/vs/editor/test/browser/editorTestServices'; +import { TestTreeSitterLibraryService } from '../../../src/vs/editor/test/common/services/testTreeSitterLibraryService'; +import { TestMenuService } from '../../../src/vs/workbench/test/browser/workbenchTestServices'; + +// Service interfaces +import { IAccessibilityService } from '../../../src/vs/platform/accessibility/common/accessibility'; +import { IKeybindingService } from '../../../src/vs/platform/keybinding/common/keybinding'; +import { IClipboardService } from '../../../src/vs/platform/clipboard/common/clipboardService'; +import { IEditorWorkerService } from '../../../src/vs/editor/common/services/editorWorker'; +import { IOpenerService } from '../../../src/vs/platform/opener/common/opener'; +import { INotificationService } from '../../../src/vs/platform/notification/common/notification'; +import { IDialogService } from '../../../src/vs/platform/dialogs/common/dialogs'; +import { IUndoRedoService } from '../../../src/vs/platform/undoRedo/common/undoRedo'; +import { UndoRedoService } from '../../../src/vs/platform/undoRedo/common/undoRedoService'; +import { ILanguageService } from '../../../src/vs/editor/common/languages/language'; +import { LanguageService } from '../../../src/vs/editor/common/services/languageService'; +import { ILanguageConfigurationService } from '../../../src/vs/editor/common/languages/languageConfigurationRegistry'; +import { IConfigurationService } from '../../../src/vs/platform/configuration/common/configuration'; +import { ITextResourcePropertiesService } from '../../../src/vs/editor/common/services/textResourceConfiguration'; +import { IColorTheme, IThemeService } from '../../../src/vs/platform/theme/common/themeService'; +import { ILogService, NullLogService, ILoggerService, NullLoggerService } from '../../../src/vs/platform/log/common/log'; +import { IModelService } from '../../../src/vs/editor/common/services/model'; +import { ModelService } from '../../../src/vs/editor/common/services/modelService'; +import { ICodeEditorService } from '../../../src/vs/editor/browser/services/codeEditorService'; +import { IContextKeyService } from '../../../src/vs/platform/contextkey/common/contextkey'; +import { ICommandService } from '../../../src/vs/platform/commands/common/commands'; +import { ITelemetryService } from '../../../src/vs/platform/telemetry/common/telemetry'; +import { NullTelemetryServiceShape } from '../../../src/vs/platform/telemetry/common/telemetryUtils'; +import { ILanguageFeatureDebounceService, LanguageFeatureDebounceService } from '../../../src/vs/editor/common/services/languageFeatureDebounce'; +import { ILanguageFeaturesService } from '../../../src/vs/editor/common/services/languageFeatures'; +import { LanguageFeaturesService } from '../../../src/vs/editor/common/services/languageFeaturesService'; +import { ITreeSitterLibraryService } from '../../../src/vs/editor/common/services/treeSitter/treeSitterLibraryService'; +import { IInlineCompletionsService, InlineCompletionsService } from '../../../src/vs/editor/browser/services/inlineCompletionsService'; +import { ICodeLensCache } from '../../../src/vs/editor/contrib/codelens/browser/codeLensCache'; +import { IHoverService } from '../../../src/vs/platform/hover/browser/hover'; +import { IDataChannelService, NullDataChannelService } from '../../../src/vs/platform/dataChannel/common/dataChannel'; +import { IContextMenuService, IContextViewService } from '../../../src/vs/platform/contextview/browser/contextView'; +import { ILabelService } from '../../../src/vs/platform/label/common/label'; +import { IMenuService } from '../../../src/vs/platform/actions/common/actions'; +import { IActionViewItemService, NullActionViewItemService } from '../../../src/vs/platform/actions/browser/actionViewItemService'; +import { IDefaultAccountService } from '../../../src/vs/platform/defaultAccount/common/defaultAccount'; +import { IStorageService, IStorageValueChangeEvent, IWillSaveStateEvent, StorageScope, StorageTarget, IStorageTargetChangeEvent, IStorageEntry, WillSaveStateReason, IWorkspaceStorageValueChangeEvent, IProfileStorageValueChangeEvent, IApplicationStorageValueChangeEvent } from '../../../src/vs/platform/storage/common/storage'; +import { Emitter, Event } from '../../../src/vs/base/common/event'; +import { mock } from '../../../src/vs/base/test/common/mock'; +import { IAnyWorkspaceIdentifier } from '../../../src/vs/platform/workspace/common/workspace'; +import { IUserDataProfile } from '../../../src/vs/platform/userDataProfile/common/userDataProfile'; +import { IUserInteractionService, MockUserInteractionService } from '../../../src/vs/platform/userInteraction/browser/userInteractionService'; + +// Editor +import { ITextModel } from '../../../src/vs/editor/common/model'; + + + +// Import color registrations to ensure colors are available +import '../../../src/vs/platform/theme/common/colors/baseColors'; +import '../../../src/vs/platform/theme/common/colors/editorColors'; +import '../../../src/vs/platform/theme/common/colors/listColors'; +import '../../../src/vs/platform/theme/common/colors/miscColors'; +import '../../../src/vs/workbench/common/theme'; + +/** + * A storage service that never stores anything and always returns the default/fallback value. + * This is useful for fixtures where we want consistent behavior without persisted state. + */ +class NullStorageService implements IStorageService { + + declare readonly _serviceBrand: undefined; + + private readonly _onDidChangeValue = new Emitter(); + onDidChangeValue(scope: StorageScope.WORKSPACE, key: string | undefined, disposable: DisposableStore): Event; + onDidChangeValue(scope: StorageScope.PROFILE, key: string | undefined, disposable: DisposableStore): Event; + onDidChangeValue(scope: StorageScope.APPLICATION, key: string | undefined, disposable: DisposableStore): Event; + onDidChangeValue(scope: StorageScope, key: string | undefined, disposable: DisposableStore): Event { + return Event.filter(this._onDidChangeValue.event, e => e.scope === scope && (key === undefined || e.key === key), disposable); + } + + private readonly _onDidChangeTarget = new Emitter(); + readonly onDidChangeTarget: Event = this._onDidChangeTarget.event; + + private readonly _onWillSaveState = new Emitter(); + readonly onWillSaveState: Event = this._onWillSaveState.event; + + get(key: string, scope: StorageScope, fallbackValue: string): string; + get(key: string, scope: StorageScope, fallbackValue?: string): string | undefined; + get(_key: string, _scope: StorageScope, fallbackValue?: string): string | undefined { + return fallbackValue; + } + + getBoolean(key: string, scope: StorageScope, fallbackValue: boolean): boolean; + getBoolean(key: string, scope: StorageScope, fallbackValue?: boolean): boolean | undefined; + getBoolean(_key: string, _scope: StorageScope, fallbackValue?: boolean): boolean | undefined { + return fallbackValue; + } + + getNumber(key: string, scope: StorageScope, fallbackValue: number): number; + getNumber(key: string, scope: StorageScope, fallbackValue?: number): number | undefined; + getNumber(_key: string, _scope: StorageScope, fallbackValue?: number): number | undefined { + return fallbackValue; + } + + getObject(key: string, scope: StorageScope, fallbackValue: T): T; + getObject(key: string, scope: StorageScope, fallbackValue?: T): T | undefined; + getObject(_key: string, _scope: StorageScope, fallbackValue?: T): T | undefined { + return fallbackValue; + } + + store(_key: string, _value: string | boolean | number | undefined | null, _scope: StorageScope, _target: StorageTarget): void { + // no-op + } + + storeAll(_entries: IStorageEntry[], _external: boolean): void { + // no-op + } + + remove(_key: string, _scope: StorageScope): void { + // no-op + } + + isNew(_scope: StorageScope): boolean { + return true; + } + + flush(_reason?: WillSaveStateReason): Promise { + return Promise.resolve(); + } + + optimize(_scope: StorageScope): Promise { + return Promise.resolve(); + } + + log(): void { + // no-op + } + + keys(_scope: StorageScope, _target: StorageTarget): string[] { + return []; + } + + switch(): Promise { + return Promise.resolve(); + } + + hasScope(_scope: IAnyWorkspaceIdentifier | IUserDataProfile): boolean { + return false; + } +} + + +// ============================================================================ +// Themes +// ============================================================================ + +const themingRegistry = Registry.as(ThemingExtensions.ThemingContribution); +const mockEnvironmentService: IEnvironmentService = Object.create(null); + +export const darkTheme = ColorThemeData.createUnloadedThemeForThemeType( + ColorScheme.DARK, + COLOR_THEME_DARK_INITIAL_COLORS +); + +export const lightTheme = ColorThemeData.createUnloadedThemeForThemeType( + ColorScheme.LIGHT, + COLOR_THEME_LIGHT_INITIAL_COLORS +); + +let globalStyleSheet: CSSStyleSheet | undefined; +let iconsStyleSheetCache: CSSStyleSheet | undefined; +let darkThemeStyleSheet: CSSStyleSheet | undefined; +let lightThemeStyleSheet: CSSStyleSheet | undefined; + +function getGlobalStyleSheet(): CSSStyleSheet { + if (!globalStyleSheet) { + globalStyleSheet = new CSSStyleSheet(); + const globalRules: string[] = []; + for (const sheet of Array.from(document.styleSheets)) { + try { + for (const rule of Array.from(sheet.cssRules)) { + globalRules.push(rule.cssText); + } + } catch { + // Cross-origin stylesheets can't be read + } + } + globalStyleSheet.replaceSync(globalRules.join('\n')); + } + return globalStyleSheet; +} + +function getIconsStyleSheetCached(): CSSStyleSheet { + if (!iconsStyleSheetCache) { + iconsStyleSheetCache = new CSSStyleSheet(); + const iconsSheet = getIconsStyleSheet(undefined); + iconsStyleSheetCache.replaceSync(iconsSheet.getCSS() as string); + iconsSheet.dispose(); + } + return iconsStyleSheetCache; +} + +function getThemeStyleSheet(theme: ColorThemeData): CSSStyleSheet { + const isDark = theme.type === ColorScheme.DARK; + if (isDark && darkThemeStyleSheet) { + return darkThemeStyleSheet; + } + if (!isDark && lightThemeStyleSheet) { + return lightThemeStyleSheet; + } + + const sheet = new CSSStyleSheet(); + const css = generateColorThemeCSS( + theme, + ':host', + themingRegistry.getThemingParticipants(), + mockEnvironmentService + ); + sheet.replaceSync(css.code); + + if (isDark) { + darkThemeStyleSheet = sheet; + } else { + lightThemeStyleSheet = sheet; + } + return sheet; +} + +/** + * Applies theme styling to a shadow DOM container. + * Adds theme class names and adopts shared stylesheets. + */ +export function setupTheme(container: HTMLElement, theme: ColorThemeData): void { + container.classList.add(...theme.classNames); + + const shadowRoot = container.getRootNode() as ShadowRoot; + if (shadowRoot.adoptedStyleSheets !== undefined) { + shadowRoot.adoptedStyleSheets = [ + getGlobalStyleSheet(), + getIconsStyleSheetCached(), + getThemeStyleSheet(theme), + ]; + } +} + + +// ============================================================================ +// Services +// ============================================================================ + +export interface ServiceRegistration { + define(id: ServiceIdentifier, ctor: new (...args: never[]) => T): void; + defineInstance(id: ServiceIdentifier, instance: T): void; +} + +export interface CreateServicesOptions { + /** + * The color theme to use for the theme service. + */ + colorTheme?: IColorTheme; + /** + * Additional services to register after the base editor services. + */ + additionalServices?: (registration: ServiceRegistration) => void; +} + +/** + * Creates a TestInstantiationService with all services needed for CodeEditorWidget. + * Additional services can be registered via the options callback. + */ +export function createEditorServices(disposables: DisposableStore, options?: CreateServicesOptions): TestInstantiationService { + const services = new ServiceCollection(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const serviceIdentifiers: ServiceIdentifier[] = []; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const define = (id: ServiceIdentifier, ctor: new (...args: any[]) => T) => { + if (!services.has(id)) { + services.set(id, new SyncDescriptor(ctor)); + } + serviceIdentifiers.push(id); + }; + + const defineInstance = (id: ServiceIdentifier, instance: T) => { + if (!services.has(id)) { + services.set(id, instance); + } + serviceIdentifiers.push(id); + }; + + // Base editor services + define(IAccessibilityService, TestAccessibilityService); + define(IKeybindingService, MockKeybindingService); + define(IClipboardService, TestClipboardService); + define(IEditorWorkerService, TestEditorWorkerService); + defineInstance(IOpenerService, NullOpenerService); + define(INotificationService, TestNotificationService); + define(IDialogService, TestDialogService); + define(IUndoRedoService, UndoRedoService); + define(ILanguageService, LanguageService); + define(ILanguageConfigurationService, TestLanguageConfigurationService); + define(IConfigurationService, TestConfigurationService); + define(ITextResourcePropertiesService, TestTextResourcePropertiesService); + defineInstance(IStorageService, new NullStorageService()); + if (options?.colorTheme) { + defineInstance(IThemeService, new TestThemeService(options.colorTheme)); + } else { + define(IThemeService, TestThemeService); + } + define(ILogService, NullLogService); + define(IModelService, ModelService); + define(ICodeEditorService, TestCodeEditorService); + define(IContextKeyService, MockContextKeyService); + define(ICommandService, TestCommandService); + define(ITelemetryService, NullTelemetryServiceShape); + define(ILoggerService, NullLoggerService); + define(IDataChannelService, NullDataChannelService); + define(IEnvironmentService, class extends mock() { + declare readonly _serviceBrand: undefined; + override isBuilt: boolean = true; + override isExtensionDevelopment: boolean = false; + }); + define(ILanguageFeatureDebounceService, LanguageFeatureDebounceService); + define(ILanguageFeaturesService, LanguageFeaturesService); + define(ITreeSitterLibraryService, TestTreeSitterLibraryService); + define(IInlineCompletionsService, InlineCompletionsService); + defineInstance(ICodeLensCache, { + _serviceBrand: undefined, + put: () => { }, + get: () => undefined, + delete: () => { }, + } as ICodeLensCache); + defineInstance(IHoverService, { + _serviceBrand: undefined, + showDelayedHover: () => undefined, + setupDelayedHover: () => ({ dispose: () => { } }), + setupDelayedHoverAtMouse: () => ({ dispose: () => { } }), + showInstantHover: () => undefined, + hideHover: () => { }, + showAndFocusLastHover: () => { }, + setupManagedHover: () => ({ dispose: () => { }, show: () => { }, hide: () => { }, update: () => { } }), + showManagedHover: () => { }, + } as IHoverService); + defineInstance(IDefaultAccountService, { + _serviceBrand: undefined, + onDidChangeDefaultAccount: new Emitter().event, + onDidChangePolicyData: new Emitter().event, + policyData: null, + getDefaultAccount: async () => null, + getDefaultAccountAuthenticationProvider: () => ({ id: 'test', name: 'Test', scopes: [], enterprise: false }), + setDefaultAccountProvider: () => { }, + refresh: async () => null, + signIn: async () => null, + } as IDefaultAccountService); + + // User interaction service with focus simulation enabled (all elements appear focused in fixtures) + defineInstance(IUserInteractionService, new MockUserInteractionService(true, false)); + + // Allow additional services to be registered + options?.additionalServices?.({ define, defineInstance }); + + const instantiationService = disposables.add(new TestInstantiationService(services, true)); + + disposables.add(toDisposable(() => { + for (const id of serviceIdentifiers) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instanceOrDescriptor = services.get(id) as any; + if (typeof instanceOrDescriptor?.dispose === 'function') { + instanceOrDescriptor.dispose(); + } + } + })); + + return instantiationService; +} + +/** + * Registers additional services needed by workbench components (merge editor, etc.). + * Use with createEditorServices additionalServices option. + */ +export function registerWorkbenchServices(registration: ServiceRegistration): void { + registration.defineInstance(IContextMenuService, { + showContextMenu: () => { }, + onDidShowContextMenu: () => ({ dispose: () => { } }), + onDidHideContextMenu: () => ({ dispose: () => { } }), + } as unknown as IContextMenuService); + + registration.defineInstance(IContextViewService, { + showContextView: () => ({ dispose: () => { } }), + hideContextView: () => { }, + getContextViewElement: () => null, + layout: () => { }, + } as unknown as IContextViewService); + + registration.defineInstance(ILabelService, { + getUriLabel: (uri: URI) => uri.path, + getUriBasenameLabel: (uri: URI) => uri.path.split('/').pop() ?? '', + getWorkspaceLabel: () => '', + getHostLabel: () => '', + getSeparator: () => '/', + registerFormatter: () => ({ dispose: () => { } }), + onDidChangeFormatters: () => ({ dispose: () => { } }), + registerCachedFormatter: () => ({ dispose: () => { } }), + } as unknown as ILabelService); + + registration.define(IMenuService, TestMenuService); + registration.define(IActionViewItemService, NullActionViewItemService); +} + + +// ============================================================================ +// Text Models +// ============================================================================ + +/** + * Creates a text model using the ModelService. + */ +export function createTextModel( + instantiationService: TestInstantiationService, + text: string, + uri: URI, + languageId?: string +): ITextModel { + const modelService = instantiationService.get(IModelService); + const languageService = instantiationService.get(ILanguageService); + const languageSelection = languageId ? languageService.createById(languageId) : null; + return modelService.createModel(text, languageSelection, uri); +} + + +// ============================================================================ +// Fixture Adapters +// ============================================================================ + +export interface ComponentFixtureContext { + container: HTMLElement; + disposableStore: DisposableStore; + theme: ColorThemeData; +} + +export interface ComponentFixtureOptions { + render: (context: ComponentFixtureContext) => HTMLElement | Promise; +} + +type ThemedFixtures = ReturnType; + +/** + * Creates Dark and Light fixture variants from a single render function. + * The render function receives a context with container and disposableStore. + */ +export function defineComponentFixture(options: ComponentFixtureOptions): ThemedFixtures { + const createFixture = (theme: typeof darkTheme | typeof lightTheme) => defineFixture({ + isolation: 'shadow-dom', + displayMode: { type: 'component' }, + properties: [], + background: theme === darkTheme ? 'dark' : 'light', + render: async (container: HTMLElement) => { + const disposableStore = new DisposableStore(); + setupTheme(container, theme); + return options.render({ container, disposableStore, theme }); + }, + }); + + return defineFixtureVariants({ + Dark: createFixture(darkTheme), + Light: createFixture(lightTheme), + }); +} + +type ThemedFixtureGroupInput = Record; + +/** + * Creates a nested fixture group from themed fixtures. + * E.g., { MergeEditor: { Dark: ..., Light: ... } } becomes a nested group: MergeEditor > Dark/Light + */ +export function defineThemedFixtureGroup(group: ThemedFixtureGroupInput): ReturnType { + return defineFixtureGroup(group); +} diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 97fb9dc76aa..4db5338149d 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -8,62 +8,87 @@ "name": "@vscode/sample-source", "version": "0.0.0", "devDependencies": { - "@vscode/rollup-plugin-esm-url": "^1.0.1-0", - "vite": "^7.1.11" + "@vscode/component-explorer": "next", + "@vscode/component-explorer-vite-plugin": "next", + "@vscode/rollup-plugin-esm-url": "^1.0.1-1", + "vite": "npm:rolldown-vite@latest" } }, - "../lib": { - "name": "monaco-editor-core", - "version": "0.0.0", - "extraneous": true, + "node_modules/@emnapi/core": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.8.1.tgz", + "integrity": "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==", + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "postcss-copy": "^7.1.0", - "postcss-copy-assets": "^0.3.1", - "rollup": "^4.35.0", - "rollup-plugin-esbuild": "^6.2.1", - "rollup-plugin-lib-style": "^2.3.2", - "rollup-plugin-postcss": "^4.0.2" + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", - "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", - "cpu": [ - "ppc64" - ], + "node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", - "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", - "cpu": [ - "arm" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", - "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", + "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/runtime": { + "version": "0.101.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.101.0.tgz", + "integrity": "sha512-t3qpfVZIqSiLQ5Kqt/MC4Ge/WCOGrrcagAdzTcDaggupjiGxUx4nJF2v6wUCXWSzWHn5Ns7XLv13fCJEwCOERQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.101.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.101.0.tgz", + "integrity": "sha512-nuFhqlUzJX+gVIPPfuE6xurd4lST3mdcWOhyK/rZO0B9XWMKm79SuszIQEnSMmmDhq1DC8WWVYGVd+6F93o1gQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-beta.53.tgz", + "integrity": "sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==", "cpu": [ "arm64" ], @@ -74,30 +99,13 @@ "android" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", - "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", - "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-beta.53.tgz", + "integrity": "sha512-yIsKqMz0CtRnVa6x3Pa+mzTihr4Ty+Z6HfPbZ7RVbk1Uxnco4+CUn7Qbm/5SBol1JD/7nvY8rphAgyAi7Lj6Vg==", "cpu": [ "arm64" ], @@ -108,13 +116,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", - "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-beta.53.tgz", + "integrity": "sha512-GTXe+mxsCGUnJOFMhfGWmefP7Q9TpYUseHvhAhr21nCTgdS8jPsvirb0tJwM3lN0/u/cg7bpFNa16fQrjKrCjQ==", "cpu": [ "x64" ], @@ -125,30 +133,13 @@ "darwin" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", - "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", - "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-beta.53.tgz", + "integrity": "sha512-9Tmp7bBvKqyDkMcL4e089pH3RsjD3SUungjmqWtyhNOxoQMh0fSmINTyYV8KXtE+JkxYMPWvnEt+/mfpVCkk8w==", "cpu": [ "x64" ], @@ -159,13 +150,13 @@ "freebsd" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", - "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-beta.53.tgz", + "integrity": "sha512-a1y5fiB0iovuzdbjUxa7+Zcvgv+mTmlGGC4XydVIsyl48eoxgaYkA3l9079hyTyhECsPq+mbr0gVQsFU11OJAQ==", "cpu": [ "arm" ], @@ -176,13 +167,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", - "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-beta.53.tgz", + "integrity": "sha512-bpIGX+ov9PhJYV+wHNXl9rzq4F0QvILiURn0y0oepbQx+7stmQsKA0DhPGwmhfvF856wq+gbM8L92SAa/CBcLg==", "cpu": [ "arm64" ], @@ -193,132 +184,13 @@ "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", - "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", - "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", - "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", - "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", - "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", - "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", - "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", - "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-beta.53.tgz", + "integrity": "sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==", "cpu": [ "arm64" ], @@ -326,16 +198,16 @@ "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", - "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-beta.53.tgz", + "integrity": "sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==", "cpu": [ "x64" ], @@ -343,33 +215,16 @@ "license": "MIT", "optional": true, "os": [ - "netbsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", - "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", - "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-beta.53.tgz", + "integrity": "sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==", "cpu": [ "x64" ], @@ -377,16 +232,16 @@ "license": "MIT", "optional": true, "os": [ - "openbsd" + "linux" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", - "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-beta.53.tgz", + "integrity": "sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==", "cpu": [ "arm64" ], @@ -397,30 +252,30 @@ "openharmony" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", - "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-beta.53.tgz", + "integrity": "sha512-BUjAEgpABEJXilGq/BPh7jeU3WAJ5o15c1ZEgHaDWSz3LB881LQZnbNJHmUiM4d1JQWMYYyR1Y490IBHi2FPJg==", "cpu": [ - "x64" + "wasm32" ], "dev": true, "license": "MIT", "optional": true, - "os": [ - "sunos" - ], + "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.0" + }, "engines": { - "node": ">=18" + "node": ">=14.0.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", - "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-beta.53.tgz", + "integrity": "sha512-s27uU7tpCWSjHBnxyVXHt3rMrQdJq5MHNv3BzsewCIroIw3DJFjMH1dzCPPMUFxnh1r52Nf9IJ/eWp6LDoyGcw==", "cpu": [ "arm64" ], @@ -431,30 +286,13 @@ "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", - "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", - "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-beta.53.tgz", + "integrity": "sha512-cjWL/USPJ1g0en2htb4ssMjIycc36RvdQAx1WlXnS6DpULswiUTVXPDesTifSKYSyvx24E0YqQkEm0K/M2Z/AA==", "cpu": [ "x64" ], @@ -465,13 +303,20 @@ "win32" ], "engines": { - "node": ">=18" + "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.53.tgz", + "integrity": "sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.49.0.tgz", - "integrity": "sha512-rlKIeL854Ed0e09QGYFlmDNbka6I3EQFw7iZuugQjMb11KMpJCLPFL4ZPbMfaEhLADEL1yx0oujGkBQ7+qW3eA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "cpu": [ "arm" ], @@ -480,12 +325,13 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.49.0.tgz", - "integrity": "sha512-cqPpZdKUSQYRtLLr6R4X3sD4jCBO1zUmeo3qrWBCqYIeH8Q3KRL4F3V7XJ2Rm8/RJOQBZuqzQGWPjjvFUcYa/w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "cpu": [ "arm64" ], @@ -494,12 +340,13 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.49.0.tgz", - "integrity": "sha512-99kMMSMQT7got6iYX3yyIiJfFndpojBmkHfTc1rIje8VbjhmqBXE+nb7ZZP3A5skLyujvT0eIUCUsxAe6NjWbw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "cpu": [ "arm64" ], @@ -508,12 +355,13 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.49.0.tgz", - "integrity": "sha512-y8cXoD3wdWUDpjOLMKLx6l+NFz3NlkWKcBCBfttUn+VGSfgsQ5o/yDUGtzE9HvsodkP0+16N0P4Ty1VuhtRUGg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "cpu": [ "x64" ], @@ -522,12 +370,13 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.49.0.tgz", - "integrity": "sha512-3mY5Pr7qv4GS4ZvWoSP8zha8YoiqrU+e0ViPvB549jvliBbdNLrg2ywPGkgLC3cmvN8ya3za+Q2xVyT6z+vZqA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "cpu": [ "arm64" ], @@ -536,12 +385,13 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.49.0.tgz", - "integrity": "sha512-C9KzzOAQU5gU4kG8DTk+tjdKjpWhVWd5uVkinCwwFub2m7cDYLOdtXoMrExfeBmeRy9kBQMkiyJ+HULyF1yj9w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "cpu": [ "x64" ], @@ -550,12 +400,13 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.49.0.tgz", - "integrity": "sha512-OVSQgEZDVLnTbMq5NBs6xkmz3AADByCWI4RdKSFNlDsYXdFtlxS59J+w+LippJe8KcmeSSM3ba+GlsM9+WwC1w==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "cpu": [ "arm" ], @@ -564,12 +415,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.49.0.tgz", - "integrity": "sha512-ZnfSFA7fDUHNa4P3VwAcfaBLakCbYaxCk0jUnS3dTou9P95kwoOLAMlT3WmEJDBCSrOEFFV0Y1HXiwfLYJuLlA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "cpu": [ "arm" ], @@ -578,12 +430,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.49.0.tgz", - "integrity": "sha512-Z81u+gfrobVK2iV7GqZCBfEB1y6+I61AH466lNK+xy1jfqFLiQ9Qv716WUM5fxFrYxwC7ziVdZRU9qvGHkYIJg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "cpu": [ "arm64" ], @@ -592,12 +445,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.49.0.tgz", - "integrity": "sha512-zoAwS0KCXSnTp9NH/h9aamBAIve0DXeYpll85shf9NJ0URjSTzzS+Z9evmolN+ICfD3v8skKUPyk2PO0uGdFqg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "cpu": [ "arm64" ], @@ -606,12 +460,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, - "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.49.0.tgz", - "integrity": "sha512-2QyUyQQ1ZtwZGiq0nvODL+vLJBtciItC3/5cYN8ncDQcv5avrt2MbKt1XU/vFAJlLta5KujqyHdYtdag4YEjYQ==", + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "cpu": [ "loong64" ], @@ -620,12 +475,28 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.49.0.tgz", - "integrity": "sha512-k9aEmOWt+mrMuD3skjVJSSxHckJp+SiFzFG+v8JLXbc/xi9hv2icSkR3U7uQzqy+/QbbYY7iNB9eDTwrELo14g==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "cpu": [ "ppc64" ], @@ -634,12 +505,28 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.49.0.tgz", - "integrity": "sha512-rDKRFFIWJ/zJn6uk2IdYLc09Z7zkE5IFIOWqpuU0o6ZpHcdniAyWkwSUWE/Z25N/wNDmFHHMzin84qW7Wzkjsw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "cpu": [ "riscv64" ], @@ -648,12 +535,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.49.0.tgz", - "integrity": "sha512-FkkhIY/hYFVnOzz1WeV3S9Bd1h0hda/gRqvZCMpHWDHdiIHn6pqsY3b5eSbvGccWHMQ1uUzgZTKS4oGpykf8Tw==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "cpu": [ "riscv64" ], @@ -662,12 +550,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.49.0.tgz", - "integrity": "sha512-gRf5c+A7QiOG3UwLyOOtyJMD31JJhMjBvpfhAitPAoqZFcOeK3Kc1Veg1z/trmt+2P6F/biT02fU19GGTS529A==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "cpu": [ "s390x" ], @@ -676,12 +565,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.49.0.tgz", - "integrity": "sha512-BR7+blScdLW1h/2hB/2oXM+dhTmpW3rQt1DeSiCP9mc2NMMkqVgjIN3DDsNpKmezffGC9R8XKVOLmBkRUcK/sA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "cpu": [ "x64" ], @@ -690,12 +580,13 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.49.0.tgz", - "integrity": "sha512-hDMOAe+6nX3V5ei1I7Au3wcr9h3ktKzDvF2ne5ovX8RZiAHEtX1A5SNNk4zt1Qt77CmnbqT+upb/umzoPMWiPg==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "cpu": [ "x64" ], @@ -704,12 +595,43 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "peer": true + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.49.0.tgz", - "integrity": "sha512-wkNRzfiIGaElC9kXUT+HLx17z7D0jl+9tGYRKwd8r7cUqTL7GYAvgUY++U2hK6Ar7z5Z6IRRoWC8kQxpmM7TDA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "cpu": [ "arm64" ], @@ -718,12 +640,13 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.49.0.tgz", - "integrity": "sha512-gq5aW/SyNpjp71AAzroH37DtINDcX1Qw2iv9Chyz49ZgdOP3NV8QCyKZUrGsYX9Yyggj5soFiRCgsL3HwD8TdA==", + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "cpu": [ "ia32" ], @@ -732,12 +655,13 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.49.0.tgz", - "integrity": "sha512-gEtqFbzmZLFk2xKh7g0Rlo8xzho8KrEFEkzvHbfUGkrgXOpZ4XagQ6n+wIZFNh1nTb8UD16J4nFSFKXYgnbdBg==", + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "cpu": [ "x64" ], @@ -746,65 +670,84 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true + }, + "node_modules/@vscode/component-explorer": { + "version": "0.1.1-2", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer/-/component-explorer-0.1.1-2.tgz", + "integrity": "sha512-2VMoXLnDBk+hKrhw+iGUsEjnCd1YiiZqe+1LdQIKdk16zqYRtJ5iO6yDxZ4cKy3Wphd+qLDUWmZSULNtKioMrQ==", + "dev": true, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + } + }, + "node_modules/@vscode/component-explorer-vite-plugin": { + "version": "0.1.1-2", + "resolved": "https://registry.npmjs.org/@vscode/component-explorer-vite-plugin/-/component-explorer-vite-plugin-0.1.1-2.tgz", + "integrity": "sha512-iYSp8shDZEJJrjMWGneWyjFbFyED5Og74c9h5XBmVPZBDN4INfOTmPlC+HYTv/CL5+NFxpl91CdtacCmqz2EXw==", + "dev": true, + "dependencies": { + "tinyglobby": "^0.2.0" + }, + "peerDependencies": { + "@vscode/component-explorer": "*", + "vite": "*" + } }, "node_modules/@vscode/rollup-plugin-esm-url": { - "version": "1.0.1-0", - "resolved": "https://registry.npmjs.org/@vscode/rollup-plugin-esm-url/-/rollup-plugin-esm-url-1.0.1-0.tgz", - "integrity": "sha512-5k9c2ZK6xTTa2MGa0uD+f/a1cZV8SCQm9ZhorQDyBRvobIzOTg57LjOl3di9Z6zawPh7nDgbWp6g+GnSSpdCrg==", + "version": "1.0.1-1", + "resolved": "https://registry.npmjs.org/@vscode/rollup-plugin-esm-url/-/rollup-plugin-esm-url-1.0.1-1.tgz", + "integrity": "sha512-vNmIR3ZyiwACUi8qnXhKNukoXaFkOM9skiqVOVHNKJTBb7kJS+evtyadrBc/fMm1y303WQWBNA90E7fCCsE2Sw==", "dev": true, "license": "MIT", "peerDependencies": { "rollup": "^3.0.0 || ^4.0.0" } }, - "node_modules/esbuild": { - "version": "0.25.9", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", - "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, + "license": "Apache-2.0", "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.9", - "@esbuild/android-arm": "0.25.9", - "@esbuild/android-arm64": "0.25.9", - "@esbuild/android-x64": "0.25.9", - "@esbuild/darwin-arm64": "0.25.9", - "@esbuild/darwin-x64": "0.25.9", - "@esbuild/freebsd-arm64": "0.25.9", - "@esbuild/freebsd-x64": "0.25.9", - "@esbuild/linux-arm": "0.25.9", - "@esbuild/linux-arm64": "0.25.9", - "@esbuild/linux-ia32": "0.25.9", - "@esbuild/linux-loong64": "0.25.9", - "@esbuild/linux-mips64el": "0.25.9", - "@esbuild/linux-ppc64": "0.25.9", - "@esbuild/linux-riscv64": "0.25.9", - "@esbuild/linux-s390x": "0.25.9", - "@esbuild/linux-x64": "0.25.9", - "@esbuild/netbsd-arm64": "0.25.9", - "@esbuild/netbsd-x64": "0.25.9", - "@esbuild/openbsd-arm64": "0.25.9", - "@esbuild/openbsd-x64": "0.25.9", - "@esbuild/openharmony-arm64": "0.25.9", - "@esbuild/sunos-x64": "0.25.9", - "@esbuild/win32-arm64": "0.25.9", - "@esbuild/win32-ia32": "0.25.9", - "@esbuild/win32-x64": "0.25.9" + "node": ">=8" } }, "node_modules/fdir": { @@ -840,6 +783,289 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -908,12 +1134,74 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/rollup": { - "version": "4.49.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.49.0.tgz", - "integrity": "sha512-3IVq0cGJ6H7fKXXEdVt+RcYvRCt8beYY9K1760wGQwSAHZcS9eot1zDG5axUbcp/kWRi5zKIIDX8MoKv/TzvZA==", + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-beta.53", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-beta.53.tgz", + "integrity": "sha512-Qd9c2p0XKZdgT5AYd+KgAMggJ8ZmCs3JnS9PTMWkyUfteKlfmKtxJbWTHkVakxwXs1Ub7jrRYVeFeF7N0sQxyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.101.0", + "@rolldown/pluginutils": "1.0.0-beta.53" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-beta.53", + "@rolldown/binding-darwin-arm64": "1.0.0-beta.53", + "@rolldown/binding-darwin-x64": "1.0.0-beta.53", + "@rolldown/binding-freebsd-x64": "1.0.0-beta.53", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-beta.53", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-beta.53", + "@rolldown/binding-linux-arm64-musl": "1.0.0-beta.53", + "@rolldown/binding-linux-x64-gnu": "1.0.0-beta.53", + "@rolldown/binding-linux-x64-musl": "1.0.0-beta.53", + "@rolldown/binding-openharmony-arm64": "1.0.0-beta.53", + "@rolldown/binding-wasm32-wasi": "1.0.0-beta.53", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-beta.53", + "@rolldown/binding-win32-x64-msvc": "1.0.0-beta.53" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -925,29 +1213,45 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.49.0", - "@rollup/rollup-android-arm64": "4.49.0", - "@rollup/rollup-darwin-arm64": "4.49.0", - "@rollup/rollup-darwin-x64": "4.49.0", - "@rollup/rollup-freebsd-arm64": "4.49.0", - "@rollup/rollup-freebsd-x64": "4.49.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.49.0", - "@rollup/rollup-linux-arm-musleabihf": "4.49.0", - "@rollup/rollup-linux-arm64-gnu": "4.49.0", - "@rollup/rollup-linux-arm64-musl": "4.49.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.49.0", - "@rollup/rollup-linux-ppc64-gnu": "4.49.0", - "@rollup/rollup-linux-riscv64-gnu": "4.49.0", - "@rollup/rollup-linux-riscv64-musl": "4.49.0", - "@rollup/rollup-linux-s390x-gnu": "4.49.0", - "@rollup/rollup-linux-x64-gnu": "4.49.0", - "@rollup/rollup-linux-x64-musl": "4.49.0", - "@rollup/rollup-win32-arm64-msvc": "4.49.0", - "@rollup/rollup-win32-ia32-msvc": "4.49.0", - "@rollup/rollup-win32-x64-msvc": "4.49.0", + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" } }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "loose-envify": "^1.1.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -975,18 +1279,28 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/vite": { - "version": "7.1.11", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", - "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "name": "rolldown-vite", + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/rolldown-vite/-/rolldown-vite-7.3.1.tgz", + "integrity": "sha512-LYzdNAjRHhF2yA4JUQm/QyARyi216N2rpJ0lJZb8E9FU2y5v6Vk+xq/U4XBOxMefpWixT5H3TslmAHm1rqIq2w==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", + "@oxc-project/runtime": "0.101.0", "fdir": "^6.5.0", + "lightningcss": "^1.30.2", "picomatch": "^4.0.3", "postcss": "^8.5.6", - "rollup": "^4.43.0", + "rolldown": "1.0.0-beta.53", "tinyglobby": "^0.2.15" }, "bin": { @@ -1003,9 +1317,9 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", + "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", - "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", @@ -1018,15 +1332,15 @@ "@types/node": { "optional": true }, + "esbuild": { + "optional": true + }, "jiti": { "optional": true }, "less": { "optional": true }, - "lightningcss": { - "optional": true - }, "sass": { "optional": true }, diff --git a/build/vite/package.json b/build/vite/package.json index f65f359145e..9800e54af40 100644 --- a/build/vite/package.json +++ b/build/vite/package.json @@ -9,7 +9,14 @@ "preview": "vite preview" }, "devDependencies": { - "@vscode/rollup-plugin-esm-url": "^1.0.1-0", - "vite": "^7.1.11" + "@vscode/rollup-plugin-esm-url": "^1.0.1-1", + "vite": "npm:rolldown-vite@latest", + "@vscode/component-explorer": "next", + "@vscode/component-explorer-vite-plugin": "next" + }, + "overrides": { + "@vscode/component-explorer-vite-plugin": { + "vite": "$vite" + } } } diff --git a/build/vite/setup-dev.ts b/build/vite/setup-dev.ts index 8f1f997599c..b0ef8682c9a 100644 --- a/build/vite/setup-dev.ts +++ b/build/vite/setup-dev.ts @@ -10,6 +10,7 @@ import { getSingletonServiceDescriptors, InstantiationType, registerSingleton } import { IWebWorkerService } from '../../src/vs/platform/webWorker/browser/webWorkerService.ts'; // eslint-disable-next-line local/code-no-standalone-editor import { StandaloneWebWorkerService } from '../../src/vs/editor/standalone/browser/services/standaloneWebWorkerService.ts'; +import './style.css'; enableHotReload(); diff --git a/build/vite/style.css b/build/vite/style.css index b9573061e51..f1ee3ca812f 100644 --- a/build/vite/style.css +++ b/build/vite/style.css @@ -7,3 +7,9 @@ height: 400px; border: 1px solid black; } + +@font-face { + font-family: "codicon"; + font-display: block; + src: url("~@vscode/codicons/dist/codicon.ttf") format("truetype"); +} diff --git a/build/vite/vite.config.ts b/build/vite/vite.config.ts index 1ddb4f53adf..6cdde88076a 100644 --- a/build/vite/vite.config.ts +++ b/build/vite/vite.config.ts @@ -5,9 +5,10 @@ import { createLogger, defineConfig, Plugin } from 'vite'; import path, { join } from 'path'; -import { rollupEsmUrlPlugin } from '@vscode/rollup-plugin-esm-url'; +import { componentExplorer } from '@vscode/component-explorer-vite-plugin'; import { statSync } from 'fs'; import { pathToFileURL } from 'url'; +import { rollupEsmUrlPlugin } from '@vscode/rollup-plugin-esm-url'; function injectBuiltinExtensionsPlugin(): Plugin { let builtinExtensionsCache: unknown[] | null = null; @@ -166,9 +167,18 @@ export default defineConfig({ plugins: [ rollupEsmUrlPlugin({}), injectBuiltinExtensionsPlugin(), - createHotClassSupport() + createHotClassSupport(), + componentExplorer({ + logLevel: 'verbose', + include: 'build/vite/**/*.fixture.ts', + }), ], customLogger: logger, + resolve: { + alias: { + '~@vscode/codicons': '/node_modules/@vscode/codicons', + } + }, esbuild: { tsconfigRaw: { compilerOptions: { diff --git a/build/win32/code.iss b/build/win32/code.iss index d889ca8c4d3..0d47e15103f 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1453,6 +1453,12 @@ begin Result := not (IsBackgroundUpdate() and FileExists(Path)); end; +// Check if VS Code created a cancel file to signal that the update should be aborted +function CancelFileExists(): Boolean; +begin + Result := FileExists(ExpandConstant('{param:cancel}')) +end; + function ShouldRunAfterUpdate(): Boolean; begin if IsBackgroundUpdate() then @@ -1639,11 +1645,17 @@ begin Log('Checking whether application is still running...'); while (CheckForMutexes('{#AppMutex}')) do begin + if CancelFileExists() then + begin + Log('Cancel file detected, aborting background update.'); + DeleteFile(ExpandConstant('{app}\updating_version')); + Abort; + end; Sleep(1000) end; Log('Application appears not to be running.'); - if not SessionEndFileExists() then begin + if not SessionEndFileExists() and not CancelFileExists() then begin StopTunnelServiceIfNeeded(); Log('Invoking inno_updater for background update'); Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); @@ -1657,7 +1669,7 @@ begin end; #endif end else begin - Log('Skipping inno_updater.exe call because OS session is ending'); + Log('Skipping inno_updater.exe call because OS session is ending or cancel was requested'); end; end else begin if IsVersionedUpdate() then begin diff --git a/eslint.config.js b/eslint.config.js index 96e1232427b..f60ff793db5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2064,9 +2064,12 @@ export default tseslint.config( // Additional extension strictness rules { files: [ - 'extensions/markdown-language-features/**/*.ts', - 'extensions/mermaid-chat-features/**/*.ts', - 'extensions/media-preview/**/*.ts', + 'extensions/markdown-language-features/src/**/*.ts', + 'extensions/markdown-language-features/notebook/**/*.ts', + 'extensions/markdown-language-features/preview-src/**/*.ts', + 'extensions/mermaid-chat-features/chat-webview-src/**/*.ts', + 'extensions/mermaid-chat-features/src/**/*.ts', + 'extensions/media-preview/src/**/*.ts', 'extensions/simple-browser/**/*.ts', 'extensions/typescript-language-features/**/*.ts', ], diff --git a/extensions/esbuild-extension-common.ts b/extensions/esbuild-extension-common.ts new file mode 100644 index 00000000000..513656ae89f --- /dev/null +++ b/extensions/esbuild-extension-common.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +/** + * @fileoverview Common build script for extensions. + */ +import path from 'node:path'; +import esbuild from 'esbuild'; + +type BuildOptions = Partial & { + outdir: string; +}; + +/** + * Build the source code once using esbuild. + */ +async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { + await esbuild.build({ + bundle: true, + minify: true, + sourcemap: false, + format: 'cjs', + platform: 'node', + target: ['es2024'], + external: ['vscode'], + ...options, + }); + + await didBuild?.(options.outdir); +} + +/** + * Build the source code once using esbuild, logging errors instead of throwing. + */ +async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { + try { + await build(options, didBuild); + } catch (err) { + console.error(err); + } +} + +interface RunConfig { + srcDir: string; + outdir: string; + entryPoints: string[] | Record | { in: string; out: string }[]; + additionalOptions?: Partial; +} + +export async function run(config: RunConfig, args: string[], didBuild?: (outDir: string) => unknown): Promise { + let outdir = config.outdir; + const outputRootIndex = args.indexOf('--outputRoot'); + if (outputRootIndex >= 0) { + const outputRoot = args[outputRootIndex + 1]; + const outputDirName = path.basename(outdir); + outdir = path.join(outputRoot, outputDirName); + } + + const resolvedOptions: BuildOptions = { + entryPoints: config.entryPoints, + outdir, + logOverride: { + 'import-is-undefined': 'error', + }, + ...(config.additionalOptions || {}), + }; + + const isWatch = args.indexOf('--watch') >= 0; + if (isWatch) { + await tryBuild(resolvedOptions, didBuild); + const watcher = await import('@parcel/watcher'); + watcher.subscribe(config.srcDir, () => tryBuild(resolvedOptions, didBuild)); + } else { + return build(resolvedOptions, didBuild).catch(() => process.exit(1)); + } +} diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index 4932b07d5d4..80aebb0616a 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -460,7 +460,7 @@ export class ApiImpl implements API { return null; } - await this.#model.openRepository(root.fsPath); + await this.#model.openRepository(root.fsPath, true, true); return this.getRepository(root) || null; } diff --git a/extensions/json/package.json b/extensions/json/package.json index 73265dc5f23..1bc6fa85e53 100644 --- a/extensions/json/package.json +++ b/extensions/json/package.json @@ -70,6 +70,9 @@ ".ember-cli", "typedoc.json" ], + "filenamePatterns": [ + "**/.github/hooks/*.json" + ], "configuration": "./language-configuration.json" }, { diff --git a/extensions/markdown-language-features/esbuild-browser.ts b/extensions/markdown-language-features/esbuild-browser.ts new file mode 100644 index 00000000000..2c46e390c06 --- /dev/null +++ b/extensions/markdown-language-features/esbuild-browser.ts @@ -0,0 +1,40 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Copy the language server worker main file to the output directory. + */ +async function copyServerWorkerMain(outDir: string): Promise { + const srcPath = path.join(import.meta.dirname, 'node_modules', 'vscode-markdown-languageserver', 'dist', 'browser', 'workerMain.js'); + const destPath = path.join(outDir, 'serverWorkerMain.js'); + await fs.promises.copyFile(srcPath, destPath); +} + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.browser.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + alias: { + 'path': 'path-browserify', + }, + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/esbuild.ts b/extensions/markdown-language-features/esbuild.ts new file mode 100644 index 00000000000..67835c9a1d7 --- /dev/null +++ b/extensions/markdown-language-features/esbuild.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +/** + * Copy the language server worker main file to the output directory. + */ +async function copyServerWorkerMain(outDir: string): Promise { + const srcPath = path.join(import.meta.dirname, 'node_modules', 'vscode-markdown-languageserver', 'dist', 'node', 'workerMain.js'); + const destPath = path.join(outDir, 'serverWorkerMain.js'); + await fs.promises.copyFile(srcPath, destPath); +} + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/extension-browser.webpack.config.js b/extensions/markdown-language-features/extension-browser.webpack.config.js deleted file mode 100644 index 5471319a4c4..00000000000 --- a/extensions/markdown-language-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,27 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import CopyPlugin from 'copy-webpack-plugin'; -import { browser, browserPlugins } from '../shared.webpack.config.mjs'; - -export default browser({ - context: import.meta.dirname, - entry: { - extension: './src/extension.browser.ts' - }, - plugins: [ - ...browserPlugins(import.meta.dirname), // add plugins, don't replace inherited - new CopyPlugin({ - patterns: [ - { - from: './node_modules/vscode-markdown-languageserver/dist/browser/workerMain.js', - to: 'serverWorkerMain.js', - } - ], - }), - ], -}, { - configFile: 'tsconfig.browser.json' -}); diff --git a/extensions/markdown-language-features/extension.webpack.config.js b/extensions/markdown-language-features/extension.webpack.config.js deleted file mode 100644 index 51c9912f9af..00000000000 --- a/extensions/markdown-language-features/extension.webpack.config.js +++ /dev/null @@ -1,28 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import CopyPlugin from 'copy-webpack-plugin'; -import withDefaults, { nodePlugins } from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] - }, - entry: { - extension: './src/extension.ts', - }, - plugins: [ - ...nodePlugins(import.meta.dirname), // add plugins, don't replace inherited - new CopyPlugin({ - patterns: [ - { - from: './node_modules/vscode-markdown-languageserver/dist/node/workerMain.js', - to: 'serverWorkerMain.js', - } - ], - }), - ], -}); diff --git a/extensions/markdown-math/esbuild-browser.ts b/extensions/markdown-math/esbuild-browser.ts new file mode 100644 index 00000000000..a2659e5ff46 --- /dev/null +++ b/extensions/markdown-math/esbuild-browser.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + }, +}, process.argv); diff --git a/extensions/media-preview/extension-browser.webpack.config.js b/extensions/markdown-math/esbuild.ts similarity index 53% rename from extensions/media-preview/extension-browser.webpack.config.js rename to extensions/markdown-math/esbuild.ts index 6c86474b4e5..232f589197b 100644 --- a/extensions/media-preview/extension-browser.webpack.config.js +++ b/extensions/markdown-math/esbuild.ts @@ -2,12 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/markdown-math/extension-browser.webpack.config.js b/extensions/markdown-math/extension-browser.webpack.config.js deleted file mode 100644 index b758f2d8155..00000000000 --- a/extensions/markdown-math/extension-browser.webpack.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - } -}); diff --git a/extensions/media-preview/esbuild-browser.ts b/extensions/media-preview/esbuild-browser.ts new file mode 100644 index 00000000000..a2659e5ff46 --- /dev/null +++ b/extensions/media-preview/esbuild-browser.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + }, +}, process.argv); diff --git a/extensions/mermaid-chat-features/extension.webpack.config.js b/extensions/media-preview/esbuild.ts similarity index 53% rename from extensions/mermaid-chat-features/extension.webpack.config.js rename to extensions/media-preview/esbuild.ts index 4928186ae55..232f589197b 100644 --- a/extensions/mermaid-chat-features/extension.webpack.config.js +++ b/extensions/media-preview/esbuild.ts @@ -2,15 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, - entry: { - extension: './src/extension.ts', - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/mermaid-chat-features/esbuild-browser.ts b/extensions/mermaid-chat-features/esbuild-browser.ts new file mode 100644 index 00000000000..a2659e5ff46 --- /dev/null +++ b/extensions/mermaid-chat-features/esbuild-browser.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + platform: 'browser', + format: 'cjs', + define: { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }, + }, +}, process.argv); diff --git a/extensions/markdown-math/extension.webpack.config.js b/extensions/mermaid-chat-features/esbuild.ts similarity index 53% rename from extensions/markdown-math/extension.webpack.config.js rename to extensions/mermaid-chat-features/esbuild.ts index 4928186ae55..232f589197b 100644 --- a/extensions/markdown-math/extension.webpack.config.js +++ b/extensions/mermaid-chat-features/esbuild.ts @@ -2,15 +2,16 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.ts'; -export default withDefaults({ - context: import.meta.dirname, - resolve: { - mainFields: ['module', 'main'] +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + entryPoints: { + 'extension': path.join(srcDir, 'extension.ts'), }, - entry: { - extension: './src/extension.ts', - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/mermaid-chat-features/extension-browser.webpack.config.js b/extensions/mermaid-chat-features/extension-browser.webpack.config.js deleted file mode 100644 index b758f2d8155..00000000000 --- a/extensions/mermaid-chat-features/extension-browser.webpack.config.js +++ /dev/null @@ -1,13 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/extension.ts' - } -}); diff --git a/extensions/prompt-basics/package.json b/extensions/prompt-basics/package.json index 1765ac15d8c..a2c950a3443 100644 --- a/extensions/prompt-basics/package.json +++ b/extensions/prompt-basics/package.json @@ -34,6 +34,9 @@ ".instructions.md", "copilot-instructions.md" ], + "filenamePatterns": [ + "**/.claude/rules/**/*.md" + ], "configuration": "./language-configuration.json" }, { @@ -47,7 +50,8 @@ ".chatmode.md" ], "filenamePatterns": [ - "**/.github/agents/*.md" + "**/.github/agents/*.md", + "**/.claude/agents/*.md" ], "configuration": "./language-configuration.json" }, diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index c946703eea8..44d0d3273f3 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -5,7 +5,7 @@ "type": "dark", "colors": { "foreground": "#bfbfbf", - "disabledForeground": "#444444", + "disabledForeground": "#666666", "errorForeground": "#f48771", "descriptionForeground": "#999999", "icon.foreground": "#888888", @@ -15,6 +15,7 @@ "textCodeBlock.background": "#242526", "textLink.foreground": "#48A0C7", "textLink.activeForeground": "#53A5CA", + "textPreformat.background": "#262626", "textPreformat.foreground": "#888888", "textSeparator.foreground": "#2a2a2aFF", "button.background": "#3994BCF2", @@ -93,7 +94,7 @@ "menu.foreground": "#bfbfbf", "menu.selectionBackground": "#3994BC26", "menu.selectionForeground": "#bfbfbf", - "menu.separatorBackground": "#838485", + "menu.separatorBackground": "#2A2B2C", "menu.border": "#2A2B2CFF", "commandCenter.foreground": "#bfbfbf", "commandCenter.activeForeground": "#bfbfbf", @@ -102,6 +103,7 @@ "commandCenter.border": "#2E3031", "editor.background": "#121314", "editor.foreground": "#BBBEBF", + "editorStickyScroll.background": "#121314", "editorLineNumber.foreground": "#858889", "editorLineNumber.activeForeground": "#BBBEBF", "editorCursor.foreground": "#BBBEBF", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 37e3e681334..3ed64c055bf 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -23,7 +23,7 @@ "button.border": "#F2F3F4FF", "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#E6E6E6", + "button.secondaryHoverBackground": "#F3F3F3", "checkbox.background": "#EDEDED", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#202020", @@ -49,7 +49,7 @@ "inputValidation.errorForeground": "#202020", "scrollbar.shadow": "#00000000", "widget.shadow": "#00000000", - "widget.border": "#F2F3F4FF", + "widget.border": "#EEEEF1", "editorStickyScroll.shadow": "#00000000", "sideBarStickyScroll.shadow": "#00000000", "panelStickyScroll.shadow": "#00000000", @@ -64,7 +64,7 @@ "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#E0E0E0", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F7F7F7", + "list.hoverBackground": "#F3F3F3", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -106,7 +106,7 @@ "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", - "commandCenter.activeBackground": "#F0F0F3", + "commandCenter.activeBackground": "#F3F3F3", "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", @@ -127,21 +127,21 @@ "editorLink.activeForeground": "#0069CC", "editorWhitespace.foreground": "#66666640", "editorIndentGuide.background": "#F7F7F740", - "editorIndentGuide.activeBackground": "#F7F7F7", + "editorIndentGuide.activeBackground": "#F3F3F3", "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F2F3F4FF", "editorWidget.background": "#F0F0F3", - "editorWidget.border": "#F2F3F4FF", + "editorWidget.border": "#EEEEF1", "editorWidget.foreground": "#202020", "editorSuggestWidget.background": "#F0F0F3", - "editorSuggestWidget.border": "#F2F3F4FF", + "editorSuggestWidget.border": "#EEEEF1", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", "editorHoverWidget.background": "#F0F0F3", - "editorHoverWidget.border": "#F2F3F4FF", + "editorHoverWidget.border": "#EEEEF1", "peekView.border": "#0069CC", "peekViewEditor.background": "#F0F0F3", "peekViewEditor.matchHighlightBackground": "#0069CC33", @@ -179,13 +179,13 @@ "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#666666", - "statusBarItem.activeBackground": "#E6E6E6", - "statusBarItem.hoverBackground": "#F7F7F7", + "statusBarItem.activeBackground": "#F3F3F3", + "statusBarItem.hoverBackground": "#F3F3F3", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", "statusBarItem.prominentHoverBackground": "#0069CC", - "tab.activeBackground": "#FAFAFD", + "tab.activeBackground": "#FFFFFF", "tab.activeForeground": "#202020", "tab.inactiveBackground": "#FAFAFD", "tab.inactiveForeground": "#666666", @@ -193,7 +193,7 @@ "tab.lastPinnedBorder": "#F2F3F4FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#F7F7F7", + "tab.hoverBackground": "#F3F3F3", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", "tab.unfocusedActiveForeground": "#666666", @@ -202,7 +202,7 @@ "editorGroupHeader.tabsBackground": "#FAFAFD", "editorGroupHeader.tabsBorder": "#F2F3F4FF", "breadcrumb.foreground": "#666666", - "breadcrumb.background": "#FAFAFD", + "breadcrumb.background": "#FFFFFF", "breadcrumb.focusForeground": "#202020", "breadcrumb.activeSelectionForeground": "#202020", "breadcrumbPicker.background": "#F0F0F3", @@ -265,7 +265,8 @@ "charts.yellow": "#667309", "charts.orange": "#d18616", "charts.green": "#388A34", - "charts.purple": "#652D90" + "charts.purple": "#652D90", + "agentStatusIndicator.background": "#FFFFFF", }, "tokenColors": [ { diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 1401d0551a6..7c46dad4509 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -7,7 +7,7 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; - --radius-xl: 12px; + /* --radius-lg: 12px; */ --shadow-xs: 0 0 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); @@ -131,15 +131,11 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); + /* background: var(--vs) */ position: relative; z-index: 5; border-radius: 0; border-top: none !important; - background: linear-gradient( - to bottom, - color-mix(in srgb, var(--vscode-focusBorder) 10%, transparent) 0%, - transparent 100% - ), var(--vscode-tab-activeBackground) !important; } .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab:hover:not(.active) { @@ -177,12 +173,16 @@ -webkit-backdrop-filter: var(--backdrop-blur-lg); } -.monaco-workbench.vs-dark .quick-input-widget { - border: 1px solid var(--vscode-menu-border) !important; +/* Remove backdrop-filter when quick chat is active, because it creates a new + containing block that shifts position:fixed suggest widgets to the right. */ +.monaco-workbench .quick-input-widget:has(.interactive-session) { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background-color: var(--vscode-quickInput-background) !important; } -.monaco-workbench .quick-input-widget .monaco-list-rows { - background-color: transparent !important; +.monaco-workbench.vs-dark .quick-input-widget { + border: 1px solid var(--vscode-menu-border) !important; } .monaco-workbench .quick-input-widget .quick-input-header, @@ -199,6 +199,10 @@ outline: none !important; } +.monaco-workbench .monaco-editor .suggest-widget .monaco-list { + border-radius: var(--radius-lg); +} + .monaco-workbench .quick-input-widget .monaco-inputbox { box-shadow: none !important; background: transparent !important; @@ -263,6 +267,8 @@ backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); background: color-mix(in srgb, var(--vscode-notifications-background) 60%, transparent) !important; + border: 1px solid var(--vscode-editorWidget-border) !important; + box-shadow: var(--shadow-lg) !important; } .monaco-workbench.vs-dark .notifications-center { @@ -277,16 +283,18 @@ /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { - box-shadow: var(--shadow-lg); - border-radius: var(--radius-xl); - backdrop-filter: var(--backdrop-blur-md); - -webkit-backdrop-filter: var(--backdrop-blur-md); + border-radius: var(--radius-lg); } .monaco-workbench .context-view .monaco-menu { box-shadow: var(--shadow-lg); border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); +} + +.monaco-workbench .monaco-menu-container > .monaco-scrollable-element { + border-radius: var(--radius-lg) !important; + box-shadow: var(--shadow-lg) !important; } .monaco-workbench .action-widget { @@ -302,8 +310,7 @@ /* Suggest Widget */ .monaco-workbench .monaco-editor .suggest-widget { box-shadow: var(--shadow-lg); - border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); background: color-mix(in srgb, var(--vscode-editorSuggestWidget-background) 60%, transparent) !important; @@ -323,10 +330,17 @@ margin-top: 4px !important; } +.monaco-workbench .inline-chat-gutter-menu { + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + backdrop-filter: var(--backdrop-blur-md); + -webkit-backdrop-filter: var(--backdrop-blur-md); +} + /* Dialog */ .monaco-workbench .monaco-dialog-box { box-shadow: var(--shadow-2xl); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; @@ -429,6 +443,10 @@ background: color-mix(in srgb, var(--vscode-breadcrumbPicker-background) 60%, transparent) !important; } +.monaco-workbench.vs .breadcrumbs-control { + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + /* Input Boxes */ .monaco-workbench .monaco-inputbox, .monaco-workbench .suggest-input-container { @@ -530,7 +548,7 @@ /* Parameter Hints */ .monaco-workbench .monaco-editor .parameter-hints-widget { box-shadow: var(--shadow-lg); - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-md); -webkit-backdrop-filter: var(--backdrop-blur-md); } @@ -561,10 +579,14 @@ /* Sticky Scroll */ .monaco-workbench .monaco-editor .sticky-widget { box-shadow: var(--shadow-md) !important; - border-bottom: none !important; - background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; + border-bottom: var(--vscode-editorWidget-border) !important; + background: transparent !important; + backdrop-filter: var(--backdrop-blur-md) !important; + -webkit-backdrop-filter: var(--backdrop-blur-md) !important; +} + +.monaco-workbench .monaco-editor .sticky-widget > * { + background: transparent !important; } .monaco-workbench.vs-dark .monaco-editor .sticky-widget { @@ -572,31 +594,9 @@ } .monaco-workbench .monaco-editor .sticky-widget .sticky-widget-lines { - background-color: transparent !important; - background: transparent !important; -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget, -.monaco-workbench .monaco-editor .sticky-widget-focus-preview, -.monaco-workbench .monaco-editor .sticky-scroll-focus-line, -.monaco-workbench .monaco-editor .focused .sticky-widget, -.monaco-workbench .monaco-editor:has(.sticky-widget:focus-within) .sticky-widget { - background: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - box-shadow: var(--shadow-hover) !important; -} - -.monaco-editor .sticky-widget .sticky-line-content, -.monaco-workbench .monaco-editor .sticky-widget .sticky-line-number { - backdrop-filter: var(--backdrop-blur-lg) !important; - -webkit-backdrop-filter: var(--backdrop-blur-lg) !important; - background-color: color-mix(in srgb, var(--vscode-editor-background) 60%, transparent); -} - -.monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-content, -.monaco-workbench.vs-dark .monaco-editor .sticky-widget .sticky-line-number { - background-color: color-mix(in srgb, var(--vscode-editor-background) 30%, transparent); + background: color-mix(in srgb, var(--vscode-editor-background) 40%, transparent) !important; + backdrop-filter: var(--backdrop-blur-md) !important; + -webkit-backdrop-filter: var(--backdrop-blur-md) !important; } .monaco-editor .rename-box.preview { @@ -608,9 +608,9 @@ /* Notebook */ -/* .monaco-workbench .notebookOverlay.notebook-editor { +.monaco-workbench .notebookOverlay.notebook-editor { z-index: 35 !important; -} */ +} .monaco-workbench .notebookOverlay .monaco-list-row .cell-editor-part:before { box-shadow: inset var(--shadow-sm); @@ -633,7 +633,7 @@ .monaco-workbench .monaco-editor .inline-chat { box-shadow: var(--shadow-lg); border: none; - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } /* Command Center */ @@ -652,10 +652,25 @@ .monaco-workbench .part.titlebar .command-center .agent-status-pill { box-shadow: inset var(--shadow-sm); + border-color: var(--vscode-input-border); +} + +.monaco-workbench .part.titlebar .command-center .agent-status-badge { + border-color: var(--vscode-input-border); +} + +.monaco-workbench.vs-dark .monaco-action-bar:not(.vertical) .agent-status-badge-section.sparkle .action-container:hover, +.monaco-workbench.vs-dark .monaco-action-bar:not(.vertical) .agent-status-badge-section.sparkle .dropdown-action-container:hover + { + background-color: var(--vscode-toolbar-hoverBackground); +} + +.monaco-workbench.vs-dark .monaco-action-bar:not(.vertical) .agent-status-badge .monaco-dropdown-with-primary:not(.disabled):hover { + background-color: var(--vscode-commandCenter-activeBackground); } .monaco-dialog-modal-block .dialog-shadow { - border-radius: var(--radius-xl); + border-radius: var(--radius-lg); } .monaco-workbench .unified-quick-access-tabs { diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index 667dc4e9ea7..4cd0263a1a0 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -173,36 +173,6 @@ "description": "%typescript.enablePromptUseWorkspaceTsdk%", "scope": "window" }, - "javascript.referencesCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%javascript.referencesCodeLens.enabled%", - "scope": "window" - }, - "javascript.referencesCodeLens.showOnAllFunctions": { - "type": "boolean", - "default": false, - "description": "%javascript.referencesCodeLens.showOnAllFunctions%", - "scope": "window" - }, - "typescript.referencesCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%typescript.referencesCodeLens.enabled%", - "scope": "window" - }, - "typescript.referencesCodeLens.showOnAllFunctions": { - "type": "boolean", - "default": false, - "description": "%typescript.referencesCodeLens.showOnAllFunctions%", - "scope": "window" - }, - "typescript.implementationsCodeLens.enabled": { - "type": "boolean", - "default": false, - "description": "%typescript.implementationsCodeLens.enabled%", - "scope": "window" - }, "typescript.experimental.useTsgo": { "type": "boolean", "default": false, @@ -212,16 +182,103 @@ "experimental" ] }, + "js/ts.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.enabled.unifiedDeprecationMessage%", + "scope": "window" + }, + "typescript.referencesCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.enabled.unifiedDeprecationMessage%", + "scope": "window" + }, + "js/ts.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", + "scope": "window" + }, + "typescript.referencesCodeLens.showOnAllFunctions": { + "type": "boolean", + "default": false, + "description": "%configuration.referencesCodeLens.showOnAllFunctions%", + "markdownDeprecationMessage": "%configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage%", + "scope": "window" + }, + "js/ts.implementationsCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "typescript.implementationsCodeLens.enabled": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.enabled%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage%", + "scope": "window" + }, + "js/ts.implementationsCodeLens.showOnInterfaceMethods": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "typescript.implementationsCodeLens.showOnInterfaceMethods": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.showOnInterfaceMethods%", + "description": "%configuration.implementationsCodeLens.showOnInterfaceMethods%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage%", "scope": "window" }, + "js/ts.implementationsCodeLens.showOnAllClassMethods": { + "type": "boolean", + "default": false, + "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "typescript.implementationsCodeLens.showOnAllClassMethods": { "type": "boolean", "default": false, - "description": "%typescript.implementationsCodeLens.showOnAllClassMethods%", + "description": "%configuration.implementationsCodeLens.showOnAllClassMethods%", + "markdownDeprecationMessage": "%configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage%", "scope": "window" }, "typescript.reportStyleChecksAsWarnings": { diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 43f62e918f3..ed640ef85f0 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -49,13 +49,16 @@ "javascript.validate.enable": "Enable/disable JavaScript validation.", "javascript.goToProjectConfig.title": "Go to Project Configuration (jsconfig / tsconfig)", "typescript.goToProjectConfig.title": "Go to Project Configuration (tsconfig)", - "javascript.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript files.", - "javascript.referencesCodeLens.showOnAllFunctions": "Enable/disable references CodeLens on all functions in JavaScript files.", - "typescript.referencesCodeLens.enabled": "Enable/disable references CodeLens in TypeScript files.", - "typescript.referencesCodeLens.showOnAllFunctions": "Enable/disable references CodeLens on all functions in TypeScript files.", - "typescript.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens. This CodeLens shows the implementers of an interface.", - "typescript.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on interface methods.", - "typescript.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all class methods instead of only on abstract methods.", + "configuration.referencesCodeLens.enabled": "Enable/disable references CodeLens in JavaScript and TypeScript files. This CodeLens shows the number of references for classes and exported functions and allows you to peek or navigate to them.", + "configuration.referencesCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.enabled#` instead.", + "configuration.referencesCodeLens.showOnAllFunctions": "Enable/disable the references CodeLens on all functions in JavaScript and TypeScript files.", + "configuration.referencesCodeLens.showOnAllFunctions.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.referencesCodeLens.showOnAllFunctions#` instead.", + "configuration.implementationsCodeLens.enabled": "Enable/disable implementations CodeLens in TypeScript files. This CodeLens shows the implementers of a TypeScript interface.", + "configuration.implementationsCodeLens.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.enabled#` instead.", + "configuration.implementationsCodeLens.showOnInterfaceMethods": "Enable/disable implementations CodeLens on TypeScript interface methods.", + "configuration.implementationsCodeLens.showOnInterfaceMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnInterfaceMethods#` instead.", + "configuration.implementationsCodeLens.showOnAllClassMethods": "Enable/disable showing implementations CodeLens above all TypeScript class methods instead of only on abstract methods.", + "configuration.implementationsCodeLens.showOnAllClassMethods.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.implementationsCodeLens.showOnAllClassMethods#` instead.", "typescript.openTsServerLog.title": "Open TS Server log", "typescript.restartTsServer": "Restart TS Server", "typescript.selectTypeScriptVersion.title": "Select TypeScript Version...", diff --git a/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts b/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts index d012a6ab9d9..d32cfa129f8 100644 --- a/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts +++ b/extensions/typescript-language-features/src/languageFeatures/codeLens/implementationsCodeLens.ts @@ -11,10 +11,16 @@ import type * as Proto from '../../tsServer/protocol/protocol'; import * as PConst from '../../tsServer/protocol/protocol.const'; import * as typeConverters from '../../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { conditionalRegistration, requireGlobalConfiguration, requireSomeCapability } from '../util/dependentRegistration'; +import { readUnifiedConfig, unifiedConfigSection } from '../../utils/configuration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig, requireSomeCapability } from '../util/dependentRegistration'; import { ReferencesCodeLens, TypeScriptBaseCodeLensProvider, getSymbolRange } from './baseCodeLensProvider'; import { ExecutionTarget } from '../../tsServer/server'; +const Config = Object.freeze({ + enabled: 'implementationsCodeLens.enabled', + showOnInterfaceMethods: 'implementationsCodeLens.showOnInterfaceMethods', + showOnAllClassMethods: 'implementationsCodeLens.showOnAllClassMethods', +}); export default class TypeScriptImplementationsCodeLensProvider extends TypeScriptBaseCodeLensProvider { public constructor( @@ -25,14 +31,30 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip super(client, _cachedResponse); this._register( vscode.workspace.onDidChangeConfiguration(evt => { - if (evt.affectsConfiguration(`${language.id}.implementationsCodeLens.showOnInterfaceMethods`) || - evt.affectsConfiguration(`${language.id}.implementationsCodeLens.showOnAllClassMethods`)) { + if ( + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.enabled}`) || + evt.affectsConfiguration(`${language.id}.${Config.enabled}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnInterfaceMethods}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnInterfaceMethods}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnAllClassMethods}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnAllClassMethods}`) + ) { this.changeEmitter.fire(); } }) ); } + + override async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const enabled = readUnifiedConfig(Config.enabled, false, { scope: document, fallbackSection: this.language.id }); + if (!enabled) { + return []; + } + + return super.provideCodeLenses(document, token); + } + public async resolveCodeLens( codeLens: ReferencesCodeLens, token: vscode.CancellationToken, @@ -88,8 +110,6 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip item: Proto.NavigationTree, parent: Proto.NavigationTree | undefined ): vscode.Range | undefined { - const cfg = vscode.workspace.getConfiguration(this.language.id); - // Always show on interfaces if (item.kind === PConst.Kind.interface) { return getSymbolRange(document, item); @@ -111,7 +131,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip if ( item.kind === PConst.Kind.method && parent?.kind === PConst.Kind.interface && - cfg.get('implementationsCodeLens.showOnInterfaceMethods', false) + readUnifiedConfig('implementationsCodeLens.showOnInterfaceMethods', false, { scope: document, fallbackSection: this.language.id }) ) { return getSymbolRange(document, item); } @@ -121,7 +141,7 @@ export default class TypeScriptImplementationsCodeLensProvider extends TypeScrip if ( item.kind === PConst.Kind.method && parent?.kind === PConst.Kind.class && - cfg.get('implementationsCodeLens.showOnAllClassMethods', false) + readUnifiedConfig('implementationsCodeLens.showOnAllClassMethods', false, { scope: document, fallbackSection: this.language.id }) ) { // But not private ones as these can never be overridden if (/\bprivate\b/.test(item.kindModifiers ?? '')) { @@ -141,7 +161,7 @@ export function register( cachedResponse: CachedResponse, ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'implementationsCodeLens.enabled'), + requireHasModifiedUnifiedConfig(Config.enabled, language.id), requireSomeCapability(client, ClientCapability.Semantic), ], () => { return vscode.languages.registerCodeLensProvider(selector.semantic, diff --git a/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts b/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts index de7d1f6d900..0942f7f8f3a 100644 --- a/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts +++ b/extensions/typescript-language-features/src/languageFeatures/codeLens/referencesCodeLens.ts @@ -12,9 +12,14 @@ import * as PConst from '../../tsServer/protocol/protocol.const'; import { ExecutionTarget } from '../../tsServer/server'; import * as typeConverters from '../../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; -import { conditionalRegistration, requireGlobalConfiguration, requireSomeCapability } from '../util/dependentRegistration'; +import { readUnifiedConfig, unifiedConfigSection } from '../../utils/configuration'; +import { conditionalRegistration, requireHasModifiedUnifiedConfig, requireSomeCapability } from '../util/dependentRegistration'; import { ReferencesCodeLens, TypeScriptBaseCodeLensProvider, getSymbolRange } from './baseCodeLensProvider'; +const Config = Object.freeze({ + enabled: 'referencesCodeLens.enabled', + showOnAllFunctions: 'referencesCodeLens.showOnAllFunctions', +}); export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLensProvider { public constructor( @@ -25,13 +30,27 @@ export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLens super(client, _cachedResponse); this._register( vscode.workspace.onDidChangeConfiguration(evt => { - if (evt.affectsConfiguration(`${language.id}.referencesCodeLens.showOnAllFunctions`)) { + if ( + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.enabled}`) || + evt.affectsConfiguration(`${language.id}.${Config.enabled}`) || + evt.affectsConfiguration(`${unifiedConfigSection}.${Config.showOnAllFunctions}`) || + evt.affectsConfiguration(`${language.id}.${Config.showOnAllFunctions}`) + ) { this.changeEmitter.fire(); } }) ); } + override async provideCodeLenses(document: vscode.TextDocument, token: vscode.CancellationToken): Promise { + const enabled = readUnifiedConfig(Config.enabled, false, { scope: document, fallbackSection: this.language.id }); + if (!enabled) { + return []; + } + + return super.provideCodeLenses(document, token); + } + public async resolveCodeLens(codeLens: ReferencesCodeLens, token: vscode.CancellationToken): Promise { const args = typeConverters.Position.toFileLocationRequestArgs(codeLens.file, codeLens.range.start); const response = await this.client.execute('references', args, token, { @@ -76,7 +95,7 @@ export class TypeScriptReferencesCodeLensProvider extends TypeScriptBaseCodeLens switch (item.kind) { case PConst.Kind.function: { - const showOnAllFunctions = vscode.workspace.getConfiguration(this.language.id).get('referencesCodeLens.showOnAllFunctions'); + const showOnAllFunctions = readUnifiedConfig(Config.showOnAllFunctions, false, { scope: document, fallbackSection: this.language.id }); if (showOnAllFunctions && item.nameSpan) { return getSymbolRange(document, item); } @@ -137,7 +156,7 @@ export function register( cachedResponse: CachedResponse, ) { return conditionalRegistration([ - requireGlobalConfiguration(language.id, 'referencesCodeLens.enabled'), + requireHasModifiedUnifiedConfig(Config.enabled, language.id), requireSomeCapability(client, ClientCapability.Semantic), ], () => { return vscode.languages.registerCodeLensProvider(selector.semantic, diff --git a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts index 8371b470997..916bfd8f3ae 100644 --- a/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts +++ b/extensions/typescript-language-features/src/languageFeatures/util/dependentRegistration.ts @@ -6,6 +6,7 @@ import * as vscode from 'vscode'; import { API } from '../../tsServer/api'; import { ClientCapability, ITypeScriptServiceClient } from '../../typescriptService'; +import { hasModifiedUnifiedConfig } from '../../utils/configuration'; import { Disposable } from '../../utils/dispose'; export class Condition extends Disposable { @@ -102,6 +103,21 @@ export function requireGlobalConfiguration( ); } +/** + * Requires that a configuration value has been modified from its default value in either the global or workspace scope + * + * Does not check the value, only that it has been modified from the default. + */ +export function requireHasModifiedUnifiedConfig( + configValue: string, + fallbackSection: string, +) { + return new Condition( + () => hasModifiedUnifiedConfig(configValue, { fallbackSection }), + vscode.workspace.onDidChangeConfiguration + ); +} + export function requireSomeCapability( client: ITypeScriptServiceClient, ...capabilities: readonly ClientCapability[] diff --git a/extensions/typescript-language-features/src/utils/configuration.ts b/extensions/typescript-language-features/src/utils/configuration.ts new file mode 100644 index 00000000000..b10a70fd27a --- /dev/null +++ b/extensions/typescript-language-features/src/utils/configuration.ts @@ -0,0 +1,74 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; + +type ConfigurationScope = vscode.ConfigurationScope | null | undefined; + +export const unifiedConfigSection = 'js/ts'; + +/** + * Gets a configuration value, checking the unified `js/ts` setting first, + * then falling back to the language-specific setting. + */ +export function readUnifiedConfig( + subSectionName: string, + defaultValue: T, + options: { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + } +): T { + // Check unified setting first + const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); + const unifiedInspect = unifiedConfig.inspect(subSectionName); + if (hasModifiedValue(unifiedInspect)) { + return unifiedConfig.get(subSectionName, defaultValue); + } + + // Fall back to language-specific setting + const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); + return languageConfig.get(subSectionName, defaultValue); +} + +/** + * Checks if an inspected configuration value has any user-defined values set. + */ +function hasModifiedValue(inspect: ReturnType): boolean { + if (!inspect) { + return false; + } + + return ( + typeof inspect.globalValue !== 'undefined' + || typeof inspect.workspaceValue !== 'undefined' + || typeof inspect.workspaceFolderValue !== 'undefined' + || typeof inspect.globalLanguageValue !== 'undefined' + || typeof inspect.workspaceLanguageValue !== 'undefined' + || typeof inspect.workspaceFolderLanguageValue !== 'undefined' + || ((inspect.languageIds?.length ?? 0) > 0) + ); +} + +/** + * Checks if a unified configuration value has been modified from its default value. + */ +export function hasModifiedUnifiedConfig( + subSectionName: string, + options: { + readonly scope?: ConfigurationScope; + readonly fallbackSection: string; + } +): boolean { + // Check unified setting + const unifiedConfig = vscode.workspace.getConfiguration(unifiedConfigSection, options.scope); + if (hasModifiedValue(unifiedConfig.inspect(subSectionName))) { + return true; + } + + // Check language-specific setting + const languageConfig = vscode.workspace.getConfiguration(options.fallbackSection, options.scope); + return hasModifiedValue(languageConfig.inspect(subSectionName)); +} diff --git a/package-lock.json b/package-lock.json index 49beda3da1a..47f013803d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-5", + "@vscode/codicons": "^0.0.45-6", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -30,16 +30,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -2947,9 +2947,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-5", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-5.tgz", - "integrity": "sha512-tvRtMrVAe+CnlePF1Z3uhRfu0mLhAVgrSiY9zuEaGyXyBlN1/06bnpXno8XYb4O3ggOFx2phximqje4dhoLnkQ==", + "version": "0.0.45-6", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", + "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/deviceid": { @@ -3895,30 +3895,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -3928,7 +3928,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -3950,63 +3950,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index d7794f4e979..545b1a0812d 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "postinstall": "node build/npm/postinstall.ts", "compile": "npm run gulp compile", "compile-check-ts-native": "tsgo --project ./src/tsconfig.json --noEmit --skipLibCheck", - "watch": "npm-run-all2 -lp watch-client watch-extensions", + "watch": "npm-run-all2 -lp watch-client-transpile watch-client watch-extensions", "watchd": "deemon npm run watch", "watch-webd": "deemon npm run watch-web", "kill-watchd": "deemon --kill npm run watch", @@ -30,6 +30,9 @@ "watch-client": "npm run gulp watch-client", "watch-clientd": "deemon npm run watch-client", "kill-watch-clientd": "deemon --kill npm run watch-client", + "watch-client-transpile": "npx tsx build/next/index.ts transpile --watch", + "watch-client-transpiled": "deemon npm run watch-client-transpile", + "kill-watch-client-transpiled": "deemon --kill npm run watch-client-transpile", "watch-extensions": "npm run gulp watch-extensions watch-extension-media", "watch-extensionsd": "deemon npm run watch-extensions", "kill-watch-extensionsd": "deemon --kill npm run watch-extensions", @@ -77,7 +80,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-5", + "@vscode/codicons": "^0.0.45-6", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -92,16 +95,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index a6c0504563b..de6d200c162 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -22,16 +22,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -577,30 +577,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -610,67 +610,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.144.tgz", - "integrity": "sha512-hHVZas1eJNq5t3g6EkljriMK6IeUQKyaB+88p7B5KfuDUC53RFSxNj9lQxE37iSdzOC17qE+lzfdk5rGu1+WBg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.152.tgz", + "integrity": "sha512-Hkt+KPuifM8kqDKtbHq1uIhqdZMQazKTl9zaqjcWY3Vogx7+JVr6F+eN89KHnrvhUUOmhAM0JQAIRv1O+upfUw==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index e90da38f129..603dc5559ba 100644 --- a/remote/package.json +++ b/remote/package.json @@ -17,16 +17,16 @@ "@vscode/vscode-languagedetection": "1.0.21", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.1.0", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/headless": "^6.1.0-beta.144", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/headless": "^6.1.0-beta.152", + "@xterm/xterm": "^6.1.0-beta.152", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index a5d7be69eff..48c97fba4c5 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,19 +10,19 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-5", + "@vscode/codicons": "^0.0.45-6", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-5", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-5.tgz", - "integrity": "sha512-tvRtMrVAe+CnlePF1Z3uhRfu0mLhAVgrSiY9zuEaGyXyBlN1/06bnpXno8XYb4O3ggOFx2phximqje4dhoLnkQ==", + "version": "0.0.45-6", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", + "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { @@ -99,30 +99,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.144.tgz", - "integrity": "sha512-yH7eL8Gd9SxjNCe7WIRCHhKBfo7hggjLw6CznCY39HoUdF87xfCuk3mBj6itvZLNkSx8uvB8IXfYmXasLarwEg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.152.tgz", + "integrity": "sha512-D+wFHTTNj1qzlSL1h15tgFh6JgK/SSaotkohtaKykkKFmkdGrtJq8PpINaFipRDrZXX0d9eOD+wrMfz6IG+5Yw==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.144.tgz", - "integrity": "sha512-PvLt7MokuHItiYnHqtX8qTTWS73vQgPcpuBKVapozM4yp65Y4kwSt0nOrohKqiyCTWPyMWW0NcaStoUJLHXyvg==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.152.tgz", + "integrity": "sha512-pyQ/hQr3O0gY1La+6ZXdh0tI/+6MmNo2eFPNyWzB21J02xMu6nc30+B/H9VlPSR3AXHno5U67AWra5Y4FrE+5A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.144.tgz", - "integrity": "sha512-JhfkL4HtMhO1XdXugAGpYwwIzp9mE90G+J1M00FsLVmccuI53BAI16+SLUZ4w3AOonwWg/vlVxGSxKSkSY+D6A==", + "version": "0.11.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.152.tgz", + "integrity": "sha512-DglTaxmWHolTfryequU/7+Q4bjpDywt7UDsE3SdbC7O/9fa1qaOZMVlxKtRBtMBBzX5PXa+Ha4qAaMS2psr3UQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -132,58 +132,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.144.tgz", - "integrity": "sha512-VbQu1y40UHeLKinJgEb8FwSq4Czx5h4o/TSvAVkXegdH8FAWx8YiOpNvFLEIAgboQBqgV2lgn2Azec70Ielqzg==", + "version": "0.3.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.152.tgz", + "integrity": "sha512-H3qNwUaTNDRm51s8IzcYRinnQBSf7QDXWkcyAuDlprDJlR5BFhmGr9hpMV/KlCo2s6nhWrFjiwkd642DJ7McMg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.144.tgz", - "integrity": "sha512-+gjbQZBWqZiq8JciliUz+B6M5YPSIzT30pOi0IfC5GZHeDeDF82H02UwD2PDGweMJaWdPNtwSxrXTYr9SvtwjA==", + "version": "0.17.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.152.tgz", + "integrity": "sha512-0T/xDg0yh3PlS9HWOioIrNGP0OfUp4MtBJ7M2sfR+h23KKa4gl1ec7S1TsGU4gsvEMBKG1TB6jReX4vlKGYc4A==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.144.tgz", - "integrity": "sha512-OqXJf6OYOpsM4kBRHXeRVwEisWzHbnMH1ROBwmEspDnXP2NiOfoKW6E0C4Cla5qN91AYZTXcGwbyp7D+DfH9aQ==", + "version": "0.15.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.152.tgz", + "integrity": "sha512-GnhwKg0dkpAI1gZmm9L69Xjseal5pKwXFaMUxzm+Viajcp/PdqK1pEBJX5RndToNF0Ti3xu4e6BFO7dqY/J9TA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.144.tgz", - "integrity": "sha512-5NILSGbDh0t6jB5YEDzQQGAFo8zUmp7JGEBNkgCG6GHEAC8C4o8L68+RNrN+PcaAi6ucClyB4eqHQt3ZwQJ//A==", + "version": "0.10.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.152.tgz", + "integrity": "sha512-2HSpMjbckGAmU/56CTGuEbCZJZHxUlfNJ2uzR4akZyVVLEmavC4thHVSGT7Ei1zzpHZsAg0y4WMbcp4wzpPv3g==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.143", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.143.tgz", - "integrity": "sha512-wXWwg043EsLWZvbJoxdvS+xyp4KC2f9Mhxgv8i4Kby6zYXOIKuhvh/s+VpR0dzbeHECzKO0wUJT5SKDPxFv1hA==", + "version": "0.20.0-beta.151", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.151.tgz", + "integrity": "sha512-3ogsmZKPKc8n9Mjik4jTmNYT2Nbboe/zqcjDNG7RONO3w/tUyoKQshYCMBxxGMNLDwvh3BQ/D9/6JvdNWA1ShA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.144" + "@xterm/xterm": "^6.1.0-beta.152" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.144", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.144.tgz", - "integrity": "sha512-sV4pvPEYhJJ4crzgggmGwyoQgXoxXAK4fo1VqW2XxTpWpnhG7hdkZ7a25yEqF7Oq7GKXdr5jyNzl6QVv8Cm/pg==", + "version": "6.1.0-beta.152", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.152.tgz", + "integrity": "sha512-XHJ5ab19V6tmcHmBE7k9IYjXSwTxUd0c7oKLa5J+ZO0+aiXE8UKh9OEDw1oyl5ZQhw9gn71cGEo4TpB58KhfoQ==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index 4bf2586bc81..4f591ed9906 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,19 +5,19 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-5", + "@vscode/codicons": "^0.0.45-6", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", - "@xterm/addon-clipboard": "^0.3.0-beta.144", - "@xterm/addon-image": "^0.10.0-beta.144", - "@xterm/addon-ligatures": "^0.11.0-beta.144", - "@xterm/addon-progress": "^0.3.0-beta.144", - "@xterm/addon-search": "^0.17.0-beta.144", - "@xterm/addon-serialize": "^0.15.0-beta.144", - "@xterm/addon-unicode11": "^0.10.0-beta.144", - "@xterm/addon-webgl": "^0.20.0-beta.143", - "@xterm/xterm": "^6.1.0-beta.144", + "@xterm/addon-clipboard": "^0.3.0-beta.152", + "@xterm/addon-image": "^0.10.0-beta.152", + "@xterm/addon-ligatures": "^0.11.0-beta.152", + "@xterm/addon-progress": "^0.3.0-beta.152", + "@xterm/addon-search": "^0.17.0-beta.152", + "@xterm/addon-serialize": "^0.15.0-beta.152", + "@xterm/addon-unicode11": "^0.10.0-beta.152", + "@xterm/addon-webgl": "^0.20.0-beta.151", + "@xterm/xterm": "^6.1.0-beta.152", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", diff --git a/src/vs/base/browser/pixelRatio.ts b/src/vs/base/browser/pixelRatio.ts index d2d93b66f30..1e11a248aa2 100644 --- a/src/vs/base/browser/pixelRatio.ts +++ b/src/vs/base/browser/pixelRatio.ts @@ -5,7 +5,7 @@ import { getWindowId, onDidUnregisterWindow } from './dom.js'; import { Emitter, Event } from '../common/event.js'; -import { Disposable, markAsSingleton } from '../common/lifecycle.js'; +import { Disposable, markAsSingleton, toDisposable } from '../common/lifecycle.js'; type BackingStoreContext = CanvasRenderingContext2D & { webkitBackingStorePixelRatio?: number; @@ -32,6 +32,8 @@ class DevicePixelRatioMonitor extends Disposable { this._listener = () => this._handleChange(targetWindow, true); this._mediaQueryList = null; this._handleChange(targetWindow, false); + + this._register(toDisposable(() => this._mediaQueryList?.removeEventListener('change', this._listener))); } private _handleChange(targetWindow: Window, fireEvent: boolean): void { diff --git a/src/vs/base/browser/ui/actionbar/actionViewItems.ts b/src/vs/base/browser/ui/actionbar/actionViewItems.ts index 5fed79fe771..71d0f15a2f8 100644 --- a/src/vs/base/browser/ui/actionbar/actionViewItems.ts +++ b/src/vs/base/browser/ui/actionbar/actionViewItems.ts @@ -436,12 +436,12 @@ export class ActionViewItem extends BaseActionViewItem { if (this.options.isTabList) { this.label.setAttribute('aria-selected', this.action.checked ? 'true' : 'false'); } else { - this.label.setAttribute('aria-checked', this.action.checked ? 'true' : 'false'); - this.label.setAttribute('role', 'checkbox'); + this.label.setAttribute('aria-pressed', this.action.checked ? 'true' : 'false'); + this.label.setAttribute('role', 'button'); } } else { this.label.classList.remove('checked'); - this.label.removeAttribute(this.options.isTabList ? 'aria-selected' : 'aria-checked'); + this.label.removeAttribute(this.options.isTabList ? 'aria-selected' : 'aria-pressed'); this.label.setAttribute('role', this.getDefaultAriaRole()); } } diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.css b/src/vs/base/browser/ui/codicons/codicon/codicon.css index 02154e77b68..d7f257db934 100644 --- a/src/vs/base/browser/ui/codicons/codicon/codicon.css +++ b/src/vs/base/browser/ui/codicons/codicon/codicon.css @@ -6,7 +6,7 @@ @font-face { font-family: "codicon"; font-display: block; - src: url("./codicon.ttf?5d4d76ab2ce5108968ad644d591a16a6") format("truetype"); + src: url("./codicon.ttf") format("truetype"); } .codicon[class*='codicon-'] { diff --git a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf index a3098e1715c..ff46f210b8a 100644 Binary files a/src/vs/base/browser/ui/codicons/codicon/codicon.ttf and b/src/vs/base/browser/ui/codicons/codicon/codicon.ttf differ diff --git a/src/vs/base/browser/ui/contextview/contextview.ts b/src/vs/base/browser/ui/contextview/contextview.ts index 44c3c080e24..b3bfc63cb79 100644 --- a/src/vs/base/browser/ui/contextview/contextview.ts +++ b/src/vs/base/browser/ui/contextview/contextview.ts @@ -7,11 +7,13 @@ import { BrowserFeatures } from '../../canIUse.js'; import * as DOM from '../../dom.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../common/lifecycle.js'; +import { AnchorAlignment, AnchorAxisAlignment, AnchorPosition, IRect, layout2d } from '../../../common/layout.js'; import * as platform from '../../../common/platform.js'; -import { Range } from '../../../common/range.js'; import { OmitOptional } from '../../../common/types.js'; import './contextview.css'; +export { AnchorAlignment, AnchorAxisAlignment, AnchorPosition } from '../../../common/layout.js'; + export const enum ContextViewDOMPosition { ABSOLUTE = 1, FIXED, @@ -31,18 +33,6 @@ export function isAnchor(obj: unknown): obj is IAnchor | OmitOptional { return !!anchor && typeof anchor.x === 'number' && typeof anchor.y === 'number'; } -export const enum AnchorAlignment { - LEFT, RIGHT -} - -export const enum AnchorPosition { - BELOW, ABOVE -} - -export const enum AnchorAxisAlignment { - VERTICAL, HORIZONTAL -} - export interface IDelegate { /** * The anchor where to position the context view. @@ -73,66 +63,40 @@ export interface IContextViewProvider { layout(): void; } -export interface IPosition { - top: number; - left: number; -} +export function getAnchorRect(anchor: HTMLElement | StandardMouseEvent | IAnchor): IRect { + // Get the element's position and size (to anchor the view) + if (DOM.isHTMLElement(anchor)) { + const elementPosition = DOM.getDomNodePagePosition(anchor); -export interface ISize { - width: number; - height: number; -} + // In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element + // e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level. + // Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5 + const zoom = DOM.getDomNodeZoomLevel(anchor); -export interface IView extends IPosition, ISize { } - -export const enum LayoutAnchorPosition { - Before, - After -} - -export enum LayoutAnchorMode { - AVOID, - ALIGN -} - -export interface ILayoutAnchor { - offset: number; - size: number; - mode?: LayoutAnchorMode; // default: AVOID - position: LayoutAnchorPosition; -} - -/** - * Lays out a one dimensional view next to an anchor in a viewport. - * - * @returns The view offset within the viewport. - */ -export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): number { - const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size; - const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset; - - if (anchor.position === LayoutAnchorPosition.Before) { - if (viewSize <= viewportSize - layoutAfterAnchorBoundary) { - return layoutAfterAnchorBoundary; // happy case, lay it out after the anchor - } - - if (viewSize <= layoutBeforeAnchorBoundary) { - return layoutBeforeAnchorBoundary - viewSize; // ok case, lay it out before the anchor - } - - return Math.max(viewportSize - viewSize, 0); // sad case, lay it over the anchor + return { + top: elementPosition.top * zoom, + left: elementPosition.left * zoom, + width: elementPosition.width * zoom, + height: elementPosition.height * zoom + }; + } else if (isAnchor(anchor)) { + return { + top: anchor.y, + left: anchor.x, + width: anchor.width || 1, + height: anchor.height || 2 + }; } else { - if (viewSize <= layoutBeforeAnchorBoundary) { - return layoutBeforeAnchorBoundary - viewSize; // happy case, lay it out before the anchor - } - - - if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) { - return layoutAfterAnchorBoundary; // ok case, lay it out after the anchor - } - - - return 0; // sad case, lay it over the anchor + return { + top: anchor.posy, + left: anchor.posx, + // We are about to position the context view where the mouse + // cursor is. To prevent the view being exactly under the mouse + // when showing and thus potentially triggering an action within, + // we treat the mouse location like a small sized block element. + width: 2, + height: 2 + }; } } @@ -270,82 +234,14 @@ export class ContextView extends Disposable { } // Get anchor - const anchor = this.delegate!.getAnchor(); - - // Compute around - let around: IView; - - // Get the element's position and size (to anchor the view) - if (DOM.isHTMLElement(anchor)) { - const elementPosition = DOM.getDomNodePagePosition(anchor); - - // In areas where zoom is applied to the element or its ancestors, we need to adjust the size of the element - // e.g. The title bar has counter zoom behavior meaning it applies the inverse of zoom level. - // Window Zoom Level: 1.5, Title Bar Zoom: 1/1.5, Size Multiplier: 1.5 - const zoom = DOM.getDomNodeZoomLevel(anchor); - - around = { - top: elementPosition.top * zoom, - left: elementPosition.left * zoom, - width: elementPosition.width * zoom, - height: elementPosition.height * zoom - }; - } else if (isAnchor(anchor)) { - around = { - top: anchor.y, - left: anchor.x, - width: anchor.width || 1, - height: anchor.height || 2 - }; - } else { - around = { - top: anchor.posy, - left: anchor.posx, - // We are about to position the context view where the mouse - // cursor is. To prevent the view being exactly under the mouse - // when showing and thus potentially triggering an action within, - // we treat the mouse location like a small sized block element. - width: 2, - height: 2 - }; - } - - const viewSizeWidth = DOM.getTotalWidth(this.view); - const viewSizeHeight = DOM.getTotalHeight(this.view); - - const anchorPosition = this.delegate!.anchorPosition ?? AnchorPosition.BELOW; - const anchorAlignment = this.delegate!.anchorAlignment ?? AnchorAlignment.LEFT; - const anchorAxisAlignment = this.delegate!.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL; - - let top: number; - let left: number; - + const anchor = getAnchorRect(this.delegate!.getAnchor()); const activeWindow = DOM.getActiveWindow(); - if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) { - const verticalAnchor: ILayoutAnchor = { offset: around.top - activeWindow.pageYOffset, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; - const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; - - top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset; - - // if view intersects vertically with anchor, we must avoid the anchor - if (Range.intersects({ start: top, end: top + viewSizeHeight }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) { - horizontalAnchor.mode = LayoutAnchorMode.AVOID; - } - - left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor); - } else { - const horizontalAnchor: ILayoutAnchor = { offset: around.left, size: around.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; - const verticalAnchor: ILayoutAnchor = { offset: around.top, size: around.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; - - left = layout(activeWindow.innerWidth, viewSizeWidth, horizontalAnchor); - - // if view intersects horizontally with anchor, we must avoid the anchor - if (Range.intersects({ start: left, end: left + viewSizeWidth }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) { - verticalAnchor.mode = LayoutAnchorMode.AVOID; - } - - top = layout(activeWindow.innerHeight, viewSizeHeight, verticalAnchor) + activeWindow.pageYOffset; - } + const viewport = { top: activeWindow.pageYOffset, left: activeWindow.pageXOffset, width: activeWindow.innerWidth, height: activeWindow.innerHeight }; + const view = { width: DOM.getTotalWidth(this.view), height: DOM.getTotalHeight(this.view) }; + const anchorPosition = this.delegate!.anchorPosition; + const anchorAlignment = this.delegate!.anchorAlignment; + const anchorAxisAlignment = this.delegate!.anchorAxisAlignment; + const { top, left } = layout2d(viewport, view, anchor, { anchorAlignment, anchorPosition, anchorAxisAlignment }); this.view.classList.remove('top', 'bottom', 'left', 'right'); this.view.classList.add(anchorPosition === AnchorPosition.BELOW ? 'bottom' : 'top'); diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index f48c4488073..c747ea1cd87 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -11,7 +11,6 @@ import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { StandardMouseEvent } from '../../mouseEvent.js'; import { ActionBar, ActionsOrientation, IActionViewItemProvider } from '../actionbar/actionbar.js'; import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from '../actionbar/actionViewItems.js'; -import { AnchorAlignment, layout, LayoutAnchorPosition } from '../contextview/contextview.js'; import { DomScrollableElement } from '../scrollbar/scrollableElement.js'; import { EmptySubmenuAction, IAction, IActionRunner, Separator, SubmenuAction } from '../../../common/actions.js'; import { RunOnceScheduler } from '../../../common/async.js'; @@ -26,6 +25,7 @@ import { DisposableStore } from '../../../common/lifecycle.js'; import { isLinux, isMacintosh } from '../../../common/platform.js'; import { ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import * as strings from '../../../common/strings.js'; +import { AnchorAlignment, layout, LayoutAnchorPosition } from '../../../common/layout.js'; export const MENU_MNEMONIC_REGEX = /\(&([^\s&])\)|(^|[^&])&([^\s&])/; export const MENU_ESCAPED_MNEMONIC_REGEX = /(&)?(&)([^\s&])/g; @@ -859,7 +859,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { const ret = { top: 0, left: 0 }; // Start with horizontal - ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }); + ret.left = layout(windowDimensions.width, submenu.width, { position: expandDirection.horizontal === HorizontalDirection.Right ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, offset: entry.left, size: entry.width }).position; // We don't have enough room to layout the menu fully, so we are overlapping the menu if (ret.left >= entry.left && ret.left < entry.left + entry.width) { @@ -872,7 +872,7 @@ class SubmenuMenuActionViewItem extends BaseMenuActionViewItem { } // Now that we have a horizontal position, try layout vertically - ret.top = layout(windowDimensions.height, submenu.height, { position: LayoutAnchorPosition.Before, offset: entry.top, size: 0 }); + ret.top = layout(windowDimensions.height, submenu.height, { position: LayoutAnchorPosition.Before, offset: entry.top, size: 0 }).position; // We didn't have enough room below, but we did above, so we shift down to align the menu if (ret.top + submenu.height === entry.top && ret.top + entry.height + submenu.height <= windowDimensions.height) { diff --git a/src/vs/base/common/layout.ts b/src/vs/base/common/layout.ts new file mode 100644 index 00000000000..b3ca8f372b1 --- /dev/null +++ b/src/vs/base/common/layout.ts @@ -0,0 +1,166 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Range } from './range.js'; + +export interface IAnchor { + x: number; + y: number; + width?: number; + height?: number; +} + +export const enum AnchorAlignment { + LEFT, RIGHT +} + +export const enum AnchorPosition { + BELOW, ABOVE +} + +export const enum AnchorAxisAlignment { + VERTICAL, HORIZONTAL +} + +interface IPosition { + readonly top: number; + readonly left: number; +} + +interface ISize { + readonly width: number; + readonly height: number; +} + +export interface IRect extends IPosition, ISize { } + +export const enum LayoutAnchorPosition { + Before, + After +} + +export enum LayoutAnchorMode { + AVOID, + ALIGN +} + +export interface ILayoutAnchor { + offset: number; + size: number; + mode?: LayoutAnchorMode; // default: AVOID + position: LayoutAnchorPosition; +} + +export interface ILayoutResult { + position: number; + result: 'ok' | 'flipped' | 'overlap'; +} + +/** + * Lays out a one dimensional view next to an anchor in a viewport. + * + * @returns The view offset within the viewport. + */ +export function layout(viewportSize: number, viewSize: number, anchor: ILayoutAnchor): ILayoutResult { + const layoutAfterAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset : anchor.offset + anchor.size; + const layoutBeforeAnchorBoundary = anchor.mode === LayoutAnchorMode.ALIGN ? anchor.offset + anchor.size : anchor.offset; + + if (anchor.position === LayoutAnchorPosition.Before) { + if (viewSize <= viewportSize - layoutAfterAnchorBoundary) { + return { position: layoutAfterAnchorBoundary, result: 'ok' }; // happy case, lay it out after the anchor + } + + if (viewSize <= layoutBeforeAnchorBoundary) { + return { position: layoutBeforeAnchorBoundary - viewSize, result: 'flipped' }; // ok case, lay it out before the anchor + } + + return { position: Math.max(viewportSize - viewSize, 0), result: 'overlap' }; // sad case, lay it over the anchor + } else { + if (viewSize <= layoutBeforeAnchorBoundary) { + return { position: layoutBeforeAnchorBoundary - viewSize, result: 'ok' }; // happy case, lay it out before the anchor + } + + if (viewSize <= viewportSize - layoutAfterAnchorBoundary && layoutBeforeAnchorBoundary < viewSize / 2) { + return { position: layoutAfterAnchorBoundary, result: 'flipped' }; // ok case, lay it out after the anchor + } + + return { position: 0, result: 'overlap' }; // sad case, lay it over the anchor + } +} + +interface ILayout2DOptions { + readonly anchorAlignment?: AnchorAlignment; // default: left + readonly anchorPosition?: AnchorPosition; // default: above + readonly anchorAxisAlignment?: AnchorAxisAlignment; // default: vertical +} + +export interface ILayout2DResult { + top: number; + left: number; + bottom: number; + right: number; + anchorAlignment: AnchorAlignment; + anchorPosition: AnchorPosition; +} + +export function layout2d(viewport: IRect, view: ISize, anchor: IRect, options?: ILayout2DOptions): ILayout2DResult { + let anchorAlignment = options?.anchorAlignment ?? AnchorAlignment.LEFT; + let anchorPosition = options?.anchorPosition ?? AnchorPosition.BELOW; + const anchorAxisAlignment = options?.anchorAxisAlignment ?? AnchorAxisAlignment.VERTICAL; + + let top: number; + let left: number; + + if (anchorAxisAlignment === AnchorAxisAlignment.VERTICAL) { + const verticalAnchor: ILayoutAnchor = { offset: anchor.top - viewport.top, size: anchor.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; + const horizontalAnchor: ILayoutAnchor = { offset: anchor.left, size: anchor.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; + + const verticalLayoutResult = layout(viewport.height, view.height, verticalAnchor); + top = verticalLayoutResult.position + viewport.top; + + if (verticalLayoutResult.result === 'flipped') { + anchorPosition = anchorPosition === AnchorPosition.BELOW ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + + // if view intersects vertically with anchor, we must avoid the anchor + if (Range.intersects({ start: top, end: top + view.height }, { start: verticalAnchor.offset, end: verticalAnchor.offset + verticalAnchor.size })) { + horizontalAnchor.mode = LayoutAnchorMode.AVOID; + } + + const horizontalLayoutResult = layout(viewport.width, view.width, horizontalAnchor); + left = horizontalLayoutResult.position; + + if (horizontalLayoutResult.result === 'flipped') { + anchorAlignment = anchorAlignment === AnchorAlignment.LEFT ? AnchorAlignment.RIGHT : AnchorAlignment.LEFT; + } + } else { + const horizontalAnchor: ILayoutAnchor = { offset: anchor.left, size: anchor.width, position: anchorAlignment === AnchorAlignment.LEFT ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After }; + const verticalAnchor: ILayoutAnchor = { offset: anchor.top, size: anchor.height, position: anchorPosition === AnchorPosition.BELOW ? LayoutAnchorPosition.Before : LayoutAnchorPosition.After, mode: LayoutAnchorMode.ALIGN }; + + const horizontalLayoutResult = layout(viewport.width, view.width, horizontalAnchor); + left = horizontalLayoutResult.position; + + if (horizontalLayoutResult.result === 'flipped') { + anchorAlignment = anchorAlignment === AnchorAlignment.LEFT ? AnchorAlignment.RIGHT : AnchorAlignment.LEFT; + } + + // if view intersects horizontally with anchor, we must avoid the anchor + if (Range.intersects({ start: left, end: left + view.width }, { start: horizontalAnchor.offset, end: horizontalAnchor.offset + horizontalAnchor.size })) { + verticalAnchor.mode = LayoutAnchorMode.AVOID; + } + + const verticalLayoutResult = layout(viewport.height, view.height, verticalAnchor); + top = verticalLayoutResult.position + viewport.top; + + if (verticalLayoutResult.result === 'flipped') { + anchorPosition = anchorPosition === AnchorPosition.BELOW ? AnchorPosition.ABOVE : AnchorPosition.BELOW; + } + } + + const right = viewport.width - (left + view.width); + const bottom = viewport.height - (top + view.height); + + return { top, left, bottom, right, anchorAlignment, anchorPosition }; +} diff --git a/src/vs/base/common/uri.ts b/src/vs/base/common/uri.ts index ac2ae962d72..53267ec7b00 100644 --- a/src/vs/base/common/uri.ts +++ b/src/vs/base/common/uri.ts @@ -22,7 +22,11 @@ function _validateUri(ret: URI, _strict?: boolean): void { // scheme, https://tools.ietf.org/html/rfc3986#section-3.1 // ALPHA *( ALPHA / DIGIT / "+" / "-" / "." ) if (ret.scheme && !_schemePattern.test(ret.scheme)) { - throw new Error('[UriError]: Scheme contains illegal characters.'); + const matches = [...ret.scheme.matchAll(/[^\w\d+.-]/gu)]; + const detail = matches.length > 0 + ? ` Found '${matches[0][0]}' at index ${matches[0].index} (${matches.length} total)` + : ''; + throw new Error(`[UriError]: Scheme contains illegal characters.${detail} (len:${ret.scheme.length})`); } // path, http://tools.ietf.org/html/rfc3986#section-3.3 diff --git a/src/vs/base/test/browser/ui/contextview/contextview.test.ts b/src/vs/base/test/common/layout.test.ts similarity index 60% rename from src/vs/base/test/browser/ui/contextview/contextview.test.ts rename to src/vs/base/test/common/layout.test.ts index 4058d33f4a9..a6be1ea8ed2 100644 --- a/src/vs/base/test/browser/ui/contextview/contextview.test.ts +++ b/src/vs/base/test/common/layout.test.ts @@ -4,27 +4,26 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { layout, LayoutAnchorPosition } from '../../../../browser/ui/contextview/contextview.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; +import { layout, LayoutAnchorPosition } from '../../common/layout.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js'; -suite('Contextview', function () { +suite('Layout', function () { test('layout', () => { - assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.Before }), 0); - assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.Before }), 50); - assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.Before }), 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.Before }).position, 0); + assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.Before }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.Before }).position, 180); - assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.After }), 0); - assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.After }), 30); - assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.After }), 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 0, position: LayoutAnchorPosition.After }).position, 0); + assert.strictEqual(layout(200, 20, { offset: 50, size: 0, position: LayoutAnchorPosition.After }).position, 30); + assert.strictEqual(layout(200, 20, { offset: 200, size: 0, position: LayoutAnchorPosition.After }).position, 180); + assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.Before }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.Before }).position, 100); + assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.Before }).position, 130); - assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.Before }), 50); - assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.Before }), 100); - assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.Before }), 130); - - assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.After }), 50); - assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.After }), 30); - assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.After }), 130); + assert.strictEqual(layout(200, 20, { offset: 0, size: 50, position: LayoutAnchorPosition.After }).position, 50); + assert.strictEqual(layout(200, 20, { offset: 50, size: 50, position: LayoutAnchorPosition.After }).position, 30); + assert.strictEqual(layout(200, 20, { offset: 150, size: 50, position: LayoutAnchorPosition.After }).position, 130); }); ensureNoDisposablesAreLeakedInTestSuite(); diff --git a/src/vs/editor/browser/view/viewController.ts b/src/vs/editor/browser/view/viewController.ts index cd312c96271..9f94a1b4f82 100644 --- a/src/vs/editor/browser/view/viewController.ts +++ b/src/vs/editor/browser/view/viewController.ts @@ -16,6 +16,7 @@ import { EditorOption } from '../../common/config/editorOptions.js'; import * as platform from '../../../base/common/platform.js'; import { StandardTokenType } from '../../common/encodedTokenAttributes.js'; import { ITextModel } from '../../common/model.js'; +import { containsRTL } from '../../../base/common/strings.js'; export interface IMouseDispatchData { position: Position; @@ -181,15 +182,30 @@ export class ViewController { return undefined; } - // Get 1-based boundaries of the string content (excluding quotes). - const start = lineTokens.getStartOffset(index) + 2; - const end = lineTokens.getEndOffset(index); - - if (column !== start && column !== end) { + // Verify the click is after starting or before closing quote. + const tokenStart = lineTokens.getStartOffset(index); + const tokenEnd = lineTokens.getEndOffset(index); + if (column !== tokenStart + 2 && column !== tokenEnd) { return undefined; } - return new Selection(lineNumber, start, lineNumber, end); + // Verify the token looks like a complete quoted string (quote ... quote). + const lineContent = model.getLineContent(lineNumber); + const firstChar = lineContent.charAt(tokenStart); + if (firstChar !== '"' && firstChar !== '\'' && firstChar !== '`') { + return undefined; + } + if (lineContent.charAt(tokenEnd - 1) !== firstChar) { + return undefined; + } + + // Skip if string contains RTL characters. + const content = lineContent.substring(tokenStart + 1, tokenEnd - 1); + if (containsRTL(content)) { + return undefined; + } + + return new Selection(lineNumber, tokenStart + 2, lineNumber, tokenEnd); } public dispatchMouse(data: IMouseDispatchData): void { diff --git a/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts b/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts index 78d6e9b37b7..db160425769 100644 --- a/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts +++ b/src/vs/editor/browser/widget/diffEditor/diffProviderFactoryService.ts @@ -60,6 +60,7 @@ export class WorkerBasedDocumentDiffProvider implements IDocumentDiffProvider, I public dispose(): void { this.diffAlgorithmOnDidChangeSubscription?.dispose(); + this.onDidChangeEventEmitter.dispose(); } async computeDiff(original: ITextModel, modified: ITextModel, options: IDocumentDiffProviderOptions, cancellationToken: CancellationToken): Promise { diff --git a/src/vs/editor/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index a6e7322d41c..acc23078dae 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -496,7 +496,7 @@ export interface IEditorOptions { * Enable quick suggestions (shadow suggestions) * Defaults to true. */ - quickSuggestions?: boolean | IQuickSuggestionsOptions; + quickSuggestions?: boolean | QuickSuggestionsValue | IQuickSuggestionsOptions; /** * Quick suggestions show delay (in ms) * Defaults to 10 (ms) @@ -3712,7 +3712,7 @@ class PlaceholderOption extends BaseEditorOption { +class EditorQuickSuggestions extends BaseEditorOption { public override readonly defaultValue: InternalQuickSuggestionsOptions; @@ -3743,32 +3743,45 @@ class EditorQuickSuggestions extends BaseEditorOption apply same value to all token types + const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off', 'offWhenInlineCompletions']; + const validated = stringSet(input as QuickSuggestionsValue, this.defaultValue.other, allowedValues); + return { comments: validated, strings: validated, other: validated }; + } if (!input || typeof input !== 'object') { - // invalid object + // invalid input return this.defaultValue; } const { other, comments, strings } = (input); - const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off']; + const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off', 'offWhenInlineCompletions']; let validatedOther: QuickSuggestionsValue; let validatedComments: QuickSuggestionsValue; let validatedStrings: QuickSuggestionsValue; diff --git a/src/vs/editor/common/model.ts b/src/vs/editor/common/model.ts index f39515c3348..5195e0b6353 100644 --- a/src/vs/editor/common/model.ts +++ b/src/vs/editor/common/model.ts @@ -1182,6 +1182,13 @@ export interface ITextModel { */ getCustomLineHeightsDecorations(ownerId?: number): IModelDecoration[]; + /** + * Gets all the decorations that contain custom line heights. + * @param range The range to search in + * @param ownerId If set, it will ignore decorations belonging to other owners. + */ + getCustomLineHeightsDecorationsInRange(range: Range, ownerId?: number): IModelDecoration[]; + /** * @internal */ diff --git a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsImpl.ts b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsImpl.ts index 546daf83fdc..aeda2bdbd31 100644 --- a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsImpl.ts +++ b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsImpl.ts @@ -21,7 +21,7 @@ import { LineTokens } from '../../tokens/lineTokens.js'; export class BracketPairsTextModelPart extends Disposable implements IBracketPairsTextModelPart { private readonly bracketPairsTree = this._register(new MutableDisposable>()); - private readonly onDidChangeEmitter = new Emitter(); + private readonly onDidChangeEmitter = this._register(new Emitter()); public readonly onDidChange = this.onDidChangeEmitter.event; private get canBuildAST() { diff --git a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree.ts b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree.ts index 5a5bf23050a..445b09c7b2e 100644 --- a/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree.ts +++ b/src/vs/editor/common/model/bracketPairsTextModelPart/bracketPairsTree/bracketPairsTree.ts @@ -55,7 +55,7 @@ export class BracketPairsTree extends Disposable { private readonly getLanguageConfiguration: (languageId: string) => ResolvedLanguageConfiguration ) { super(); - this.didChangeEmitter = new Emitter(); + this.didChangeEmitter = this._register(new Emitter()); this.denseKeyProvider = new DenseKeyProvider(); this.brackets = new LanguageAgnosticBracketTokens(this.denseKeyProvider, this.getLanguageConfiguration); this.onDidChange = this.didChangeEmitter.event; diff --git a/src/vs/editor/common/model/bracketPairsTextModelPart/colorizedBracketPairsDecorationProvider.ts b/src/vs/editor/common/model/bracketPairsTextModelPart/colorizedBracketPairsDecorationProvider.ts index 66e78d57cb0..ab5828275a6 100644 --- a/src/vs/editor/common/model/bracketPairsTextModelPart/colorizedBracketPairsDecorationProvider.ts +++ b/src/vs/editor/common/model/bracketPairsTextModelPart/colorizedBracketPairsDecorationProvider.ts @@ -21,7 +21,7 @@ export class ColorizedBracketPairsDecorationProvider extends Disposable implemen private colorizationOptions: BracketPairColorizationOptions; private readonly colorProvider = new ColorProvider(); - private readonly onDidChangeEmitter = new Emitter(); + private readonly onDidChangeEmitter = this._register(new Emitter()); public readonly onDidChange = this.onDidChangeEmitter.event; constructor(private readonly textModel: TextModel) { diff --git a/src/vs/editor/common/model/textModel.ts b/src/vs/editor/common/model/textModel.ts index ede464c5a82..bb3a86c9857 100644 --- a/src/vs/editor/common/model/textModel.ts +++ b/src/vs/editor/common/model/textModel.ts @@ -299,7 +299,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati private readonly _guidesTextModelPart: GuidesTextModelPart; public get guides(): IGuidesTextModelPart { return this._guidesTextModelPart; } - private readonly _attachedViews = new AttachedViews(); + private readonly _attachedViews = this._register(new AttachedViews()); private readonly _viewModels = new Set(); constructor( @@ -1563,6 +1563,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati rawContentChanges.push( new ModelRawLineChanged( editLineNumber, + currentEditLineNumber, this.getLineContent(currentEditLineNumber), decorationsInCurrentLine )); @@ -1593,7 +1594,8 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati rawContentChanges.push( new ModelRawLinesInserted( spliceLineNumber + 1, - startLineNumber + insertingLinesCnt, + fromLineNumber, + cnt, newLines, injectedTexts ) @@ -1653,7 +1655,7 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati if (affectedInjectedTextLines && affectedInjectedTextLines.size > 0) { const affectedLines = Array.from(affectedInjectedTextLines); - const lineChangeEvents = affectedLines.map(lineNumber => new ModelRawLineChanged(lineNumber, this.getLineContent(lineNumber), this._getInjectedTextInLine(lineNumber))); + const lineChangeEvents = affectedLines.map(lineNumber => new ModelRawLineChanged(lineNumber, lineNumber, this.getLineContent(lineNumber), this._getInjectedTextInLine(lineNumber))); this._onDidChangeContentOrInjectedText(new ModelInjectedTextChangedEvent(lineChangeEvents)); } this._fireOnDidChangeLineHeight(affectedLineHeights); @@ -1865,6 +1867,12 @@ export class TextModel extends Disposable implements model.ITextModel, IDecorati return decs; } + public getCustomLineHeightsDecorationsInRange(range: Range, ownerId: number = 0): model.IModelDecoration[] { + const decs = this._decorationsTree.getCustomLineHeightsInInterval(this, this.getOffsetAt(range.getStartPosition()), this.getOffsetAt(range.getEndPosition()), ownerId); + pushMany(decs, this._fontTokenDecorationsProvider.getDecorationsInRange(range, ownerId)); + return decs; + } + private _getInjectedTextInLine(lineNumber: number): LineInjectedText[] { const startOffset = this._buffer.getOffsetAt(lineNumber, 1); const endOffset = startOffset + this._buffer.getLineLength(lineNumber); @@ -2249,6 +2257,12 @@ class DecorationsTrees { return this._ensureNodesHaveRanges(host, result).filter((i) => typeof i.options.lineHeight === 'number'); } + public getCustomLineHeightsInInterval(host: IDecorationsTreesHost, start: number, end: number, filterOwnerId: number): model.IModelDecoration[] { + const versionId = host.getVersionId(); + const result = this._intervalSearch(start, end, filterOwnerId, false, false, versionId, false); + return this._ensureNodesHaveRanges(host, result).filter((i) => typeof i.options.lineHeight === 'number'); + } + public getAll(host: IDecorationsTreesHost, filterOwnerId: number, filterOutValidation: boolean, filterFontDecorations: boolean, overviewRulerOnly: boolean, onlyMarginDecorations: boolean): model.IModelDecoration[] { const versionId = host.getVersionId(); const result = this._search(filterOwnerId, filterOutValidation, filterFontDecorations, overviewRulerOnly, versionId, onlyMarginDecorations); diff --git a/src/vs/editor/common/model/tokens/abstractSyntaxTokenBackend.ts b/src/vs/editor/common/model/tokens/abstractSyntaxTokenBackend.ts index 2daa1d88fc6..344b2334f19 100644 --- a/src/vs/editor/common/model/tokens/abstractSyntaxTokenBackend.ts +++ b/src/vs/editor/common/model/tokens/abstractSyntaxTokenBackend.ts @@ -6,7 +6,7 @@ import { equals } from '../../../../base/common/arrays.js'; import { RunOnceScheduler } from '../../../../base/common/async.js'; import { Emitter, Event } from '../../../../base/common/event.js'; -import { Disposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, IDisposable } from '../../../../base/common/lifecycle.js'; import { LineRange } from '../../core/ranges/lineRange.js'; import { StandardTokenType } from '../../encodedTokenAttributes.js'; import { ILanguageIdCodec } from '../../languages.js'; @@ -21,7 +21,7 @@ import { equalsIfDefinedC, thisEqualsC, arrayEqualsC } from '../../../../base/co /** * @internal */ -export class AttachedViews { +export class AttachedViews implements IDisposable { private readonly _onDidChangeVisibleRanges = new Emitter<{ view: IAttachedView; state: AttachedViewState | undefined }>(); public readonly onDidChangeVisibleRanges = this._onDidChangeVisibleRanges.event; @@ -57,6 +57,10 @@ export class AttachedViews { this._onDidChangeVisibleRanges.fire({ view, state: undefined }); this._viewsChanged.trigger(undefined); } + + public dispose(): void { + this._onDidChangeVisibleRanges.dispose(); + } } /** diff --git a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts index ef7e2d3bfb4..4ca5dea066c 100644 --- a/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts +++ b/src/vs/editor/common/model/tokens/tokenizationFontDecorationsProvider.ts @@ -25,10 +25,10 @@ export class TokenizationFontDecorationProvider extends Disposable implements De private static DECORATION_COUNT = 0; - private readonly _onDidChangeLineHeight = new Emitter>(); + private readonly _onDidChangeLineHeight = this._register(new Emitter>()); public readonly onDidChangeLineHeight = this._onDidChangeLineHeight.event; - private readonly _onDidChangeFont = new Emitter>(); + private readonly _onDidChangeFont = this._register(new Emitter>()); public readonly onDidChangeFont = this._onDidChangeFont.event; private _fontAnnotatedString: IAnnotatedString = new AnnotatedString(); diff --git a/src/vs/editor/common/textModelEvents.ts b/src/vs/editor/common/textModelEvents.ts index b25c00aae8a..4fa24afd2c1 100644 --- a/src/vs/editor/common/textModelEvents.ts +++ b/src/vs/editor/common/textModelEvents.ts @@ -308,9 +308,13 @@ export class LineInjectedText { export class ModelRawLineChanged { public readonly changeType = RawContentChangedType.LineChanged; /** - * The line that has changed. + * The line number that has changed (before the change was applied). */ public readonly lineNumber: number; + /** + * The new line number the old one is mapped to (after the change was applied). + */ + public readonly lineNumberPostEdit: number; /** * The new value of the line. */ @@ -320,8 +324,9 @@ export class ModelRawLineChanged { */ public readonly injectedText: LineInjectedText[] | null; - constructor(lineNumber: number, detail: string, injectedText: LineInjectedText[] | null) { + constructor(lineNumber: number, lineNumberPostEdit: number, detail: string, injectedText: LineInjectedText[] | null) { this.lineNumber = lineNumber; + this.lineNumberPostEdit = lineNumberPostEdit; this.detail = detail; this.injectedText = injectedText; } @@ -409,10 +414,26 @@ export class ModelRawLinesInserted { * Before what line did the insertion begin */ public readonly fromLineNumber: number; + /** + * The actual start line number in the updated buffer where the newly inserted content can be found. + */ + public readonly fromLineNumberPostEdit: number; + /** + * The count of inserted lines. + */ + public readonly count: number; /** * `toLineNumber` - `fromLineNumber` + 1 denotes the number of lines that were inserted */ - public readonly toLineNumber: number; + public get toLineNumber(): number { + return this.fromLineNumber + this.count - 1; + } + /** + * The actual end line number of the insertion in the updated buffer. + */ + public get toLineNumberPostEdit(): number { + return this.fromLineNumberPostEdit + this.count - 1; + } /** * The text that was inserted */ @@ -422,10 +443,11 @@ export class ModelRawLinesInserted { */ public readonly injectedTexts: (LineInjectedText[] | null)[]; - constructor(fromLineNumber: number, toLineNumber: number, detail: string[], injectedTexts: (LineInjectedText[] | null)[]) { + constructor(fromLineNumber: number, fromLineNumberPostEdit: number, count: number, detail: string[], injectedTexts: (LineInjectedText[] | null)[]) { this.injectedTexts = injectedTexts; this.fromLineNumber = fromLineNumber; - this.toLineNumber = toLineNumber; + this.fromLineNumberPostEdit = fromLineNumberPostEdit; + this.count = count; this.detail = detail; } } diff --git a/src/vs/editor/common/viewLayout/lineHeights.ts b/src/vs/editor/common/viewLayout/lineHeights.ts index 47e402e075b..bceebf4aad9 100644 --- a/src/vs/editor/common/viewLayout/lineHeights.ts +++ b/src/vs/editor/common/viewLayout/lineHeights.ts @@ -5,6 +5,10 @@ import { binarySearch2 } from '../../../base/common/arrays.js'; import { intersection } from '../../../base/common/collections.js'; +import { IEditorConfiguration } from '../config/editorConfiguration.js'; +import { EditorOption } from '../config/editorOptions.js'; +import { ICoordinatesConverter } from '../coordinatesConverter.js'; +import { IModelDecoration } from '../model.js'; export class CustomLine { @@ -62,7 +66,7 @@ export class LineHeightsManager { private _defaultLineHeight: number; private _hasPending: boolean = false; - constructor(defaultLineHeight: number, customLineHeightData: ICustomLineHeightData[]) { + constructor(defaultLineHeight: number, customLineHeightData: CustomLineHeightData[]) { this._defaultLineHeight = defaultLineHeight; if (customLineHeightData.length > 0) { for (const data of customLineHeightData) { @@ -227,7 +231,7 @@ export class LineHeightsManager { } } - public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { const insertCount = toLineNumber - fromLineNumber + 1; const candidateStartIndexOfInsertion = this._binarySearchOverOrderedCustomLinesArray(fromLineNumber); let startIndexOfInsertion: number; @@ -243,7 +247,22 @@ export class LineHeightsManager { } else { startIndexOfInsertion = -(candidateStartIndexOfInsertion + 1); } - const toReAdd: ICustomLineHeightData[] = []; + const maxLineHeightPerLine = new Map(); + for (const lineHeightAdded of lineHeightsAdded) { + for (let lineNumber = lineHeightAdded.startLineNumber; lineNumber <= lineHeightAdded.endLineNumber; lineNumber++) { + if (lineNumber >= fromLineNumber && lineNumber <= toLineNumber) { + const currentMax = maxLineHeightPerLine.get(lineNumber) ?? this._defaultLineHeight; + maxLineHeightPerLine.set(lineNumber, Math.max(currentMax, lineHeightAdded.lineHeight)); + } + } + this.insertOrChangeCustomLineHeight( + lineHeightAdded.decorationId, + lineHeightAdded.startLineNumber, + lineHeightAdded.endLineNumber, + lineHeightAdded.lineHeight + ); + } + const toReAdd: CustomLineHeightData[] = []; const decorationsImmediatelyAfter = new Set(); for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { if (this._orderedCustomLines[i].lineNumber === fromLineNumber) { @@ -257,9 +276,12 @@ export class LineHeightsManager { } } const decorationsWithGaps = intersection(decorationsImmediatelyBefore, decorationsImmediatelyAfter); + const specialHeightToAdd = Array.from(maxLineHeightPerLine.values()).reduce((acc, height) => acc + height, 0); + const defaultHeightToAdd = (insertCount - maxLineHeightPerLine.size) * this._defaultLineHeight; + const prefixSumToAdd = specialHeightToAdd + defaultHeightToAdd; for (let i = startIndexOfInsertion; i < this._orderedCustomLines.length; i++) { this._orderedCustomLines[i].lineNumber += insertCount; - this._orderedCustomLines[i].prefixSum += this._defaultLineHeight * insertCount; + this._orderedCustomLines[i].prefixSum += prefixSumToAdd; } if (decorationsWithGaps.size > 0) { @@ -281,8 +303,8 @@ export class LineHeightsManager { for (const dec of toReAdd) { this.insertOrChangeCustomLineHeight(dec.decorationId, dec.startLineNumber, dec.endLineNumber, dec.lineHeight); } - this.commit(); } + this.commit(); } public commit(): void { @@ -363,11 +385,27 @@ export class LineHeightsManager { } } -export interface ICustomLineHeightData { - readonly decorationId: string; - readonly startLineNumber: number; - readonly endLineNumber: number; - readonly lineHeight: number; +export class CustomLineHeightData { + + constructor( + readonly decorationId: string, + readonly startLineNumber: number, + readonly endLineNumber: number, + readonly lineHeight: number + ) { } + + public static fromDecorations(decorations: IModelDecoration[], coordinatesConverter: ICoordinatesConverter, configuration: IEditorConfiguration): CustomLineHeightData[] { + const defaultLineHeight = configuration.options.get(EditorOption.lineHeight); + return decorations.map((d) => { + const viewRange = coordinatesConverter.convertModelRangeToViewRange(d.range); + return new CustomLineHeightData( + d.id, + viewRange.startLineNumber, + viewRange.endLineNumber, + d.options.lineHeight ? d.options.lineHeight * defaultLineHeight : 0 + ); + }); + } } class ArrayMap { diff --git a/src/vs/editor/common/viewLayout/linesLayout.ts b/src/vs/editor/common/viewLayout/linesLayout.ts index b773e5dd4cb..376b0f2c7c9 100644 --- a/src/vs/editor/common/viewLayout/linesLayout.ts +++ b/src/vs/editor/common/viewLayout/linesLayout.ts @@ -5,7 +5,7 @@ import { IEditorWhitespace, IPartialViewLinesViewportData, ILineHeightChangeAccessor, IViewWhitespaceViewportData, IWhitespaceChangeAccessor } from '../viewModel.js'; import * as strings from '../../../base/common/strings.js'; -import { ICustomLineHeightData, LineHeightsManager } from './lineHeights.js'; +import { CustomLineHeightData, LineHeightsManager } from './lineHeights.js'; interface IPendingChange { id: string; newAfterLineNumber: number; newHeight: number } interface IPendingRemove { id: string } @@ -95,7 +95,7 @@ export class LinesLayout { private _paddingBottom: number; private _lineHeightsManager: LineHeightsManager; - constructor(lineCount: number, defaultLineHeight: number, paddingTop: number, paddingBottom: number, customLineHeightData: ICustomLineHeightData[]) { + constructor(lineCount: number, defaultLineHeight: number, paddingTop: number, paddingBottom: number, customLineHeightData: CustomLineHeightData[]) { this._instanceId = strings.singleLetterHash(++LinesLayout.INSTANCE_COUNT); this._pendingChanges = new PendingChanges(); this._lastWhitespaceId = 0; @@ -155,7 +155,7 @@ export class LinesLayout { * * @param lineCount New number of lines. */ - public onFlushed(lineCount: number, customLineHeightData: ICustomLineHeightData[]): void { + public onFlushed(lineCount: number, customLineHeightData: CustomLineHeightData[]): void { this._lineCount = lineCount; this._lineHeightsManager = new LineHeightsManager(this._lineHeightsManager.defaultLineHeight, customLineHeightData); } @@ -353,8 +353,9 @@ export class LinesLayout { * * @param fromLineNumber The line number at which the insertion started, inclusive * @param toLineNumber The line number at which the insertion ended, inclusive. + * @param lineHeightsAdded The custom line height data for the inserted lines. */ - public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { + public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { fromLineNumber = fromLineNumber | 0; toLineNumber = toLineNumber | 0; @@ -366,7 +367,7 @@ export class LinesLayout { this._arr[i].afterLineNumber += (toLineNumber - fromLineNumber + 1); } } - this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber); + this._lineHeightsManager.onLinesInserted(fromLineNumber, toLineNumber, lineHeightsAdded); } /** diff --git a/src/vs/editor/common/viewLayout/viewLayout.ts b/src/vs/editor/common/viewLayout/viewLayout.ts index 10130ea8dcb..048dc241eae 100644 --- a/src/vs/editor/common/viewLayout/viewLayout.ts +++ b/src/vs/editor/common/viewLayout/viewLayout.ts @@ -12,7 +12,7 @@ import { IEditorConfiguration } from '../config/editorConfiguration.js'; import { LinesLayout } from './linesLayout.js'; import { IEditorWhitespace, IPartialViewLinesViewportData, ILineHeightChangeAccessor, IViewLayout, IViewWhitespaceViewportData, IWhitespaceChangeAccessor, Viewport } from '../viewModel.js'; import { ContentSizeChangedEvent } from '../viewModelEventDispatcher.js'; -import { ICustomLineHeightData } from './lineHeights.js'; +import { CustomLineHeightData } from './lineHeights.js'; const SMOOTH_SCROLLING_TIME = 125; @@ -164,7 +164,7 @@ export class ViewLayout extends Disposable implements IViewLayout { public readonly onDidScroll: Event; public readonly onDidContentSizeChange: Event; - constructor(configuration: IEditorConfiguration, lineCount: number, customLineHeightData: ICustomLineHeightData[], scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { + constructor(configuration: IEditorConfiguration, lineCount: number, customLineHeightData: CustomLineHeightData[], scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable) { super(); this._configuration = configuration; @@ -237,14 +237,14 @@ export class ViewLayout extends Disposable implements IViewLayout { this._configureSmoothScrollDuration(); } } - public onFlushed(lineCount: number, customLineHeightData: ICustomLineHeightData[]): void { + public onFlushed(lineCount: number, customLineHeightData: CustomLineHeightData[]): void { this._linesLayout.onFlushed(lineCount, customLineHeightData); } public onLinesDeleted(fromLineNumber: number, toLineNumber: number): void { this._linesLayout.onLinesDeleted(fromLineNumber, toLineNumber); } - public onLinesInserted(fromLineNumber: number, toLineNumber: number): void { - this._linesLayout.onLinesInserted(fromLineNumber, toLineNumber); + public onLinesInserted(fromLineNumber: number, toLineNumber: number, lineHeightsAdded: CustomLineHeightData[]): void { + this._linesLayout.onLinesInserted(fromLineNumber, toLineNumber, lineHeightsAdded); } // ---- end view event handlers diff --git a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts index 7fe9093e983..0247519b550 100644 --- a/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts +++ b/src/vs/editor/common/viewModel/minimapTokensColorTracker.ts @@ -21,7 +21,7 @@ export class MinimapTokensColorTracker extends Disposable { private _colors!: RGBA8[]; private _backgroundIsLight!: boolean; - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); public readonly onDidChange: Event = this._onDidChange.event; private constructor() { diff --git a/src/vs/editor/common/viewModel/viewModelImpl.ts b/src/vs/editor/common/viewModel/viewModelImpl.ts index 954072212bc..f3f33283bfc 100644 --- a/src/vs/editor/common/viewModel/viewModelImpl.ts +++ b/src/vs/editor/common/viewModel/viewModelImpl.ts @@ -41,7 +41,7 @@ import { FocusChangedEvent, HiddenAreasChangedEvent, ModelContentChangedEvent, M import { IViewModelLines, ViewModelLinesFromModelAsIs, ViewModelLinesFromProjectedModel } from './viewModelLines.js'; import { IThemeService } from '../../../platform/theme/common/themeService.js'; import { GlyphMarginLanesModel } from './glyphLanesModel.js'; -import { ICustomLineHeightData } from '../viewLayout/lineHeights.js'; +import { CustomLineHeightData } from '../viewLayout/lineHeights.js'; import { TextModelEditSource } from '../textModelEditSource.js'; import { InlineDecoration } from './inlineDecorations.js'; import { ICoordinatesConverter } from '../coordinatesConverter.js'; @@ -196,23 +196,23 @@ export class ViewModel extends Disposable implements IViewModel { this._eventDispatcher.removeViewEventHandler(eventHandler); } - private _getCustomLineHeights(): ICustomLineHeightData[] { + private _getCustomLineHeights(): CustomLineHeightData[] { const allowVariableLineHeights = this._configuration.options.get(EditorOption.allowVariableLineHeights); if (!allowVariableLineHeights) { return []; } - const defaultLineHeight = this._configuration.options.get(EditorOption.lineHeight); const decorations = this.model.getCustomLineHeightsDecorations(this._editorId); - return decorations.map((d) => { - const lineNumber = d.range.startLineNumber; - const viewRange = this.coordinatesConverter.convertModelRangeToViewRange(new Range(lineNumber, 1, lineNumber, this.model.getLineMaxColumn(lineNumber))); - return { - decorationId: d.id, - startLineNumber: viewRange.startLineNumber, - endLineNumber: viewRange.endLineNumber, - lineHeight: d.options.lineHeight ? d.options.lineHeight * defaultLineHeight : 0 - }; - }); + return CustomLineHeightData.fromDecorations(decorations, this.coordinatesConverter, this._configuration); + } + + private _getCustomLineHeightsForLines(fromLineNumber: number, toLineNumber: number): CustomLineHeightData[] { + const allowVariableLineHeights = this._configuration.options.get(EditorOption.allowVariableLineHeights); + if (!allowVariableLineHeights) { + return []; + } + const modelRange = new Range(fromLineNumber, 1, toLineNumber, this.model.getLineMaxColumn(toLineNumber)); + const decorations = this.model.getCustomLineHeightsDecorationsInRange(modelRange, this._editorId); + return CustomLineHeightData.fromDecorations(decorations, this.coordinatesConverter, this._configuration); } private _updateConfigurationViewLineCountNow(): void { @@ -379,7 +379,7 @@ export class ViewModel extends Disposable implements IViewModel { const linesInsertedEvent = this._lines.onModelLinesInserted(versionId, change.fromLineNumber, change.toLineNumber, insertedLineBreaks); if (linesInsertedEvent !== null) { eventsCollector.emitViewEvent(linesInsertedEvent); - this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber); + this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber, this._getCustomLineHeightsForLines(change.fromLineNumberPostEdit, change.toLineNumberPostEdit)); } hadOtherModelChange = true; break; @@ -394,7 +394,7 @@ export class ViewModel extends Disposable implements IViewModel { } if (linesInsertedEvent) { eventsCollector.emitViewEvent(linesInsertedEvent); - this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber); + this.viewLayout.onLinesInserted(linesInsertedEvent.fromLineNumber, linesInsertedEvent.toLineNumber, this._getCustomLineHeightsForLines(change.lineNumberPostEdit, change.lineNumberPostEdit)); } if (linesDeletedEvent) { eventsCollector.emitViewEvent(linesDeletedEvent); diff --git a/src/vs/editor/contrib/codelens/browser/codelensController.ts b/src/vs/editor/contrib/codelens/browser/codelensController.ts index 3a3877a2664..861a14a9ad7 100644 --- a/src/vs/editor/contrib/codelens/browser/codelensController.ts +++ b/src/vs/editor/contrib/codelens/browser/codelensController.ts @@ -76,6 +76,7 @@ export class CodeLensContribution implements IEditorContribution { this._localDispose(); this._localToDispose.dispose(); this._disposables.dispose(); + this._resolveCodeLensesScheduler.dispose(); this._oldCodeLensModels.dispose(); this._currentCodeLensModel?.dispose(); } diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerSaturationBox.ts b/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerSaturationBox.ts index 28cb4f4f869..9f58f0531d5 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerSaturationBox.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerSaturationBox.ts @@ -21,10 +21,10 @@ export class SaturationBox extends Disposable { private height!: number; private monitor: GlobalPointerMoveMonitor | null; - private readonly _onDidChange = new Emitter<{ s: number; v: number }>(); + private readonly _onDidChange = this._register(new Emitter<{ s: number; v: number }>()); readonly onDidChange: Event<{ s: number; v: number }> = this._onDidChange.event; - private readonly _onColorFlushed = new Emitter(); + private readonly _onColorFlushed = this._register(new Emitter()); readonly onColorFlushed: Event = this._onColorFlushed.event; constructor(container: HTMLElement, private readonly model: ColorPickerModel, private pixelRatio: number) { diff --git a/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerStrip.ts b/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerStrip.ts index 5702c8e4bcd..19bb2acc203 100644 --- a/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerStrip.ts +++ b/src/vs/editor/contrib/colorPicker/browser/colorPickerParts/colorPickerStrip.ts @@ -20,10 +20,10 @@ export abstract class Strip extends Disposable { protected slider: HTMLElement; private height!: number; - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; - private readonly _onColorFlushed = new Emitter(); + private readonly _onColorFlushed = this._register(new Emitter()); readonly onColorFlushed: Event = this._onColorFlushed.event; constructor(container: HTMLElement, protected model: ColorPickerModel, type: ColorPickerWidgetType) { diff --git a/src/vs/editor/contrib/find/browser/findController.ts b/src/vs/editor/contrib/find/browser/findController.ts index 6459340af24..ec2ee490e19 100644 --- a/src/vs/editor/contrib/find/browser/findController.ts +++ b/src/vs/editor/contrib/find/browser/findController.ts @@ -131,7 +131,7 @@ export class CommonFindController extends Disposable implements IEditorContribut this._notificationService = notificationService; this._hoverService = hoverService; - this._updateHistoryDelayer = new Delayer(500); + this._updateHistoryDelayer = this._register(new Delayer(500)); this._state = this._register(new FindReplaceState()); this.loadQueryState(); this._register(this._state.onFindReplaceStateChange((e) => this._onStateChanged(e))); diff --git a/src/vs/editor/contrib/folding/browser/foldingModel.ts b/src/vs/editor/contrib/folding/browser/foldingModel.ts index c7721163695..8e07ac5fe5e 100644 --- a/src/vs/editor/contrib/folding/browser/foldingModel.ts +++ b/src/vs/editor/contrib/folding/browser/foldingModel.ts @@ -8,6 +8,7 @@ import { IModelDecorationOptions, IModelDecorationsChangeAccessor, IModelDeltaDe import { FoldingRegion, FoldingRegions, ILineRange, FoldRange, FoldSource } from './foldingRanges.js'; import { hash } from '../../../../base/common/hash.js'; import { SelectedLines } from './folding.js'; +import { IDisposable } from '../../../../base/common/lifecycle.js'; export interface IDecorationProvider { getDecorationOption(isCollapsed: boolean, isHidden: boolean, isManual: boolean): IModelDecorationOptions; @@ -28,7 +29,7 @@ interface ILineMemento extends ILineRange { export type CollapseMemento = ILineMemento[]; -export class FoldingModel { +export class FoldingModel implements IDisposable { private readonly _textModel: ITextModel; private readonly _decorationProvider: IDecorationProvider; @@ -229,6 +230,7 @@ export class FoldingModel { public dispose() { this._decorationProvider.removeDecorations(this._editorDecorationIds); + this._updateEventEmitter.dispose(); } getAllRegionsAtLine(lineNumber: number, filter?: (r: FoldingRegion, level: number) => boolean): FoldingRegion[] { diff --git a/src/vs/editor/contrib/folding/browser/hiddenRangeModel.ts b/src/vs/editor/contrib/folding/browser/hiddenRangeModel.ts index f7927e0877b..1530d3405f3 100644 --- a/src/vs/editor/contrib/folding/browser/hiddenRangeModel.ts +++ b/src/vs/editor/contrib/folding/browser/hiddenRangeModel.ts @@ -13,7 +13,7 @@ import { IModelContentChangedEvent } from '../../../common/textModelEvents.js'; import { countEOL } from '../../../common/core/misc/eolCounter.js'; import { FoldingModel } from './foldingModel.js'; -export class HiddenRangeModel { +export class HiddenRangeModel implements IDisposable { private readonly _foldingModel: FoldingModel; private _hiddenRanges: IRange[]; @@ -134,6 +134,7 @@ export class HiddenRangeModel { this._foldingModelListener.dispose(); this._foldingModelListener = null; } + this._updateEventEmitter.dispose(); } } diff --git a/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts b/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts index b8394a1e72c..1fad47c247c 100644 --- a/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts +++ b/src/vs/editor/contrib/gotoError/browser/gotoErrorWidget.ts @@ -294,6 +294,7 @@ export class MarkerNavigationWidget extends PeekViewWidget { override dispose(): void { this._callOnDispose.dispose(); + this._onDidSelectRelatedInformation.dispose(); super.dispose(); } diff --git a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts index f7330a59080..c6b40951477 100644 --- a/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts +++ b/src/vs/editor/contrib/gotoSymbol/browser/peek/referencesWidget.ts @@ -248,7 +248,7 @@ export class ReferenceWidget extends peekView.PeekViewWidget { private readonly _disposeOnNewModel = new DisposableStore(); private readonly _callOnDispose = new DisposableStore(); - private readonly _onDidSelectReference = new Emitter(); + private readonly _onDidSelectReference = this._callOnDispose.add(new Emitter()); readonly onDidSelectReference = this._onDidSelectReference.event; private _tree!: ReferencesTree; diff --git a/src/vs/editor/contrib/hover/browser/hoverUtils.ts b/src/vs/editor/contrib/hover/browser/hoverUtils.ts index 997d4512c1a..1dc56a043b6 100644 --- a/src/vs/editor/contrib/hover/browser/hoverUtils.ts +++ b/src/vs/editor/contrib/hover/browser/hoverUtils.ts @@ -6,12 +6,16 @@ import * as dom from '../../../../base/browser/dom.js'; import { IEditorMouseEvent } from '../../../browser/editorBrowser.js'; +const enum PADDING { + VALUE = 3 +} + export function isMousePositionWithinElement(element: HTMLElement, posx: number, posy: number): boolean { const elementRect = dom.getDomNodePagePosition(element); - if (posx < elementRect.left - || posx > elementRect.left + elementRect.width - || posy < elementRect.top - || posy > elementRect.top + elementRect.height) { + if (posx < elementRect.left + PADDING.VALUE + || posx > elementRect.left + elementRect.width - PADDING.VALUE + || posy < elementRect.top + PADDING.VALUE + || posy > elementRect.top + elementRect.height - PADDING.VALUE) { return false; } return true; diff --git a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts index e289a3dbca0..ba261eaa4a4 100644 --- a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts @@ -36,8 +36,8 @@ import { HoverStartSource } from './hoverOperation.js'; import { ScrollEvent } from '../../../../base/common/scrollable.js'; const $ = dom.$; -const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.add, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); -const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.remove, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); +const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.addSmall, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); +const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.removeSmall, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); export class MarkdownHover implements IHoverPart { diff --git a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts index e40987aeefe..593eb58d304 100644 --- a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts +++ b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts @@ -114,10 +114,10 @@ suite('Hover Utils', () => { test('returns true when mouse is on element edges', () => { const element = createMockElement(100, 100, 200, 100); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); // top-left corner - assert.strictEqual(isMousePositionWithinElement(element, 300, 100), true); // top-right corner - assert.strictEqual(isMousePositionWithinElement(element, 100, 200), true); // bottom-left corner - assert.strictEqual(isMousePositionWithinElement(element, 300, 200), true); // bottom-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); // top-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 100), false); // top-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 200), false); // bottom-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 200), false); // bottom-right corner }); test('returns false when mouse is left of element', () => { @@ -146,16 +146,16 @@ suite('Hover Utils', () => { test('handles element at origin (0,0)', () => { const element = createMockElement(0, 0, 100, 100); - assert.strictEqual(isMousePositionWithinElement(element, 0, 0), true); + assert.strictEqual(isMousePositionWithinElement(element, 0, 0), false); assert.strictEqual(isMousePositionWithinElement(element, 50, 50), true); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); }); test('handles small elements (1x1)', () => { const element = createMockElement(100, 100, 1, 1); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); - assert.strictEqual(isMousePositionWithinElement(element, 101, 101), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); assert.strictEqual(isMousePositionWithinElement(element, 102, 102), false); }); }); diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index dc04e89e4cf..aab6cd72985 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -82,7 +82,7 @@ export class InlineCompletionsModel extends Disposable { }); public get isAcceptingPartially() { return this._isAcceptingPartially; } - private readonly _onDidAccept = new Emitter(); + private readonly _onDidAccept = this._register(new Emitter()); public readonly onDidAccept = this._onDidAccept.event; private readonly _editorObs; diff --git a/src/vs/editor/contrib/parameterHints/browser/parameterHintsModel.ts b/src/vs/editor/contrib/parameterHints/browser/parameterHintsModel.ts index 0c07303dc17..c7bf621e8c2 100644 --- a/src/vs/editor/contrib/parameterHints/browser/parameterHintsModel.ts +++ b/src/vs/editor/contrib/parameterHints/browser/parameterHintsModel.ts @@ -78,7 +78,7 @@ export class ParameterHintsModel extends Disposable { this.editor = editor; this.providers = providers; - this.throttledDelayer = new Delayer(delay); + this.throttledDelayer = this._register(new Delayer(delay)); this._register(this.editor.onDidBlurEditorWidget(() => this.cancel())); this._register(this.editor.onDidChangeConfiguration(() => this.onEditorConfigurationChange())); diff --git a/src/vs/editor/contrib/peekView/browser/peekView.ts b/src/vs/editor/contrib/peekView/browser/peekView.ts index fe9cffb401e..c578441baa9 100644 --- a/src/vs/editor/contrib/peekView/browser/peekView.ts +++ b/src/vs/editor/contrib/peekView/browser/peekView.ts @@ -128,6 +128,7 @@ export abstract class PeekViewWidget extends ZoneWidget { this.disposed = true; // prevent consumers who dispose on onDidClose from looping super.dispose(); this._onDidClose.fire(this); + this._onDidClose.dispose(); const e = observableCodeEditor(this.editor); e.openedPeekWidgets.set(e.openedPeekWidgets.get() - 1, undefined); diff --git a/src/vs/editor/contrib/rename/browser/renameWidget.ts b/src/vs/editor/contrib/rename/browser/renameWidget.ts index 5ec01001456..b7e32811605 100644 --- a/src/vs/editor/contrib/rename/browser/renameWidget.ts +++ b/src/vs/editor/contrib/rename/browser/renameWidget.ts @@ -905,11 +905,11 @@ class InputWithButton implements IDisposable { private _sparkleIcon: HTMLElement | undefined; private _stopIcon: HTMLElement | undefined; - private readonly _onDidInputChange = new Emitter(); - public readonly onDidInputChange = this._onDidInputChange.event; - private readonly _disposables = new DisposableStore(); + private readonly _onDidInputChange = this._disposables.add(new Emitter()); + public readonly onDidInputChange = this._onDidInputChange.event; + get domNode() { if (!this._domNode) { diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts index 2c8b1c55fca..e9980435903 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollController.ts @@ -76,6 +76,7 @@ export class StickyScrollController extends Disposable implements IEditorContrib private _minRebuildFromLine: number | undefined; private _mouseTarget: EventTarget | null = null; private _cursorPositionListener: IDisposable | undefined; + private _positionLineNumber: number | undefined; private readonly _onDidChangeStickyScrollHeight = this._register(new Emitter<{ height: number }>()); public readonly onDidChangeStickyScrollHeight = this._onDidChangeStickyScrollHeight.event; @@ -478,7 +479,11 @@ export class StickyScrollController extends Disposable implements IEditorContrib const lineNumberOption = this._editor.getOption(EditorOption.lineNumbers); if (lineNumberOption.renderType === RenderLineNumbersType.Relative) { if (!this._cursorPositionListener) { - this._cursorPositionListener = this._editor.onDidChangeCursorPosition(() => { + this._cursorPositionListener = this._editor.onDidChangeCursorPosition((e) => { + if (this._positionLineNumber === e.position.lineNumber) { + return; + } + this._positionLineNumber = e.position.lineNumber; this._showEndForLine = undefined; this._renderStickyScroll(0); }); diff --git a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts index 38947cfee8d..2a7c1cf5b1b 100644 --- a/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts +++ b/src/vs/editor/contrib/stickyScroll/browser/stickyScrollWidget.ts @@ -503,11 +503,11 @@ class RenderedStickyLine { innerLineNumberHTML.style.paddingLeft = `${layoutInfo.lineNumbersLeft}px`; lineNumberHTMLNode.appendChild(innerLineNumberHTML); - const foldingIcon = this._renderFoldingIconForLine(editor, foldingModel, lineNumber, lineHeight, isOnGlyphMargin); - if (foldingIcon) { - lineNumberHTMLNode.appendChild(foldingIcon.domNode); - foldingIcon.domNode.style.left = `${layoutInfo.lineNumbersWidth + layoutInfo.lineNumbersLeft}px`; - foldingIcon.domNode.style.lineHeight = `${lineHeight}px`; + this.foldingIcon = this._renderFoldingIconForLine(editor, foldingModel, lineNumber, lineHeight, isOnGlyphMargin); + if (this.foldingIcon) { + lineNumberHTMLNode.appendChild(this.foldingIcon.domNode); + this.foldingIcon.domNode.style.left = `${layoutInfo.lineNumbersWidth + layoutInfo.lineNumbersLeft}px`; + this.foldingIcon.domNode.style.lineHeight = `${lineHeight}px`; } editor.applyFontInfo(lineHTMLNode); diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index fcf97af35a6..30c1276d5c4 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -415,7 +415,10 @@ export class SuggestModel implements IDisposable { const lineTokens = model.tokenization.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { - return; + if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' + || (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) { + return; + } } } diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts index 8e94fe1ef61..af10b515098 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidget.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidget.ts @@ -319,6 +319,11 @@ export class SuggestWidget implements IDisposable { this._showTimeout.dispose(); this._contentWidget.dispose(); this.element.dispose(); + this._onDidSelect.dispose(); + this._onDidFocus.dispose(); + this._onDidHide.dispose(); + this._onDidShow.dispose(); + this._onDetailsKeydown.dispose(); } private _onEditorMouseDown(mouseEvent: IEditorMouseEvent): void { diff --git a/src/vs/editor/contrib/suggest/browser/suggestWidgetDetails.ts b/src/vs/editor/contrib/suggest/browser/suggestWidgetDetails.ts index c13978ec43f..9d42306730d 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestWidgetDetails.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestWidgetDetails.ts @@ -82,6 +82,8 @@ export class SuggestDetailsWidget { dispose(): void { this._disposables.dispose(); this._renderDisposeable.dispose(); + this._onDidClose.dispose(); + this._onDidChangeContents.dispose(); } private _configureFont(): void { diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index dccb55ef441..ff465706c0c 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -15,7 +15,7 @@ import { Selection } from '../../../../common/core/selection.js'; import { Handler } from '../../../../common/editorCommon.js'; import { ITextModel } from '../../../../common/model.js'; import { TextModel } from '../../../../common/model/textModel.js'; -import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, IState, TokenizationRegistry } from '../../../../common/languages.js'; +import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, InlineCompletionsProvider, IState, TokenizationRegistry } from '../../../../common/languages.js'; import { MetadataConsts } from '../../../../common/encodedTokenAttributes.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { NullState } from '../../../../common/languages/nullTokenize.js'; @@ -41,6 +41,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browser/suggest.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1228,4 +1229,142 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); + + test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); + + return new Promise((resolve, reject) => { + const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => { + unexpectedSuggestSub.dispose(); + reject(new Error('Quick suggestions should not have been triggered')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + // Wait for the quick suggest delay to pass without triggering + setTimeout(() => { + unexpectedSuggestSub.dispose(); + resolve(); + }, 200); + }); + }); + }); + + test('offWhenInlineCompletions - allows quick suggest when no inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // No inline completions provider registered for 'test' scheme + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); + + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); + }); + }); + + test('offWhenInlineCompletions - allows quick suggest when inlineSuggest is disabled', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + inlineSuggest: { enabled: false } + }); + + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); + }); + }); + }); + + test('string shorthand - "off" disables quick suggestions for all token types', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + return withOracle((suggestOracle, editor) => { + // Use string shorthand instead of object form + editor.updateOptions({ quickSuggestions: 'off' }); + + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by string shorthand "off"')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); + }); + }); + }); + }); + + test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + // Use string shorthand — applies to all token types + editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); + + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); + }); + }); + }); + }); }); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index d56f1c23bea..7fda56bbc64 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -721,11 +721,11 @@ export class StandaloneConfigurationService implements IConfigurationService { } } -class StandaloneResourceConfigurationService implements ITextResourceConfigurationService { +class StandaloneResourceConfigurationService extends Disposable implements ITextResourceConfigurationService { declare readonly _serviceBrand: undefined; - private readonly _onDidChangeConfiguration = new Emitter(); + private readonly _onDidChangeConfiguration = this._register(new Emitter()); public readonly onDidChangeConfiguration = this._onDidChangeConfiguration.event; constructor( @@ -733,9 +733,10 @@ class StandaloneResourceConfigurationService implements ITextResourceConfigurati @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService ) { - this.configurationService.onDidChangeConfiguration((e) => { + super(); + this._register(this.configurationService.onDidChangeConfiguration((e) => { this._onDidChangeConfiguration.fire({ affectedKeys: e.affectedKeys, affectsConfiguration: (resource: URI, configuration: string) => e.affectsConfiguration(configuration) }); - }); + })); } getValue(resource: URI, section?: string): T; diff --git a/src/vs/editor/test/browser/view/viewController.test.ts b/src/vs/editor/test/browser/view/viewController.test.ts index a3b26791ce8..95d44801cb7 100644 --- a/src/vs/editor/test/browser/view/viewController.test.ts +++ b/src/vs/editor/test/browser/view/viewController.test.ts @@ -12,8 +12,11 @@ import { NavigationCommandRevealType } from '../../../browser/coreCommands.js'; import { ViewController } from '../../../browser/view/viewController.js'; import { ViewUserInputEvents } from '../../../browser/view/viewUserInputEvents.js'; import { Position } from '../../../common/core/position.js'; +import { MetadataConsts, StandardTokenType } from '../../../common/encodedTokenAttributes.js'; +import { EncodedTokenizationResult, ITokenizationSupport, TokenizationRegistry } from '../../../common/languages.js'; import { ILanguageService } from '../../../common/languages/language.js'; import { ILanguageConfigurationService } from '../../../common/languages/languageConfigurationRegistry.js'; +import { NullState } from '../../../common/languages/nullTokenize.js'; import { MonospaceLineBreaksComputerFactory } from '../../../common/viewModel/monospaceLineBreaksComputer.js'; import { ViewModel } from '../../../common/viewModel/viewModelImpl.js'; import { instantiateTextModel } from '../../../test/common/testTextModel.js'; @@ -145,3 +148,230 @@ suite('ViewController - Bracket content selection', () => { testBracketSelection('var x = {};', new Position(1, 10), ''); }); }); + +interface TokenSpan { + startIndex: number; + type: StandardTokenType; +} + +suite('ViewController - String content selection', () => { + let disposables: DisposableStore; + let instantiationService: TestInstantiationService; + let languageConfigurationService: ILanguageConfigurationService; + let languageService: ILanguageService; + let viewModel: ViewModel | undefined; + + setup(() => { + disposables = new DisposableStore(); + instantiationService = createCodeEditorServices(disposables); + languageConfigurationService = instantiationService.get(ILanguageConfigurationService); + languageService = instantiationService.get(ILanguageService); + viewModel = undefined; + }); + + teardown(() => { + viewModel?.dispose(); + viewModel = undefined; + disposables.dispose(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function createViewControllerWithTokens(text: string, lineTokens: TokenSpan[]): ViewController { + const languageId = 'stringTestMode'; + disposables.add(languageService.registerLanguage({ id: languageId })); + disposables.add(languageConfigurationService.register(languageId, { + brackets: [ + ['{', '}'], + ['[', ']'], + ['(', ')'], + ] + })); + + const encodedLanguageId = languageService.languageIdCodec.encodeLanguageId(languageId); + const makeMetadata = (type: StandardTokenType) => ( + (encodedLanguageId << MetadataConsts.LANGUAGEID_OFFSET) + | (type << MetadataConsts.TOKEN_TYPE_OFFSET) + ) >>> 0; + + const tokenizationSupport: ITokenizationSupport = { + getInitialState: () => NullState, + tokenize: undefined!, + tokenizeEncoded: (_line, _hasEOL, state) => { + const arr = new Uint32Array(lineTokens.length * 2); + for (let i = 0; i < lineTokens.length; i++) { + arr[i * 2] = lineTokens[i].startIndex; + arr[i * 2 + 1] = makeMetadata(lineTokens[i].type); + } + return new EncodedTokenizationResult(arr, [], state); + } + }; + + disposables.add(TokenizationRegistry.register(languageId, tokenizationSupport)); + + const configuration = disposables.add(new TestConfiguration({})); + const monospaceLineBreaksComputerFactory = MonospaceLineBreaksComputerFactory.create(configuration.options); + const model = disposables.add(instantiateTextModel(instantiationService, text, languageId)); + + model.tokenization.forceTokenization(1); + + viewModel = new ViewModel( + 1, + configuration, + model, + monospaceLineBreaksComputerFactory, + monospaceLineBreaksComputerFactory, + null!, + disposables.add(new TestLanguageConfigurationService()), + new TestThemeService(), + { setVisibleLines() { } }, + { batchChanges: (cb: any) => cb() } + ); + + return new ViewController( + configuration, + viewModel, + new ViewUserInputEvents(viewModel.coordinatesConverter), + { + paste: () => { }, + type: () => { }, + compositionType: () => { }, + startComposition: () => { }, + endComposition: () => { }, + cut: () => { } + } + ); + } + + function doubleClickAt(controller: ViewController, position: Position): string { + controller.dispatchMouse({ + position, + mouseColumn: position.column, + startedOnLineNumbers: false, + revealType: NavigationCommandRevealType.Minimal, + mouseDownCount: 2, + inSelectionMode: false, + altKey: false, + ctrlKey: false, + metaKey: false, + shiftKey: false, + leftButton: true, + middleButton: false, + onInjectedText: false + }); + const selections = viewModel!.getSelections(); + return viewModel!.model.getValueInRange(selections[0]); + } + + // -- Happy-path: whole string as a single token including quotes -- + + test('Select string content clicking right after opening double quote', () => { + // 0123456789... + const text = 'var x = "hello";'; + // Token layout: [0..8) Other [8..15) String("hello") [15..16) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + // Column right after opening quote: offset 9 → column 10 + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + test('Select string content clicking at closing double quote', () => { + const text = 'var x = "hello";'; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + // Column at closing quote: offset 14 → column 15 + assert.strictEqual(doubleClickAt(controller, new Position(1, 15)), 'hello'); + }); + + test('Select string content with single quotes', () => { + const text = `var x = 'hello';`; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + test('Select string content with backtick quotes', () => { + const text = 'var x = `hello`;'; + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 15, type: StandardTokenType.Other }, + ]); + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + // -- Click in middle of string should NOT select the whole string -- + + test('Click in middle of string does not select whole string', () => { + // 0123456789012345678901 + const text = 'var x = "hello world";'; + // Token layout: [0..8) Other [8..21) String("hello world") [21..22) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 21, type: StandardTokenType.Other }, + ]); + // Click on 'w' in "world" — word select should pick 'world', not 'hello world' + assert.strictEqual(doubleClickAt(controller, new Position(1, 16)), 'world'); + }); + + // -- Bail-out: quotes as separate tokens (theme issue #292784) -- + + test('Separate quote tokens fall back to word select', () => { + // 0 1 2 + // 0123456789012345678901234 + const text = 'var x = "hello world";'; + // Theme tokenizes quotes as separate Other tokens: + // [0..8) Other [8..9) Other(") [9..20) String(hello world) [20..21) Other(") [21..22) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.Other }, // opening " + { startIndex: 9, type: StandardTokenType.String }, // hello world + { startIndex: 20, type: StandardTokenType.Other }, // closing " + { startIndex: 21, type: StandardTokenType.Other }, + ]); + // The String token "hello world" doesn't start with a quote char → should bail out. + // Click right after opening quote (column 10) → word select picks just 'hello'. + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'hello'); + }); + + // -- Bail-out: RTL content in string (#293384) -- + + test('RTL content in string falls back to word select', () => { + const text = 'var x = "שלום עולם";'; + // Token layout: [0..8) Other [8..19) String("שלום עולם") [19..20) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, + { startIndex: 19, type: StandardTokenType.Other }, + ]); + // Should bail out due to RTL content → word select picks first word + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'שלום'); + }); + + // -- Bail-out: mismatched quotes (#293203 — string split at braces) -- + + test('String token without matching closing quote falls back to word select', () => { + // 0123456789012345 + const text = 'var x = "a {} b";'; + // Hypothetical tokenizer splits: [0..8) Other [8..11) String("a ) [11..13) Other({}) [13..17) String( b") [17..18) Other + const controller = createViewControllerWithTokens(text, [ + { startIndex: 0, type: StandardTokenType.Other }, + { startIndex: 8, type: StandardTokenType.String }, // `"a ` — starts with " but doesn't end with " + { startIndex: 11, type: StandardTokenType.Other }, // `{}` + { startIndex: 13, type: StandardTokenType.String }, // ` b"` — ends with " but doesn't start with " + { startIndex: 16, type: StandardTokenType.Other }, + ]); + // First String token starts with " but ends with space → bail out → word select picks 'a' + assert.strictEqual(doubleClickAt(controller, new Position(1, 10)), 'a'); + }); +}); diff --git a/src/vs/editor/test/common/model/model.test.ts b/src/vs/editor/test/common/model/model.test.ts index 9113fbdff2d..72140c26360 100644 --- a/src/vs/editor/test/common/model/model.test.ts +++ b/src/vs/editor/test/common/model/model.test.ts @@ -130,7 +130,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 'foo My First Line', null) + new ModelRawLineChanged(1, 1, 'foo My First Line', null) ], 2, false, @@ -144,8 +144,8 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 'My new line', null), - new ModelRawLinesInserted(2, 2, ['No longer First Line'], [null]), + new ModelRawLineChanged(1, 1, 'My new line', null), + new ModelRawLinesInserted(2, 2, 1, ['No longer First Line'], [null]), ], 2, false, @@ -216,7 +216,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 'y First Line', null), + new ModelRawLineChanged(1, 1, 'y First Line', null), ], 2, false, @@ -230,7 +230,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, '', null), + new ModelRawLineChanged(1, 1, '', null), ], 2, false, @@ -244,7 +244,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 'My Second Line', null), + new ModelRawLineChanged(1, 1, 'My Second Line', null), new ModelRawLinesDeleted(2, 2), ], 2, @@ -259,7 +259,7 @@ suite('Editor Model - Model', () => { }); assert.deepStrictEqual(e, new ModelRawContentChangedEvent( [ - new ModelRawLineChanged(1, 'My Third Line', null), + new ModelRawLineChanged(1, 1, 'My Third Line', null), new ModelRawLinesDeleted(2, 3), ], 2, diff --git a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts index 19153646275..c94726913ba 100644 --- a/src/vs/editor/test/common/viewLayout/lineHeights.test.ts +++ b/src/vs/editor/test/common/viewLayout/lineHeights.test.ts @@ -195,7 +195,7 @@ suite('Editor ViewLayout - LineHeightsManager', () => { manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); manager.commit(); - manager.onLinesInserted(3, 4); // Insert 2 lines at line 3 + manager.onLinesInserted(3, 4, []); // Insert 2 lines at line 3 assert.strictEqual(manager.heightForLineNumber(5), 10); assert.strictEqual(manager.heightForLineNumber(6), 10); @@ -209,7 +209,7 @@ suite('Editor ViewLayout - LineHeightsManager', () => { manager.insertOrChangeCustomLineHeight('dec1', 5, 7, 20); manager.commit(); - manager.onLinesInserted(6, 7); // Insert 2 lines at line 6 + manager.onLinesInserted(6, 7, []); // Insert 2 lines at line 6 assert.strictEqual(manager.heightForLineNumber(5), 20); assert.strictEqual(manager.heightForLineNumber(6), 20); diff --git a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts index 7bf20a78d84..ceb624ac274 100644 --- a/src/vs/editor/test/common/viewLayout/linesLayout.test.ts +++ b/src/vs/editor/test/common/viewLayout/linesLayout.test.ts @@ -208,7 +208,7 @@ suite('Editor ViewLayout - LinesLayout', () => { // Insert two lines at the beginning // 10 lines // whitespace: - a(6,10) - linesLayout.onLinesInserted(1, 2); + linesLayout.onLinesInserted(1, 2, []); assert.strictEqual(linesLayout.getLinesTotalHeight(), 20); assert.strictEqual(linesLayout.getVerticalOffsetForLineNumber(1), 0); assert.strictEqual(linesLayout.getVerticalOffsetForLineNumber(2), 1); @@ -909,7 +909,7 @@ suite('Editor ViewLayout - LinesLayout', () => { assert.strictEqual(linesLayout.getWhitespaceAccumulatedHeightBeforeLineNumber(5), 50); // Insert a line before line 1 - linesLayout.onLinesInserted(1, 1); + linesLayout.onLinesInserted(1, 1, []); // whitespaces: d(3, 30), c(4, 20) assert.strictEqual(linesLayout.getWhitespacesCount(), 2); assert.strictEqual(linesLayout.getAfterLineNumberForWhitespaceIndex(0), 3); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 22641b4e01a..80497d1a193 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -2322,6 +2322,12 @@ declare namespace monaco.editor { * @param ownerId If set, it will ignore decorations belonging to other owners. */ getCustomLineHeightsDecorations(ownerId?: number): IModelDecoration[]; + /** + * Gets all the decorations that contain custom line heights. + * @param range The range to search in + * @param ownerId If set, it will ignore decorations belonging to other owners. + */ + getCustomLineHeightsDecorationsInRange(range: Range, ownerId?: number): IModelDecoration[]; /** * Normalize a string containing whitespace according to indentation rules (converts to spaces or to tabs). */ @@ -3645,7 +3651,7 @@ declare namespace monaco.editor { * Enable quick suggestions (shadow suggestions) * Defaults to true. */ - quickSuggestions?: boolean | IQuickSuggestionsOptions; + quickSuggestions?: boolean | QuickSuggestionsValue | IQuickSuggestionsOptions; /** * Quick suggestions show delay (in ms) * Defaults to 10 (ms) @@ -4587,7 +4593,7 @@ declare namespace monaco.editor { cycle?: boolean; } - export type QuickSuggestionsValue = 'on' | 'inline' | 'off'; + export type QuickSuggestionsValue = 'on' | 'inline' | 'off' | 'offWhenInlineCompletions'; /** * Configuration options for quick suggestions @@ -5374,7 +5380,7 @@ declare namespace monaco.editor { showUnused: IEditorOption; showDeprecated: IEditorOption; inlayHints: IEditorOption>>; - snippetSuggestions: IEditorOption; + snippetSuggestions: IEditorOption; smartSelect: IEditorOption>>; smoothScrolling: IEditorOption; stopRenderingLineAfter: IEditorOption; diff --git a/src/vs/platform/accessibility/browser/accessibilityService.ts b/src/vs/platform/accessibility/browser/accessibilityService.ts index d61976dcefc..d6db2a65b33 100644 --- a/src/vs/platform/accessibility/browser/accessibilityService.ts +++ b/src/vs/platform/accessibility/browser/accessibilityService.ts @@ -18,14 +18,14 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe private _accessibilityModeEnabledContext: IContextKey; protected _accessibilitySupport = AccessibilitySupport.Unknown; - protected readonly _onDidChangeScreenReaderOptimized = new Emitter(); + protected readonly _onDidChangeScreenReaderOptimized = this._register(new Emitter()); protected _configMotionReduced: 'auto' | 'on' | 'off'; protected _systemMotionReduced: boolean; - protected readonly _onDidChangeReducedMotion = new Emitter(); + protected readonly _onDidChangeReducedMotion = this._register(new Emitter()); private _linkUnderlinesEnabled: boolean; - protected readonly _onDidChangeLinkUnderline = new Emitter(); + protected readonly _onDidChangeLinkUnderline = this._register(new Emitter()); constructor( @IContextKeyService private readonly _contextKeyService: IContextKeyService, diff --git a/src/vs/platform/actionWidget/browser/actionList.ts b/src/vs/platform/actionWidget/browser/actionList.ts index 3c12b59418e..63e7b67818a 100644 --- a/src/vs/platform/actionWidget/browser/actionList.ts +++ b/src/vs/platform/actionWidget/browser/actionList.ts @@ -411,10 +411,20 @@ export class ActionList extends Disposable { focusPrevious() { this._list.focusPrevious(1, true, undefined, this.focusCondition); + const focused = this._list.getFocus(); + if (focused.length > 0) { + this._list.reveal(focused[0]); + } + this._list.domFocus(); } focusNext() { this._list.focusNext(1, true, undefined, this.focusCondition); + const focused = this._list.getFocus(); + if (focused.length > 0) { + this._list.reveal(focused[0]); + } + this._list.domFocus(); } acceptSelected(preview?: boolean) { @@ -447,6 +457,7 @@ export class ActionList extends Disposable { } private onFocus() { + this._list.domFocus(); const focused = this._list.getFocus(); if (focused.length === 0) { return; diff --git a/src/vs/platform/actions/browser/floatingMenu.ts b/src/vs/platform/actions/browser/floatingMenu.ts index ef02d991bdc..3c101ed9140 100644 --- a/src/vs/platform/actions/browser/floatingMenu.ts +++ b/src/vs/platform/actions/browser/floatingMenu.ts @@ -48,7 +48,7 @@ export class FloatingClickWidget extends Widget { } export abstract class AbstractFloatingClickMenu extends Disposable { - private readonly renderEmitter = new Emitter(); + private readonly renderEmitter = this._register(new Emitter()); protected get onDidRender() { return this.renderEmitter.event; } private readonly menu: IMenu; diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 7c470fb6cca..a81fe449d62 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -273,6 +273,7 @@ export class MenuId { static readonly ChatEditingCodeBlockContext = new MenuId('ChatEditingCodeBlockContext'); static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu'); static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext'); + static readonly ChatTipContext = new MenuId('ChatTipContext'); static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar'); static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu'); static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index f12e732db79..f035cdec47c 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -200,6 +200,11 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ enumItemLabels?: string[]; + /** + * Optional keywords used for search purposes. + */ + keywords?: string[]; + /** * When specified, controls the presentation format of string settings. * Otherwise, the presentation format defaults to `singleline`. diff --git a/src/vs/platform/dialogs/browser/dialog.ts b/src/vs/platform/dialogs/browser/dialog.ts index ec0ab5aa54b..01d12f126da 100644 --- a/src/vs/platform/dialogs/browser/dialog.ts +++ b/src/vs/platform/dialogs/browser/dialog.ts @@ -14,7 +14,7 @@ import { ILayoutService } from '../../layout/browser/layoutService.js'; import { IProductService } from '../../product/common/productService.js'; import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../theme/browser/defaultStyles.js'; -const defaultDialogAllowableCommands = [ +const defaultDialogAllowableCommands = new Set([ 'workbench.action.quit', 'workbench.action.reloadWindow', 'copy', @@ -23,14 +23,14 @@ const defaultDialogAllowableCommands = [ 'editor.action.clipboardCopyAction', 'editor.action.clipboardCutAction', 'editor.action.clipboardPasteAction' -]; +]); export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { return { keyEventProcessor: (event: StandardKeyboardEvent) => { const resolved = keybindingService.softDispatch(event, layoutService.activeContainer); if (resolved.kind === ResultKind.KbFound && resolved.commandId) { - if (!allowableCommands.includes(resolved.commandId)) { + if (!allowableCommands.has(resolved.commandId)) { EventHelper.stop(event, true); } } diff --git a/src/vs/platform/extensionManagement/common/extensionEnablementService.ts b/src/vs/platform/extensionManagement/common/extensionEnablementService.ts index 85498cbfa74..a39576f9cff 100644 --- a/src/vs/platform/extensionManagement/common/extensionEnablementService.ts +++ b/src/vs/platform/extensionManagement/common/extensionEnablementService.ts @@ -14,7 +14,7 @@ export class GlobalExtensionEnablementService extends Disposable implements IGlo declare readonly _serviceBrand: undefined; - private _onDidChangeEnablement = new Emitter<{ readonly extensions: IExtensionIdentifier[]; readonly source?: string }>(); + private _onDidChangeEnablement = this._register(new Emitter<{ readonly extensions: IExtensionIdentifier[]; readonly source?: string }>()); readonly onDidChangeEnablement: Event<{ readonly extensions: IExtensionIdentifier[]; readonly source?: string }> = this._onDidChangeEnablement.event; private readonly storageManager: StorageManager; diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 0107bb94b58..b6a71de582b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,18 +45,18 @@ const _allApiProposals = { }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', - version: 2 + version: 6 }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', }, chatParticipantAdditions: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts', - version: 1 + version: 3 }, chatParticipantPrivate: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts', - version: 12 + version: 13 }, chatPromptFiles: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts', diff --git a/src/vs/platform/log/common/logIpc.ts b/src/vs/platform/log/common/logIpc.ts index 4a767070d98..a75622ae2b6 100644 --- a/src/vs/platform/log/common/logIpc.ts +++ b/src/vs/platform/log/common/logIpc.ts @@ -45,7 +45,8 @@ export class LoggerChannelClient extends AbstractLoggerService implements ILogge this.channel.call('registerLogger', [logger, this.windowId]); } - override deregisterLogger(resource: URI): void { + override deregisterLogger(idOrResource: URI | string): void { + const resource = this.toResource(idOrResource); super.deregisterLogger(resource); this.channel.call('deregisterLogger', [resource, this.windowId]); } diff --git a/src/vs/platform/menubar/electron-main/menubar.ts b/src/vs/platform/menubar/electron-main/menubar.ts index d8f65c50b4d..0594c7e23ef 100644 --- a/src/vs/platform/menubar/electron-main/menubar.ts +++ b/src/vs/platform/menubar/electron-main/menubar.ts @@ -82,9 +82,9 @@ export class Menubar extends Disposable { ) { super(); - this.menuUpdater = new RunOnceScheduler(() => this.doUpdateMenu(), 0); + this.menuUpdater = this._register(new RunOnceScheduler(() => this.doUpdateMenu(), 0)); - this.menuGC = new RunOnceScheduler(() => { this.oldMenus = []; }, 10000); + this.menuGC = this._register(new RunOnceScheduler(() => { this.oldMenus = []; }, 10000)); this.menubarMenus = Object.create(null); this.keybindings = Object.create(null); diff --git a/src/vs/platform/policy/node/nativePolicyService.ts b/src/vs/platform/policy/node/nativePolicyService.ts index 5b08cd99480..feb4ba1f2fb 100644 --- a/src/vs/platform/policy/node/nativePolicyService.ts +++ b/src/vs/platform/policy/node/nativePolicyService.ts @@ -12,7 +12,7 @@ import { ILogService } from '../../log/common/log.js'; export class NativePolicyService extends AbstractPolicyService implements IPolicyService { - private throttler = new Throttler(); + private throttler = this._register(new Throttler()); private readonly watcher = this._register(new MutableDisposable()); constructor( diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index 0636687742d..bd509719a3c 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -20,6 +20,11 @@ border-top-left-radius: 5px; } +.quick-input-widget.no-drag .quick-input-titlebar, +.quick-input-widget.no-drag .quick-input-title, +.quick-input-widget.no-drag .quick-input-header { + cursor: default; +} .quick-input-widget .monaco-inputbox .monaco-action-bar { top: 0; } diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index fae2f7ffb45..8e5283ef9ad 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -37,6 +37,8 @@ import { TriStateCheckbox, createToggleActionViewItemProvider } from '../../../b import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; +import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; +import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; const $ = dom.$; @@ -541,6 +543,7 @@ export class QuickInputController extends Disposable { input.quickNavigate = options.quickNavigate; input.hideInput = !!options.hideInput; input.contextKey = options.contextKey; + input.anchor = options.anchor; input.busy = true; Promise.all([picks, options.activeItem]) .then(([items, _activeItem]) => { @@ -710,6 +713,7 @@ export class QuickInputController extends Disposable { ui.container.style.display = ''; this.updateLayout(); + this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); @@ -861,16 +865,52 @@ export class QuickInputController extends Disposable { private updateLayout() { if (this.ui && this.isVisible()) { const style = this.ui.container.style; - const width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); + let width = Math.min(this.dimension!.width * 0.62 /* golden cut */, QuickInputController.MAX_WIDTH); style.width = width + 'px'; + let listHeight = this.dimension && this.dimension.height * 0.4; + // Position - style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; - style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; + if (this.controller?.anchor) { + const container = this.layoutService.getContainer(dom.getActiveWindow()).getBoundingClientRect(); + const anchor = getAnchorRect(this.controller.anchor); + width = 380; + listHeight = this.dimension ? Math.min(this.dimension.height * 0.2, 200) : 200; + + // Beware: + // We need to add some extra pixels to the height to account for the input and padding. + const containerHeight = Math.floor(listHeight) + 6 + 26 + 16; + const { top, left, right, bottom, anchorAlignment, anchorPosition } = layout2d(container, { width, height: containerHeight }, anchor, { anchorPosition: AnchorPosition.ABOVE }); + + if (anchorAlignment === AnchorAlignment.RIGHT) { + style.right = `${right}px`; + style.left = 'initial'; + } else { + style.left = `${left}px`; + style.right = 'initial'; + } + + if (anchorPosition === AnchorPosition.ABOVE) { + style.bottom = `${bottom}px`; + style.top = 'initial'; + } else { + style.top = `${top}px`; + style.bottom = 'initial'; + } + + style.width = `${width}px`; + style.height = ''; + } else { + style.top = `${this.viewState?.top ? Math.round(this.dimension!.height * this.viewState.top) : this.titleBarOffset}px`; + style.left = `${Math.round((this.dimension!.width * (this.viewState?.left ?? 0.5 /* center */)) - (width / 2))}px`; + style.right = ''; + style.bottom = ''; + style.height = ''; + } this.ui.inputBox.layout(); - this.ui.list.layout(this.dimension && this.dimension.height * 0.4); - this.ui.tree.layout(this.dimension && this.dimension.height * 0.4); + this.ui.list.layout(listHeight); + this.ui.tree.layout(listHeight); } } @@ -965,6 +1005,8 @@ export interface IQuickInputControllerHost extends ILayoutService { } class QuickInputDragAndDropController extends Disposable { readonly dndViewState = observableValue<{ top?: number; left?: number; done: boolean } | undefined>(this, undefined); + private _enabled = true; + private readonly _snapThreshold = 20; private readonly _snapLineHorizontalRatio = 0.25; @@ -1000,6 +1042,10 @@ class QuickInputDragAndDropController extends Disposable { } layoutContainer(dimension = this._layoutService.activeContainerDimension): void { + if (!this._enabled) { + return; + } + const state = this.dndViewState.get(); const dragAreaRect = this._quickInputContainer.getBoundingClientRect(); if (state?.top && state?.left) { @@ -1011,6 +1057,11 @@ class QuickInputDragAndDropController extends Disposable { } } + setEnabled(enabled: boolean): void { + this._enabled = enabled; + this._quickInputContainer.classList.toggle('no-drag', !enabled); + } + setAlignment(alignment: 'top' | 'center' | { top: number; left: number }, done = true): void { if (alignment === 'top') { this.dndViewState.set({ @@ -1041,6 +1092,10 @@ class QuickInputDragAndDropController extends Disposable { // Double click this._register(dom.addDisposableGenericMouseUpListener(dragArea, (event: MouseEvent) => { + if (!this._enabled) { + return; + } + const originEvent = new StandardMouseEvent(dom.getWindow(dragArea), event); if (originEvent.detail !== 2) { return; @@ -1057,6 +1112,10 @@ class QuickInputDragAndDropController extends Disposable { // Mouse down this._register(dom.addDisposableGenericMouseDownListener(dragArea, (e: MouseEvent) => { + if (!this._enabled) { + return; + } + const activeWindow = dom.getWindow(this._layoutService.activeContainer); const originEvent = new StandardMouseEvent(activeWindow, e); diff --git a/src/vs/platform/quickinput/browser/quickInputList.ts b/src/vs/platform/quickinput/browser/quickInputList.ts index 21dfbc49628..8580b264014 100644 --- a/src/vs/platform/quickinput/browser/quickInputList.ts +++ b/src/vs/platform/quickinput/browser/quickInputList.ts @@ -686,13 +686,13 @@ export class QuickInputList extends Disposable { //#region QuickInputList Events - private readonly _onKeyDown = new Emitter(); + private readonly _onKeyDown = this._register(new Emitter()); /** * Event that is fired when the tree receives a keydown. */ readonly onKeyDown: Event = this._onKeyDown.event; - private readonly _onLeave = new Emitter(); + private readonly _onLeave = this._register(new Emitter()); /** * Event that is fired when the tree would no longer have focus. */ @@ -710,13 +710,13 @@ export class QuickInputList extends Disposable { private readonly _checkedElementsObservable = observableValueOpts({ equalsFn: equals }, new Array()); readonly onChangedCheckedElements: Event = Event.fromObservable(this._checkedElementsObservable, this._store); - private readonly _onButtonTriggered = new Emitter>(); + private readonly _onButtonTriggered = this._register(new Emitter>()); onButtonTriggered = this._onButtonTriggered.event; - private readonly _onSeparatorButtonTriggered = new Emitter(); + private readonly _onSeparatorButtonTriggered = this._register(new Emitter()); onSeparatorButtonTriggered = this._onSeparatorButtonTriggered.event; - private readonly _elementChecked = new Emitter<{ element: IQuickPickElement; checked: boolean }>(); + private readonly _elementChecked = this._register(new Emitter<{ element: IQuickPickElement; checked: boolean }>()); private readonly _elementCheckedEventBufferer = new EventBufferer(); //#endregion diff --git a/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts b/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts index 24d2cfc380f..896564c768a 100644 --- a/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts +++ b/src/vs/platform/quickinput/browser/tree/quickInputTreeController.ts @@ -62,7 +62,7 @@ export class QuickInputTreeController extends Disposable { private readonly _onDidCheckedLeafItemsChange = this._register(new Emitter>()); readonly onDidChangeCheckedLeafItems = this._onDidCheckedLeafItemsChange.event; - private readonly _onLeave = new Emitter(); + private readonly _onLeave = this._register(new Emitter()); /** * Event that is fired when the tree would no longer have focus. */ diff --git a/src/vs/platform/quickinput/common/quickInput.ts b/src/vs/platform/quickinput/common/quickInput.ts index 9ff9d71fe6c..9426be48e2f 100644 --- a/src/vs/platform/quickinput/common/quickInput.ts +++ b/src/vs/platform/quickinput/common/quickInput.ts @@ -197,6 +197,11 @@ export interface IPickOptions { */ activeItem?: Promise | T; + /** + * an optional anchor for the picker + */ + anchor?: HTMLElement | { x: number; y: number }; + onKeyMods?: (keyMods: IKeyMods) => void; onDidFocus?: (entry: T) => void; onDidTriggerItemButton?: (context: IQuickPickItemButtonContext) => void; @@ -353,6 +358,11 @@ export interface IQuickInput extends IDisposable { */ ignoreFocusOut: boolean; + /** + * An optional anchor for the quick input. + */ + anchor?: HTMLElement | { x: number; y: number }; + /** * Shows the quick input. */ diff --git a/src/vs/platform/remote/browser/browserSocketFactory.ts b/src/vs/platform/remote/browser/browserSocketFactory.ts index cad70612060..eafeef861a1 100644 --- a/src/vs/platform/remote/browser/browserSocketFactory.ts +++ b/src/vs/platform/remote/browser/browserSocketFactory.ts @@ -49,7 +49,7 @@ export interface IWebSocket { class BrowserWebSocket extends Disposable implements IWebSocket { - private readonly _onData = new Emitter(); + private readonly _onData = this._register(new Emitter()); public readonly onData = this._onData.event; private readonly _onOpen = this._register(new Emitter()); diff --git a/src/vs/platform/remote/common/remoteHosts.ts b/src/vs/platform/remote/common/remoteHosts.ts index 9583e30f1f0..f6fc4cb18fa 100644 --- a/src/vs/platform/remote/common/remoteHosts.ts +++ b/src/vs/platform/remote/common/remoteHosts.ts @@ -25,6 +25,30 @@ export function getRemoteName(authority: string | undefined): string | undefined return authority.substr(0, pos); } +/** + * Returns the suffix part of the authority after the '+' character. + * For remote connections, this is typically the server/tunnel identifier. + * Examples: + * - For tunnels: `tunnel+myTunnel` returns `myTunnel` + * - For SSH: `ssh+myserver` returns `myserver` + * - For localhost: `localhost:8000` returns `undefined` + * @param authority The remote authority string. + * @returns The suffix after the '+' character, or undefined if there is no '+' character. + */ +export function getRemoteServerRootPath(authority: string): string | undefined; +export function getRemoteServerRootPath(authority: undefined): undefined; +export function getRemoteServerRootPath(authority: string | undefined): string | undefined; +export function getRemoteServerRootPath(authority: string | undefined): string | undefined { + if (!authority) { + return undefined; + } + const pos = authority.indexOf('+'); + if (pos < 0) { + return undefined; + } + return authority.substring(pos + 1); +} + export function parseAuthorityWithPort(authority: string): { host: string; port: number } { const { host, port } = parseAuthority(authority); if (typeof port === 'undefined') { diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index ac968da53bb..d85e651b231 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -28,11 +28,25 @@ type RemoteTunnelEnablementClassification = { comment: 'Reporting when Remote Tunnel access is turned on or off'; enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' }; + tunnelName?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the tunnel being enabled or disabled' }; }; type RemoteTunnelEnablementEvent = { enabled: boolean; service: boolean; + tunnelName?: string; +}; + +type RemoteTunnelConnectedClassification = { + owner: 'aeschli'; + comment: 'Reporting when a Remote Tunnel connection is established'; + tunnelName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the connected tunnel' }; + isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the connection is attached to an existing tunnel process' }; +}; + +type RemoteTunnelConnectedEvent = { + tunnelName: string; + isAttached: boolean; }; const restartTunnelOnConfigurationChanges: readonly string[] = [ @@ -55,13 +69,13 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ declare readonly _serviceBrand: undefined; - private readonly _onDidTokenFailedEmitter = new Emitter(); + private readonly _onDidTokenFailedEmitter = this._register(new Emitter()); public readonly onDidTokenFailed = this._onDidTokenFailedEmitter.event; - private readonly _onDidChangeTunnelStatusEmitter = new Emitter(); + private readonly _onDidChangeTunnelStatusEmitter = this._register(new Emitter()); public readonly onDidChangeTunnelStatus = this._onDidChangeTunnelStatusEmitter.event; - private readonly _onDidChangeModeEmitter = new Emitter(); + private readonly _onDidChangeModeEmitter = this._register(new Emitter()); public readonly onDidChangeMode = this._onDidChangeModeEmitter.event; private readonly _logger: ILogger; @@ -95,7 +109,7 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ ) { super(); this._logger = this._register(loggerService.createLogger(joinPath(environmentService.logsHome, `${LOG_ID}.log`), { id: LOG_ID, name: LOGGER_NAME })); - this._startTunnelProcessDelayer = new Delayer(100); + this._startTunnelProcessDelayer = this._register(new Delayer(100)); this._register(this._logger.onDidChangeLogLevel(l => this._logger.info('Log level changed to ' + LogLevelToString(l)))); @@ -241,9 +255,11 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ } private async updateTunnelProcess(): Promise { + const tunnelName = this._getTunnelName(); this.telemetryService.publicLog2('remoteTunnel.enablement', { enabled: this._mode.active, service: this._mode.active && this._mode.asService, + tunnelName, }); if (this._tunnelProcess) { @@ -396,6 +412,10 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ const m = message.match(/Open this link in your browser (https:\/\/([^\/\s]+)\/([^\/\s]+)\/([^\/\s]+))/); if (m) { const info: ConnectionInfo = { link: m[1], domain: m[2], tunnelName: m[4], isAttached }; + this.telemetryService.publicLog2('remoteTunnel.connected', { + tunnelName: info.tunnelName, + isAttached: info.isAttached, + }); this.setTunnelStatus(TunnelStates.connected(info, serviceInstallFailed)); } else if (message.match(/error refreshing token/)) { serveCommand.cancel(); diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index f326709753a..8de7fdcc25c 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -21,20 +21,17 @@ import { removeAnsiEscapeCodesFromPrompt } from '../../../../base/common/strings import { ShellEnvDetectionCapability } from '../capabilities/shellEnvDetectionCapability.js'; import { PromptTypeDetectionCapability } from '../capabilities/promptTypeDetectionCapability.js'; - -/** - * Shell integration is a feature that enhances the terminal's understanding of what's happening - * in the shell by injecting special sequences into the shell's prompt using the "Set Text - * Parameters" sequence (`OSC Ps ; Pt ST`). - * - * Definitions: - * - OSC: `\x1b]` - * - Ps: A single (usually optional) numeric parameter, composed of one or more digits. - * - Pt: A text parameter composed of printable characters. - * - ST: `\x7` - * - * This is inspired by a feature of the same name in the FinalTerm, iTerm2 and kitty terminals. - */ +// Shell integration is a feature that enhances the terminal's understanding of what's happening +// in the shell by injecting special sequences into the shell's prompt using the "Set Text +// Parameters" sequence (`OSC Ps ; Pt ST`). +// +// Definitions: +// - OSC: `\x1b]` +// - Ps: A single (usually optional) numeric parameter, composed of one or more digits. +// - Pt: A text parameter composed of printable characters. +// - ST: `\x7` +// +// This is inspired by a feature of the same name in the FinalTerm, iTerm2 and kitty terminals. /** * The identifier for the first numeric parameter (`Ps`) for OSC commands used by shell integration. @@ -174,6 +171,8 @@ const enum VSCodeOscPt { /** * Similar to prompt start but for line continuations. * + * Format: `OSC 633 ; F ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ ContinuationStart = 'F', @@ -181,6 +180,8 @@ const enum VSCodeOscPt { /** * Similar to command start but for line continuations. * + * Format: `OSC 633 ; G ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ ContinuationEnd = 'G', @@ -188,6 +189,8 @@ const enum VSCodeOscPt { /** * The start of the right prompt. * + * Format: `OSC 633 ; H ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ RightPromptStart = 'H', @@ -195,6 +198,8 @@ const enum VSCodeOscPt { /** * The end of the right prompt. * + * Format: `OSC 633 ; I ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ RightPromptEnd = 'I', @@ -224,7 +229,7 @@ const enum VSCodeOscPt { /** * Sets a mark/point-of-interest in the buffer. * - * Format: `OSC 633 ; SetMark [; Id=] [; Hidden]` + * Format: `OSC 633 ; SetMark [; Id=] [; Hidden] ST` * * `Id` - The identifier of the mark that can be used to reference it * `Hidden` - When set, the mark will be available to reference internally but will not visible @@ -236,7 +241,7 @@ const enum VSCodeOscPt { /** * Sends the shell's complete environment in JSON format. * - * Format: `OSC 633 ; EnvJson ; ; ` + * Format: `OSC 633 ; EnvJson ; ; ST` * * - `Environment` - A stringified JSON object containing the shell's complete environment. The * variables and values use the same encoding rules as the {@link CommandLine} sequence. @@ -250,7 +255,7 @@ const enum VSCodeOscPt { /** * Delete a single environment variable from cached environment. * - * Format: `OSC 633 ; EnvSingleDelete ; ; [; ]` + * Format: `OSC 633 ; EnvSingleDelete ; ; [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -262,7 +267,7 @@ const enum VSCodeOscPt { /** * The start of the collecting user's environment variables individually. * - * Format: `OSC 633 ; EnvSingleStart ; [; ]` + * Format: `OSC 633 ; EnvSingleStart ; [; ] ST` * * - `Clear` - An _mandatory_ flag indicating any cached environment variables will be cleared. * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order @@ -275,7 +280,7 @@ const enum VSCodeOscPt { /** * Sets an entry of single environment variable to transactional pending map of environment variables. * - * Format: `OSC 633 ; EnvSingleEntry ; ; [; ]` + * Format: `OSC 633 ; EnvSingleEntry ; ; [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -288,7 +293,7 @@ const enum VSCodeOscPt { * The end of the collecting user's environment variables individually. * Clears any pending environment variables and fires an event that contains user's environment. * - * Format: `OSC 633 ; EnvSingleEnd [; ]` + * Format: `OSC 633 ; EnvSingleEnd [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -305,7 +310,7 @@ const enum ITermOscPt { /** * Sets a mark/point-of-interest in the buffer. * - * Format: `OSC 1337 ; SetMark` + * Format: `OSC 1337 ; SetMark ST` */ SetMark = 'SetMark', @@ -335,9 +340,9 @@ export class ShellIntegrationAddon extends Disposable implements IShellIntegrati private _status: ShellIntegrationStatus = ShellIntegrationStatus.Off; get status(): ShellIntegrationStatus { return this._status; } - private readonly _onDidChangeStatus = new Emitter(); + private readonly _onDidChangeStatus = this._register(new Emitter()); readonly onDidChangeStatus = this._onDidChangeStatus.event; - private readonly _onDidChangeSeenSequences = new Emitter>(); + private readonly _onDidChangeSeenSequences = this._register(new Emitter>()); readonly onDidChangeSeenSequences = this._onDidChangeSeenSequences.event; constructor( diff --git a/src/vs/platform/terminal/electron-main/electronPtyHostStarter.ts b/src/vs/platform/terminal/electron-main/electronPtyHostStarter.ts index 491becb4ccc..5f4401c5e4b 100644 --- a/src/vs/platform/terminal/electron-main/electronPtyHostStarter.ts +++ b/src/vs/platform/terminal/electron-main/electronPtyHostStarter.ts @@ -25,9 +25,9 @@ export class ElectronPtyHostStarter extends Disposable implements IPtyHostStarte private utilityProcess: UtilityProcess | undefined = undefined; - private readonly _onRequestConnection = new Emitter(); + private readonly _onRequestConnection = this._register(new Emitter()); readonly onRequestConnection = this._onRequestConnection.event; - private readonly _onWillShutdown = new Emitter(); + private readonly _onWillShutdown = this._register(new Emitter()); readonly onWillShutdown = this._onWillShutdown.event; constructor( diff --git a/src/vs/platform/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index 0cde9e762e1..826b09f47c8 100644 --- a/src/vs/platform/terminal/node/ptyService.ts +++ b/src/vs/platform/terminal/node/ptyService.ts @@ -18,7 +18,7 @@ import { escapeNonWindowsPath } from '../common/terminalEnvironment.js'; import type { ISerializeOptions, SerializeAddon as XtermSerializeAddon } from '@xterm/addon-serialize'; import type { Unicode11Addon as XtermUnicode11Addon } from '@xterm/addon-unicode11'; import { IGetTerminalLayoutInfoArgs, IProcessDetails, ISetTerminalLayoutInfoArgs, ITerminalTabLayoutInfoDto } from '../common/terminalProcess.js'; -import { getWindowsBuildNumber } from './terminalEnvironment.js'; +import { getWindowsBuildNumber, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { TerminalProcess } from './terminalProcess.js'; import { localize } from '../../../nls.js'; import { ignoreProcessNames } from './childProcessMonitor.js'; @@ -37,6 +37,24 @@ import { hasKey, isFunction, isNumber, isString } from '../../../base/common/typ type XtermTerminal = pkg.Terminal; const { Terminal: XtermTerminal } = pkg; +/** + * Sanitizes arguments for logging, specifically handling env objects in createProcess calls. + */ +function sanitizeArgsForLogging(fnName: string, args: unknown[]): unknown[] { + // createProcess signature: shellLaunchConfig, cwd, cols, rows, unicodeVersion, env (index 5), executableEnv (index 6), ... + if (fnName === 'createProcess' && args.length > 5) { + const sanitizedArgs = [...args]; + if (args[5] && typeof args[5] === 'object') { + sanitizedArgs[5] = sanitizeEnvForLogging(args[5] as IProcessEnvironment); + } + if (args[6] && typeof args[6] === 'object') { + sanitizedArgs[6] = sanitizeEnvForLogging(args[6] as IProcessEnvironment); + } + return sanitizedArgs; + } + return args; +} + interface ITraceRpcArgs { logService: ILogService; simulatedLatency: number; @@ -50,7 +68,8 @@ export function traceRpc(_target: Object, key: string, descriptor: PropertyDescr const fn = descriptor.value; descriptor[fnKey] = async function (this: TThis, ...args: unknown[]) { if (this.traceRpcArgs.logService.getLevel() === LogLevel.Trace) { - this.traceRpcArgs.logService.trace(`[RPC Request] PtyService#${fn.name}(${args.map(e => JSON.stringify(e)).join(', ')})`); + const sanitizedArgs = sanitizeArgsForLogging(fn.name, args); + this.traceRpcArgs.logService.trace(`[RPC Request] PtyService#${fn.name}(${sanitizedArgs.map(e => JSON.stringify(e)).join(', ')})`); } if (this.traceRpcArgs.simulatedLatency) { await timeout(this.traceRpcArgs.simulatedLatency); @@ -151,7 +170,7 @@ export class PtyService extends Disposable implements IPtyService { })); this._detachInstanceRequestStore = this._register(new RequestStore(undefined, this._logService)); - this._detachInstanceRequestStore.onCreateRequest(this._onDidRequestDetach.fire, this._onDidRequestDetach); + this._register(this._detachInstanceRequestStore.onCreateRequest(this._onDidRequestDetach.fire, this._onDidRequestDetach)); this._autoRepliesContribution = new AutoRepliesPtyServiceContribution(this._logService); diff --git a/src/vs/platform/terminal/node/terminalEnvironment.ts b/src/vs/platform/terminal/node/terminalEnvironment.ts index 1d150cd6b8e..62a0c53bb88 100644 --- a/src/vs/platform/terminal/node/terminalEnvironment.ts +++ b/src/vs/platform/terminal/node/terminalEnvironment.ts @@ -375,3 +375,56 @@ function areZshBashFishLoginArgs(originalArgs: SingleOrMany): boolean { return isString(originalArgs) && shLoginArgs.includes(originalArgs.toLowerCase()) || !isString(originalArgs) && originalArgs.length === 1 && shLoginArgs.includes(originalArgs[0].toLowerCase()); } + +/** + * Patterns that indicate sensitive environment variable names. + */ +const sensitiveEnvVarNames = /^(?:.*_)?(?:API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|PWD|CREDENTIAL|AUTH|PRIVATE_?KEY|ACCESS_?KEY|CLIENT_?SECRET|APIKEY)(?:_.*)?$/i; + +/** + * Patterns for detecting secret values in environment variables. + */ +const secretValuePatterns = [ + // JWT tokens + /^eyJ[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+\.[a-zA-Z0-9\-_]+$/, + // GitHub tokens + /^gh[psuro]_[a-zA-Z0-9]{36}$/, + /^github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}$/, + // Google API keys + /^AIza[A-Za-z0-9_\-]{35}$/, + // Slack tokens + /^xox[pbar]\-[A-Za-z0-9\-]+$/, + // Azure/MS tokens (common patterns) + /^[a-zA-Z0-9]{32,}$/, +]; + +/** + * Sanitizes environment variables for logging by redacting sensitive values. + */ +export function sanitizeEnvForLogging(env: IProcessEnvironment | undefined): IProcessEnvironment | undefined { + if (!env) { + return env; + } + const sanitized: IProcessEnvironment = {}; + for (const key of Object.keys(env)) { + const value = env[key]; + if (value === undefined) { + continue; + } + // Check if the key name suggests a sensitive value + if (sensitiveEnvVarNames.test(key)) { + sanitized[key] = ''; + continue; + } + // Check if the value matches known secret patterns + let isSecret = false; + for (const pattern of secretValuePatterns) { + if (pattern.test(value)) { + isSecret = true; + break; + } + } + sanitized[key] = isSecret ? '' : value; + } + return sanitized; +} diff --git a/src/vs/platform/terminal/node/terminalProcess.ts b/src/vs/platform/terminal/node/terminalProcess.ts index aba2699e665..707bfa7fc0f 100644 --- a/src/vs/platform/terminal/node/terminalProcess.ts +++ b/src/vs/platform/terminal/node/terminalProcess.ts @@ -17,7 +17,7 @@ import { ILogService, LogLevel } from '../../log/common/log.js'; import { IProductService } from '../../product/common/productService.js'; import { FlowControlConstants, IShellLaunchConfig, ITerminalChildProcess, ITerminalLaunchError, IProcessProperty, IProcessPropertyMap, ProcessPropertyType, TerminalShellType, IProcessReadyEvent, ITerminalProcessOptions, PosixShellType, IProcessReadyWindowsPty, GeneralShellType, ITerminalLaunchResult } from '../common/terminal.js'; import { ChildProcessMonitor } from './childProcessMonitor.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection } from './terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, sanitizeEnvForLogging } from './terminalEnvironment.js'; import { WindowsShellHelper } from './windowsShellHelper.js'; import { IPty, IPtyForkOptions, IWindowsPtyForkOptions, spawn } from 'node-pty'; import { isNumber } from '../../../base/common/types.js'; @@ -244,7 +244,11 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess return undefined; } catch (err) { this._logService.trace('node-pty.node-pty.IPty#spawn native exception', err); - return { message: `A native exception occurred during launch (${err.message})` }; + const errorMessage = err.message; + if (errorMessage?.includes('Cannot launch conpty')) { + return { message: localize('conptyLaunchFailed', "A native exception occurred during launch (Cannot launch conpty). Winpty has been removed, see {0} for more details. You can also try enabling the `{1}` setting.", 'https://code.visualstudio.com/updates/v1_109#_removal-of-winpty-support', 'terminal.integrated.windowsUseConptyDll') }; + } + return { message: `A native exception occurred during launch (${errorMessage})` }; } } @@ -301,7 +305,8 @@ export class TerminalProcess extends Disposable implements ITerminalChildProcess ): Promise { const args = shellIntegrationInjection?.newArgs || shellLaunchConfig.args || []; await this._throttleKillSpawn(); - this._logService.trace('node-pty.IPty#spawn', shellLaunchConfig.executable, args, options); + const sanitizedOptions = { ...options, env: sanitizeEnvForLogging(options.env as IProcessEnvironment | undefined) }; + this._logService.trace('node-pty.IPty#spawn', shellLaunchConfig.executable, args, sanitizedOptions); const ptyProcess = spawn(shellLaunchConfig.executable!, args, options); this._ptyProcess = ptyProcess; this._childProcessMonitor = this._register(new ChildProcessMonitor(ptyProcess.pid, this._logService)); diff --git a/src/vs/platform/terminal/node/windowsShellHelper.ts b/src/vs/platform/terminal/node/windowsShellHelper.ts index 1f32cebaa58..73254c4d241 100644 --- a/src/vs/platform/terminal/node/windowsShellHelper.ts +++ b/src/vs/platform/terminal/node/windowsShellHelper.ts @@ -49,9 +49,9 @@ export class WindowsShellHelper extends Disposable implements IWindowsShellHelpe get shellType(): TerminalShellType | undefined { return this._shellType; } private _shellTitle: string = ''; get shellTitle(): string { return this._shellTitle; } - private readonly _onShellNameChanged = new Emitter(); + private readonly _onShellNameChanged = this._register(new Emitter()); get onShellNameChanged(): Event { return this._onShellNameChanged.event; } - private readonly _onShellTypeChanged = new Emitter(); + private readonly _onShellTypeChanged = this._register(new Emitter()); get onShellTypeChanged(): Event { return this._onShellTypeChanged.event; } constructor( diff --git a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts index a4d17386a22..bb03a05958b 100644 --- a/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts +++ b/src/vs/platform/terminal/test/node/terminalEnvironment.test.ts @@ -10,7 +10,7 @@ import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/c import { NullLogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; import { ITerminalProcessOptions } from '../../common/terminal.js'; -import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure } from '../../node/terminalEnvironment.js'; +import { getShellIntegrationInjection, getWindowsBuildNumber, IShellIntegrationConfigInjection, type IShellIntegrationInjectionFailure, sanitizeEnvForLogging } from '../../node/terminalEnvironment.js'; const enabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: true, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; const disabledProcessOptions: ITerminalProcessOptions = { shellIntegration: { enabled: false, suggestEnabled: false, nonce: '' }, windowsUseConptyDll: false, environmentVariableCollections: undefined, workspaceFolder: undefined, isScreenReaderOptimized: false }; @@ -257,4 +257,92 @@ suite('platform - terminalEnvironment', async () => { }); }); }); + + suite('sanitizeEnvForLogging', () => { + test('should return undefined for undefined input', () => { + strictEqual(sanitizeEnvForLogging(undefined), undefined); + }); + + test('should return empty object for empty input', () => { + deepStrictEqual(sanitizeEnvForLogging({}), {}); + }); + + test('should pass through non-sensitive values', () => { + deepStrictEqual(sanitizeEnvForLogging({ + PATH: '/usr/bin', + HOME: '/home/user', + TERM: 'xterm-256color' + }), { + PATH: '/usr/bin', + HOME: '/home/user', + TERM: 'xterm-256color' + }); + }); + + test('should redact sensitive env var names', () => { + deepStrictEqual(sanitizeEnvForLogging({ + API_KEY: 'secret123', + GITHUB_TOKEN: 'ghp_xxxx', + MY_SECRET: 'hidden', + PASSWORD: 'pass123', + AWS_ACCESS_KEY: 'AKIA...', + DATABASE_PASSWORD: 'dbpass', + CLIENT_SECRET: 'client_secret_value', + AUTH_TOKEN: 'auth_value', + PRIVATE_KEY: 'private_key_value' + }), { + API_KEY: '', + GITHUB_TOKEN: '', + MY_SECRET: '', + PASSWORD: '', + AWS_ACCESS_KEY: '', + DATABASE_PASSWORD: '', + CLIENT_SECRET: '', + AUTH_TOKEN: '', + PRIVATE_KEY: '' + }); + }); + + test('should redact JWT tokens by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + SOME_VAR: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U' + }), { + SOME_VAR: '' + }); + }); + + test('should redact GitHub tokens by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + MY_GH: 'ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' + }), { + MY_GH: '' + }); + }); + + test('should redact Google API keys by value pattern', () => { + deepStrictEqual(sanitizeEnvForLogging({ + GOOGLE_KEY: 'AIzaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe' + }), { + GOOGLE_KEY: '' + }); + }); + + test('should redact long alphanumeric strings (potential secrets)', () => { + deepStrictEqual(sanitizeEnvForLogging({ + LONG_VALUE: 'abcdefghijklmnopqrstuvwxyz123456' + }), { + LONG_VALUE: '' + }); + }); + + test('should skip undefined values', () => { + const env: { [key: string]: string | undefined } = { + DEFINED: 'value', + UNDEFINED: undefined + }; + deepStrictEqual(sanitizeEnvForLogging(env), { + DEFINED: 'value' + }); + }); + }); }); diff --git a/src/vs/platform/theme/test/common/testThemeService.ts b/src/vs/platform/theme/test/common/testThemeService.ts index 09f4162992a..817d76b3e43 100644 --- a/src/vs/platform/theme/test/common/testThemeService.ts +++ b/src/vs/platform/theme/test/common/testThemeService.ts @@ -66,7 +66,7 @@ export class TestThemeService implements IThemeService { _onFileIconThemeChange = new Emitter(); _onProductIconThemeChange = new Emitter(); - constructor(theme = new TestColorTheme(), fileIconTheme = new TestFileIconTheme(), productIconTheme = new UnthemedProductIconTheme()) { + constructor(theme: IColorTheme = new TestColorTheme(), fileIconTheme: IFileIconTheme = new TestFileIconTheme(), productIconTheme: IProductIconTheme = new UnthemedProductIconTheme()) { this._colorTheme = theme; this._fileIconTheme = fileIconTheme; this._productIconTheme = productIconTheme; diff --git a/src/vs/platform/tunnel/common/tunnel.ts b/src/vs/platform/tunnel/common/tunnel.ts index 22e9f307d77..2c04a5ca4f8 100644 --- a/src/vs/platform/tunnel/common/tunnel.ts +++ b/src/vs/platform/tunnel/common/tunnel.ts @@ -223,11 +223,11 @@ export class DisposableTunnel { export abstract class AbstractTunnelService extends Disposable implements ITunnelService { declare readonly _serviceBrand: undefined; - private _onTunnelOpened: Emitter = new Emitter(); + private _onTunnelOpened = this._register(new Emitter()); public onTunnelOpened: Event = this._onTunnelOpened.event; - private _onTunnelClosed: Emitter<{ host: string; port: number }> = new Emitter(); + private _onTunnelClosed = this._register(new Emitter<{ host: string; port: number }>()); public onTunnelClosed: Event<{ host: string; port: number }> = this._onTunnelClosed.event; - private _onAddedTunnelProvider: Emitter = new Emitter(); + private _onAddedTunnelProvider = this._register(new Emitter()); public onAddedTunnelProvider: Event = this._onAddedTunnelProvider.event; protected readonly _tunnels = new Map }>>(); protected _tunnelProvider: ITunnelProvider | undefined; diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 0c396b41681..ed54d90f383 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -253,7 +253,15 @@ export abstract class AbstractUpdateService implements IUpdateService { if (isLatest === false && this._state.type === StateType.Ready) { this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); - await this.cancelPendingUpdate(); + + try { + await this.cancelPendingUpdate(); + } catch (error) { + this.logService.error('update#checkForOverwriteUpdates(): failed to cancel pending update, aborting overwrite'); + this.logService.error(error); + return false; + } + this._overwrite = true; this.setState(State.Overwriting(this._state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index 1433b90ef1a..fe133e702b9 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -27,7 +27,13 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau @memoize private get onRawError(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'error', (_, message) => message); } @memoize private get onRawUpdateNotAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-not-available'); } @memoize private get onRawUpdateAvailable(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-available'); } - @memoize private get onRawUpdateDownloaded(): Event { return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-downloaded', (_, version: string, productVersion: string, timestamp: number) => ({ version, productVersion, timestamp })); } + @memoize private get onRawUpdateDownloaded(): Event { + return Event.fromNodeEventEmitter(electron.autoUpdater, 'update-downloaded', (_, version: string, productVersion: string, releaseDate: Date | number) => ({ + version, + productVersion, + timestamp: releaseDate instanceof Date ? releaseDate.getTime() || undefined : releaseDate + })); + } constructor( @ILifecycleMainService lifecycleMainService: ILifecycleMainService, diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 37c7f4e8ec1..7778a01ffa3 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; @@ -18,6 +18,7 @@ import { transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { checksum } from '../../../base/node/crypto.js'; import * as pfs from '../../../base/node/pfs.js'; +import { killTree } from '../../../base/node/processes.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { IFileService } from '../../files/common/files.js'; @@ -40,6 +41,10 @@ async function pollUntil(fn: () => boolean, millis = 1000): Promise { interface IAvailableUpdate { packagePath: string; updateFilePath?: string; + /** File path used to signal the Inno Setup installer to cancel */ + cancelFilePath?: string; + /** The Inno Setup process that is applying the update in the background */ + updateProcess?: ChildProcess; } let _updateType: UpdateType | undefined = undefined; @@ -75,7 +80,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun @IProductService productService: IProductService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -168,14 +173,18 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return createUpdateURL(this.productService.updateUrl!, platform, quality, commit, options); } - protected doCheckForUpdates(explicit: boolean): void { + protected doCheckForUpdates(explicit: boolean, pendingCommit?: string): void { if (!this.quality) { return; } const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background }); - this.setState(State.CheckingForUpdates(explicit)); + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + + // Only set CheckingForUpdates if we're not already in Overwriting state + if (this.state.type !== StateType.Overwriting) { + this.setState(State.CheckingForUpdates(explicit)); + } this.requestService.request({ url }, CancellationToken.None) .then(asJson) @@ -183,7 +192,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const updateType = getUpdateType(); if (!update || !update.url || !update.version || !update.productVersion) { - this.setState(State.Idle(updateType)); + // If we were checking for an overwrite update and found nothing newer, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(updateType)); + } return Promise.resolve(null); } @@ -245,13 +261,12 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); }).then(packagePath => { this.availableUpdate = { packagePath }; + this.saveUpdateMetadata(update); this.setState(State.Downloaded(update, explicit, this._overwrite)); const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, explicit, this._overwrite)); } @@ -264,7 +279,15 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // only show message when explicitly checking for updates const message: string | undefined = explicit ? (err.message || err) : undefined; - this.setState(State.Idle(getUpdateType(), message)); + + // If we were checking for an overwrite update and it failed, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(getUpdateType(), message)); + } }); } @@ -312,15 +335,36 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const cachePath = await this.cachePath; const sessionEndFlagPath = path.join(cachePath, 'session-ending.flag'); + const cancelFilePath = path.join(cachePath, `cancel.flag`); + try { + await unlink(cancelFilePath); + } catch { + // ignore + } this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); + this.availableUpdate.cancelFilePath = cancelFilePath; await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); - const child = spawn(this.availableUpdate.packagePath, ['/verysilent', '/log', `/update="${this.availableUpdate.updateFilePath}"`, `/sessionend="${sessionEndFlagPath}"`, '/nocloseapplications', '/mergetasks=runcode,!desktopicon,!quicklaunchicon'], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true - }); + const child = spawn(this.availableUpdate.packagePath, + [ + '/verysilent', + '/log', + `/update="${this.availableUpdate.updateFilePath}"`, + `/sessionend="${sessionEndFlagPath}"`, + `/cancel="${cancelFilePath}"`, + '/nocloseapplications', + '/mergetasks=runcode,!desktopicon,!quicklaunchicon' + ], + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + windowsVerbatimArguments: true + } + ); + + // Track the process so we can cancel it if needed + this.availableUpdate.updateProcess = child; child.once('exit', () => { this.availableUpdate = undefined; @@ -335,6 +379,58 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => this.setState(State.Ready(update, explicit, this._overwrite))); } + protected override async cancelPendingUpdate(): Promise { + if (!this.availableUpdate) { + return; + } + + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; + + if (updateProcess && updateProcess.exitCode === null) { + // Remove all listeners to prevent the exit handler from changing state + updateProcess.removeAllListeners(); + const exitPromise = new Promise(resolve => updateProcess.once('exit', () => resolve(true))); + + // Write the cancel file to signal Inno Setup to exit gracefully + if (cancelFilePath) { + try { + await pfs.Promises.writeFile(cancelFilePath, 'cancel'); + } catch (err) { + this.logService.warn('update#cancelPendingUpdate: failed to write cancel file', err); + } + } + + // Wait for the process to exit gracefully, then force-kill if needed + const pid = updateProcess.pid; + const exited = await Promise.race([exitPromise, timeout(30 * 1000).then(() => false)]); + if (pid && !exited) { + this.logService.trace('update#cancelPendingUpdate: process did not exit gracefully, killing process tree'); + await killTree(pid, true); + } + } + + // Clean up the flag file + if (updateFilePath) { + try { + await unlink(updateFilePath); + } catch (err) { + // ignore + } + } + + // Clean up the cancel file + if (cancelFilePath) { + try { + await unlink(cancelFilePath); + } catch (err) { + // ignore + } + } + + this.availableUpdate = undefined; + } + protected override doQuitAndInstall(): void { if (this.state.type !== StateType.Ready || !this.availableUpdate) { return; @@ -352,6 +448,30 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private async saveUpdateMetadata(update: IUpdate): Promise { + try { + const cachePath = await this.cachePath; + const metadataPath = path.join(cachePath, 'update-metadata.json'); + await pfs.Promises.writeFile(metadataPath, JSON.stringify(update)); + } catch (e) { + this.logService.error('update#saveUpdateMetadata: failed to save', e); + } + } + + private async loadUpdateMetadata(): Promise { + try { + const cachePath = await this.cachePath; + const metadataPath = path.join(cachePath, 'update-metadata.json'); + if (await pfs.Promises.exists(metadataPath)) { + const content = await readFile(metadataPath, 'utf8'); + return JSON.parse(content); + } + } catch (e) { + this.logService.error('update#loadUpdateMetadata: failed to load', e); + } + return undefined; + } + protected override getUpdateType(): UpdateType { return getUpdateType(); } @@ -362,16 +482,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - const update: IUpdate = { version: 'unknown', productVersion: 'unknown' }; + const update: IUpdate = await this.loadUpdateMetadata() ?? { version: 'unknown', productVersion: 'unknown' }; this.setState(State.Downloading(update, true, false)); this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, true, false)); } diff --git a/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts b/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts index fef99758ee1..a425fcc2437 100644 --- a/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts +++ b/src/vs/platform/userDataSync/common/userDataSyncEnablementService.ts @@ -16,10 +16,10 @@ export class UserDataSyncEnablementService extends Disposable implements IUserDa _serviceBrand: undefined; - private _onDidChangeEnablement = new Emitter(); + private _onDidChangeEnablement = this._register(new Emitter()); readonly onDidChangeEnablement: Event = this._onDidChangeEnablement.event; - private _onDidChangeResourceEnablement = new Emitter<[SyncResource, boolean]>(); + private _onDidChangeResourceEnablement = this._register(new Emitter<[SyncResource, boolean]>()); readonly onDidChangeResourceEnablement: Event<[SyncResource, boolean]> = this._onDidChangeResourceEnablement.event; constructor( diff --git a/src/vs/server/node/serverServices.ts b/src/vs/server/node/serverServices.ts index 053aa4395a6..d37663dfefb 100644 --- a/src/vs/server/node/serverServices.ts +++ b/src/vs/server/node/serverServices.ts @@ -115,10 +115,10 @@ export async function setupServerServices(connectionToken: ServerConnectionToken socketServer.registerChannel('logger', new LoggerChannel(loggerService, (ctx: RemoteAgentConnectionContext) => getUriTransformer(ctx.remoteAuthority))); const logger = loggerService.createLogger('remoteagent', { name: localize('remoteExtensionLog', "Server") }); - const logService = new LogService(logger, [new ServerLogger(getLogLevel(environmentService))]); + const logService = disposables.add(new LogService(logger, [new ServerLogger(getLogLevel(environmentService))])); services.set(ILogService, logService); setTimeout(() => cleanupOlderLogs(environmentService.logsHome.with({ scheme: Schemas.file }).fsPath).then(null, err => logService.error(err)), 10000); - logService.onDidChangeLogLevel(logLevel => log(logService, logLevel, `Log level changed to ${LogLevelToString(logService.getLevel())}`)); + disposables.add(logService.onDidChangeLogLevel(logLevel => log(logService, logLevel, `Log level changed to ${LogLevelToString(logService.getLevel())}`))); logService.trace(`Remote configuration data at ${REMOTE_DATA_FOLDER}`); logService.trace('process arguments:', environmentService.args); diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index 20eab0ab271..ac03a724943 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -98,7 +98,6 @@ import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; import './mainThreadMeteredConnection.js'; -import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 986f8f27139..da9a664e813 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -5,7 +5,7 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { IMarkdownString, MarkdownString } from '../../../base/common/htmlContent.js'; import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; @@ -24,7 +24,7 @@ import { ChatEditorInput } from '../../contrib/chat/browser/widgetHosts/editor/c import { IChatRequestVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { awaitStatsForSession } from '../../contrib/chat/common/chat.js'; import { IChatContentInlineReference, IChatProgress, IChatService, ResponseModelState } from '../../contrib/chat/common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemProvider, IChatSessionProviderOptionItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSession, IChatSessionContentProvider, IChatSessionHistoryItem, IChatSessionItem, IChatSessionItemController, IChatSessionProviderOptionItem, IChatSessionsService } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatModel } from '../../contrib/chat/common/model/chatModel.js'; import { IChatAgentRequest } from '../../contrib/chat/common/participants/chatAgents.js'; @@ -320,11 +320,59 @@ export class ObservableChatSession extends Disposable implements IChatSession { } } +class MainThreadChatSessionItemController extends Disposable implements IChatSessionItemController { + + private readonly _proxy: ExtHostChatSessionsShape; + private readonly _handle: number; + + private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); + public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; + + constructor( + proxy: ExtHostChatSessionsShape, + handle: number + ) { + super(); + this._proxy = proxy; + this._handle = handle; + } + + private readonly _items = new ResourceMap(); + get items(): IChatSessionItem[] { + return Array.from(this._items.values()); + } + + refresh(token: CancellationToken): Promise { + return this._proxy.$refreshChatSessionItems(this._handle, token); + } + + setItems(items: readonly IChatSessionItem[]): void { + this._items.clear(); + for (const item of items) { + this._items.set(item.resource, item); + } + this._onDidChangeChatSessionItems.fire(); + } + + updateItem(item: IChatSessionItem): void { + if (this._items.has(item.resource)) { + this._items.set(item.resource, item); + this._onDidChangeChatSessionItems.fire(); + } else { + console.warn(`Item with resource ${item.resource.toString()} does not exist. Skipping update.`); + } + } + + fireOnDidChangeChatSessionItems(): void { + this._onDidChangeChatSessionItems.fire(); + } +} + @extHostNamedCustomer(MainContext.MainThreadChatSessions) export class MainThreadChatSessions extends Disposable implements MainThreadChatSessionsShape { - private readonly _itemProvidersRegistrations = this._register(new DisposableMap; + private readonly _itemControllerRegistrations = this._register(new DisposableMap()); private readonly _contentProvidersRegistrations = this._register(new DisposableMap()); private readonly _sessionTypeToHandle = new Map(); @@ -361,8 +409,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat })); this._register(this._agentSessionsService.model.onDidChangeSessionArchivedState(session => { - for (const [handle, { provider }] of this._itemProvidersRegistrations) { - if (provider.chatSessionType === session.providerType) { + for (const [handle, { chatSessionType }] of this._itemControllerRegistrations) { + if (chatSessionType === session.providerType) { this._proxy.$onDidChangeChatSessionItemState(handle, session.resource, session.isArchived()); } } @@ -373,32 +421,79 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return this._sessionTypeToHandle.get(chatSessionType); } - $registerChatSessionItemProvider(handle: number, chatSessionType: string): void { - // Register the provider handle - this tracks that a provider exists + $registerChatSessionItemController(handle: number, chatSessionType: string): void { const disposables = new DisposableStore(); - const changeEmitter = disposables.add(new Emitter()); - const provider: IChatSessionItemProvider = { - chatSessionType, - onDidChangeChatSessionItems: Event.debounce(changeEmitter.event, (_, e) => e, 200), - provideChatSessionItems: (token) => this._provideChatSessionItems(handle, token), - }; - disposables.add(this._chatSessionsService.registerChatSessionItemProvider(provider)); - this._itemProvidersRegistrations.set(handle, { + const controller = disposables.add(new MainThreadChatSessionItemController(this._proxy, handle)); + disposables.add(this._chatSessionsService.registerChatSessionItemController(chatSessionType, controller)); + + this._itemControllerRegistrations.set(handle, { + chatSessionType, + controller, dispose: () => disposables.dispose(), - provider, - onDidChangeItems: changeEmitter, }); disposables.add(this._chatSessionsService.registerChatModelChangeListeners( this._chatService, chatSessionType, - () => changeEmitter.fire() + () => controller.fireOnDidChangeChatSessionItems() )); } $onDidChangeChatSessionItems(handle: number): void { - this._itemProvidersRegistrations.get(handle)?.onDidChangeItems.fire(); + this._itemControllerRegistrations.get(handle)?.controller.fireOnDidChangeChatSessionItems(); + } + + private async _resolveSessionItem(item: Dto): Promise { + const uri = URI.revive(item.resource); + const model = this._chatService.getSession(uri); + if (model) { + item = await this.handleSessionModelOverrides(model, item); + } + + // We can still get stats if there is no model or if fetching from model failed + if (!item.changes || !model) { + const stats = (await this._chatService.getMetadataForSession(uri))?.stats; + const diffs: IAgentSession['changes'] = { + files: stats?.fileCount || 0, + insertions: stats?.added || 0, + deletions: stats?.removed || 0 + }; + if (hasValidDiff(diffs)) { + item.changes = diffs; + } + } + + return { + ...item, + changes: revive(item.changes), + resource: uri, + iconPath: item.iconPath, + tooltip: item.tooltip ? this._reviveTooltip(item.tooltip) : undefined, + archived: item.archived, + }; + } + + async $setChatSessionItems(handle: number, items: Dto[]): Promise { + const registration = this._itemControllerRegistrations.get(handle); + if (!registration) { + this._logService.warn(`No chat session controller registered for handle ${handle}`); + return; + } + + const resolvedItems = await Promise.all(items.map(item => this._resolveSessionItem(item))); + registration.controller.setItems(resolvedItems); + } + + async $updateChatSessionItem(controllerHandle: number, item: Dto): Promise { + const registration = this._itemControllerRegistrations.get(controllerHandle); + if (!registration) { + this._logService.warn(`No chat session controller registered for handle ${controllerHandle}`); + return; + } + + const resolvedItem = await this._resolveSessionItem(item); + registration.controller.updateItem(resolvedItem); } $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { @@ -412,7 +507,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat const modifiedResource = URI.revive(modifiedCompoennts); this._logService.trace(`$onDidCommitChatSessionItem: handle(${handle}), original(${originalResource}), modified(${modifiedResource})`); - const chatSessionType = this._itemProvidersRegistrations.get(handle)?.provider.chatSessionType; + const chatSessionType = this._itemControllerRegistrations.get(handle)?.chatSessionType; if (!chatSessionType) { this._logService.error(`No chat session type found for provider handle ${handle}`); return; @@ -482,46 +577,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } - private async _provideChatSessionItems(handle: number, token: CancellationToken): Promise { - try { - // Get all results as an array from the RPC call - const sessions = await this._proxy.$provideChatSessionItems(handle, token); - return Promise.all(sessions.map(async session => { - const uri = URI.revive(session.resource); - const model = this._chatService.getSession(uri); - if (model) { - session = await this.handleSessionModelOverrides(model, session); - } - - // We can still get stats if there is no model or if fetching from model failed - if (!session.changes || !model) { - const stats = (await this._chatService.getMetadataForSession(uri))?.stats; - // TODO: we shouldn't be converting this, the types should match - const diffs: IAgentSession['changes'] = { - files: stats?.fileCount || 0, - insertions: stats?.added || 0, - deletions: stats?.removed || 0 - }; - if (hasValidDiff(diffs)) { - session.changes = diffs; - } - } - - return { - ...session, - changes: revive(session.changes), - resource: uri, - iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, - archived: session.archived, - } satisfies IChatSessionItem; - })); - } catch (error) { - this._logService.error('Error providing chat sessions:', error); - } - return []; - } - private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { // Override desciription if there's an in-progress count const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); @@ -590,8 +645,8 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } - $unregisterChatSessionItemProvider(handle: number): void { - this._itemProvidersRegistrations.deleteAndDispose(handle); + $unregisterChatSessionItemController(handle: number): void { + this._itemControllerRegistrations.deleteAndDispose(handle); } $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void { diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts deleted file mode 100644 index d76cbc2a46c..00000000000 --- a/src/vs/workbench/api/browser/mainThreadHooks.ts +++ /dev/null @@ -1,43 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI, UriComponents } from '../../../base/common/uri.js'; -import { Disposable } from '../../../base/common/lifecycle.js'; -import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; -import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; - -@extHostNamedCustomer(MainContext.MainThreadHooks) -export class MainThreadHooks extends Disposable implements MainThreadHooksShape { - - constructor( - extHostContext: IExtHostContext, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, - ) { - super(); - const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); - - const proxy: IHooksExecutionProxy = { - runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise => { - const result = await extHostProxy.$runHookCommand(hookCommand, input, token); - return { - kind: result.kind as HookCommandResultKind, - result: result.result - }; - } - }; - - this._hooksExecutionService.setProxy(proxy); - } - - async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise { - const uri = URI.revive(sessionResource); - return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token }); - } -} diff --git a/src/vs/workbench/api/common/configurationExtensionPoint.ts b/src/vs/workbench/api/common/configurationExtensionPoint.ts index 4a2cf457445..46304274ef0 100644 --- a/src/vs/workbench/api/common/configurationExtensionPoint.ts +++ b/src/vs/workbench/api/common/configurationExtensionPoint.ts @@ -116,6 +116,13 @@ const configurationEntrySchema: IJSONSchema = { type: 'boolean', description: nls.localize('scope.ignoreSync', 'When enabled, Settings Sync will not sync the user value of this configuration by default.') }, + keywords: { + type: 'array', + items: { + type: 'string' + }, + description: nls.localize('scope.keywords', 'A list of keywords that help users find this setting in the Settings editor. These are not shown to the user.') + }, tags: { type: 'array', items: { diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index c58a6d55165..d41aab9b5d6 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -65,7 +65,6 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js'; import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostInitDataService } from './extHostInitDataService.js'; -import { IExtHostHooks } from './extHostHooks.js'; import { ExtHostInteractive } from './extHostInteractive.js'; import { ExtHostLabelService } from './extHostLabelService.js'; import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js'; @@ -245,7 +244,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); - rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -257,7 +255,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); - const extHostHooks = accessor.get(IExtHostHooks); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1661,10 +1658,6 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, - async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise { - checkProposedApiEnabled(extension, 'chatHooks'); - return extHostHooks.executeHook(hookType, options, token); - }, }; // namespace: lm @@ -2048,6 +2041,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I ChatRequestTurn2: extHostTypes.ChatRequestTurn, ChatResponseTurn: extHostTypes.ChatResponseTurn, ChatResponseTurn2: extHostTypes.ChatResponseTurn2, + ChatSubagentToolInvocationData: extHostTypes.ChatSubagentToolInvocationData, ChatToolInvocationPart: extHostTypes.ChatToolInvocationPart, ChatLocation: extHostTypes.ChatLocation, ChatSessionStatus: extHostTypes.ChatSessionStatus, diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0d23cba7e36..422aa1b5827 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,9 +99,6 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3234,12 +3231,6 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } -export type IHookCommandDto = Dto; - -export interface ExtHostHooksShape { - $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3295,10 +3286,6 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } -export interface MainThreadHooksShape extends IDisposable { - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3428,8 +3415,10 @@ export interface IChatSessionProviderOptions { } export interface MainThreadChatSessionsShape extends IDisposable { - $registerChatSessionItemProvider(handle: number, chatSessionType: string): void; - $unregisterChatSessionItemProvider(handle: number): void; + $registerChatSessionItemController(handle: number, chatSessionType: string): void; + $unregisterChatSessionItemController(handle: number): void; + $setChatSessionItems(handle: number, items: Dto[]): Promise; + $updateChatSessionItem(handle: number, item: Dto): Promise; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; @@ -3443,7 +3432,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { } export interface ExtHostChatSessionsShape { - $provideChatSessionItems(providerHandle: number, token: CancellationToken): Promise[]>; + $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, token: CancellationToken): Promise; @@ -3534,7 +3523,6 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), - MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3614,7 +3602,6 @@ export const ExtHostContext = { ExtHostMeteredConnection: createProxyIdentifier('ExtHostMeteredConnection'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), - ExtHostHooks: createProxyIdentifier('ExtHostHooks'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), }; diff --git a/src/vs/workbench/api/common/extHostChatContext.ts b/src/vs/workbench/api/common/extHostChatContext.ts index 1e1a75fa152..74a9fab7e5f 100644 --- a/src/vs/workbench/api/common/extHostChatContext.ts +++ b/src/vs/workbench/api/common/extHostChatContext.ts @@ -50,7 +50,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext throw new Error('Workspace context provider not found'); } const provider = entry.provider as vscode.ChatWorkspaceContextProvider; - const result = (await provider.provideChatContext(token)) ?? []; + const result = (await provider.provideWorkspaceChatContext?.(token)) ?? (await provider.provideChatContext?.(token)) ?? []; return this._convertItems(handle, result); } @@ -63,7 +63,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext throw new Error('Explicit context provider not found'); } const provider = entry.provider as vscode.ChatExplicitContextProvider; - const result = (await provider.provideChatContext(token)) ?? []; + const result = (await provider.provideExplicitChatContext?.(token)) ?? (await provider.provideChatContext?.(token)) ?? []; return this._convertItems(handle, result); } @@ -77,7 +77,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!extItem) { throw new Error('Chat context item not found'); } - return this._doResolve(provider.resolveChatContext.bind(provider), context, extItem, token); + return this._doResolve((provider.resolveExplicitChatContext ?? provider.resolveChatContext)?.bind(provider), context, extItem, token); } // Resource context provider methods @@ -89,7 +89,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext } const provider = entry.provider as vscode.ChatResourceContextProvider; - const result = await provider.provideChatContext({ resource: URI.revive(options.resource) }, token); + const result = (await provider.provideResourceChatContext?.({ resource: URI.revive(options.resource) }, token)) ?? (await provider.provideChatContext?.({ resource: URI.revive(options.resource) }, token)); if (!result) { return undefined; } @@ -109,7 +109,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext command: result.command ? { id: result.command.command } : undefined }; if (options.withValue && !item.value) { - const resolved = await provider.resolveChatContext(result, token); + const resolved = await (provider.resolveResourceChatContext ?? provider.resolveChatContext)?.bind(provider)(result, token); item.value = resolved?.value; item.tooltip = resolved?.tooltip ? MarkdownString.from(resolved.tooltip) : item.tooltip; } @@ -127,7 +127,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (!extItem) { throw new Error('Chat context item not found'); } - return this._doResolve(provider.resolveChatContext.bind(provider), context, extItem, token); + return this._doResolve((provider.resolveResourceChatContext ?? provider.resolveChatContext)?.bind(provider), context, extItem, token); } // Command execution @@ -209,7 +209,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext if (provider.provideWorkspaceChatContext) { const workspaceProvider: vscode.ChatWorkspaceContextProvider = { onDidChangeWorkspaceChatContext: provider.onDidChangeWorkspaceChatContext, - provideChatContext: (token) => provider.provideWorkspaceChatContext!(token) + provideWorkspaceChatContext: (token) => provider.provideWorkspaceChatContext!(token) }; disposables.push(this.registerChatWorkspaceContextProvider(id, workspaceProvider)); } @@ -217,8 +217,8 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext // Register explicit context provider if the provider supports it if (provider.provideChatContextExplicit) { const explicitProvider: vscode.ChatExplicitContextProvider = { - provideChatContext: (token) => provider.provideChatContextExplicit!(token), - resolveChatContext: provider.resolveChatContext + provideExplicitChatContext: (token) => provider.provideChatContextExplicit!(token), + resolveExplicitChatContext: provider.resolveChatContext ? (context, token) => provider.resolveChatContext!(context, token) : (context) => context }; @@ -228,8 +228,8 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext // Register resource context provider if the provider supports it and has a selector if (provider.provideChatContextForResource && selector) { const resourceProvider: vscode.ChatResourceContextProvider = { - provideChatContext: (options, token) => provider.provideChatContextForResource!(options, token), - resolveChatContext: provider.resolveChatContext + provideResourceChatContext: (options, token) => provider.provideChatContextForResource!(options, token), + resolveResourceChatContext: provider.resolveChatContext ? (context, token) => provider.resolveChatContext!(context, token) : (context) => context }; @@ -315,7 +315,7 @@ export class ExtHostChatContext extends Disposable implements ExtHostChatContext return; } const provideWorkspaceContext = async () => { - const workspaceContexts = await provider.provideChatContext(CancellationToken.None); + const workspaceContexts = (await provider.provideWorkspaceChatContext?.(CancellationToken.None) ?? await provider.provideChatContext?.(CancellationToken.None)); const resolvedContexts = this._convertItems(handle, workspaceContexts ?? []); return this._proxy.$updateWorkspaceContextItems(handle, resolvedContexts); }; diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index e900c111d9e..febaa3a413d 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -9,7 +9,7 @@ import { coalesce } from '../../../base/common/arrays.js'; import { DeferredPromise } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; @@ -19,10 +19,10 @@ import { SymbolKind, SymbolKinds } from '../../../editor/common/languages.js'; import { IExtensionDescription } from '../../../platform/extensions/common/extensions.js'; import { ILogService } from '../../../platform/log/common/log.js'; import { IChatRequestVariableEntry, IDiagnosticVariableEntryFilterData, IPromptFileVariableEntry, ISymbolVariableEntry, PromptFileVariableKind } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionItem, IChatSessionProviderOptionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; -import { Proxied } from '../../services/extensions/common/proxyIdentifier.js'; +import { Dto, Proxied } from '../../services/extensions/common/proxyIdentifier.js'; import { ChatSessionDto, ExtHostChatSessionsShape, IChatAgentProgressShape, IChatSessionProviderOptions, MainContext, MainThreadChatSessionsShape } from './extHost.protocol.js'; import { ChatAgentResponseStream } from './extHostChatAgents2.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; @@ -257,17 +257,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private readonly _proxy: Proxied; - private _itemProviderHandlePool = 0; - private readonly _chatSessionItemProviders = new Map(); - private _itemControllerHandlePool = 0; private readonly _chatSessionItemControllers = new Map()); + + const collection = new ChatSessionItemCollectionImpl(() => { + // Noop for providers + }); + + // Helper to push items to main thread + const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { + collection.replace(items); + const convertedItems: Array> = []; + for (const sessionContent of items) { + this._sessionItems.set(sessionContent.resource, sessionContent); + convertedItems.push(typeConvert.ChatSessionItem.from(sessionContent)); + } + void this._proxy.$setChatSessionItems(handle, convertedItems); + }; + + const controller: vscode.ChatSessionItemController = { + id: chatSessionType, + items: collection, + createChatSessionItem: (_resource: vscode.Uri, _label: string) => { + throw new Error('Not implemented for providers'); + }, + onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, + dispose: () => { + disposables.dispose(); + }, + refreshHandler: async (token: vscode.CancellationToken) => { + const items = await provider.provideChatSessionItems(token) ?? []; + updateItems(items); + }, + }; + + this._chatSessionItemControllers.set(handle, { chatSessionType: chatSessionType, controller, extension, disposable: disposables, onDidChangeChatSessionItemStateEmitter }); + this._proxy.$registerChatSessionItemController(handle, chatSessionType); + if (provider.onDidChangeChatSessionItems) { disposables.add(provider.onDidChangeChatSessionItems(() => { - this._logService.trace(`ExtHostChatSessions. Firing $onDidChangeChatSessionItems for ${chatSessionType}`); + this._logService.trace(`ExtHostChatSessions. Provider items changed for ${chatSessionType}`); this._proxy.$onDidChangeChatSessionItems(handle); })); } @@ -347,35 +374,30 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return { dispose: () => { - this._chatSessionItemProviders.delete(handle); + this._chatSessionItemControllers.delete(handle); disposables.dispose(); - this._proxy.$unregisterChatSessionItemProvider(handle); + this._proxy.$unregisterChatSessionItemController(handle); } }; } - createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: (token: vscode.CancellationToken) => Thenable): vscode.ChatSessionItemController { const controllerHandle = this._itemControllerHandlePool++; const disposables = new DisposableStore(); let isDisposed = false; - let refreshIdPool = 0; - let activeRefreshId: number | undefined = undefined; - - const onDidChangeItemsEmitter = disposables.add(new Emitter()); const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - const notifyItemsChanged = () => { - // Suppress updates when a refresh is already happening - if (typeof activeRefreshId === 'undefined') { - onDidChangeItemsEmitter.fire(); + const onItemsChanged = () => { + const items: Array> = []; + for (const [_, item] of collection) { + this._sessionItems.set(item.resource, item); + items.push(typeConvert.ChatSessionItem.from(item)); } + void this._proxy.$setChatSessionItems(controllerHandle, items); }; - const collection = new ChatSessionItemCollectionImpl(() => { - notifyItemsChanged(); - }); + const collection = new ChatSessionItemCollectionImpl(onItemsChanged); const controller = Object.freeze({ id, @@ -384,17 +406,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('ChatSessionItemController has been disposed'); } - const opId = ++refreshIdPool; - activeRefreshId = opId; - - try { - this._logService.trace(`ExtHostChatSessions. Controller(${id}).refresh()`); - await refreshHandler(refreshToken); - } finally { - if (activeRefreshId === opId) { - activeRefreshId = undefined; - } - } + this._logService.trace(`ExtHostChatSessions. Controller(${id}).refresh()`); + await refreshHandler(refreshToken); }, items: collection, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, @@ -403,10 +416,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('ChatSessionItemController has been disposed'); } - return new ChatSessionItemImpl(resource, label, () => { - // TODO: Optimize to only update the specific item - notifyItemsChanged(); + const item = new ChatSessionItemImpl(resource, label, () => { + void this._proxy.$updateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); }); + return item; }, dispose: () => { isDisposed = true; @@ -414,21 +427,14 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }); - this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id, onDidChangeChatSessionItemStateEmitter }); + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, chatSessionType: id, onDidChangeChatSessionItemStateEmitter }); - // Controllers are implemented using providers on the ext host side for now - disposables.add(this.registerChatSessionItemProvider(extension, id, { - onDidChangeChatSessionItems: onDidChangeItemsEmitter.event, - onDidCommitChatSessionItem: Event.None, - provideChatSessionItems: async (token: CancellationToken): Promise => { - await controller.refreshHandler(token); - return Array.from(controller.items, x => x[1]); - }, - })); + // Register the controller with the main thread + this._proxy.$registerChatSessionItemController(controllerHandle, id); disposables.add(toDisposable(() => { this._chatSessionItemControllers.delete(controllerHandle); - this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + this._proxy.$unregisterChatSessionItemController(controllerHandle); })); return controller; @@ -460,70 +466,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }); } - private convertChatSessionStatus(status: vscode.ChatSessionStatus | undefined): ChatSessionStatus | undefined { - if (status === undefined) { - return undefined; - } - - switch (status) { - case 0: // vscode.ChatSessionStatus.Failed - return ChatSessionStatus.Failed; - case 1: // vscode.ChatSessionStatus.Completed - return ChatSessionStatus.Completed; - case 2: // vscode.ChatSessionStatus.InProgress - return ChatSessionStatus.InProgress; - // Need to support NeedsInput status if we ever export it to the extension API - default: - return undefined; - } - } - - private convertChatSessionItem(sessionContent: vscode.ChatSessionItem): IChatSessionItem { - // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties - const timing = sessionContent.timing; - const created = timing?.created ?? timing?.startTime ?? 0; - const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; - const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; - - return { - resource: sessionContent.resource, - label: sessionContent.label, - description: sessionContent.description ? typeConvert.MarkdownString.from(sessionContent.description) : undefined, - badge: sessionContent.badge ? typeConvert.MarkdownString.from(sessionContent.badge) : undefined, - status: this.convertChatSessionStatus(sessionContent.status), - archived: sessionContent.archived, - tooltip: typeConvert.MarkdownString.fromStrict(sessionContent.tooltip), - timing: { - created, - lastRequestStarted, - lastRequestEnded, - }, - changes: sessionContent.changes instanceof Array ? sessionContent.changes : undefined, - metadata: sessionContent.metadata, - }; - } - - async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const itemProvider = this._chatSessionItemProviders.get(handle); - if (!itemProvider) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } - - this._logService.trace(`ExtHostChatSessions:$provideChatSessionItems(${itemProvider.sessionType})`); - const items = await itemProvider.provider.provideChatSessionItems(token) ?? []; - if (token.isCancellationRequested) { - return []; - } - - const response: IChatSessionItem[] = []; - for (const sessionContent of items) { - this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(sessionContent)); - } - return response; - } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { @@ -785,6 +727,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } + async $refreshChatSessionItems(handle: number, token: CancellationToken): Promise { + const controllerData = this._chatSessionItemControllers.get(handle); + if (!controllerData) { + this._logService.warn(`No controller found for handle ${handle}`); + return; + } + + await controllerData.controller.refreshHandler(token); + } + $onDidChangeChatSessionItemState(controllerHandle: number, sessionResourceComponents: UriComponents, archived: boolean): void { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts deleted file mode 100644 index d03d803c47c..00000000000 --- a/src/vs/workbench/api/common/extHostHooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostHooksShape } from './extHost.protocol.js'; - -export const IExtHostHooks = createDecorator('IExtHostHooks'); - -export interface IChatHookExecutionOptions { - readonly input?: unknown; - readonly toolInvocationToken: unknown; -} - -export interface IExtHostHooks extends ExtHostHooksShape { - executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; -} diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 68b95881b32..6095aca4be1 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -127,6 +127,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, + preToolUseResult: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.preToolUseResult : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; diff --git a/src/vs/workbench/api/common/extHostLanguageModels.ts b/src/vs/workbench/api/common/extHostLanguageModels.ts index bba6ded35ad..f4383ce38d2 100644 --- a/src/vs/workbench/api/common/extHostLanguageModels.ts +++ b/src/vs/workbench/api/common/extHostLanguageModels.ts @@ -221,6 +221,7 @@ export class ExtHostLanguageModels implements ExtHostLanguageModelsShape { tooltip: m.tooltip, version: m.version, multiplier: m.multiplier, + multiplierNumeric: m.multiplierNumeric, maxInputTokens: m.maxInputTokens, maxOutputTokens: m.maxOutputTokens, auth, diff --git a/src/vs/workbench/api/common/extHostNotebookKernels.ts b/src/vs/workbench/api/common/extHostNotebookKernels.ts index 52eaf027866..eecdf625b00 100644 --- a/src/vs/workbench/api/common/extHostNotebookKernels.ts +++ b/src/vs/workbench/api/common/extHostNotebookKernels.ts @@ -150,6 +150,7 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { let _executeHandler = handler ?? _defaultExecutHandler; let _interruptHandler: ((this: vscode.NotebookController, notebook: vscode.NotebookDocument) => void | Thenable) | undefined; let _variableProvider: vscode.NotebookVariableProvider | undefined; + let _variableProviderDisposable: IDisposable | undefined; this._proxy.$addKernel(handle, data).catch(err => { // this can happen when a kernel with that ID is already registered @@ -234,9 +235,10 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { }, set variableProvider(value) { checkProposedApiEnabled(extension, 'notebookVariableProvider'); + _variableProviderDisposable?.dispose(); _variableProvider = value; data.hasVariableProvider = !!value; - value?.onDidChangeVariables(e => that._proxy.$variablesUpdated(e.uri)); + _variableProviderDisposable = value?.onDidChangeVariables(e => that._proxy.$variablesUpdated(e.uri)); _update(); }, get variableProvider() { @@ -270,6 +272,7 @@ export class ExtHostNotebookKernels implements ExtHostNotebookKernelsShape { this._kernelData.delete(handle); onDidChangeSelection.dispose(); onDidReceiveMessage.dispose(); + _variableProviderDisposable?.dispose(); this._proxy.$removeKernel(handle); } }, diff --git a/src/vs/workbench/api/common/extHostStatusBar.ts b/src/vs/workbench/api/common/extHostStatusBar.ts index 5387c3834d0..4cb635b3d53 100644 --- a/src/vs/workbench/api/common/extHostStatusBar.ts +++ b/src/vs/workbench/api/common/extHostStatusBar.ts @@ -304,6 +304,8 @@ export class ExtHostStatusBarEntry implements vscode.StatusBarItem { public dispose(): void { this.hide(); + this._staleCommandRegistrations.dispose(); + this._latestCommandRegistration?.dispose(); this._onDispose?.(); this._disposed = true; } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index d85253332ae..cf4575655c4 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -16,6 +16,7 @@ import { parse, revive } from '../../../base/common/marshalling.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; import { Mimes } from '../../../base/common/mime.js'; import { cloneAndChange } from '../../../base/common/objects.js'; +import { OS } from '../../../base/common/platform.js'; import { IPrefixTreeNode, WellDefinedPrefixTree } from '../../../base/common/prefixTree.js'; import { basename } from '../../../base/common/resources.js'; import { ThemeIcon } from '../../../base/common/themables.js'; @@ -41,11 +42,12 @@ import { DEFAULT_EDITOR_ASSOCIATION, SaveReason } from '../../common/editor.js'; import { IViewBadge } from '../../common/views.js'; import { IChatAgentRequest, IChatAgentResult } from '../../contrib/chat/common/participants/chatAgents.js'; import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/chatModel.js'; -import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatExtensionsContent, IChatExternalToolInvocationUpdate, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMoveMessage, IChatMultiDiffDataSerialized, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatTaskDto, IChatTaskResult, IChatTerminalToolInvocationData, IChatTextEdit, IChatThinkingPart, IChatToolInvocationSerialized, IChatTreeData, IChatUserActionEvent, IChatWarningMessage, IChatWorkspaceEdit } from '../../contrib/chat/common/chatService/chatService.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; +import { ChatSessionStatus, IChatSessionItem } from '../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; +import { IChatRequestHooks, IHookCommand, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -2899,7 +2901,7 @@ export namespace ChatResponseMovePart { } export namespace ChatToolInvocationPart { - export function from(part: vscode.ChatToolInvocationPart): IChatToolInvocationSerialized { + export function from(part: vscode.ChatToolInvocationPart): IChatToolInvocationSerialized | IChatExternalToolInvocationUpdate { // Check if toolSpecificData is ChatMcpToolInvocationData (has input and output) // If so, convert to resultDetails for rendering via ChatInputOutputMarkdownProgressPart let resultDetails: IToolResultInputOutputDetails | undefined; @@ -2913,7 +2915,29 @@ export namespace ChatToolInvocationPart { toolSpecificData = part.toolSpecificData ? convertToolSpecificData(part.toolSpecificData) : undefined; } - // Convert extension API ChatToolInvocationPart to internal serialized format + const presentation = part.presentation === 'hidden' + ? ToolInvocationPresentation.Hidden + : part.presentation === 'hiddenAfterComplete' + ? ToolInvocationPresentation.HiddenAfterComplete + : undefined; + + // When isComplete is explicitly set (not undefined), use the update DTO to enable + // live tool invocation updates. Extensions can push with isComplete: false to start + // an in-progress invocation, then push again with isComplete: true to complete it. + if (part.enablePartialUpdate) { + return { + kind: 'externalToolInvocationUpdate', + toolCallId: part.toolCallId, + toolName: part.toolName, + isComplete: !!part.isComplete, + invocationMessage: part.invocationMessage ? MarkdownString.from(part.invocationMessage) : undefined, + pastTenseMessage: part.pastTenseMessage ? MarkdownString.from(part.pastTenseMessage) : undefined, + toolSpecificData, + subagentInvocationId: part.subAgentInvocationId, + }; + } + + // Convert extension API ChatToolInvocationPart to internal serialized format (legacy path) return { kind: 'toolInvocationSerialized', toolCallId: part.toolCallId, @@ -2922,16 +2946,12 @@ export namespace ChatToolInvocationPart { originMessage: part.originMessage ? MarkdownString.from(part.originMessage) : undefined, pastTenseMessage: part.pastTenseMessage ? MarkdownString.from(part.pastTenseMessage) : undefined, isConfirmed: part.isConfirmed, - isComplete: part.isComplete ?? true, + isComplete: true, source: ToolDataSource.External, // isError: part.isError ?? false, toolSpecificData, resultDetails, - presentation: part.presentation === 'hidden' - ? ToolInvocationPresentation.Hidden - : part.presentation === 'hiddenAfterComplete' - ? ToolInvocationPresentation.HiddenAfterComplete - : undefined, + presentation, subAgentInvocationId: part.subAgentInvocationId }; } @@ -3010,6 +3030,15 @@ export namespace ChatToolInvocationPart { } }) }; + } else if (data instanceof types.ChatSubagentToolInvocationData) { + // Convert extension API subagent tool data to internal format + return { + kind: 'subagent', + description: data.description, + agentName: data.agentName, + prompt: data.prompt, + result: data.result, + }; } return data; } @@ -3045,7 +3074,7 @@ export namespace ChatToolInvocationPart { const toolInvocation = new types.ChatToolInvocationPart( part.toolId || part.toolName, part.toolCallId, - part.isError + part.errorMessage ); if (part.invocationMessage) { @@ -3410,6 +3439,7 @@ export namespace ChatAgentRequest { subAgentName: request.subAgentName, parentRequestId: request.parentRequestId, hasHooksEnabled: request.hasHooksEnabled ?? false, + hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { @@ -3437,6 +3467,8 @@ export namespace ChatAgentRequest { delete (requestWithAllProps as any).parentRequestId; // eslint-disable-next-line local/code-no-any-casts delete (requestWithAllProps as any).hasHooksEnabled; + // eslint-disable-next-line local/code-no-any-casts + delete (requestWithAllProps as any).hooks; } if (!isProposedApiEnabled(extension, 'chatParticipantAdditions')) { @@ -4055,13 +4087,86 @@ export namespace SourceControlInputBoxValidationType { } } -export namespace ChatHookResult { - export function to(result: IHookResult): vscode.ChatHookResult { +export namespace ChatRequestHooksConverter { + export function to(hooks: IChatRequestHooks): vscode.ChatRequestHooks { + const result: Record = {}; + for (const [hookType, commands] of Object.entries(hooks)) { + if (!commands || commands.length === 0) { + continue; + } + const converted: vscode.ChatHookCommand[] = []; + for (const cmd of commands) { + const resolved = ChatHookCommand.to(cmd); + if (resolved) { + converted.push(resolved); + } + } + if (converted.length > 0) { + result[hookType] = converted; + } + } + return result; + } +} + +export namespace ChatHookCommand { + export function to(hook: IHookCommand): vscode.ChatHookCommand | undefined { + const command = resolveEffectiveCommand(hook, OS); + if (!command) { + return undefined; + } return { - stopReason: result.stopReason, - messageForUser: result.messageForUser, - output: result.output, - success: result.success, + command, + cwd: hook.cwd, + env: hook.env, + timeout: hook.timeout, + }; + } +} + +export namespace ChatSessionItem { + + function convertStatus(status: vscode.ChatSessionStatus | undefined): ChatSessionStatus | undefined { + if (status === undefined) { + return undefined; + } + + switch (status) { + case 0: // vscode.ChatSessionStatus.Failed + return ChatSessionStatus.Failed; + case 1: // vscode.ChatSessionStatus.Completed + return ChatSessionStatus.Completed; + case 2: // vscode.ChatSessionStatus.InProgress + return ChatSessionStatus.InProgress; + case 3: // vscode.ChatSessionStatus.NeedsInput + return ChatSessionStatus.NeedsInput; + default: + return undefined; + } + } + + export function from(sessionContent: vscode.ChatSessionItem): Dto { + // Support both new (created, lastRequestStarted, lastRequestEnded) and old (startTime, endTime) timing properties + const timing = sessionContent.timing; + const created = timing?.created ?? timing?.startTime ?? 0; + const lastRequestStarted = timing?.lastRequestStarted ?? timing?.startTime; + const lastRequestEnded = timing?.lastRequestEnded ?? timing?.endTime; + + return { + resource: sessionContent.resource, + label: sessionContent.label, + description: sessionContent.description ? MarkdownString.from(sessionContent.description) : undefined, + badge: sessionContent.badge ? MarkdownString.from(sessionContent.badge) : undefined, + status: convertStatus(sessionContent.status), + archived: sessionContent.archived, + tooltip: MarkdownString.fromStrict(sessionContent.tooltip), + timing: { + created, + lastRequestStarted, + lastRequestEnded, + }, + changes: sessionContent.changes instanceof Array ? sessionContent.changes : undefined, + metadata: sessionContent.metadata, }; } } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 324132be24c..d7d1c805b87 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3176,6 +3176,19 @@ export class McpToolInvocationContentData { } } +export class ChatSubagentToolInvocationData { + description?: string; + agentName?: string; + prompt?: string; + result?: string; + constructor(description?: string, agentName?: string, prompt?: string, result?: string) { + this.description = description; + this.agentName = agentName; + this.prompt = prompt; + this.result = result; + } +} + export class ChatResponseExternalEditPart { applied: Thenable; didGetApplied!: (value: string) => void; @@ -3477,7 +3490,7 @@ export enum ChatTodoStatus { export class ChatToolInvocationPart { toolName: string; toolCallId: string; - isError?: boolean; + errorMessage?: string; invocationMessage?: string | vscode.MarkdownString; originMessage?: string | vscode.MarkdownString; pastTenseMessage?: string | vscode.MarkdownString; @@ -3490,10 +3503,10 @@ export class ChatToolInvocationPart { constructor(toolName: string, toolCallId: string, - isError?: boolean) { + errorMessage?: string) { this.toolName = toolName; this.toolCallId = toolCallId; - this.isError = isError; + this.errorMessage = errorMessage; } } @@ -3539,7 +3552,8 @@ export enum ChatLocation { export enum ChatSessionStatus { Failed = 0, Completed = 1, - InProgress = 2 + InProgress = 2, + NeedsInput = 3 } export class ChatSessionChangedFile { diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 5f52766f40a..55acd8bd9c1 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,8 +31,6 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; -import { NodeExtHostHooks } from './extHostHooksNode.js'; // ######################################################################### // ### ### @@ -55,4 +53,3 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); -registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts deleted file mode 100644 index 251305dbb58..00000000000 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ /dev/null @@ -1,190 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type * as vscode from 'vscode'; -import { spawn } from 'child_process'; -import { homedir } from 'os'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { URI, isUriComponents } from '../../../base/common/uri.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue, getEffectiveCommandSource, resolveEffectiveCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; - -const SIGKILL_DELAY_MS = 5000; - -export class NodeExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - throw new Error('Invalid or missing tool invocation token'); - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise { - this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); - - try { - return await this._executeCommand(hookCommand, input, token); - } catch (err) { - return { - kind: HookCommandResultKind.Error, - result: err instanceof Error ? err.message : String(err) - }; - } - } - - private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise { - const home = homedir(); - const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; - const cwd = cwdUri ? cwdUri.fsPath : home; - - // Resolve the effective command for the current platform - // This applies windows/linux/osx overrides and falls back to command - const effectiveCommand = resolveEffectiveCommand(hook as Parameters[0]); - if (!effectiveCommand) { - return Promise.resolve({ - kind: HookCommandResultKind.Error, - result: 'No command specified for the current platform' - }); - } - - // Execute the command, preserving legacy behavior for explicit shell types: - // - powershell source: run through PowerShell so PowerShell-specific commands work - // - bash source: run through bash so bash-specific commands work - // - otherwise: use default shell via spawn with shell: true - const commandSource = getEffectiveCommandSource(hook as Parameters[0]); - let shellExecutable: string | undefined; - let shellArgs: string[] | undefined; - - if (commandSource === 'powershell') { - shellExecutable = 'powershell.exe'; - shellArgs = ['-Command', effectiveCommand]; - } else if (commandSource === 'bash') { - shellExecutable = 'bash'; - shellArgs = ['-c', effectiveCommand]; - } - - const child = shellExecutable && shellArgs - ? spawn(shellExecutable, shellArgs, { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - }) - : spawn(effectiveCommand, [], { - stdio: 'pipe', - cwd, - env: { ...process.env, ...hook.env }, - shell: true, - }); - - return new Promise((resolve, reject) => { - const stdout: string[] = []; - const stderr: string[] = []; - let exitCode: number | null = null; - let exited = false; - - const disposables = new DisposableStore(); - const sigkillTimeout = disposables.add(new MutableDisposable()); - - const killWithEscalation = () => { - if (exited) { - return; - } - child.kill('SIGTERM'); - sigkillTimeout.value = disposableTimeout(() => { - if (!exited) { - child.kill('SIGKILL'); - } - }, SIGKILL_DELAY_MS); - }; - - const cleanup = () => { - exited = true; - disposables.dispose(); - }; - - // Collect output - child.stdout.on('data', data => stdout.push(data.toString())); - child.stderr.on('data', data => stderr.push(data.toString())); - - // Set up timeout (default 30 seconds) - disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000)); - - // Set up cancellation - if (token) { - disposables.add(token.onCancellationRequested(killWithEscalation)); - } - - // Write input to stdin - if (input !== undefined && input !== null) { - try { - // Use a replacer to convert URI values to filesystem paths. - // URIs arrive as UriComponents objects via the RPC boundary. - child.stdin.write(JSON.stringify(input, (_key, value) => { - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - })); - } catch { - // Ignore stdin write errors - } - } - child.stdin.end(); - - // Capture exit code - child.on('exit', code => { exitCode = code; }); - - // Resolve on close (after streams flush) - child.on('close', () => { - cleanup(); - const code = exitCode ?? 1; - const stdoutStr = stdout.join(''); - const stderrStr = stderr.join(''); - - if (code === 0) { - // Success - try to parse stdout as JSON, otherwise return as string - let result: string | object = stdoutStr; - try { - result = JSON.parse(stdoutStr); - } catch { - // Keep as string if not valid JSON - } - resolve({ kind: HookCommandResultKind.Success, result }); - } else { - // Error - resolve({ kind: HookCommandResultKind.Error, result: stderrStr }); - } - }); - - child.on('error', err => { - cleanup(); - reject(err); - }); - }); - } -} diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index fdb8e734540..2f846d3c7fb 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -60,7 +60,7 @@ suite('ObservableChatSession', function () { $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), - $provideChatSessionItems: sinon.stub(), + $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), }; }); @@ -357,7 +357,7 @@ suite('MainThreadChatSessions', function () { $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), - $provideChatSessionItems: sinon.stub(), + $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), }; diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts deleted file mode 100644 index 5ff8dad0f6e..00000000000 --- a/src/vs/workbench/api/test/node/extHostHooks.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { URI } from '../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../platform/log/common/log.js'; -import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; -import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js'; -import { HookCommandResultKind } from '../../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../../contrib/chat/common/hooks/hooksTypes.js'; -import { IExtHostRpcService } from '../../common/extHostRpcService.js'; -import { CancellationToken } from '../../../../base/common/cancellation.js'; - -function createHookCommandDto(command: string, options?: Partial>): IHookCommandDto { - return { - type: 'command', - command, - ...options, - }; -} - -function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService { - return { - _serviceBrand: undefined, - getProxy(): T { - return mainThreadProxy as unknown as T; - }, - set(_identifier: unknown, instance: R): R { - return instance; - }, - dispose(): void { }, - assertRegistered(): void { }, - drain(): Promise { return Promise.resolve(); }, - } as IExtHostRpcService; -} - -suite.skip('ExtHostHooks', () => { - ensureNoDisposablesAreLeakedInTestSuite(); - - let hooksService: NodeExtHostHooks; - - setup(() => { - const mockMainThreadProxy: MainThreadHooksShape = { - $executeHook: async (): Promise => { - return []; - }, - dispose: () => { } - }; - - const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy); - hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService()); - }); - - test('$runHookCommand runs command and returns success result', async () => { - const hookCommand = createHookCommandDto('echo "hello world"'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'hello world'); - }); - - test('$runHookCommand parses JSON output', async () => { - const hookCommand = createHookCommandDto('echo \'{"key": "value"}\''); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, { key: 'value' }); - }); - - test('$runHookCommand returns error result for non-zero exit code', async () => { - const hookCommand = createHookCommandDto('exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - }); - - test('$runHookCommand captures stderr on failure', async () => { - const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - assert.strictEqual((result.result as string).trim(), 'error message'); - }); - - test('$runHookCommand passes input to stdin as JSON', async () => { - const hookCommand = createHookCommandDto('cat'); - const input = { tool: 'bash', args: { command: 'ls' } }; - const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.deepStrictEqual(result.result, input); - }); - - test('$runHookCommand returns error for invalid command', async () => { - const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - }); - - test('$runHookCommand uses custom environment variables', async () => { - const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - assert.strictEqual((result.result as string).trim(), 'custom_value'); - }); - - test('$runHookCommand uses custom cwd', async () => { - const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') }); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Success); - // The result should contain /tmp or /private/tmp (macOS symlink) - assert.ok((result.result as string).includes('tmp')); - }); -}); diff --git a/src/vs/workbench/api/worker/extHost.worker.services.ts b/src/vs/workbench/api/worker/extHost.worker.services.ts index 14ec71b80e9..d6055bcf0f6 100644 --- a/src/vs/workbench/api/worker/extHost.worker.services.ts +++ b/src/vs/workbench/api/worker/extHost.worker.services.ts @@ -8,12 +8,10 @@ import { InstantiationType, registerSingleton } from '../../../platform/instanti import { ILogService } from '../../../platform/log/common/log.js'; import { ExtHostAuthentication, IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { IExtHostExtensionService } from '../common/extHostExtensionService.js'; -import { IExtHostHooks } from '../common/extHostHooks.js'; import { ExtHostLogService } from '../common/extHostLogService.js'; import { ExtensionStoragePaths, IExtensionStoragePaths } from '../common/extHostStoragePaths.js'; import { ExtHostTelemetry, IExtHostTelemetry } from '../common/extHostTelemetry.js'; import { ExtHostExtensionService } from './extHostExtensionService.js'; -import { WorkerExtHostHooks } from './extHostHooksWorker.js'; // ######################################################################### // ### ### @@ -26,4 +24,3 @@ registerSingleton(IExtHostAuthentication, ExtHostAuthentication, InstantiationTy registerSingleton(IExtHostExtensionService, ExtHostExtensionService, InstantiationType.Eager); registerSingleton(IExtensionStoragePaths, ExtensionStoragePaths, InstantiationType.Eager); registerSingleton(IExtHostTelemetry, new SyncDescriptor(ExtHostTelemetry, [true], true)); -registerSingleton(IExtHostHooks, WorkerExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/worker/extHostHooksWorker.ts b/src/vs/workbench/api/worker/extHostHooksWorker.ts deleted file mode 100644 index faaef307d0b..00000000000 --- a/src/vs/workbench/api/worker/extHostHooksWorker.ts +++ /dev/null @@ -1,49 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { ILogService } from '../../../platform/log/common/log.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; -import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; -import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; -import { IExtHostRpcService } from '../common/extHostRpcService.js'; -import * as typeConverters from '../common/extHostTypeConverters.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; - -export class WorkerExtHostHooks implements IExtHostHooks { - - private readonly _mainThreadProxy: MainThreadHooksShape; - - constructor( - @IExtHostRpcService extHostRpc: IExtHostRpcService, - @ILogService private readonly _logService: ILogService - ) { - this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); - } - - async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { - if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { - throw new Error('Invalid or missing tool invocation token'); - } - - const context = options.toolInvocationToken as IToolInvocationContext; - - const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); - return results.map(r => typeConverters.ChatHookResult.to(r as IHookResult)); - } - - async $runHookCommand(_hookCommand: IHookCommandDto, _input: unknown, _token: CancellationToken): Promise { - this._logService.debug('[WorkerExtHostHooks] Hook commands are not supported in web worker context'); - - // Web worker cannot run shell commands - return an error - return { - kind: HookCommandResultKind.Error, - result: 'Hook commands are not supported in web worker context' - }; - } -} diff --git a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 080b6871061..60ffabb3a63 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -41,7 +41,7 @@ import { SwitchCompositeViewAction } from '../compositeBarActions.js'; export class ActivitybarPart extends Part { - static readonly ACTION_HEIGHT = 48; + static readonly ACTION_HEIGHT = 32; static readonly pinnedViewContainersKey = 'workbench.activity.pinnedViewlets2'; static readonly placeholderViewContainersKey = 'workbench.activity.placeholderViewlets'; @@ -49,8 +49,8 @@ export class ActivitybarPart extends Part { //#region IView - readonly minimumWidth: number = 48; - readonly maximumWidth: number = 48; + readonly minimumWidth: number = 36; + readonly maximumWidth: number = 36; readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; @@ -77,7 +77,7 @@ export class ActivitybarPart extends Part { viewContainersWorkspaceStateKey: ActivitybarPart.viewContainersWorkspaceStateKey, orientation: ActionsOrientation.VERTICAL, icon: true, - iconSize: 24, + iconSize: 16, activityHoverOptions: { position: () => this.layoutService.getSideBarPosition() === Position.LEFT ? HoverPosition.RIGHT : HoverPosition.LEFT, }, diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 8cc8ca0e484..6c19b80055b 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -12,7 +12,7 @@ .monaco-workbench .activitybar > .content .composite-bar > .monaco-action-bar .action-item::after { position: absolute; content: ''; - width: 48px; + width: 36px; height: 2px; display: none; background-color: transparent; @@ -46,8 +46,8 @@ z-index: 1; display: flex; overflow: hidden; - width: 48px; - height: 48px; + width: 36px; + height: 32px; margin-right: 0; box-sizing: border-box; @@ -55,12 +55,12 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label:not(.codicon) { font-size: 15px; - line-height: 40px; - padding: 0 0 0 48px; + line-height: 32px; + padding: 0 0 0 36px; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label.codicon { - font-size: 24px; + font-size: 16px; align-items: center; justify-content: center; } @@ -157,27 +157,28 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { position: absolute; - top: 24px; - right: 8px; + top: 17px; + right: 6px; font-size: 9px; font-weight: 600; - min-width: 8px; - height: 16px; - line-height: 16px; - padding: 0 4px; - border-radius: 20px; + min-width: 9px; + height: 13px; + line-height: 13px; + padding: 0 2px; + border-radius: 13px; text-align: center; + border: none !important; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { position: absolute; font-weight: 600; - font-size: 9px; - line-height: 10px; - top: 24px; - right: 6px; - padding: 2px 3px; - border-radius: 7px; + font-size: 8px; + line-height: 8px; + top: 14px; + right: 2px; + padding: 2px 2px; + border-radius: 6px; background-color: var(--vscode-profileBadge-background); color: var(--vscode-profileBadge-foreground); border: 2px solid var(--vscode-activityBar-background); diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index abe1427ca20..452cc971b24 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-workbench .part.activitybar { - width: 48px; + width: 36px; height: 100%; } diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index c5e4d18e457..7664074eced 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -19,14 +19,14 @@ import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/brows export class BrowserDialogHandler extends AbstractDialogHandler { - private static readonly ALLOWABLE_COMMANDS = [ + private static readonly ALLOWABLE_COMMANDS = new Set([ 'copy', 'cut', 'editor.action.selectAll', 'editor.action.clipboardCopyAction', 'editor.action.clipboardCutAction', 'editor.action.clipboardPasteAction' - ]; + ]); constructor( @ILogService private readonly logService: ILogService, diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index fd15522971e..693918dc2ca 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -27,7 +27,7 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet import { ActiveGroupEditorsByMostRecentlyUsedQuickAccess } from './editorQuickAccess.js'; import { SideBySideEditor } from './sideBySideEditor.js'; import { TextDiffEditor } from './textDiffEditor.js'; -import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; +import { ActiveEditorCanSplitInGroupContext, ActiveEditorGroupEmptyContext, ActiveEditorGroupLockedContext, ActiveEditorStickyContext, EditorPartModalContext, EditorPartModalMaximizedContext, MultipleEditorGroupsContext, SideBySideEditorActiveContext, TextCompareEditorActiveContext } from '../../../common/contextkeys.js'; import { CloseDirection, EditorInputCapabilities, EditorsOrder, IResourceDiffEditorInput, IUntitledTextResourceEditorInput, isEditorInputWithOptionsAndGroup } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; import { SideBySideEditorInput } from '../../../common/editor/sideBySideEditorInput.js'; @@ -107,6 +107,7 @@ export const NEW_EMPTY_EDITOR_WINDOW_COMMAND_ID = 'workbench.action.newEmptyEdit export const CLOSE_MODAL_EDITOR_COMMAND_ID = 'workbench.action.closeModalEditor'; export const MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID = 'workbench.action.moveModalEditorToMain'; +export const TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID = 'workbench.action.toggleModalEditorMaximized'; export const API_OPEN_EDITOR_COMMAND_ID = '_workbench.open'; export const API_OPEN_DIFF_EDITOR_COMMAND_ID = '_workbench.diff'; @@ -1435,6 +1436,39 @@ function registerModalEditorCommands(): void { } }); + registerAction2(class extends Action2 { + constructor() { + super({ + id: TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID, + title: localize2('toggleModalEditorMaximized', 'Maximize Modal Editor'), + category: Categories.View, + f1: true, + precondition: EditorPartModalContext, + icon: Codicon.screenFull, + toggled: { + condition: EditorPartModalMaximizedContext, + icon: Codicon.screenNormal, + title: localize('restoreModalEditorSize', "Restore Modal Editor") + }, + menu: { + id: MenuId.ModalEditorTitle, + group: 'navigation', + order: 1 + } + }); + } + run(accessor: ServicesAccessor): void { + const editorGroupsService = accessor.get(IEditorGroupsService); + + for (const part of editorGroupsService.parts) { + if (isModalEditorPart(part)) { + part.toggleMaximized(); + break; + } + } + } + }); + registerAction2(class extends Action2 { constructor() { super({ @@ -1475,6 +1509,8 @@ function isModalEditorPart(obj: unknown): obj is IModalEditorPart { return !!part && typeof part.close === 'function' && typeof part.onWillClose === 'function' + && typeof part.toggleMaximized === 'function' + && typeof part.maximized === 'boolean' && part.windowId === mainWindow.vscodeWindowId; } diff --git a/src/vs/workbench/browser/parts/editor/editorParts.ts b/src/vs/workbench/browser/parts/editor/editorParts.ts index 71864ffb0c2..61703e493fd 100644 --- a/src/vs/workbench/browser/parts/editor/editorParts.ts +++ b/src/vs/workbench/browser/parts/editor/editorParts.ts @@ -155,6 +155,7 @@ export class EditorParts extends MultiWindowParts { diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index de3bc1e1f33..19b64fc8c85 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -6,15 +6,15 @@ /** Modal Editor Part: Modal Block */ .monaco-modal-editor-block { position: fixed; - height: 100%; width: 100%; left: 0; - top: 0; /* z-index for modal editors: below dialogs, quick input, context views, hovers but above other things */ z-index: 2000; display: flex; justify-content: center; align-items: center; + /* Never allow content to escape above the title bar */ + overflow: hidden; } .monaco-modal-editor-block.dimmed { diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index ab5be6496b2..6e65299f400 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -4,7 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import './media/modalEditorPart.css'; -import { $, addDisposableListener, append, EventType } from '../../../../base/browser/dom.js'; +import { $, addDisposableListener, append, EventHelper, EventType } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -13,19 +14,30 @@ import { IConfigurationService } from '../../../../platform/configuration/common import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { IEditorGroupView, IEditorPartsView } from './editor.js'; import { EditorPart } from './editorPart.js'; import { GroupDirection, GroupsOrder, IModalEditorPart } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; -import { EditorPartModalContext } from '../../../common/contextkeys.js'; +import { EditorPartModalContext, EditorPartModalMaximizedContext } from '../../../common/contextkeys.js'; import { Verbosity } from '../../../common/editor.js'; import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { localize } from '../../../../nls.js'; +const defaultModalEditorAllowableCommands = new Set([ + 'workbench.action.quit', + 'workbench.action.reloadWindow', + 'workbench.action.closeActiveEditor', + 'workbench.action.closeAllEditors', + 'workbench.action.files.save', + 'workbench.action.files.saveAll', +]); + export interface ICreateModalEditorPartResult { readonly part: ModalEditorPartImpl; readonly instantiationService: IInstantiationService; @@ -39,6 +51,7 @@ export class ModalEditorPart { @IInstantiationService private readonly instantiationService: IInstantiationService, @IEditorService private readonly editorService: IEditorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, + @IKeybindingService private readonly keybindingService: IKeybindingService, ) { } @@ -112,16 +125,52 @@ export class ModalEditorPart { } })); + // Block certain workbench commands from being dispatched while the modal is open + disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); + if (resolved.kind === ResultKind.KbFound && resolved.commandId) { + if ( + resolved.commandId.startsWith('workbench.') && + !defaultModalEditorAllowableCommands.has(resolved.commandId) + ) { + EventHelper.stop(event, true); + } + } + })); + // Handle close event from editor part disposables.add(Event.once(editorPart.onWillClose)(() => { disposables.dispose(); })); // Layout the modal editor part - disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, () => { + const layoutModal = () => { const containerDimension = this.layoutService.mainContainerDimension; - const width = Math.min(containerDimension.width * 0.8, 1200); - const height = Math.min(containerDimension.height * 0.8, 800); + const titleBarOffset = this.layoutService.mainContainerOffset.top; + const availableHeight = Math.max(containerDimension.height - titleBarOffset, 0); + + let width: number; + let height: number; + + if (editorPart.maximized) { + const padding = 16; // Keep a small margin around all edges + width = Math.max(containerDimension.width - padding, 0); + height = Math.max(availableHeight - padding, 0); + } else { + const maxWidth = 1200; + const maxHeight = 800; + const targetWidth = containerDimension.width * 0.8; + const targetHeight = availableHeight * 0.8; + width = Math.min(targetWidth, maxWidth, containerDimension.width); + height = Math.min(targetHeight, maxHeight, availableHeight); + } + + height = Math.min(height, availableHeight); // Ensure the modal never exceeds available height (below the title bar) + + // Shift the modal block below the title bar + modalElement.style.top = `${titleBarOffset}px`; + modalElement.style.height = `calc(100% - ${titleBarOffset}px)`; editorPartContainer.style.width = `${width}px`; editorPartContainer.style.height = `${height}px`; @@ -129,7 +178,9 @@ export class ModalEditorPart { const borderSize = 2; // Account for 1px border on all sides and modal header height const headerHeight = 32 + 1 /* border bottom */; editorPart.layout(width - borderSize, height - borderSize - headerHeight, 0, 0); - })); + }; + disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); + disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); // Focus the modal editorPartContainer.focus(); @@ -149,6 +200,12 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { private readonly _onWillClose = this._register(new Emitter()); readonly onWillClose = this._onWillClose.event; + private readonly _onDidChangeMaximized = this._register(new Emitter()); + readonly onDidChangeMaximized = this._onDidChangeMaximized.event; + + private _maximized = false; + get maximized(): boolean { return this._maximized; } + private readonly optionsDisposable = this._register(new MutableDisposable()); constructor( @@ -185,10 +242,20 @@ class ModalEditorPartImpl extends EditorPart implements IModalEditorPart { this.enforceModalPartOptions(); } + toggleMaximized(): void { + this._maximized = !this._maximized; + + this._onDidChangeMaximized.fire(this._maximized); + } + protected override handleContextKeys(): void { const isModalEditorPartContext = EditorPartModalContext.bindTo(this.scopedContextKeyService); isModalEditorPartContext.set(true); + const isMaximizedContext = EditorPartModalMaximizedContext.bindTo(this.scopedContextKeyService); + isMaximizedContext.set(this._maximized); + this._register(this.onDidChangeMaximized(maximized => isMaximizedContext.set(maximized))); + super.handleContextKeys(); } diff --git a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts index 4bd53d45f69..18340a8ae9a 100644 --- a/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts +++ b/src/vs/workbench/browser/parts/statusbar/statusbarPart.ts @@ -663,7 +663,7 @@ class StatusbarPart extends Part implements IStatusbarEntryContainer { const statusBarFocusColor = this.getColor(STATUS_BAR_FOCUS_BORDER); if (!this.styleElement) { - this.styleElement = createStyleSheet(container); + this.styleElement = createStyleSheet(container, undefined, this._store); } this.styleElement.textContent = ` diff --git a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts index 721f5309ad4..2c1d45ea6aa 100644 --- a/src/vs/workbench/browser/parts/titlebar/menubarControl.ts +++ b/src/vs/workbench/browser/parts/titlebar/menubarControl.ts @@ -437,9 +437,9 @@ export class CustomMenubarControl extends MenubarControl { this._onFocusStateChange = this._register(new Emitter()); this.actionRunner = this._register(new ActionRunner()); - this.actionRunner.onDidRun(e => { + this._register(this.actionRunner.onDidRun(e => { this.telemetryService.publicLog2('workbenchActionExecuted', { id: e.action.id, from: 'menu' }); - }); + })); this.workspacesService.getRecentlyOpened().then((recentlyOpened) => { this.recentlyOpened = recentlyOpened; diff --git a/src/vs/workbench/browser/parts/titlebar/windowTitle.ts b/src/vs/workbench/browser/parts/titlebar/windowTitle.ts index a182e124a2a..d2a6791f83a 100644 --- a/src/vs/workbench/browser/parts/titlebar/windowTitle.ts +++ b/src/vs/workbench/browser/parts/titlebar/windowTitle.ts @@ -64,7 +64,7 @@ export class WindowTitle extends Disposable { private readonly activeEditorListeners = this._register(new DisposableStore()); private readonly titleUpdater = this._register(new RunOnceScheduler(() => this.doUpdateTitle(), 0)); - private readonly onDidChangeEmitter = new Emitter(); + private readonly onDidChangeEmitter = this._register(new Emitter()); readonly onDidChange = this.onDidChangeEmitter.event; get value() { return this.title ?? ''; } diff --git a/src/vs/workbench/browser/parts/views/treeView.ts b/src/vs/workbench/browser/parts/views/treeView.ts index c971d405e21..1c9305bd780 100644 --- a/src/vs/workbench/browser/parts/views/treeView.ts +++ b/src/vs/workbench/browser/parts/views/treeView.ts @@ -1823,6 +1823,7 @@ class TreeMenus implements IDisposable { dispose() { this.contextKeyService = undefined; + this._onDidChange.dispose(); } } diff --git a/src/vs/workbench/common/contextkeys.ts b/src/vs/workbench/common/contextkeys.ts index 9d00d682730..20b3b1f804d 100644 --- a/src/vs/workbench/common/contextkeys.ts +++ b/src/vs/workbench/common/contextkeys.ts @@ -95,7 +95,9 @@ export const SelectedEditorsInGroupFileOrUntitledResourceContextKey = new RawCon export const EditorPartMultipleEditorGroupsContext = new RawContextKey('editorPartMultipleEditorGroups', false, localize('editorPartMultipleEditorGroups', "Whether there are multiple editor groups opened in an editor part")); export const EditorPartSingleEditorGroupsContext = EditorPartMultipleEditorGroupsContext.toNegated(); export const EditorPartMaximizedEditorGroupContext = new RawContextKey('editorPartMaximizedEditorGroup', false, localize('editorPartEditorGroupMaximized', "Editor Part has a maximized group")); + export const EditorPartModalContext = new RawContextKey('editorPartModal', false, localize('editorPartModal', "Whether focus is in a modal editor part")); +export const EditorPartModalMaximizedContext = new RawContextKey('editorPartModalMaximized', false, localize('editorPartModalMaximized', "Whether the modal editor part is maximized")); // Editor Layout Context Keys export const EditorsVisibleContext = new RawContextKey('editorIsOpen', false, localize('editorIsOpen', "Whether an editor is open")); diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts index 267b9be32f5..4d5d76768f0 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditPane.ts @@ -104,6 +104,7 @@ export class BulkEditPane extends ViewPane { override dispose(): void { this._tree.dispose(); this._disposables.dispose(); + this._sessionDisposables.dispose(); super.dispose(); } diff --git a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts index 6c78277c3d6..31556d785ce 100644 --- a/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts +++ b/src/vs/workbench/contrib/bulkEdit/browser/preview/bulkEditTree.ts @@ -668,7 +668,9 @@ export class TextEditElementRenderer implements ITreeRenderer { diff --git a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts index 1f88332b739..0cd00519f0e 100644 --- a/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts @@ -39,8 +39,17 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation } const verifiedWidget: IChatWidget = widget; - const focusedItem = verifiedWidget.getFocus(); - if (!focusedItem) { + let focusedItem = verifiedWidget.getFocus(); + if (!focusedItem || !isResponseVM(focusedItem)) { + const responseItems = verifiedWidget.viewModel?.getItems().filter(isResponseVM); + const lastResponse = responseItems?.at(-1); + if (lastResponse) { + focusedItem = lastResponse; + verifiedWidget.focus(lastResponse); + } + } + + if (!focusedItem || !isResponseVM(focusedItem)) { return; } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index 4a6166dceea..3c23b729b2a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -88,6 +88,7 @@ export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'age content.push(localize('chat.focusMostRecentTerminal', 'To focus the last chat terminal that ran a tool, invoke the Focus Most Recent Chat Terminal command{0}.', ``)); content.push(localize('chat.focusMostRecentTerminalOutput', 'To focus the output from the last chat terminal tool, invoke the Focus Most Recent Chat Terminal Output command{0}.', ``)); content.push(localize('chat.focusQuestionCarousel', 'When a chat question appears, toggle focus between the question and the chat input{0}.', '')); + content.push(localize('chat.focusTip', 'When a tip appears, toggle focus between the tip and the chat input{0}.', '')); } if (type === 'editsView' || type === 'agentView') { if (type === 'agentView') { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index 2b6400606e7..dc57a988509 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -793,7 +793,7 @@ export function registerChatActions() { precondition: ChatContextKeys.inChatSession, keybinding: [{ weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyA, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyH, when: ChatContextKeys.inChatSession, }] }); @@ -809,6 +809,55 @@ export function registerChatActions() { } }); + registerAction2(class FocusTipAction extends Action2 { + static readonly ID = 'workbench.action.chat.focusTip'; + + constructor() { + super({ + id: FocusTipAction.ID, + title: localize2('interactiveSession.focusTip.label', "Chat: Toggle Focus Between Tip and Input"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.inChatSession, + keybinding: [{ + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.Slash, + when: ContextKeyExpr.or( + ChatContextKeys.inChatSession, + ChatContextKeys.inChatTip + ), + }] + }); + } + + run(accessor: ServicesAccessor): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + + if (!widget || !widget.toggleTipFocus()) { + alert(localize('chat.tip.focusUnavailable', "No chat tip.")); + } + } + }); + + registerAction2(class ShowContextUsageAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.showContextUsage', + title: localize2('interactiveSession.showContextUsage.label', "Show Context Window Usage"), + category: CHAT_CATEGORY, + f1: true, + precondition: ChatContextKeys.enabled, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget ?? (await widgetService.revealWidget()); + widget?.input.showContextUsageDetails(); + } + }); + const nonEnterpriseCopilotUsers = ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.notEquals(`config.${defaultChat.completionsAdvancedSetting}.authProvider`, defaultChat.provider.enterprise.id)); registerAction2(class extends Action2 { constructor() { @@ -971,13 +1020,7 @@ export function registerChatActions() { category: CHAT_CATEGORY, icon: Codicon.sparkle, f1: true, - precondition: ChatContextKeys.enabled, - menu: { - id: CHAT_CONFIG_MENU_ID, - when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), - order: 11, - group: '1_level' - } + precondition: ChatContextKeys.enabled }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index 50ae8b00a26..bb72913ae98 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -27,6 +27,8 @@ import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../platf import { IPathService } from '../../../../services/path/common/pathService.js'; import { parseAllHookFiles, IParsedHook } from '../promptSyntax/hookUtils.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { OS } from '../../../../../base/common/platform.js'; /** * URL encodes path segments for use in markdown links. @@ -155,6 +157,7 @@ export function registerChatCustomizationDiagnosticsAction() { const commandService = accessor.get(ICommandService); const workspaceContextService = accessor.get(IWorkspaceContextService); const labelService = accessor.get(ILabelService); + const remoteAgentService = accessor.get(IRemoteAgentService); const token = CancellationToken.None; const workspaceFolders = workspaceContextService.getWorkspace().folders; @@ -180,7 +183,7 @@ export function registerChatCustomizationDiagnosticsAction() { statusInfos.push(skillsStatus); // 5. Hooks - const hooksStatus = await collectHooksStatus(promptsService, fileService, labelService, pathService, workspaceContextService, token); + const hooksStatus = await collectHooksStatus(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token); statusInfos.push(hooksStatus); // 6. Special files (AGENTS.md, copilot-instructions.md) @@ -308,6 +311,7 @@ async function collectHooksStatus( labelService: ILabelService, pathService: IPathService, workspaceContextService: IWorkspaceContextService, + remoteAgentService: IRemoteAgentService, token: CancellationToken ): Promise { const type = PromptsType.hook; @@ -321,8 +325,13 @@ async function collectHooksStatus( const discoveryInfo = await promptsService.getPromptDiscoveryInfo(type, token); const files = discoveryInfo.files.map(convertDiscoveryResultToFileStatus); + // Collect URIs of files skipped due to disableAllHooks so we can show their hidden hooks + const disabledFileUris = discoveryInfo.files + .filter(f => f.status === 'skipped' && f.skipReason === 'all-hooks-disabled') + .map(f => f.uri); + // Parse hook files to extract individual hooks grouped by lifecycle - const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, token); + const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token, disabledFileUris); return { type, paths, files, enabled, parsedHooks }; } @@ -336,7 +345,9 @@ async function parseHookFiles( labelService: ILabelService, pathService: IPathService, workspaceContextService: IWorkspaceContextService, - token: CancellationToken + remoteAgentService: IRemoteAgentService, + token: CancellationToken, + additionalDisabledFileUris?: URI[] ): Promise { // Get workspace root and user home for path resolution const workspaceFolder = workspaceContextService.getWorkspace().folders[0]; @@ -344,8 +355,12 @@ async function parseHookFiles( const userHomeUri = await pathService.userHome(); const userHome = userHomeUri.fsPath ?? userHomeUri.path; + // Get the remote OS (or fall back to local OS) + const remoteEnv = await remoteAgentService.getEnvironment(); + const targetOS = remoteEnv?.os ?? OS; + // Use the shared helper - return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, token); + return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token, { additionalDisabledFileUris }); } /** @@ -433,6 +448,8 @@ function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, erro return errorMessage ?? nls.localize('status.parseError', 'Parse error'); case 'disabled': return nls.localize('status.typeDisabled', 'Disabled'); + case 'all-hooks-disabled': + return nls.localize('status.allHooksDisabled', 'All hooks disabled via disableAllHooks'); default: return errorMessage ?? nls.localize('status.unknownError', 'Unknown error'); } @@ -726,16 +743,22 @@ export function formatStatusOutput( const fileHooks = hooksByFile.get(fileKey)!; const firstHook = fileHooks[0]; const filePath = getRelativePath(firstHook.fileUri, workspaceFolders); + const fileDisabled = fileHooks[0].disabled; - // File as clickable link - lines.push(`[${firstHook.filePath}](${filePath})
`); + // File as clickable link, with note if hooks are disabled via flag + if (fileDisabled) { + lines.push(`[${firstHook.filePath}](${filePath}) - *${nls.localize('status.allHooksDisabledLabel', 'all hooks disabled via disableAllHooks')}*
`); + } else { + lines.push(`[${firstHook.filePath}](${filePath})
`); + } // Flatten hooks with their lifecycle label for (let i = 0; i < fileHooks.length; i++) { const hook = fileHooks[i]; const isLast = i === fileHooks.length - 1; const prefix = isLast ? TREE_END : TREE_BRANCH; - lines.push(`${prefix} ${hook.hookTypeLabel}: \`${hook.commandLabel}\`
`); + const disabledPrefix = hook.disabled ? `${ICON_ERROR} ` : ''; + lines.push(`${prefix} ${disabledPrefix}${hook.hookTypeLabel}: \`${hook.commandLabel}\`
`); } } hasContent = true; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 466054e33e1..d1938adf33f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -809,6 +809,9 @@ class SendToNewChatAction extends Action2 { } } + // Clear the input from the current session before creating a new one + widget.setInput(''); + await widget.clear(); widget.acceptInput(inputBeforeClear, { storeToHistory: true }); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index a442be07b03..56b2429570c 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -25,7 +25,9 @@ import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatService } from '. import { isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatModeKind } from '../../common/constants.js'; import { IChatAccessibilityService, IChatWidgetService } from '../chat.js'; +import { triggerConfetti } from '../widget/chatConfetti.js'; import { CHAT_CATEGORY } from './chatActions.js'; +import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; const enableFeedbackConfig = 'config.telemetry.feedback.enabled'; @@ -75,6 +77,16 @@ export function registerChatTitleActions() { }); item.setVote(ChatAgentVoteDirection.Up); item.setVoteDownReason(undefined); + + const configurationService = accessor.get(IConfigurationService); + const accessibilityService = accessor.get(IAccessibilityService); + if (configurationService.getValue('chat.confettiOnThumbsUp') && !accessibilityService.isMotionReduced()) { + const chatWidgetService = accessor.get(IChatWidgetService); + const widget = chatWidgetService.getWidgetBySessionResource(item.session.sessionResource); + if (widget) { + triggerConfetti(widget.domNode); + } + } } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 74640d3f63c..95a563c19f5 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts @@ -13,7 +13,7 @@ import { Extensions as QuickAccessExtensions, IQuickAccessRegistry } from '../.. import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { AgentSessionsViewerOrientation, AgentSessionsViewerPosition } from './agentSessions.js'; import { IAgentSessionsService, AgentSessionsService } from './agentSessionsService.js'; -import { LocalAgentsSessionsProvider } from './localAgentSessionsProvider.js'; +import { LocalAgentsSessionsController } from './localAgentSessionsController.js'; import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../../common/contributions.js'; import { ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { ArchiveAgentSessionAction, ArchiveAgentSessionSectionAction, UnarchiveAgentSessionAction, OpenAgentSessionInEditorGroupAction, OpenAgentSessionInNewEditorGroupAction, OpenAgentSessionInNewWindowAction, ShowAgentSessionsSidebar, HideAgentSessionsSidebar, ToggleAgentSessionsSidebar, RefreshAgentSessionsViewerAction, FindAgentSessionInViewerAction, MarkAgentSessionUnreadAction, MarkAgentSessionReadAction, FocusAgentSessionsAction, SetAgentSessionsOrientationStackedAction, SetAgentSessionsOrientationSideBySideAction, PickAgentSessionAction, ArchiveAllAgentSessionsAction, MarkAllAgentSessionsReadAction, RenameAgentSessionAction, DeleteAgentSessionAction, DeleteAllLocalSessionsAction, MarkAgentSessionSectionReadAction, ToggleShowAgentSessionsAction, UnarchiveAgentSessionSectionAction } from './agentSessionsActions.js'; @@ -169,7 +169,7 @@ Registry.as(QuickAccessExtensions.Quickaccess).registerQui //#region Workbench Contributions -registerWorkbenchContribution2(LocalAgentsSessionsProvider.ID, LocalAgentsSessionsProvider, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(LocalAgentsSessionsController.ID, LocalAgentsSessionsController, WorkbenchPhase.AfterRestored); registerSingleton(IAgentSessionsService, AgentSessionsService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 89d29c70882..ff81e1273fa 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts @@ -7,7 +7,9 @@ import { localize } from '../../../../../nls.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { URI } from '../../../../../base/common/uri.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { observableValue } from '../../../../../base/common/observable.js'; import { IChatSessionTiming } from '../../common/chatService/chatService.js'; +import { IChatSessionsExtensionPoint } from '../../common/chatSessionsService.js'; import { foreground, listActiveSelectionForeground, registerColor, transparent } from '../../../../../platform/theme/common/colorRegistry.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; @@ -17,6 +19,7 @@ export enum AgentSessionProviders { Cloud = 'copilot-cloud-agent', Claude = 'claude-code', Codex = 'openai-codex', + Growth = 'copilot-growth', } export function isBuiltInAgentSessionProvider(provider: string): boolean { @@ -34,24 +37,33 @@ export function getAgentSessionProvider(sessionResource: URI | string): AgentSes case AgentSessionProviders.Cloud: case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return type; default: return undefined; } } +/** + * Observable holding the display name for the background agent session provider. + * Updated via experiment treatment to allow A/B testing of the display name. + */ +export const backgroundAgentDisplayName = observableValue('backgroundAgentDisplayName', localize('chat.session.providerLabel.background', "Background")); + export function getAgentSessionProviderName(provider: AgentSessionProviders): string { switch (provider) { case AgentSessionProviders.Local: return localize('chat.session.providerLabel.local', "Local"); case AgentSessionProviders.Background: - return localize('chat.session.providerLabel.background', "Background"); + return backgroundAgentDisplayName.get(); case AgentSessionProviders.Cloud: return localize('chat.session.providerLabel.cloud', "Cloud"); case AgentSessionProviders.Claude: return 'Claude'; case AgentSessionProviders.Codex: return 'Codex'; + case AgentSessionProviders.Growth: + return 'Growth'; } } @@ -67,6 +79,8 @@ export function getAgentSessionProviderIcon(provider: AgentSessionProviders): Th return Codicon.openai; case AgentSessionProviders.Claude: return Codicon.claude; + case AgentSessionProviders.Growth: + return Codicon.lightbulb; } } @@ -78,11 +92,16 @@ export function isFirstPartyAgentSessionProvider(provider: AgentSessionProviders return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return false; } } -export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean { +export function getAgentCanContinueIn(provider: AgentSessionProviders, contribution?: IChatSessionsExtensionPoint): boolean { + // Read-only sessions (e.g., Growth) are passive/informational and cannot be delegation targets + if (contribution?.isReadOnly) { + return false; + } switch (provider) { case AgentSessionProviders.Local: case AgentSessionProviders.Background: @@ -90,6 +109,7 @@ export function getAgentCanContinueIn(provider: AgentSessionProviders): boolean return true; case AgentSessionProviders.Claude: case AgentSessionProviders.Codex: + case AgentSessionProviders.Growth: return false; } } @@ -106,6 +126,8 @@ export function getAgentSessionProviderDescription(provider: AgentSessionProvide return localize('chat.session.providerDescription.claude', "Delegate tasks to the Claude Agent SDK using the Claude models included in your GitHub Copilot subscription. The agent iterates via chat and works interactively to implement changes on your main workspace."); case AgentSessionProviders.Codex: return localize('chat.session.providerDescription.codex', "Opens a new Codex session in the editor. Codex sessions can be managed from the chat sessions view."); + case AgentSessionProviders.Growth: + return localize('chat.session.providerDescription.growth', "Educational messages to help you learn Copilot features."); } } @@ -134,7 +156,7 @@ export interface IAgentSessionsControl { export const agentSessionReadIndicatorForeground = registerColor( 'agentSessionReadIndicator.foreground', - { dark: transparent(foreground, 0.15), light: transparent(foreground, 0.15), hcDark: null, hcLight: null }, + { dark: transparent(foreground, 0.2), light: transparent(foreground, 0.2), hcDark: null, hcLight: null }, localize('agentSessionReadIndicatorForeground', "Foreground color for the read indicator in an agent session.") ); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts index 0af4544f3e9..c07c6e5111a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsActions.ts @@ -149,7 +149,7 @@ export class PickAgentSessionAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, undefined); await agentSessionsPicker.pickAgentSession(); } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index 209ea059bcc..f83e4d138ac 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -11,7 +11,7 @@ import { registerAction2, Action2, MenuId } from '../../../../../platform/action import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProviderName } from './agentSessions.js'; +import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderName } from './agentSessions.js'; import { AgentSessionStatus, IAgentSession } from './agentSessionsModel.js'; import { IAgentSessionsFilter, IAgentSessionsFilterExcludes } from './agentSessionsViewer.js'; @@ -127,17 +127,21 @@ export class AgentSessionsFilter extends Disposable implements Required ({ - id: provider, - label: getAgentSessionProviderName(provider) - })); + const providers: { id: string; label: string }[] = [{ + id: AgentSessionProviders.Local, + label: getAgentSessionProviderName(AgentSessionProviders.Local) + }]; - for (const provider of this.chatSessionsService.getAllChatSessionContributions()) { - if (providers.find(p => p.id === provider.type)) { + for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { + if (providers.find(p => p.id === contribution.type)) { continue; // already added } - providers.push({ id: provider.type, label: provider.name }); + const knownProvider = getAgentSessionProvider(contribution.type); + providers.push({ + id: contribution.type, + label: knownProvider ? getAgentSessionProviderName(knownProvider) : contribution.displayName + }); } const that = this; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9757d370d55..37a75d93f19 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -428,7 +428,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode // Sessions changes this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.resolve(chatSessionType))); + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.updateItems([chatSessionType], CancellationToken.None))); // State this._register(this.storageService.onWillSaveState(() => { @@ -468,12 +468,21 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode const providersToResolve = Array.from(this.providersToResolve); this.providersToResolve.clear(); + const providerFilter = providersToResolve.includes(undefined) ? undefined : coalesce(providersToResolve); + + await this.chatSessionsService.refreshChatSessionItems(providerFilter, token); + await this.updateItems(providerFilter, token); + } + + /** + * Update the sessions by fetching from the service. This does not trigger an explicit refresh + */ + private async updateItems(providerFilter: readonly string[] | undefined, token: CancellationToken): Promise { const mapSessionContributionToType = new Map(); for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { mapSessionContributionToType.set(contribution.type, contribution); } - const providerFilter = providersToResolve.includes(undefined) ? undefined : coalesce(providersToResolve); const providerResults = await this.chatSessionsService.getChatSessionItems(providerFilter, token); const resolvedProviders = new Set(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index 9251a44e9fe..27fe44366cb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -67,6 +67,7 @@ export class AgentSessionsPicker { private readonly sorter = new AgentSessionsSorter(); constructor( + private readonly anchor: HTMLElement | undefined, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -78,6 +79,7 @@ export class AgentSessionsPicker { const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); + picker.anchor = this.anchor; picker.items = this.createPickerItems(filter); picker.canAcceptInBackground = true; picker.placeholder = localize('chatAgentPickerPlaceholder', "Search agent sessions by name"); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index a2c7037a622..2a89832b7f4 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -56,6 +56,9 @@ interface IAgentSessionItemTemplate { // Column 2 Row 1 readonly title: IconLabel; + readonly statusContainer: HTMLElement; + readonly statusProviderIcon: HTMLElement; + readonly statusTime: HTMLElement; readonly titleToolbar: MenuWorkbenchToolBar; // Column 2 Row 2 @@ -64,12 +67,9 @@ interface IAgentSessionItemTemplate { readonly diffRemovedSpan: HTMLSpanElement; readonly badge: HTMLElement; + readonly separator: HTMLElement; readonly description: HTMLElement; - readonly statusContainer: HTMLElement; - readonly statusProviderIcon: HTMLElement; - readonly statusTime: HTMLElement; - readonly contextKeyService: IContextKeyService; readonly elementDisposable: DisposableStore; readonly disposables: IDisposable; @@ -111,6 +111,10 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('div.agent-session-main-col', [ h('div.agent-session-title-row', [ h('div.agent-session-title@title'), + h('div.agent-session-status@statusContainer', [ + h('span.agent-session-status-provider-icon@statusProviderIcon'), + h('span.agent-session-status-time@statusTime') + ]), h('div.agent-session-title-toolbar@titleToolbar'), ]), h('div.agent-session-details-row', [ @@ -120,11 +124,8 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre h('span.agent-session-diff-removed@removedSpan') ]), h('div.agent-session-badge@badge'), + h('span.agent-session-separator@separator'), h('div.agent-session-description@description'), - h('div.agent-session-status@statusContainer', [ - h('span.agent-session-status-provider-icon@statusProviderIcon'), - h('span.agent-session-status-time@statusTime') - ]) ]) ]) ] @@ -147,6 +148,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre diffAddedSpan: elements.addedSpan, diffRemovedSpan: elements.removedSpan, badge: elements.badge, + separator: elements.separator, description: elements.description, statusContainer: elements.statusContainer, statusProviderIcon: elements.statusProviderIcon, @@ -216,6 +218,9 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre this.renderDescription(session, template, hasBadge); } + // Separator (dot between badge and description) + template.separator.classList.toggle('has-separator', hasBadge && !hasDiff); + // Status this.renderStatus(session, template); @@ -307,8 +312,8 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre const duration = this.toDuration(session.element.timing.lastRequestStarted, session.element.timing.lastRequestEnded, false, true); template.description.textContent = session.element.status === AgentSessionStatus.Failed ? - localize('chat.session.status.failedAfter', "Failed after {0}.", duration) : - localize('chat.session.status.completedAfter', "Completed in {0}.", duration); + localize('chat.session.status.failedAfter', "Failed after {0}", duration) : + localize('chat.session.status.completedAfter', "Completed in {0}", duration); } else { template.description.textContent = session.element.status === AgentSessionStatus.Failed ? localize('chat.session.status.failed', "Failed") : @@ -497,7 +502,7 @@ export class AgentSessionSectionRenderer implements ICompressibleTreeRenderer { - static readonly ITEM_HEIGHT = 52; + static readonly ITEM_HEIGHT = 44; static readonly SECTION_HEIGHT = 26; getHeight(element: AgentSessionListItem): number { @@ -708,10 +713,10 @@ export class AgentSessionsDataSource implements IAsyncDataSource= startOfYesterday) { - return localize('date.fromNow.days.singular.ago', '1 day ago'); + return localize('date.fromNow.days.singular', '1 day'); } if (sessionTime < startOfYesterday && sessionTime >= startOfTwoDaysAgo) { - return localize('date.fromNow.days.multiple.ago', '2 days ago'); + return localize('date.fromNow.days.multiple', '2 days'); } - return fromNow(sessionTime, true); + return fromNow(sessionTime, false); } export class AgentSessionsIdentityProvider implements IIdentityProvider { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css index 6103e2786e2..2da5d3bb06a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/media/agenttitlebarstatuswidget.css @@ -247,9 +247,14 @@ } .agent-status-badge-section.filtered { + color: var(--vscode-inputOption-activeForeground); background-color: var(--vscode-inputOption-activeBackground); } +.agent-status-badge-section.filtered .agent-status-icon .codicon { + color: var(--vscode-inputOption-activeForeground); +} + .agent-status-badge-section.filtered:hover { filter: brightness(1.1); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts similarity index 81% rename from src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index 14bd1a005fc..18d0bcb1c29 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -13,17 +13,13 @@ import { Schemas } from '../../../../../base/common/network.js'; import { IWorkbenchContribution } from '../../../../common/contributions.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { convertLegacyChatSessionTiming, IChatDetail, IChatService, ResponseModelState } from '../../common/chatService/chatService.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItem, IChatSessionItemController, IChatSessionsService, localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -interface IChatSessionItemWithProvider extends IChatSessionItem { - readonly provider: IChatSessionItemProvider; -} +export class LocalAgentsSessionsController extends Disposable implements IChatSessionItemController, IWorkbenchContribution { -export class LocalAgentsSessionsProvider extends Disposable implements IChatSessionItemProvider, IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.localAgentsSessionsProvider'; + static readonly ID = 'workbench.contrib.localAgentsSessionsController'; readonly chatSessionType = localChatSessionType; @@ -40,34 +36,39 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess ) { super(); - this._register(this.chatSessionsService.registerChatSessionItemProvider(this)); + this._register(this.chatSessionsService.registerChatSessionItemController(this.chatSessionType, this)); this.registerListeners(); } - private registerListeners(): void { - this._register(this.chatSessionsService.registerChatModelChangeListeners( - this.chatService, - Schemas.vscodeLocalChatSession, - () => this._onDidChangeChatSessionItems.fire() - )); + private _items: IChatSessionItem[] = []; + get items(): readonly IChatSessionItem[] { + return this._items; + } - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { - if (chatSessionType === this.chatSessionType) { - this._onDidChange.fire(); - } - })); + async refresh(token: CancellationToken): Promise { + this._items = await this.provideChatSessionItems(token); + } + + private registerListeners(): void { + const refreshItems = async () => { + this._onDidChangeChatSessionItems.fire(); + await this.refresh(CancellationToken.None); + this._onDidChangeChatSessionItems.fire(); + }; + + this._register(this.chatSessionsService.registerChatModelChangeListeners(this.chatService, Schemas.vscodeLocalChatSession, refreshItems)); this._register(this.chatService.onDidDisposeSession(e => { const session = e.sessionResource.filter(resource => getChatSessionType(resource) === this.chatSessionType); if (session.length > 0) { - this._onDidChangeChatSessionItems.fire(); + refreshItems(); } })); } - async provideChatSessionItems(token: CancellationToken): Promise { - const sessions: IChatSessionItemWithProvider[] = []; + private async provideChatSessionItems(token: CancellationToken): Promise { + const sessions: IChatSessionItem[] = []; const sessionsByResource = new ResourceSet(); for (const sessionDetail of await this.chatService.getLiveSessionItems()) { @@ -88,7 +89,7 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess return sessions; } - private async getHistoryItems(): Promise { + private async getHistoryItems(): Promise { try { const historyItems = await this.chatService.getHistorySessionItems(); @@ -98,7 +99,7 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess } } - private toChatSessionItem(chat: IChatDetail): IChatSessionItemWithProvider | undefined { + private toChatSessionItem(chat: IChatDetail): IChatSessionItem | undefined { const model = this.chatService.getSession(chat.sessionResource); let description: string | undefined; @@ -112,7 +113,6 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess return { resource: chat.sessionResource, - provider: this, label: chat.title, description, status: model ? this.modelToStatus(model) : this.chatResponseStateToStatus(chat.lastResponseState), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css index a4a142f69e7..5db250de5b3 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/media/agentsessionsviewer.css @@ -9,6 +9,14 @@ height: 100%; min-height: 0; + .pane-body & .monaco-scrollable-element { + padding: 0 6px; + } + + .monaco-list-row { + border-radius: 6px; + } + .monaco-list-row .force-no-twistie { display: none !important; } @@ -21,55 +29,60 @@ color: unset; } } + } + .monaco-list:focus .monaco-list-row.selected .agent-session-details-row { .agent-session-diff-container { - background-color: unset; - outline: 1px solid var(--vscode-agentSessionSelectedBadge-border); - .agent-session-diff-added, .agent-session-diff-removed { - color: unset; + color: unset; } } - - .agent-session-badge { - background-color: unset; - outline: 1px solid var(--vscode-agentSessionSelectedBadge-border); - } } - .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-details-row .agent-session-diff-container, - .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-details-row .agent-session-badge { - outline: 1px solid var(--vscode-agentSessionSelectedUnfocusedBadge-border); + .monaco-list-row.selected .agent-session-title { + color: unset; + } + + .monaco-list-row.selected .agent-session-status { + color: unset; + } + + .monaco-list:not(:focus) .monaco-list-row.selected .agent-session-details-row { + color: var(--vscode-descriptionForeground); } .monaco-list-row .agent-session-title-toolbar { /* for the absolute positioning of the toolbar below */ position: relative; height: 16px; + display: none; .monaco-toolbar { /* this is required because the overal height (including the padding needed for hover feedback) would push down the title otherwise */ position: relative; right: 0; top: 0; - display: none; } } - .monaco-list-row:hover .agent-session-title-toolbar, - .monaco-list-row.focused .agent-session-title-toolbar { + /* On hover or keyboard focus: show toolbar, hide status */ + .monaco-list-row:hover, + .monaco-list-row.focused { - .monaco-toolbar { + .agent-session-title-toolbar { display: block; } + + .agent-session-status { + display: none; + } } .agent-session-item { display: flex; flex-direction: row; - /* to offset from possible scrollbar */ - padding: 8px 12px 8px 8px; + padding: 4px 6px; &.archived { color: var(--vscode-descriptionForeground); @@ -85,10 +98,16 @@ .agent-session-icon-col { display: flex; align-items: flex-start; + line-height: 17px; .agent-session-icon { flex-shrink: 0; - font-size: 16px; + font-size: 12px; + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; &.codicon.codicon-session-in-progress { color: var(--vscode-textLink-foreground); @@ -113,23 +132,25 @@ } .agent-session-main-col { - padding-left: 8px; + padding-left: 6px; } .agent-session-title-row, .agent-session-details-row { display: flex; align-items: center; - line-height: 16px; } .agent-session-title-row { + line-height: 17px; padding-bottom: 4px; } .agent-session-details-row { gap: 4px; font-size: 12px; + line-height: 15px; + max-height: 15px; color: var(--vscode-descriptionForeground); .rendered-markdown { @@ -150,11 +171,8 @@ .agent-session-diff-container, .agent-session-badge { - background-color: var(--vscode-toolbar-hoverBackground); font-weight: 500; - padding: 0 4px; font-variant-numeric: tabular-nums; - border-radius: 5px; overflow: hidden; } @@ -175,6 +193,18 @@ } } + .agent-session-separator { + display: none; + + &.has-separator { + display: inline; + + &::before { + content: '\00B7'; + } + } + } + .agent-session-badge { p { @@ -186,7 +216,7 @@ } .codicon { - font-size: 12px; + font-size: 11px; } } } @@ -195,17 +225,27 @@ .agent-session-description { /* push other items to the end */ flex: 1; - text-overflow: ellipsis; overflow: hidden; + white-space: nowrap; + margin-right: 16px; + mask-image: linear-gradient(to right, black calc(100% - 32px), transparent); + -webkit-mask-image: linear-gradient(to right, black calc(100% - 32px), transparent); + } + + .agent-session-title { + font-size: 13px; } .agent-session-status { display: flex; align-items: center; font-variant-numeric: tabular-nums; + font-size: 12px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; .agent-session-status-provider-icon { - font-size: 12px; + font-size: 11px; margin-right: 4px; &.hidden { @@ -219,11 +259,10 @@ display: flex; align-items: center; font-size: 11px; + font-weight: 500; color: var(--vscode-descriptionForeground); - text-transform: uppercase; - letter-spacing: 0.5px; /* align with session item padding */ - padding: 0 12px 0 8px; + padding: 0 6px; .agent-session-section-label { flex: 1; diff --git a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts index 60e6559ffc3..e5fb7e719a9 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/chatAttachmentResolveService.ts @@ -47,6 +47,7 @@ export interface IChatAttachmentResolveService { resolveSymbolsAttachContext(symbols: DocumentSymbolTransferData[]): ISymbolVariableEntry[]; resolveNotebookOutputAttachContext(data: NotebookCellOutputTransferData): IChatRequestVariableEntry[]; resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[]; + resolveDirectoryImages(directoryUri: URI): Promise; } export class ChatAttachmentResolveService implements IChatAttachmentResolveService { @@ -277,6 +278,45 @@ export class ChatAttachmentResolveService implements IChatAttachmentResolveServi return []; } + // --- DIRECTORIES --- + + public async resolveDirectoryImages(directoryUri: URI): Promise { + const imageEntries: IChatRequestVariableEntry[] = []; + await this._collectDirectoryImages(directoryUri, imageEntries); + return imageEntries; + } + + private async _collectDirectoryImages(directoryUri: URI, results: IChatRequestVariableEntry[]): Promise { + let stat; + try { + stat = await this.fileService.resolve(directoryUri); + } catch { + return; + } + + if (!stat.children) { + return; + } + + const childPromises: Promise[] = []; + + for (const child of stat.children) { + if (child.isDirectory && !child.isSymbolicLink) { + childPromises.push(this._collectDirectoryImages(child.resource, results)); + } else if (child.isFile && !child.isSymbolicLink && SUPPORTED_IMAGE_EXTENSIONS_REGEX.test(child.resource.path)) { + childPromises.push( + this.resolveImageEditorAttachContext(child.resource).then(entry => { + if (entry) { + results.push(entry); + } + }).catch(() => { /* skip unreadable images */ }) + ); + } + } + + await Promise.all(childPromises); + } + // --- SOURCE CONTROL --- public resolveSourceControlHistoryItemAttachContext(data: SCMHistoryItemTransferData[]): ISCMHistoryItemVariableEntry[] { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index e06da23ad20..e3fea92bd42 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,10 +52,9 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; -import { HooksExecutionService, IHooksExecutionService } from '../common/hooks/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; import { hookFileSchema, HOOK_SCHEMA_URI, HOOK_FILE_GLOB } from '../common/promptSyntax/hookSchema.js'; @@ -90,6 +89,7 @@ import { registerChatToolActions } from './actions/chatToolActions.js'; import { ChatTransferContribution } from './actions/chatTransfer.js'; import { registerChatCustomizationDiagnosticsAction } from './actions/chatCustomizationDiagnosticsAction.js'; import './agentSessions/agentSessions.contribution.js'; +import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; @@ -132,6 +132,7 @@ import { LanguageModelToolsConfirmationService } from './tools/languageModelTool import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; import './promptSyntax/promptToolsCodeLensProvider.js'; +import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; @@ -142,6 +143,7 @@ import { ChatRepoInfoContribution } from './chatRepoInfo.js'; import { VALID_PROMPT_FOLDER_PATTERN } from '../common/promptSyntax/utils/promptFilesLocator.js'; import { ChatTipService, IChatTipService } from './chatTipService.js'; import { ChatQueuePickerRendering } from './widget/input/chatQueuePickerActionItem.js'; +import { PlanAgentDefaultModel } from './planAgentDefaultModel.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; @@ -285,6 +287,11 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, + 'chat.confettiOnThumbsUp': { + type: 'boolean', + description: nls.localize('chat.confettiOnThumbsUp', "Controls whether a confetti animation is shown when clicking the thumbs up button on a chat response."), + default: false, + }, 'chat.experimental.detectParticipant.enabled': { type: 'boolean', deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), @@ -349,7 +356,7 @@ configurationRegistry.registerConfiguration({ '**/*.lock': false, // yarn.lock, bun.lock, etc. '**/*-lock.{yaml,json}': false, // pnpm-lock.yaml, package-lock.json }, - markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by chat are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), + markdownDescription: nls.localize('chat.tools.autoApprove.edits', "Controls whether edits made by the agent are automatically approved. The default is to approve all edits except those made to certain files which have the potential to cause immediate unintended side-effects, such as `**/.vscode/*.json`.\n\nSet to `true` to automatically approve edits to matching files, `false` to always require explicit approval. The last pattern matching a given file will determine whether the edit is automatically approved."), type: 'object', additionalProperties: { type: 'boolean', @@ -608,6 +615,14 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.PlanAgentDefaultModel]: { + type: 'string', + description: nls.localize('chat.planAgent.defaultModel.description', "Select the default language model to use for the Plan agent from the available providers."), + default: '', + enum: PlanAgentDefaultModel.modelIds, + enumItemLabels: PlanAgentDefaultModel.modelLabels, + markdownEnumDescriptions: PlanAgentDefaultModel.modelDescriptions + }, [ChatConfiguration.RequestQueueingEnabled]: { type: 'boolean', description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), @@ -710,6 +725,7 @@ configurationRegistry.registerConfiguration({ ), default: { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, + [CLAUDE_RULES_SOURCE_FOLDER]: true, }, additionalProperties: { type: 'boolean' }, propertyNames: { @@ -721,6 +737,7 @@ configurationRegistry.registerConfiguration({ examples: [ { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, + [CLAUDE_RULES_SOURCE_FOLDER]: true, }, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, @@ -805,6 +822,7 @@ configurationRegistry.registerConfiguration({ ), default: { [AGENTS_SOURCE_FOLDER]: true, + [CLAUDE_AGENTS_SOURCE_FOLDER]: true, }, additionalProperties: { type: 'boolean' }, propertyNames: { @@ -927,7 +945,7 @@ configurationRegistry.registerConfiguration({ title: nls.localize('chat.hookFilesLocations.title', "Hook File Locations",), markdownDescription: nls.localize( 'chat.hookFilesLocations.description', - "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`hooks.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", + "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`*.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", HOOK_DOCUMENTATION_URL, ), default: { @@ -1178,6 +1196,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr ) { super(); this.registerMaxRequestsSetting(); + this.registerBackgroundAgentDisplayName(); } @@ -1208,6 +1227,14 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr }; this._register(Event.runAndSubscribe(Event.debounce(this.entitlementService.onDidChangeEntitlement, () => { }, 1000), () => registerMaxRequestsSetting())); } + + private registerBackgroundAgentDisplayName(): void { + this.experimentService.getTreatment('backgroundAgentDisplayName').then((value) => { + if (value) { + backgroundAgentDisplayName.set(value, undefined); + } + }); + } } @@ -1366,6 +1393,66 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { agentSessionsService.getSession(sessionResource)?.setArchived(true); commandService.executeCommand(ACTION_ID_NEW_CHAT); })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'hooks', + detail: nls.localize('hooks', "Configure hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await instantiationService.invokeFunction(showConfigureHooksQuickPick); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'debug', + detail: nls.localize('debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('github.copilot.debug.showChatLogView'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'agents', + detail: nls.localize('agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.customagents'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'skills', + detail: nls.localize('skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.skills'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'instructions', + detail: nls.localize('instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.instructions'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'prompts', + detail: nls.localize('prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.prompts'); + })); this._store.add(slashCommandService.registerSlashCommand({ command: 'help', detail: '', @@ -1504,7 +1591,6 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); -registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 2d2b307f23e..fc4423e96d1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -386,6 +386,12 @@ export interface IChatWidget { * @returns Whether the operation succeeded (i.e., the focus was toggled). */ toggleQuestionCarouselFocus(): boolean; + /** + * Toggles focus between the tip widget and the chat input. + * Returns false if no tip is visible. + * @returns Whether the operation succeeded (i.e., the focus was toggled). + */ + toggleTipFocus(): boolean; hasInputFocus(): boolean; getModeRequestOptions(): Partial; getCodeBlockInfoForEditor(uri: URI): IChatCodeBlockInfo | undefined; diff --git a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts index 388edd3f00c..b25f186b1e4 100644 --- a/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatEditing/chatEditingActions.ts @@ -25,6 +25,7 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { EditorActivation } from '../../../../../platform/editor/common/editor.js'; import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IEditorPane } from '../../../../common/editor.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IAgentSessionsService } from '../agentSessions/agentSessionsService.js'; @@ -895,3 +896,71 @@ CommandsRegistry.registerCommand('_chat.editSessions.accept', async (accessor: S await editingSession.accept(...uris); } }); + +//#region View as Tree / View as List toggle + +export const CHAT_EDITS_VIEW_MODE_STORAGE_KEY = 'chat.editsViewMode'; +export const ChatEditsViewAsTreeActionId = 'chatEditing.viewAsTree'; +export const ChatEditsViewAsListActionId = 'chatEditing.viewAsList'; + +registerAction2(class ChatEditsViewAsTreeAction extends Action2 { + constructor() { + super({ + id: ChatEditsViewAsTreeActionId, + title: localize2('chatEditing.viewAsTree', "View as Tree"), + icon: Codicon.listFlat, + category: CHAT_CATEGORY, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView.negate()), + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView.negate()), + }, + ], + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + } +}); + +registerAction2(class ChatEditsViewAsListAction extends Action2 { + constructor() { + super({ + id: ChatEditsViewAsListActionId, + title: localize2('chatEditing.viewAsList', "View as List"), + icon: Codicon.listTree, + category: CHAT_CATEGORY, + menu: [ + { + id: MenuId.ChatEditingWidgetToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(hasAppliedChatEditsContextKey, ChatContextKeys.chatEditsInTreeView), + }, + { + id: MenuId.ChatEditingSessionChangesToolbar, + group: 'navigation', + order: 5, + when: ContextKeyExpr.and(ChatContextKeys.hasAgentSessionChanges, ChatContextKeys.chatEditsInTreeView), + }, + ], + }); + } + + run(accessor: ServicesAccessor): void { + const storageService = accessor.get(IStorageService); + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'list', StorageScope.PROFILE, StorageTarget.USER); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts index 9cf80f365d8..ad2c82a5abd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatManagementEditor.ts @@ -29,7 +29,7 @@ import { Event } from '../../../../../base/common/event.js'; import { Dimension } from '../../../../../base/browser/dom.js'; import { registerColor } from '../../../../../platform/theme/common/colorRegistry.js'; import { PANEL_BORDER } from '../../../../common/theme.js'; -import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { CONTEXT_MODELS_EDITOR } from '../../common/constants.js'; @@ -134,6 +134,7 @@ export class ChatManagementEditor extends EditorPane { private readonly commandService: ICommandService; private readonly chatEntitlementService: IChatEntitlementService; + private readonly actionButtonClickListener = this._register(new MutableDisposable()); constructor( group: IEditorGroup, @@ -390,7 +391,7 @@ export class ChatManagementEditor extends EditorPane { } this.actionButton.label = buttonLabel; - this.actionButton.onDidClick(() => { + this.actionButtonClickListener.value = this.actionButton.onDidClick(() => { this.commandService.executeCommand(commandId); }); } else { diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts index 7033bf061e1..3281126a9a2 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatModelsWidget.ts @@ -891,7 +891,7 @@ export class ChatModelsWidget extends Disposable { super(); this.searchFocusContextKey = CONTEXT_MODELS_SEARCH_FOCUS.bindTo(contextKeyService); - this.delayedFiltering = new Delayer(200); + this.delayedFiltering = this._register(new Delayer(200)); this.viewModel = this._register(this.instantiationService.createInstance(ChatModelsViewModel)); this.element = DOM.$('.models-widget'); this.create(this.element); diff --git a/src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts index b8ed68c27f1..d33b5a1524c 100644 --- a/src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatManagement/chatUsageWidget.ts @@ -16,7 +16,7 @@ const $ = DOM.$; export class ChatUsageWidget extends Disposable { - private readonly _onDidChangeContentHeight = new Emitter(); + private readonly _onDidChangeContentHeight = this._register(new Emitter()); readonly onDidChangeContentHeight = this._onDidChangeContentHeight.event; readonly element: HTMLElement; diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index a7a60c021a1..8a6372af31d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -5,7 +5,7 @@ import { sep } from '../../../../../base/common/path.js'; import { raceCancellationError } from '../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -31,7 +31,7 @@ import { ExtensionsRegistry } from '../../../../services/extensions/common/exten import { ChatEditorInput } from '../widgetHosts/editor/chatEditorInput.js'; import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemController, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService, isSessionInProgressStatus } from '../../common/chatSessionsService.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { IChatEditorOptions } from '../widgetHosts/editor/chatEditor.js'; @@ -44,12 +44,13 @@ import { IMarkdownString } from '../../../../../base/common/htmlContent.js'; import { IViewsService } from '../../../../services/views/common/viewsService.js'; import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; -import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; -import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; +import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { Target } from '../../common/promptSyntax/service/promptsService.js'; const extensionPoint = ExtensionsRegistry.registerExtensionPoint({ extensionPoint: 'chatSessions', @@ -199,6 +200,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint = new Map(); + private readonly _itemControllers = new Map }>(); private readonly _contributions: Map = new Map(); private readonly _contributionDisposables = this._register(new DisposableMap()); @@ -327,10 +333,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const builtinSessionProviders = [AgentSessionProviders.Local]; const contributedSessionProviders = observableFromEvent( this.onDidChangeAvailability, - () => Array.from(this._contributions.keys()).filter(isAgentSessionProviderType) as AgentSessionProviders[], + () => Array.from(this._contributions.keys()).filter(key => this._contributionDisposables.has(key) && isAgentSessionProviderType(key)) as AgentSessionProviders[], ).recomputeInitiallyAndOnChange(this._store); this._register(autorun(reader => { + backgroundAgentDisplayName.read(reader); const activatedProviders = [...builtinSessionProviders, ...contributedSessionProviders.read(reader)]; for (const provider of Object.values(AgentSessionProviders)) { if (activatedProviders.includes(provider)) { @@ -633,8 +640,8 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } if (hasChanges) { this._onDidChangeAvailability.fire(); - for (const provider of this._itemsProviders.values()) { - this._onDidChangeItemsProviders.fire(provider); + for (const chatSessionType of this._itemControllers.keys()) { + this._onDidChangeItemsProviders.fire({ chatSessionType }); } for (const { contribution } of this._contributions.values()) { this._onDidChangeSessionItems.fire({ chatSessionType: contribution.type }); @@ -646,7 +653,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private _enableContribution(contribution: IChatSessionsExtensionPoint, ext: IRelaxedExtensionDescription): void { const disposableStore = new DisposableStore(); this._contributionDisposables.set(contribution.type, disposableStore); - if (contribution.canDelegate) { + if (contribution.isReadOnly || contribution.canDelegate) { disposableStore.add(this._registerAgent(contribution, ext)); disposableStore.add(this._registerCommands(contribution)); } @@ -731,10 +738,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } async activateChatSessionItemProvider(chatViewType: string): Promise { - await this.doActivateChatSessionItemProvider(chatViewType); + await this.doActivateChatSessionItemController(chatViewType); } - private async doActivateChatSessionItemProvider(chatViewType: string): Promise { + private async doActivateChatSessionItemController(chatViewType: string): Promise { await this._extensionService.whenInstalledExtensionsRegistered(); const resolvedType = this._resolveToPrimaryType(chatViewType); if (resolvedType) { @@ -743,16 +750,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const contribution = this._contributions.get(chatViewType)?.contribution; if (contribution && !this._isContributionAvailable(contribution)) { - return undefined; + return false; } - if (this._itemsProviders.has(chatViewType)) { - return this._itemsProviders.get(chatViewType); + if (this._itemControllers.has(chatViewType)) { + return true; } await this._extensionService.activateByEvent(`onChatSession:${chatViewType}`); - return this._itemsProviders.get(chatViewType); + const controller = this._itemControllers.get(chatViewType)!; + return !!controller; } async canResolveChatSession(chatSessionResource: URI) { @@ -771,67 +779,75 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._contentProviders.has(chatSessionResource.scheme); } - public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { - const results: Array<{ readonly chatSessionType: string; readonly items: IChatSessionItem[] }> = []; - const resolvedProviderTypes = new Set(); - - // First, iterate over extension point contributions - for (const contrib of this.getAllChatSessionContributions()) { + private async tryActivateControllers(providersToResolve: readonly string[] | undefined): Promise { + await Promise.all(this.getAllChatSessionContributions().map(async (contrib) => { if (providersToResolve && !providersToResolve.includes(contrib.type)) { - continue; // skip: not considered for resolving + return; // skip: not considered for resolving } - const provider = await this.doActivateChatSessionItemProvider(contrib.type); - if (!provider) { + if (!await this.doActivateChatSessionItemController(contrib.type)) { // We requested this provider but it is not available if (providersToResolve?.includes(contrib.type)) { this._logService.trace(`[ChatSessionsService] No enabled provider found for chat session type ${contrib.type}`); } - continue; + } + })); + } + + public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { + // First, make sure contributed controller are active + await this.tryActivateControllers(providersToResolve); + + // Then actually resolve items for all active controllers + const results: Array<{ readonly chatSessionType: string; readonly items: readonly IChatSessionItem[] }> = []; + await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { + const resolvedType = this._resolveToPrimaryType(chatSessionType) ?? chatSessionType; + if (providersToResolve && !providersToResolve.includes(resolvedType)) { + return; // skip: not considered for resolving } try { - const providerSessions = await raceCancellationError(provider.provideChatSessionItems(token), token); - this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${provider.chatSessionType}`); - results.push({ chatSessionType: provider.chatSessionType, items: providerSessions }); - resolvedProviderTypes.add(provider.chatSessionType); - } catch (error) { - // Log error but continue with other providers - this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${provider.chatSessionType}`, error); - continue; - } - } + await controllerEntry.initialRefresh; // Ensure initial refresh is complete before accessing items - // Also include registered items providers that don't have corresponding contributions - // (e.g., the local session provider which is built-in and not an extension contribution) - for (const [chatSessionType, provider] of this._itemsProviders) { - if (resolvedProviderTypes.has(chatSessionType)) { - continue; // already resolved via contribution + const providerSessions = controllerEntry.controller.items; + this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${resolvedType}`); + results.push({ chatSessionType: resolvedType, items: providerSessions }); + } catch (err) { + if (!isCancellationError(err)) { + // Log error but continue with other providers + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${resolvedType}`, err); + } } - if (providersToResolve && !providersToResolve.includes(chatSessionType)) { - continue; // skip: not considered for resolving - } - - try { - const providerSessions = await raceCancellationError(provider.provideChatSessionItems(token), token); - this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for built-in provider ${chatSessionType}`); - results.push({ chatSessionType, items: providerSessions }); - } catch (error) { - this._logService.error(`[ChatSessionsService] Failed to resolve sessions for built-in provider ${chatSessionType}`, error); - continue; - } - } + })); return results; } - public registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable { - const chatSessionType = provider.chatSessionType; - this._itemsProviders.set(chatSessionType, provider); - this._onDidChangeItemsProviders.fire(provider); + public async refreshChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise { + await this.tryActivateControllers(providersToResolve); + await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { + try { + await controllerEntry.controller.refresh(token); + } catch (err) { + if (!isCancellationError(err)) { + // Log error but continue with other providers + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${chatSessionType}`, err); + } + } + })); + } + + registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable { const disposables = new DisposableStore(); - disposables.add(provider.onDidChangeChatSessionItems(() => { + + + // Register and trigger an initial refresh to populate the provider's items + const initialRefreshCts = disposables.add(new CancellationTokenSource()); + this._itemControllers.set(chatSessionType, { controller, initialRefresh: controller.refresh(initialRefreshCts.token) }); + this._onDidChangeItemsProviders.fire({ chatSessionType }); + + disposables.add(controller.onDidChangeChatSessionItems(() => { this._onDidChangeSessionItems.fire({ chatSessionType }); })); @@ -841,12 +857,13 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return { dispose: () => { + initialRefreshCts.cancel(); disposables.dispose(); - const provider = this._itemsProviders.get(chatSessionType); - if (provider) { - this._itemsProviders.delete(chatSessionType); - this._onDidChangeItemsProviders.fire(provider); + const controller = this._itemControllers.get(chatSessionType); + if (controller) { + this._itemControllers.delete(chatSessionType); + this._onDidChangeItemsProviders.fire({ chatSessionType }); } } }; @@ -1101,9 +1118,9 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ * Get the customAgentTarget for a specific session type. * When set, the mode picker should show filtered custom agents matching this target. */ - public getCustomAgentTargetForSessionType(chatSessionType: string): string | undefined { + public getCustomAgentTargetForSessionType(chatSessionType: string): Target { const contribution = this._contributions.get(chatSessionType)?.contribution; - return contribution?.customAgentTarget; + return contribution?.customAgentTarget ?? Target.Undefined; } public getContentProviderSchemes(): string[] { diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index fa7c12678e6..e6a3847ea58 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -497,10 +497,26 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } return new Promise(resolve => { + let descriptorKeys: Set = new Set(); + const updateDescriptorKeys = () => { + const descriptors = chatViewsWelcomeRegistry.get(); + descriptorKeys = new Set(descriptors.flatMap(d => d.when.keys())); + }; + updateDescriptorKeys(); + + const onDidChangeRegistry = Event.map(chatViewsWelcomeRegistry.onDidChange, () => 'registry' as const); + const onDidChangeRelevantContext = Event.map( + Event.filter(this.contextKeyService.onDidChangeContext, e => e.affectsSome(descriptorKeys)), + () => 'context' as const + ); + disposables.add(Event.any( - chatViewsWelcomeRegistry.onDidChange, - Event.map(this.contextKeyService.onDidChangeContext, () => { }) - )(() => { + onDidChangeRegistry, + onDidChangeRelevantContext + )(source => { + if (source === 'registry') { + updateDescriptorKeys(); + } if (panelAgentHasGuidance()) { resolve(); } diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 0cc3a9279ce..08f1c161b3a 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -3,14 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { localize } from '../../../../nls.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../common/constants.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { AgentFileType, IPromptsService } from '../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../common/promptSyntax/promptTypes.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { localize } from '../../../../nls.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; export const IChatTipService = createDecorator('chatTipService'); @@ -22,6 +31,16 @@ export interface IChatTip { export interface IChatTipService { readonly _serviceBrand: undefined; + /** + * Fired when the current tip is dismissed. + */ + readonly onDidDismissTip: Event; + + /** + * Fired when tips are disabled. + */ + readonly onDidDisableTips: Event; + /** * Gets a tip to show for a request, or undefined if a tip has already been shown this session. * Only one tip is shown per VS Code session (resets on reload). @@ -31,9 +50,20 @@ export interface IChatTipService { * @param contextKeyService The context key service to evaluate tip eligibility. */ getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Dismisses the current tip and allows a new one to be picked for the same request. + * The dismissed tip will not be shown again in this workspace. + */ + dismissTip(): void; + + /** + * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. + */ + disableTips(): Promise; } -interface ITipDefinition { +export interface ITipDefinition { readonly id: string; readonly message: string; /** @@ -45,6 +75,32 @@ interface ITipDefinition { * Command IDs that are allowed to be executed from this tip's markdown. */ readonly enabledCommands?: string[]; + /** + * Command IDs that, if ever executed in this workspace, make this tip ineligible. + * The tip won't be shown if the user has already performed the action it suggests. + */ + readonly excludeWhenCommandsExecuted?: string[]; + /** + * Chat mode names that, if ever used in this workspace, make this tip ineligible. + * The tip won't be shown if the user has already used the mode it suggests. + * Matches against both mode kind (e.g. 'agent') and mode name (e.g. 'Plan'). + */ + readonly excludeWhenModesUsed?: string[]; + /** + * Tool IDs that, if ever invoked in this workspace, make this tip ineligible. + * The tip won't be shown if the tool it describes has already been used. + */ + readonly excludeWhenToolsInvoked?: string[]; + /** + * If set, exclude this tip when prompt files of the specified type exist in the workspace. + */ + readonly excludeWhenPromptFilesExist?: { + readonly promptType: PromptsType; + /** Also check for this specific agent instruction file type. */ + readonly agentFileType?: AgentFileType; + /** If true, exclude the tip until the async file check completes. Default: false. */ + readonly excludeUntilChecked?: boolean; + }; } /** @@ -56,20 +112,24 @@ const TIP_CATALOG: ITipDefinition[] = [ message: localize('tip.agentMode', "Tip: Try [Agent mode](command:workbench.action.chat.openEditSession) for multi-file edits and running commands."), when: ChatContextKeys.chatModeKind.notEqualsTo(ChatModeKind.Agent), enabledCommands: ['workbench.action.chat.openEditSession'], + excludeWhenModesUsed: [ChatModeKind.Agent], }, { id: 'tip.planMode', message: localize('tip.planMode', "Tip: Try [Plan mode](command:workbench.action.chat.openPlan) to let the agent perform deep analysis and planning before implementing changes."), when: ChatContextKeys.chatModeName.notEqualsTo('Plan'), enabledCommands: ['workbench.action.chat.openPlan'], + excludeWhenModesUsed: ['Plan'], }, { id: 'tip.attachFiles', message: localize('tip.attachFiles', "Tip: Attach files or folders with # to give Copilot more context."), + excludeWhenCommandsExecuted: ['workbench.action.chat.attachContext', 'workbench.action.chat.attachFile', 'workbench.action.chat.attachFolder', 'workbench.action.chat.attachSelection'], }, { id: 'tip.codeActions', message: localize('tip.codeActions', "Tip: Select code and right-click for Copilot actions in the context menu."), + excludeWhenCommandsExecuted: ['inlineChat.start'], }, { id: 'tip.undoChanges', @@ -78,17 +138,335 @@ const TIP_CATALOG: ITipDefinition[] = [ ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Edit), ), + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint', 'workbench.action.chat.restoreLastCheckpoint'], }, { id: 'tip.customInstructions', message: localize('tip.customInstructions', "Tip: [Generate workspace instructions](command:workbench.action.chat.generateInstructions) so Copilot always has the context it needs when starting a task."), enabledCommands: ['workbench.action.chat.generateInstructions'], - } + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }, + { + id: 'tip.customAgent', + message: localize('tip.customAgent', "Tip: [Create a custom agent](command:workbench.command.new.agent) to define reusable personas with tailored instructions and tools for your workflow."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.command.new.agent'], + excludeWhenCommandsExecuted: ['workbench.command.new.agent'], + excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, + }, + { + id: 'tip.skill', + message: localize('tip.skill', "Tip: [Create a skill](command:workbench.command.new.skill) so agents can perform domain-specific tasks with reusable prompts and tools."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + enabledCommands: ['workbench.command.new.skill'], + excludeWhenCommandsExecuted: ['workbench.command.new.skill'], + excludeWhenPromptFilesExist: { promptType: PromptsType.skill, excludeUntilChecked: true }, + }, + { + id: 'tip.messageQueueing', + message: localize('tip.messageQueueing', "Tip: You can send follow-up and steering messages while the agent is working. They'll be queued and processed in order."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenCommandsExecuted: ['workbench.action.chat.queueMessage', 'workbench.action.chat.steerWithMessage'], + }, + { + id: 'tip.yoloMode', + message: localize('tip.yoloMode', "Tip: Enable [auto approve](command:workbench.action.openSettings?%5B%22chat.tools.global.autoApprove%22%5D) to let the agent run tools without manual confirmation."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('config.chat.tools.global.autoApprove', true), + ), + enabledCommands: ['workbench.action.openSettings'], + }, + { + id: 'tip.mermaid', + message: localize('tip.mermaid', "Tip: Ask the agent to draw an architectural diagram or flow chart; it can render Mermaid diagrams directly in chat."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }, + { + id: 'tip.githubRepo', + message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (e.g. @owner/repo) in your prompt to let the agent search code, browse issues, and explore pull requests from that repo."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), + ), + excludeWhenToolsInvoked: ['github-pull-request_doSearch', 'github-pull-request_issue_fetch', 'github-pull-request_formSearchQuery'], + }, + { + id: 'tip.subagents', + message: localize('tip.subagents', "Tip: For large tasks, ask the agent to work in parallel. It can split the work across subagents to finish faster."), + when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + excludeWhenToolsInvoked: ['runSubagent'], + }, + { + id: 'tip.contextUsage', + message: localize('tip.contextUsage', "Tip: [View your context window usage](command:workbench.action.chat.showContextUsage) to see how many tokens are being used and what's consuming them."), + when: ContextKeyExpr.and( + ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), + ChatContextKeys.contextUsageHasBeenOpened.negate(), + ChatContextKeys.chatSessionIsEmpty.negate(), + ), + enabledCommands: ['workbench.action.chat.showContextUsage'], + excludeWhenCommandsExecuted: ['workbench.action.chat.showContextUsage'], + }, + { + id: 'tip.sendToNewChat', + message: localize('tip.sendToNewChat', "Tip: Use [Send to New Chat](command:workbench.action.chat.sendToNewChat) to start a fresh conversation with a clean context window."), + when: ChatContextKeys.chatSessionIsEmpty.negate(), + enabledCommands: ['workbench.action.chat.sendToNewChat'], + excludeWhenCommandsExecuted: ['workbench.action.chat.sendToNewChat'], + }, ]; -export class ChatTipService implements IChatTipService { +/** + * Tracks workspace-level signals that determine whether certain tips should be + * excluded. Persists state to workspace storage and disposes listeners once all + * signals of interest have been observed. + */ +export class TipEligibilityTracker extends Disposable { + + private static readonly _COMMANDS_STORAGE_KEY = 'chat.tips.executedCommands'; + private static readonly _MODES_STORAGE_KEY = 'chat.tips.usedModes'; + private static readonly _TOOLS_STORAGE_KEY = 'chat.tips.invokedTools'; + + private readonly _executedCommands: Set; + private readonly _usedModes: Set; + private readonly _invokedTools: Set; + + private readonly _pendingCommands: Set; + private readonly _pendingModes: Set; + private readonly _pendingTools: Set; + + private readonly _commandListener = this._register(new MutableDisposable()); + private readonly _toolListener = this._register(new MutableDisposable()); + + /** + * Tip IDs excluded because prompt files of the required type exist in the workspace. + * Tips with `excludeUntilChecked` are pre-added and removed if no files are found. + */ + private readonly _excludedByFiles = new Set(); + + /** Tips that have file-based exclusions, kept for re-checks. */ + private readonly _tipsWithFileExclusions: readonly ITipDefinition[]; + + /** Generation counter per tip ID to discard stale async file-check results. */ + private readonly _fileCheckGeneration = new Map(); + + constructor( + tips: readonly ITipDefinition[], + @ICommandService commandService: ICommandService, + @IStorageService private readonly _storageService: IStorageService, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + + // --- Restore persisted state ------------------------------------------- + + const storedCmds = this._storageService.get(TipEligibilityTracker._COMMANDS_STORAGE_KEY, StorageScope.WORKSPACE); + this._executedCommands = new Set(storedCmds ? JSON.parse(storedCmds) : []); + + const storedModes = this._storageService.get(TipEligibilityTracker._MODES_STORAGE_KEY, StorageScope.WORKSPACE); + this._usedModes = new Set(storedModes ? JSON.parse(storedModes) : []); + + const storedTools = this._storageService.get(TipEligibilityTracker._TOOLS_STORAGE_KEY, StorageScope.WORKSPACE); + this._invokedTools = new Set(storedTools ? JSON.parse(storedTools) : []); + + // --- Derive what still needs tracking ---------------------------------- + + this._pendingCommands = new Set(); + for (const tip of tips) { + for (const cmd of tip.excludeWhenCommandsExecuted ?? []) { + if (!this._executedCommands.has(cmd)) { + this._pendingCommands.add(cmd); + } + } + } + + this._pendingModes = new Set(); + for (const tip of tips) { + for (const mode of tip.excludeWhenModesUsed ?? []) { + if (!this._usedModes.has(mode)) { + this._pendingModes.add(mode); + } + } + } + + this._pendingTools = new Set(); + for (const tip of tips) { + for (const toolId of tip.excludeWhenToolsInvoked ?? []) { + if (!this._invokedTools.has(toolId)) { + this._pendingTools.add(toolId); + } + } + } + + // --- Set up command listener (auto-disposes when all seen) -------------- + + if (this._pendingCommands.size > 0) { + this._commandListener.value = commandService.onDidExecuteCommand(e => { + if (this._pendingCommands.has(e.commandId)) { + this._executedCommands.add(e.commandId); + this._persistSet(TipEligibilityTracker._COMMANDS_STORAGE_KEY, this._executedCommands); + this._pendingCommands.delete(e.commandId); + + if (this._pendingCommands.size === 0) { + this._commandListener.clear(); + } + } + }); + } + + // --- Set up tool listener (auto-disposes when all seen) ----------------- + + if (this._pendingTools.size > 0) { + this._toolListener.value = languageModelToolsService.onDidInvokeTool(e => { + if (this._pendingTools.has(e.toolId)) { + this._invokedTools.add(e.toolId); + this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); + this._pendingTools.delete(e.toolId); + + if (this._pendingTools.size === 0) { + this._toolListener.clear(); + } + } + }); + } + + // --- Async file checks ------------------------------------------------- + + this._tipsWithFileExclusions = tips.filter(t => t.excludeWhenPromptFilesExist); + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + this._checkForPromptFiles(tip); + } + + // Re-check agent file exclusions when custom agents change (covers late discovery) + this._register(this._promptsService.onDidChangeCustomAgents(() => { + for (const tip of this._tipsWithFileExclusions) { + if (tip.excludeWhenPromptFilesExist!.promptType === PromptsType.agent) { + this._checkForPromptFiles(tip); + } + } + })); + } + + /** + * Records the current chat mode (kind + name) so future tip eligibility + * checks can exclude mode-related tips. No-ops once all tracked modes + * have been observed. + */ + recordCurrentMode(contextKeyService: IContextKeyService): void { + if (this._pendingModes.size === 0) { + return; + } + + let changed = false; + const kind = contextKeyService.getContextKeyValue(ChatContextKeys.chatModeKind.key); + if (kind && !this._usedModes.has(kind)) { + this._usedModes.add(kind); + this._pendingModes.delete(kind); + changed = true; + } + const name = contextKeyService.getContextKeyValue(ChatContextKeys.chatModeName.key); + if (name && !this._usedModes.has(name)) { + this._usedModes.add(name); + this._pendingModes.delete(name); + changed = true; + } + if (changed) { + this._persistSet(TipEligibilityTracker._MODES_STORAGE_KEY, this._usedModes); + } + } + + /** + * Returns `true` when the tip should be **excluded** from the eligible set. + */ + isExcluded(tip: ITipDefinition): boolean { + if (tip.excludeWhenCommandsExecuted) { + for (const cmd of tip.excludeWhenCommandsExecuted) { + if (this._executedCommands.has(cmd)) { + this._logService.debug('#ChatTips: tip excluded because command was executed', tip.id, cmd); + return true; + } + } + } + if (tip.excludeWhenModesUsed) { + for (const mode of tip.excludeWhenModesUsed) { + if (this._usedModes.has(mode)) { + this._logService.debug('#ChatTips: tip excluded because mode was used', tip.id, mode); + return true; + } + } + } + if (tip.excludeWhenToolsInvoked) { + for (const toolId of tip.excludeWhenToolsInvoked) { + if (this._invokedTools.has(toolId)) { + this._logService.debug('#ChatTips: tip excluded because tool was invoked', tip.id, toolId); + return true; + } + } + } + if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { + this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); + return true; + } + return false; + } + + private async _checkForPromptFiles(tip: ITipDefinition): Promise { + const config = tip.excludeWhenPromptFilesExist!; + const generation = (this._fileCheckGeneration.get(tip.id) ?? 0) + 1; + this._fileCheckGeneration.set(tip.id, generation); + + try { + const [promptFiles, agentInstructions] = await Promise.all([ + this._promptsService.listPromptFiles(config.promptType, CancellationToken.None), + config.agentFileType ? this._promptsService.listAgentInstructions(CancellationToken.None) : Promise.resolve([]), + ]); + + // Discard stale result if a newer check was started while we were awaiting + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + + const hasPromptFiles = promptFiles.length > 0; + const hasAgentFile = config.agentFileType + ? agentInstructions.some(f => f.type === config.agentFileType) + : false; + + if (hasPromptFiles || hasAgentFile) { + this._excludedByFiles.add(tip.id); + } else { + this._excludedByFiles.delete(tip.id); + } + } catch { + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + if (config.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } + } + } + + private _persistSet(key: string, set: Set): void { + this._storageService.store(key, JSON.stringify([...set]), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } +} + +export class ChatTipService extends Disposable implements IChatTipService { readonly _serviceBrand: undefined; + private readonly _onDidDismissTip = this._register(new Emitter()); + readonly onDidDismissTip = this._onDidDismissTip.event; + + private readonly _onDidDisableTips = this._register(new Emitter()); + readonly onDidDisableTips = this._onDidDisableTips.event; + /** * Timestamp when this service was instantiated. * Used to only show tips for requests created after this time. @@ -111,10 +489,53 @@ export class ChatTipService implements IChatTipService { */ private _shownTip: ITipDefinition | undefined; + private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; + private readonly _tracker: TipEligibilityTracker; + constructor( @IProductService private readonly _productService: IProductService, - @IConfigurationService private readonly _configurationService: IConfigurationService - ) { } + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IStorageService private readonly _storageService: IStorageService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, + ) { + super(); + this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); + } + + dismissTip(): void { + if (this._shownTip) { + const dismissed = this._getDismissedTipIds(); + dismissed.push(this._shownTip.id); + this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + this._hasShownTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + } + + private _getDismissedTipIds(): string[] { + const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.WORKSPACE); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + this._logService.debug('#ChatTips dismissed:', parsed); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + async disableTips(): Promise { + this._hasShownTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + await this._configurationService.updateValue('chat.tips.enabled', false); + this._onDidDisableTips.fire(); + } getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined { // Check if tips are enabled @@ -143,8 +564,11 @@ export class ChatTipService implements IChatTipService { return undefined; } - // Find eligible tips - const eligibleTips = TIP_CATALOG.filter(tip => this._isEligible(tip, contextKeyService)); + // Find eligible tips (excluding dismissed ones) + const dismissedIds = new Set(this._getDismissedTipIds()); + const eligibleTips = TIP_CATALOG.filter(tip => !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)); + // Record the current mode for future eligibility decisions + this._tracker.recordCurrentMode(contextKeyService); if (eligibleTips.length === 0) { return undefined; @@ -163,10 +587,15 @@ export class ChatTipService implements IChatTipService { } private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { - if (!tip.when) { - return true; + if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) { + this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); + return false; } - return contextKeyService.contextMatchesRules(tip.when); + if (this._tracker.isExcluded(tip)) { + return false; + } + this._logService.debug('#ChatTips: tip is eligible', tip.id); + return true; } private _isCopilotEnabled(): boolean { diff --git a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts index efed13bcbb7..1c695e2ded0 100644 --- a/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts +++ b/src/vs/workbench/contrib/chat/browser/languageModelsConfigurationService.ts @@ -37,7 +37,7 @@ export class LanguageModelsConfigurationService extends Disposable implements IL private readonly modelsConfigurationFile: URI; get configurationFile(): URI { return this.modelsConfigurationFile; } - private readonly _onDidChangeLanguageModelGroups = new Emitter(); + private readonly _onDidChangeLanguageModelGroups = this._register(new Emitter()); readonly onDidChangeLanguageModelGroups: Event = this._onDidChangeLanguageModelGroups.event; private languageModelsProviderGroups: LanguageModelsProviderGroups = []; diff --git a/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts new file mode 100644 index 00000000000..37c49f0774f --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/planAgentDefaultModel.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../base/common/lifecycle.js'; +import { localize } from '../../../../nls.js'; +import { IConfigurationRegistry, Extensions as ConfigurationExtensions } from '../../../../platform/configuration/common/configurationRegistry.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { ChatConfiguration } from '../common/constants.js'; +import { ILanguageModelChatMetadata, ILanguageModelsService } from '../common/languageModels.js'; +import { DEFAULT_MODEL_PICKER_CATEGORY } from '../common/widget/input/modelPickerWidget.js'; + +const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); + +export class PlanAgentDefaultModel extends Disposable { + static readonly ID = 'workbench.contrib.planAgentDefaultModel'; + static readonly configName = ChatConfiguration.PlanAgentDefaultModel; + + static modelIds: string[] = ['']; + static modelLabels: string[] = [localize('defaultModel', 'Auto (Vendor Default)')]; + static modelDescriptions: string[] = [localize('defaultModelDescription', "Use the vendor's default model")]; + + constructor( + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @ILogService private readonly logService: ILogService, + ) { + super(); + this._register(languageModelsService.onDidChangeLanguageModels(() => this._updateModelValues())); + this._updateModelValues(); + } + + private _updateModelValues(): void { + try { + // Clear arrays + PlanAgentDefaultModel.modelIds.length = 0; + PlanAgentDefaultModel.modelLabels.length = 0; + PlanAgentDefaultModel.modelDescriptions.length = 0; + + // Add default/empty option + PlanAgentDefaultModel.modelIds.push(''); + PlanAgentDefaultModel.modelLabels.push(localize('defaultModel', 'Auto (Vendor Default)')); + PlanAgentDefaultModel.modelDescriptions.push(localize('defaultModelDescription', "Use the vendor's default model")); + + const models: { identifier: string; metadata: ILanguageModelChatMetadata }[] = []; + const modelIds = this.languageModelsService.getLanguageModelIds(); + + for (const modelId of modelIds) { + try { + const metadata = this.languageModelsService.lookupLanguageModel(modelId); + if (metadata) { + models.push({ identifier: modelId, metadata }); + } else { + this.logService.warn(`[PlanAgentDefaultModel] No metadata found for model ID: ${modelId}`); + } + } catch (e) { + this.logService.error(`[PlanAgentDefaultModel] Error looking up model ${modelId}:`, e); + } + } + + const supportedModels = models.filter(model => { + if (!model.metadata?.isUserSelectable) { + return false; + } + if (!model.metadata.capabilities?.toolCalling) { + return false; + } + return true; + }); + + supportedModels.sort((a, b) => { + const aCategory = a.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY; + const bCategory = b.metadata.modelPickerCategory ?? DEFAULT_MODEL_PICKER_CATEGORY; + + if (aCategory.order !== bCategory.order) { + return aCategory.order - bCategory.order; + } + + return a.metadata.name.localeCompare(b.metadata.name); + }); + + for (const model of supportedModels) { + try { + const qualifiedName = `${model.metadata.name} (${model.metadata.vendor})`; + PlanAgentDefaultModel.modelIds.push(qualifiedName); + PlanAgentDefaultModel.modelLabels.push(model.metadata.name); + PlanAgentDefaultModel.modelDescriptions.push(model.metadata.tooltip ?? model.metadata.detail ?? ''); + } catch (e) { + this.logService.error(`[PlanAgentDefaultModel] Error adding model ${model.metadata.name}:`, e); + } + } + + configurationRegistry.notifyConfigurationSchemaUpdated({ + id: 'chatSidebar', + properties: { + [ChatConfiguration.PlanAgentDefaultModel]: {} + } + }); + } catch (e) { + this.logService.error('[PlanAgentDefaultModel] Error updating model values:', e); + } + } +} + +registerWorkbenchContribution2(PlanAgentDefaultModel.ID, PlanAgentDefaultModel, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts index 932c4f6f9d3..f009cb3eba9 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/attachInstructionsAction.ts @@ -38,8 +38,8 @@ class ManageInstructionsFilesAction extends Action2 { constructor() { super({ id: CONFIGURE_INSTRUCTIONS_ACTION_ID, - title: localize2('configure-instructions', "Configure Instructions..."), - shortTitle: localize2('configure-instructions.short', "Chat Instructions"), + title: localize2('configure-instructions', "Configure Instructions & Rules..."), + shortTitle: localize2('configure-instructions.short', "Instructions & Rules"), icon: Codicon.bookmark, f1: true, precondition: ChatContextKeys.enabled, diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index 94be213b21e..d59d0c04949 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { isEqual } from '../../../../../base/common/resources.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ChatViewId } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -11,28 +15,702 @@ import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; -import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js'; -import { NEW_HOOK_COMMAND_ID } from './newPromptFileActions.js'; +import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js'; +import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js'; +import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../services/path/common/pathService.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; +import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; +import { OS } from '../../../../../base/common/platform.js'; /** * Action ID for the `Configure Hooks` action. */ const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; +interface IHookTypeQuickPickItem extends IQuickPickItem { + readonly hookType: typeof HOOK_TYPES[number]; +} + interface IHookQuickPickItem extends IQuickPickItem { readonly hookEntry?: IParsedHook; - readonly commandId?: string; + readonly isAddNewHook?: boolean; +} + +interface IHookFileQuickPickItem extends IQuickPickItem { + readonly fileUri?: URI; + readonly isCreateNewFile?: boolean; +} + +/** + * Detects if existing hooks use Copilot CLI naming convention (camelCase). + * Returns true if any existing key matches the Copilot CLI format. + */ +function usesCopilotCliNaming(hooksObj: Record): boolean { + for (const key of Object.keys(hooksObj)) { + // Check if any key resolves to a Copilot CLI hook type + if (resolveCopilotCliHookType(key) !== undefined) { + return true; + } + } + return false; +} + +/** + * Gets the appropriate key name for a hook type based on the naming convention used in the file. + */ +function getHookTypeKeyName(hookTypeId: HookType, useCopilotCliNamingConvention: boolean): string { + if (useCopilotCliNamingConvention) { + const copilotCliName = getCopilotCliHookTypeName(hookTypeId); + if (copilotCliName) { + return copilotCliName; + } + } + // Fall back to PascalCase (enum value) + return hookTypeId; +} + +/** + * Adds a hook to an existing hook file. + */ +async function addHookToFile( + hookFileUri: URI, + hookTypeId: HookType, + fileService: IFileService, + editorService: IEditorService, + notificationService: INotificationService, + bulkEditService: IBulkEditService +): Promise { + // Parse existing file + let hooksContent: { hooks: Record }; + const fileExists = await fileService.exists(hookFileUri); + + if (fileExists) { + const existingContent = await fileService.readFile(hookFileUri); + try { + hooksContent = JSON.parse(existingContent.value.toString()); + // Ensure hooks object exists + if (!hooksContent.hooks) { + hooksContent.hooks = {}; + } + } catch { + // If parsing fails, show error and open file for user to fix + notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks file. Please fix the JSON syntax errors and try again.")); + await editorService.openEditor({ resource: hookFileUri }); + return; + } + } else { + // Create new structure + hooksContent = { hooks: {} }; + } + + // Detect source format from file URI + const sourceFormat = getHookSourceFormat(hookFileUri); + const isClaude = sourceFormat === HookSourceFormat.Claude; + + // Detect naming convention from existing keys + const useCopilotCliNamingConvention = !isClaude && usesCopilotCliNaming(hooksContent.hooks); + const hookTypeKeyName = isClaude + ? (getClaudeHookTypeName(hookTypeId) ?? hookTypeId) + : getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); + + // Also check if there's an existing key for this hook type (with either naming) + // Find existing key that resolves to the same hook type + let existingKeyForType: string | undefined; + for (const key of Object.keys(hooksContent.hooks)) { + const resolvedType = isClaude + ? resolveClaudeHookType(key) + : resolveCopilotCliHookType(key); + if (resolvedType === hookTypeId || key === hookTypeId) { + existingKeyForType = key; + break; + } + } + + // Use existing key if found, otherwise use the detected naming convention + const keyToUse = existingKeyForType ?? hookTypeKeyName; + + // Add the new hook entry (append if hook type already exists) + const newHookEntry = buildNewHookEntry(sourceFormat); + let newHookIndex: number; + if (!hooksContent.hooks[keyToUse]) { + hooksContent.hooks[keyToUse] = [newHookEntry]; + newHookIndex = 0; + } else { + hooksContent.hooks[keyToUse].push(newHookEntry); + newHookIndex = hooksContent.hooks[keyToUse].length - 1; + } + + // Write the file + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + + // Check if the file is already open in an editor + const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); + + if (existingEditor) { + // File is already open - first focus the editor, then update its model directly + await editorService.openEditor({ + resource: hookFileUri, + options: { + pinned: false + } + }); + + // Get the code editor and update its content directly + const editor = getCodeEditor(editorService.activeTextEditorControl); + if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) { + const model = editor.getModel(); + // Apply the full content replacement using executeEdits + model.pushEditOperations([], [{ + range: model.getFullModelRange(), + text: jsonContent + }], () => null); + + // Find and apply the selection + const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command'); + if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) { + editor.setSelection({ + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn + }); + editor.revealLineInCenter(selection.startLineNumber); + } + } else { + // Fallback: active editor/model check failed, apply via bulk edit service + await bulkEditService.apply([ + new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) + ], { label: localize('addHook', "Add Hook") }); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command'); + + // Re-open editor with selection + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + } + } else { + // File is not currently open in an editor + if (!fileExists) { + // File doesn't exist - write new file directly and open + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + } else { + // File exists but isn't open - open it first, then use bulk edit for undo support + await editorService.openEditor({ + resource: hookFileUri, + options: { pinned: false } + }); + + // Apply the edit via bulk edit service for proper undo support + await bulkEditService.apply([ + new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) + ], { label: localize('addHook', "Add Hook") }); + } + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command'); + + // Open editor with selection (or re-focus if already open) + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + } +} + +/** + * Awaits a single pick interaction on the given picker. + * Returns the selected item, 'back' if the back button was pressed, or undefined if cancelled. + */ +function awaitPick( + picker: IQuickPick, + backButton: IQuickInputButton, +): Promise { + return new Promise(resolve => { + let resolved = false; + const done = (value: T | 'back' | undefined) => { + if (!resolved) { + resolved = true; + disposables.dispose(); + resolve(value); + } + }; + const disposables = new DisposableStore(); + disposables.add(picker.onDidAccept(() => { + done(picker.activeItems[0] as T | undefined); + })); + disposables.add(picker.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + disposables.add(picker.onDidHide(() => { + done(undefined); + })); + }); +} + +const enum Step { + SelectHookType = 1, + SelectHook = 2, + SelectFile = 3, + SelectFolder = 4, + EnterFilename = 5, +} + +/** + * Shows the Configure Hooks quick pick UI, allowing the user to view, + * open, or create hooks. Can be called from the action or slash command. + */ +export async function showConfigureHooksQuickPick( + accessor: ServicesAccessor, +): Promise { + const promptsService = accessor.get(IPromptsService); + const quickInputService = accessor.get(IQuickInputService); + const fileService = accessor.get(IFileService); + const labelService = accessor.get(ILabelService); + const editorService = accessor.get(IEditorService); + const workspaceService = accessor.get(IWorkspaceContextService); + const pathService = accessor.get(IPathService); + const notificationService = accessor.get(INotificationService); + const bulkEditService = accessor.get(IBulkEditService); + const remoteAgentService = accessor.get(IRemoteAgentService); + + // Get the remote OS (or fall back to local OS) + const remoteEnv = await remoteAgentService.getEnvironment(); + const targetOS = remoteEnv?.os ?? OS; + + // Get workspace root and user home for path resolution + const workspaceFolder = workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + + // Parse all hook files upfront to count hooks per type + const hookEntries = await parseAllHookFiles( + promptsService, + fileService, + labelService, + workspaceRootUri, + userHome, + targetOS, + CancellationToken.None + ); + + // Count hooks per type + const hookCountByType = new Map(); + for (const entry of hookEntries) { + hookCountByType.set(entry.hookType, (hookCountByType.get(entry.hookType) ?? 0) + 1); + } + + // Create a single picker instance reused across all steps + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick({ useSeparators: true })); + const backButton = quickInputService.backButton; + let suppressHideDispose = false; + store.add(picker.onDidHide(() => { + if (!suppressHideDispose) { + store.dispose(); + } + })); + picker.show(); + + let step = Step.SelectHookType; + let selectedHookType: IHookTypeQuickPickItem | undefined; + let selectedHook: IHookQuickPickItem | undefined; + let selectedFile: IHookFileQuickPickItem | undefined; + let selectedFolder: { uri: URI } | undefined; + + // Track steps that were actually shown to the user, so Back + // skips over auto-executed steps and returns to the last visible one. + const stepHistory: Step[] = []; + const goBack = (): Step | undefined => stepHistory.pop(); + + while (true) { + switch (step) { + case Step.SelectHookType: { + // Step 1: Show all lifecycle events with hook counts + const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { + const count = hookCountByType.get(hookType.id) ?? 0; + const countLabel = count > 0 ? ` (${count})` : ''; + return { + label: `${hookType.label}${countLabel}`, + description: hookType.description, + hookType + }; + }); + + picker.items = hookTypeItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'); + picker.title = localize('commands.hooks.title', 'Hooks'); + picker.buttons = []; + + const result = await awaitPick(picker, backButton); + + if (!result || result === 'back') { + picker.hide(); + return; + } + + selectedHookType = result; + stepHistory.push(Step.SelectHookType); + step = Step.SelectHook; + break; + } + + case Step.SelectHook: { + // Filter hooks by the selected type + const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType.id); + + // Step 2: Show "Add new hook" + existing hooks of this type + const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Add new hook" option at the top + hookItems.push({ + label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, + isAddNewHook: true, + alwaysShow: true + }); + + // Add existing hooks + if (hooksOfType.length > 0) { + hookItems.push({ + type: 'separator', + label: localize('existingHooks', "Existing Hooks") + }); + + for (const entry of hooksOfType) { + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); + hookItems.push({ + label: entry.commandLabel, + description, + hookEntry: entry + }); + } + } + + // Auto-execute if only "Add new hook" is available (no existing hooks) + if (hooksOfType.length === 0) { + selectedHook = hookItems[0] as IHookQuickPickItem; + } else { + picker.items = hookItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'); + picker.title = selectedHookType!.hookType.label; + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectHookType; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedHook = result; + stepHistory.push(Step.SelectHook); + } + + // Handle clicking on existing hook (focus into command) + if (selectedHook.hookEntry) { + const entry = selectedHook.hookEntry; + let selection: ITextEditorSelection | undefined; + + // Determine the command field name to highlight based on target platform + const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); + + // Try to find the command field to highlight + if (commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + commandFieldName + ); + } catch { + // Ignore errors and just open without selection + } + } + + picker.hide(); + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + return; + } + + // "Add new hook" was selected + step = Step.SelectFile; + break; + } + + case Step.SelectFile: { + // Step 3: Handle "Add new hook" - show create new file + existing hook files + // Get existing hook files (local storage only, not User Data) + const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); + + const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Create new hook config file" option at the top + fileItems.push({ + label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, + isCreateNewFile: true, + alwaysShow: true + }); + + // Add existing hook files + if (hookFiles.length > 0) { + fileItems.push({ + type: 'separator', + label: localize('existingHookFiles', "Existing Hook Files") + }); + + for (const hookFile of hookFiles) { + const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); + fileItems.push({ + label: relativePath, + fileUri: hookFile.uri + }); + } + } + + // Auto-execute if no existing hook files + if (hookFiles.length === 0) { + selectedFile = fileItems[0] as IHookFileQuickPickItem; + } else { + picker.items = fileItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'); + picker.title = localize('commands.hooks.addHook.title', 'Add Hook'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectHook; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedFile = result; + stepHistory.push(Step.SelectFile); + } + + // Handle adding hook to existing file + if (selectedFile.fileUri) { + picker.hide(); + await addHookToFile( + selectedFile.fileUri, + selectedHookType!.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // "Create new hook config file" was selected + step = Step.SelectFolder; + break; + } + + case Step.SelectFolder: { + // Get source folders for hooks + const allFolders = await promptsService.getSourceFolders(PromptsType.hook); + const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); + + if (localFolders.length === 0) { + picker.hide(); + notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); + return; + } + + // Auto-select if only one folder, otherwise show picker + selectedFolder = localFolders[0]; + if (localFolders.length > 1) { + const folderItems = localFolders.map(folder => ({ + label: labelService.getUriLabel(folder.uri, { relative: true }), + folder + })); + + picker.items = folderItems; + picker.value = ''; + picker.placeholder = localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'); + picker.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectFile; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedFolder = result.folder; + stepHistory.push(Step.SelectFolder); + } + + step = Step.EnterFilename; + break; + } + + case Step.EnterFilename: { + // Hide the picker and show an input box for the filename + suppressHideDispose = true; + picker.hide(); + suppressHideDispose = false; + + const fileNameResult = await new Promise(resolve => { + let resolved = false; + const done = (value: string | 'back' | undefined) => { + if (!resolved) { + resolved = true; + inputDisposables.dispose(); + resolve(value); + } + }; + const inputDisposables = new DisposableStore(); + const inputBox = inputDisposables.add(quickInputService.createInputBox()); + inputBox.prompt = localize('commands.hook.filename.prompt', "Enter hook file name"); + inputBox.placeholder = localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"); + inputBox.title = localize('commands.hook.filename.title', "Hook File Name"); + inputBox.buttons = [backButton]; + inputBox.ignoreFocusOut = true; + + inputDisposables.add(inputBox.onDidAccept(async () => { + const value = inputBox.value; + if (!value || !value.trim()) { + inputBox.validationMessage = localize('commands.hook.filename.required', "File name is required"); + return; + } + const name = value.trim(); + if (/[/\\:*?"<>|]/.test(name)) { + inputBox.validationMessage = localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); + return; + } + done(name); + })); + inputDisposables.add(inputBox.onDidChangeValue(() => { + inputBox.validationMessage = undefined; + })); + inputDisposables.add(inputBox.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + inputDisposables.add(inputBox.onDidHide(() => { + done(undefined); + })); + inputBox.show(); + }); + + if (fileNameResult === 'back') { + // Re-show the picker for the previous step + picker.show(); + step = goBack() ?? Step.SelectFolder; + break; + } + if (!fileNameResult) { + store.dispose(); + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder!.uri); + + // Use user-provided filename with .json extension + const hookFileName = fileNameResult.endsWith('.json') ? fileNameResult : `${fileNameResult}.json`; + const hookFileUri = URI.joinPath(selectedFolder!.uri, hookFileName); + + // Check if file already exists + if (await fileService.exists(hookFileUri)) { + // File exists - add hook to it instead of creating new + store.dispose(); + await addHookToFile( + hookFileUri, + selectedHookType!.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // Detect if new file is a Claude hooks file based on its path + const newFileFormat = getHookSourceFormat(hookFileUri); + const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; + const hookTypeKey = isClaudeNewFile + ? (getClaudeHookTypeName(selectedHookType!.hookType.id as HookType) ?? selectedHookType!.hookType.id) + : selectedHookType!.hookType.id; + const newFileHookEntry = buildNewHookEntry(newFileFormat); + + // Create new hook file with the selected hook type + const hooksContent = { + hooks: { + [hookTypeKey]: [ + newFileHookEntry + ] + } + }; + + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); + + // Open editor with selection + store.dispose(); + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + return; + } + } + } } class ManageHooksAction extends Action2 { @@ -57,123 +735,7 @@ class ManageHooksAction extends Action2 { public override async run( accessor: ServicesAccessor, ): Promise { - const promptsService = accessor.get(IPromptsService); - const quickInputService = accessor.get(IQuickInputService); - const fileService = accessor.get(IFileService); - const labelService = accessor.get(ILabelService); - const commandService = accessor.get(ICommandService); - const editorService = accessor.get(IEditorService); - const workspaceService = accessor.get(IWorkspaceContextService); - const pathService = accessor.get(IPathService); - - // Get workspace root and user home for path resolution - const workspaceFolder = workspaceService.getWorkspace().folders[0]; - const workspaceRootUri = workspaceFolder?.uri; - const userHomeUri = await pathService.userHome(); - const userHome = userHomeUri.fsPath ?? userHomeUri.path; - const hookEntries = await parseAllHookFiles( - promptsService, - fileService, - labelService, - workspaceRootUri, - userHome, - CancellationToken.None - ); - - // Build quick pick items grouped by hook type - const items: (IHookQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "New Hook..." option at the top - items.push({ - label: `$(plus) ${localize('commands.new-hook.label', 'Add new hook...')}`, - commandId: NEW_HOOK_COMMAND_ID, - alwaysShow: true - }); - - // Group entries by hook type - const groupedByType = new Map(); - for (const entry of hookEntries) { - const existing = groupedByType.get(entry.hookType) ?? []; - existing.push(entry); - groupedByType.set(entry.hookType, existing); - } - - // Sort hook types by their position in HOOK_TYPES - const sortedHookTypes = Array.from(groupedByType.keys()).sort((a, b) => { - const indexA = HOOK_TYPES.findIndex(h => h.id === a); - const indexB = HOOK_TYPES.findIndex(h => h.id === b); - return indexA - indexB; - }); - - // Add entries grouped by hook type - for (const hookTypeId of sortedHookTypes) { - const entries = groupedByType.get(hookTypeId)!; - const hookType = HOOK_TYPES.find(h => h.id === hookTypeId)!; - - items.push({ - type: 'separator', - label: hookType.label - }); - - for (const entry of entries) { - // Use relative path from labelService for consistent display - const description = labelService.getUriLabel(entry.fileUri, { relative: true }); - - items.push({ - label: entry.commandLabel, - description, - hookEntry: entry - }); - } - } - - // Show empty state message if no hooks found - if (hookEntries.length === 0) { - items.push({ - type: 'separator', - label: localize('noHooks', "No hooks configured") - }); - } - - const selected = await quickInputService.pick(items, { - placeHolder: localize('commands.hooks.placeholder', 'Select a hook to open or add a new hook'), - title: localize('commands.hooks.title', 'Hooks') - }); - - if (selected) { - if (selected.commandId) { - await commandService.executeCommand(selected.commandId); - } else if (selected.hookEntry) { - const entry = selected.hookEntry; - let selection: ITextEditorSelection | undefined; - - // Determine the command field name to highlight based on current platform - const commandFieldName = getEffectiveCommandFieldKey(entry.command); - - // Try to find the command field to highlight - if (commandFieldName) { - try { - const content = await fileService.readFile(entry.fileUri); - selection = findHookCommandSelection( - content.value.toString(), - entry.originalHookTypeId, - entry.index, - commandFieldName - ); - } catch { - // Ignore errors and just open without selection - } - } - - await editorService.openEditor({ - resource: entry.fileUri, - options: { - selection, - pinned: false - } - }); - } - } + return showConfigureHooksQuickPick(accessor); } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index 885054127b8..e6dd6668f35 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js'; +import { findNodeAtLocation, Node, parse as parseJSONC, parseTree } from '../../../../../base/common/json.js'; import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; import { URI } from '../../../../../base/common/uri.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; @@ -11,9 +11,10 @@ import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { formatHookCommandLabel, HOOK_TYPES, HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; -import { parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { parseHooksFromFile, parseHooksIgnoringDisableAll } from '../../common/promptSyntax/hookCompatibility.js'; import * as nls from '../../../../../nls.js'; import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; /** * Converts an offset in content to a 1-based line and column. @@ -125,6 +126,13 @@ export interface IParsedHook { index: number; /** The original hook type ID as it appears in the JSON file */ originalHookTypeId: string; + /** If true, this hook is disabled via `disableAllHooks: true` in its file */ + disabled?: boolean; +} + +export interface IParseAllHookFilesOptions { + /** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */ + additionalDisabledFileUris?: readonly URI[]; } /** @@ -137,7 +145,9 @@ export async function parseAllHookFiles( labelService: ILabelService, workspaceRootUri: URI | undefined, userHome: string, - token: CancellationToken + os: OperatingSystem, + token: CancellationToken, + options?: IParseAllHookFilesOptions ): Promise { const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token); const parsedHooks: IParsedHook[] = []; @@ -145,7 +155,7 @@ export async function parseAllHookFiles( for (const hookFile of hookFiles) { try { const content = await fileService.readFile(hookFile.uri); - const json = JSON.parse(content.value.toString()); + const json = parseJSONC(content.value.toString()); // Use format-aware parsing const { hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); @@ -158,7 +168,7 @@ export async function parseAllHookFiles( for (let i = 0; i < commands.length; i++) { const command = commands[i]; - const commandLabel = formatHookCommandLabel(command) || nls.localize('commands.hook.emptyCommand', '(empty command)'); + const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)'); parsedHooks.push({ hookType, hookTypeLabel: hookTypeMeta.label, @@ -177,5 +187,44 @@ export async function parseAllHookFiles( } } + // Parse additional disabled files (e.g., files with disableAllHooks: true) + // These are parsed ignoring the disableAllHooks flag so we can show their hooks as disabled + if (options?.additionalDisabledFileUris) { + for (const uri of options.additionalDisabledFileUris) { + try { + const content = await fileService.readFile(uri); + const json = parseJSONC(content.value.toString()); + + // Parse hooks ignoring disableAllHooks - use the underlying format parsers directly + const { hooks } = parseHooksIgnoringDisableAll(uri, json, workspaceRootUri, userHome); + + for (const [hookType, { hooks: commands, originalId }] of hooks) { + const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + if (!hookTypeMeta) { + continue; + } + + for (let i = 0; i < commands.length; i++) { + const command = commands[i]; + const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)'); + parsedHooks.push({ + hookType, + hookTypeLabel: hookTypeMeta.label, + command, + commandLabel, + fileUri: uri, + filePath: labelService.getUriLabel(uri, { relative: true }), + index: i, + originalHookTypeId: originalId, + disabled: true + }); + } + } + } catch (error) { + console.error('Failed to read or parse disabled hook file', uri.toString(), error); + } + } + } + return parsedHooks; } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 6a1e8d11054..5928158fa19 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -5,7 +5,6 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -import { VSBuffer } from '../../../../../base/common/buffer.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { SnippetController2 } from '../../../../../editor/contrib/snippet/browser/snippetController2.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -26,11 +25,9 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { getCleanPromptName, SKILL_FILENAME, HOOKS_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; -import { HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; -import { findHookCommandSelection } from './hookUtils.js'; -import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; -import { Range } from '../../../../../editor/common/core/range.js'; +import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { Target } from '../../common/promptSyntax/service/promptsService.js'; +import { getTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; class AbstractNewPromptFileAction extends Action2 { @@ -86,7 +83,7 @@ class AbstractNewPromptFileAction extends Action2 { if (editor && editor.hasModel() && isEqual(editor.getModel().uri, promptUri)) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getDefaultContentSnippet(this.type, cleanName), + template: getDefaultContentSnippet(this.type, cleanName, getTarget(this.type, promptUri)), }]); } @@ -144,7 +141,7 @@ class AbstractNewPromptFileAction extends Action2 { } } -function getDefaultContentSnippet(promptType: PromptsType, name: string | undefined): string { +function getDefaultContentSnippet(promptType: PromptsType, name: string | undefined, target: Target): string { switch (promptType) { case PromptsType.prompt: return [ @@ -155,23 +152,45 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `\${3:Define the prompt content here. You can include instructions, examples, and any other relevant information to guide the AI's responses.}`, ].join('\n'); case PromptsType.instructions: - return [ - `---`, - `description: \${1:Describe when these instructions should be loaded}`, - `# applyTo: '\${1|**,**/*.ts|}' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file`, - `---`, - `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, - ].join('\n'); + if (target === Target.Claude) { + return [ + `---`, + `description: \${1:Describe when these instructions should be loaded}`, + `paths:`, + `. - "src/**/*.ts"`, + `---`, + `\${2:Provide coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, + ].join('\n'); + } else { + return [ + `---`, + `description: \${1:Describe when these instructions should be loaded}`, + `# applyTo: '\${1|**,**/*.ts|}' # when provided, instructions will automatically be added to the request context when the pattern matches an attached file`, + `---`, + `\${2:Provide project context and coding guidelines that AI should follow when generating code, answering questions, or reviewing changes.}`, + ].join('\n'); + } case PromptsType.agent: - return [ - `---`, - `name: ${name ?? '${1:agent-name}'}`, - `description: \${2:Describe what this custom agent does and when to use it.}`, - `argument-hint: \${3:The inputs this agent expects, e.g., "a task to implement" or "a question to answer".}`, - `# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.`, - `---`, - `\${4:Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.}`, - ].join('\n'); + if (target === Target.Claude) { + return [ + `---`, + `name: ${name ?? '${1:agent-name}'}`, + `description: \${2:Describe what this custom agent does and when to use it.}`, + `tools: Read, Grep, Glob, Bash # specify the tools this agent can use. If not set, all enabled tools are allowed.`, + `---`, + `\${4:Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.}`, + ].join('\n'); + } else { + return [ + `---`, + `name: ${name ?? '${1:agent-name}'}`, + `description: \${2:Describe what this custom agent does and when to use it.}`, + `argument-hint: \${3:The inputs this agent expects, e.g., "a task to implement" or "a question to answer".}`, + `# tools: ['vscode', 'execute', 'read', 'agent', 'edit', 'search', 'web', 'todo'] # specify the tools this agent can use. If not set, all enabled tools are allowed.`, + `---`, + `\${4:Define what this custom agent does, including its behavior, capabilities, and any specific instructions for its operation.}`, + ].join('\n'); + } case PromptsType.skill: return [ `---`, @@ -180,11 +199,6 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `---`, `\${3:Define the functionality provided by this skill, including detailed instructions and examples}`, ].join('\n'); - case PromptsType.hook: - return JSON.stringify({ - version: 1, - hooks: {} - }, null, 4); default: throw new Error(`Unsupported prompt type: ${promptType}`); } @@ -196,7 +210,6 @@ export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions'; export const NEW_AGENT_COMMAND_ID = 'workbench.command.new.agent'; export const NEW_SKILL_COMMAND_ID = 'workbench.command.new.skill'; -export const NEW_HOOK_COMMAND_ID = 'workbench.command.new.hook'; class NewPromptFileAction extends AbstractNewPromptFileAction { constructor() { @@ -293,174 +306,12 @@ class NewSkillFileAction extends Action2 { if (editor && editor.hasModel() && isEqual(editor.getModel().uri, skillFileUri)) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getDefaultContentSnippet(PromptsType.skill, trimmedName), + template: getDefaultContentSnippet(PromptsType.skill, trimmedName, Target.Undefined), }]); } } } -class NewHookFileAction extends Action2 { - constructor() { - super({ - id: NEW_HOOK_COMMAND_ID, - title: localize('commands.new.hook.local.title', "New Hook..."), - f1: false, - precondition: ChatContextKeys.enabled, - category: CHAT_CATEGORY, - keybinding: { - weight: KeybindingWeight.WorkbenchContrib - }, - menu: { - id: MenuId.CommandPalette, - when: ChatContextKeys.enabled - } - }); - } - - public override async run(accessor: ServicesAccessor) { - const editorService = accessor.get(IEditorService); - const fileService = accessor.get(IFileService); - const instaService = accessor.get(IInstantiationService); - const quickInputService = accessor.get(IQuickInputService); - const bulkEditService = accessor.get(IBulkEditService); - - const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.hook); - if (!selectedFolder) { - return; - } - - // Ask which hook type to add - const hookTypeItems = HOOK_TYPES.map(hookType => ({ - id: hookType.id, - label: hookType.label, - description: hookType.description - })); - - const selectedHookType = await quickInputService.pick(hookTypeItems, { - placeHolder: localize('commands.new.hook.type.placeholder', "Select a hook type to add"), - title: localize('commands.new.hook.type.title', "Add Hook") - }); - - if (!selectedHookType) { - return; - } - - // Create the hooks folder if it doesn't exist - await fileService.createFolder(selectedFolder.uri); - - // Use fixed hooks.json filename - const hookFileUri = URI.joinPath(selectedFolder.uri, HOOKS_FILENAME); - - // Check if hooks.json already exists - let hooksContent: { hooks: Record }; - const fileExists = await fileService.exists(hookFileUri); - - if (fileExists) { - // Parse existing file - const existingContent = await fileService.readFile(hookFileUri); - try { - hooksContent = JSON.parse(existingContent.value.toString()); - // Ensure hooks object exists - if (!hooksContent.hooks) { - hooksContent.hooks = {}; - } - } catch { - // If parsing fails, show error and open file for user to fix - const notificationService = accessor.get(INotificationService); - notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks.json. Please fix the JSON syntax errors and try again.")); - await editorService.openEditor({ resource: hookFileUri }); - return; - } - } else { - // Create new structure - hooksContent = { hooks: {} }; - } - - // Add the new hook entry (append if hook type already exists) - const hookTypeId = selectedHookType.id as HookType; - const newHookEntry = { - type: 'command', - command: '' - }; - let newHookIndex: number; - if (!hooksContent.hooks[hookTypeId]) { - hooksContent.hooks[hookTypeId] = [newHookEntry]; - newHookIndex = 0; - } else { - hooksContent.hooks[hookTypeId].push(newHookEntry); - newHookIndex = hooksContent.hooks[hookTypeId].length - 1; - } - - // Write the file - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - - // Check if the file is already open in an editor - const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); - - if (existingEditor) { - // File is already open - first focus the editor, then update its model directly - await editorService.openEditor({ - resource: hookFileUri, - options: { - pinned: false - } - }); - - // Get the code editor and update its content directly - const editor = getCodeEditor(editorService.activeTextEditorControl); - if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) { - const model = editor.getModel(); - // Apply the full content replacement using executeEdits - model.pushEditOperations([], [{ - range: model.getFullModelRange(), - text: jsonContent - }], () => null); - - // Find and apply the selection - const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); - if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) { - editor.setSelection({ - startLineNumber: selection.startLineNumber, - startColumn: selection.startColumn, - endLineNumber: selection.endLineNumber, - endColumn: selection.endColumn - }); - editor.revealLineInCenter(selection.startLineNumber); - } - } - } else { - // File is not currently open in an editor - if (!fileExists) { - // File doesn't exist - write new file directly and open - await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); - } else { - // File exists but isn't open - open it first, then use bulk edit for undo support - await editorService.openEditor({ - resource: hookFileUri, - options: { pinned: false } - }); - - // Apply the edit via bulk edit service for proper undo support - await bulkEditService.apply([ - new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) - ], { label: localize('addHook', "Add Hook") }); - } - - // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); - - // Open editor with selection (or re-focus if already open) - await editorService.openEditor({ - resource: hookFileUri, - options: { - selection, - pinned: false - } - }); - } - } -} - class NewUntitledPromptFileAction extends Action2 { constructor() { super({ @@ -493,7 +344,7 @@ class NewUntitledPromptFileAction extends Action2 { if (editor && editor.hasModel()) { SnippetController2.get(editor)?.apply([{ range: editor.getModel().getFullModelRange(), - template: getDefaultContentSnippet(type, undefined), + template: getDefaultContentSnippet(type, undefined, Target.Undefined), }]); } @@ -506,6 +357,5 @@ export function registerNewPromptFileActions(): void { registerAction2(NewInstructionsFileAction); registerAction2(NewAgentFileAction); registerAction2(NewSkillFileAction); - registerAction2(NewHookFileAction); registerAction2(NewUntitledPromptFileAction); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts index 1937a193db7..96e913156aa 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileRewriter.ts @@ -12,6 +12,7 @@ import { ITextModel } from '../../../../../editor/common/model.js'; import { ILanguageModelToolsService, IToolAndToolSetEnablementMap } from '../../common/tools/languageModelToolsService.js'; import { PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { formatArrayValue } from '../../common/promptSyntax/utils/promptEditHelper.js'; export class PromptFileRewriter { constructor( @@ -43,13 +44,14 @@ export class PromptFileRewriter { this.rewriteAttribute(model, '', toolsAttr.range); return; } else { - this.rewriteTools(model, newTools, toolsAttr.value.range); + this.rewriteTools(model, newTools, toolsAttr.value.range, toolsAttr.value.type === 'string'); } } - public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap, range: Range): void { + public rewriteTools(model: ITextModel, newTools: IToolAndToolSetEnablementMap, range: Range, isString: boolean): void { const newToolNames = this._languageModelToolsService.toFullReferenceNames(newTools); - const newValue = `[${newToolNames.map(s => `'${s}'`).join(', ')}]`; + const newEntries = newToolNames.map(toolName => formatArrayValue(toolName)).join(', '); + const newValue = isString ? newEntries : `[${newEntries}]`; this.rewriteAttribute(model, newValue, range); } @@ -83,3 +85,4 @@ export class PromptFileRewriter { this.rewriteAttribute(model, newName, nameAttr.value.range); } } + diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index 3a8d9fda951..021f38acfd4 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -15,13 +15,14 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { showToolsPicker } from '../actions/chatToolPicker.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { ALL_PROMPTS_LANGUAGE_SELECTOR, getPromptsTypeForLanguageId, PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, Target } from '../../common/promptSyntax/service/promptsService.js'; import { registerEditorFeature } from '../../../../../editor/common/editorFeatures.js'; import { PromptFileRewriter } from './promptFileRewriter.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IEditorModel } from '../../../../../editor/common/editorCommon.js'; -import { PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { isGithubTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { isTarget, parseCommaSeparatedList, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; +import { getTarget, isVSCodeOrDefaultTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { isBoolean } from '../../../../../base/common/types.js'; class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider { @@ -40,10 +41,10 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider this._register(this.languageService.codeLensProvider.register(ALL_PROMPTS_LANGUAGE_SELECTOR, this)); this._register(CommandsRegistry.registerCommand(this.cmdId, (_accessor, ...args) => { - const [first, second, third, forth] = args; - const model = first as IEditorModel; - if (isITextModel(model) && Range.isIRange(second) && Array.isArray(third) && (typeof forth === 'string' || forth === undefined)) { - this.updateTools(model as ITextModel, Range.lift(second), third, forth); + const [modelArg, rangeArg, isStringArg, toolsArg, targetArg] = args; + const model = modelArg as IEditorModel; + if (isITextModel(model) && Range.isIRange(rangeArg) && isBoolean(isStringArg) && Array.isArray(toolsArg) && isTarget(targetArg)) { + this.updateTools(model as ITextModel, Range.lift(rangeArg), isStringArg, toolsArg, targetArg); } })); } @@ -61,15 +62,23 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider return undefined; } - if (isGithubTarget(promptType, header.target)) { + const target = getTarget(promptType, header); + if (!isVSCodeOrDefaultTarget(target)) { return undefined; } const toolsAttr = header.getAttribute(PromptHeaderAttributes.tools); - if (!toolsAttr || toolsAttr.value.type !== 'array') { + if (!toolsAttr) { return undefined; } - const items = toolsAttr.value.items; + let value = toolsAttr.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type !== 'array') { + return undefined; + } + const items = value.items; const selectedTools = items.filter(item => item.type === 'string').map(item => item.value); const codeLens: CodeLens = { @@ -77,19 +86,19 @@ class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider command: { title: localize('configure-tools.capitalized.ellipsis', "Configure Tools..."), id: this.cmdId, - arguments: [model, toolsAttr.value.range, selectedTools, header.target] + arguments: [model, toolsAttr.range, toolsAttr.value.type === 'string', selectedTools, target] } }; return { lenses: [codeLens] }; } - private async updateTools(model: ITextModel, range: Range, selectedTools: readonly string[], target: string | undefined): Promise { - const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, target, undefined); + private async updateTools(model: ITextModel, range: Range, isString: boolean, selectedTools: readonly string[], target: Target): Promise { + const selectedToolsNow = () => this.languageModelToolsService.toToolAndToolSetEnablementMap(selectedTools, undefined); const newSelectedAfter = await this.instantiationService.invokeFunction(showToolsPicker, localize('placeholder', "Select tools"), 'codeLens', undefined, selectedToolsNow); if (!newSelectedAfter) { return; } - this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range); + this.instantiationService.createInstance(PromptFileRewriter).rewriteTools(model, newSelectedAfter, range, isString); } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 0be7666e978..80612e6daf3 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -20,9 +20,11 @@ import { derived, derivedOpts, IObservable, IReader, observableFromEventOpts, Ob import Severity from '../../../../../base/common/severity.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; @@ -34,8 +36,6 @@ import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; -import { IPostToolUseCallerInput, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../common/hooks/hooksTypes.js'; -import { IHooksExecutionService } from '../../common/hooks/hooksExecutionService.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatRequestToolReferenceEntry, toToolSetVariableEntry, toToolVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IVariableReference } from '../../common/chatModes.js'; @@ -44,12 +44,11 @@ import { ChatConfiguration } from '../../common/constants.js'; import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { IChatModel, IChatRequestModel } from '../../common/model/chatModel.js'; import { ChatToolInvocation } from '../../common/model/chatProgressTypes/chatToolInvocation.js'; -import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; -import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, ILanguageModelToolsService, IPreparedToolInvocation, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolResult, IToolResultInputOutputDetails, SpecedToolAliases, stringifyPromptTsxPart, isToolSet, ToolDataSource, toolContentToA11yString, toolMatchesModel, ToolSet, VSCodeToolReference, IToolSet, ToolSetForModel, IToolInvokedEvent } from '../../common/tools/languageModelToolsService.js'; -import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; -import { URI } from '../../../../../base/common/uri.js'; import { chatSessionResourceToId } from '../../common/model/chatUri.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { ILanguageModelToolsConfirmationService } from '../../common/tools/languageModelToolsConfirmationService.js'; +import { CountTokensCallback, createToolSchemaUri, IBeginToolCallOptions, IExternalPreToolUseHookResult, ILanguageModelToolsService, IPreparedToolInvocation, isToolSet, IToolAndToolSetEnablementMap, IToolData, IToolImpl, IToolInvocation, IToolInvokedEvent, IToolResult, IToolResultInputOutputDetails, IToolSet, SpecedToolAliases, stringifyPromptTsxPart, ToolDataSource, ToolInvocationPresentation, toolMatchesModel, ToolSet, ToolSetForModel, VSCodeToolReference } from '../../common/tools/languageModelToolsService.js'; +import { getToolConfirmationAlert } from '../accessibility/chatAccessibilityProvider.js'; const jsonSchemaRegistry = Registry.as(JSONContributionRegistry.Extensions.JSONContribution); @@ -68,6 +67,10 @@ const enum AutoApproveStorageKeys { const SkipAutoApproveConfirmationKey = 'vscode.chat.tools.global.autoApprove.testMode'; +// This tool will always require user confirmation even in auto approval mode. +// Users cannot auto approve this tool via settings either, as this is a tool used before the agentic loop. +const toolIdThatCannotBeAutoApproved = 'vscode_get_confirmation_with_options'; + export const globalAutoApproveDescription = localize2( { key: 'autoApprove2.markdown', @@ -96,7 +99,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo readonly onDidInvokeTool = this._onDidInvokeTool.event; /** Throttle tools updates because it sends all tools and runs on context key updates */ - private readonly _onDidChangeToolsScheduler = new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750); + private readonly _onDidChangeToolsScheduler = this._register(new RunOnceScheduler(() => this._onDidChangeTools.fire(), 750)); private readonly _tools = new Map(); private readonly _toolContextKeys = new Set(); private readonly _ctxToolsCount: IContextKey; @@ -121,7 +124,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo @IAccessibilitySignalService private readonly _accessibilitySignalService: IAccessibilitySignalService, @IStorageService private readonly _storageService: IStorageService, @ILanguageModelToolsConfirmationService private readonly _confirmationService: ILanguageModelToolsConfirmationService, - @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, + @ICommandService private readonly _commandService: ICommandService, ) { super(); @@ -206,6 +209,13 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (agentModeEnabled !== false) { return true; } + + // Internal tools that explicitly cannot be referenced in prompts are always permitted + // since they are infrastructure tools (e.g. inline_chat_exit), not user-facing agent tools + if (!isToolSet(toolOrToolSet) && toolOrToolSet.canBeReferencedInPrompt === false && toolOrToolSet.source.type === 'internal') { + return true; + } + const permittedInternalToolSetIds = [SpecedToolAliases.read, SpecedToolAliases.search, SpecedToolAliases.web]; if (isToolSet(toolOrToolSet)) { const permitted = toolOrToolSet.source.type === 'internal' && permittedInternalToolSetIds.includes(toolOrToolSet.referenceName); @@ -361,103 +371,69 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * Execute the preToolUse hook and handle denial. - * Returns an object containing: - * - denialResult: A tool result if the hook denied execution (caller should return early) - * - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions) - * @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one. - */ - private async _executePreToolUseHook( + private _handlePreToolUseDenial( dto: IToolInvocation, + hookResult: IExternalPreToolUseHookResult, toolData: IToolData | undefined, - request: IChatRequestModel | undefined, pendingInvocation: ChatToolInvocation | undefined, - token: CancellationToken - ): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> { - // Skip hook if no session context or tool doesn't exist - if (!dto.context?.sessionResource || !toolData) { - return {}; - } + request: IChatRequestModel | undefined, + ): IToolResult { + const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); + const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); - const hookInput: IPreToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - toolCallId: dto.callId, - }; - const hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); - - if (hookResult?.permissionDecision === 'deny') { - const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); - const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); - - // Handle the tool invocation in cancelled state - if (toolData) { - if (pendingInvocation) { - // If there's an existing streaming invocation, cancel it - pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); - } else if (request) { - // Otherwise create a new cancelled invocation and add it to the chat model - const toolInvocation = ChatToolInvocation.createCancelled( - { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, - dto.parameters, - ToolConfirmKind.Denied, - reason - ); - this._chatService.appendProgress(request, toolInvocation); - } + if (toolData) { + if (pendingInvocation) { + pendingInvocation.presentation = ToolInvocationPresentation.Hidden; + pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); + } else if (request) { + const cancelledInvocation = ChatToolInvocation.createCancelled( + { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, + dto.parameters, + ToolConfirmKind.Denied, + reason + ); + cancelledInvocation.presentation = ToolInvocationPresentation.Hidden; + this._chatService.appendProgress(request, cancelledInvocation); } - - const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason); - return { - denialResult: { - content: [{ kind: 'text', value: denialMessage }], - toolResultError: hookReason, - }, - hookResult, - }; } - return { hookResult }; + return { + content: [{ kind: 'text', value: `Tool execution denied: ${hookReason}` }], + toolResultError: hookReason, + }; } /** - * Execute the postToolUse hook after tool completion. - * If the hook returns a "block" decision, additional context is appended to the tool result - * as feedback for the agent indicating the block and reason. The tool has already run, - * so blocking only provides feedback. + * Validate updatedInput from a preToolUse hook against the tool's input schema + * using the json.validate command from the JSON extension. + * @returns An error message string if validation fails, or undefined if valid. */ - private async _executePostToolUseHook( - dto: IToolInvocation, - toolResult: IToolResult, - token: CancellationToken - ): Promise { - if (!dto.context?.sessionResource) { - return; + private async _validateUpdatedInput(toolId: string, toolData: IToolData | undefined, updatedInput: object): Promise { + if (!toolData?.inputSchema) { + return undefined; } - const hookInput: IPostToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - getToolResponseText: () => toolContentToA11yString(toolResult.content), - toolCallId: dto.callId, + type JsonDiagnostic = { + message: string; + range: { line: number; character: number }[]; + severity: string; + code?: string | number; }; - const hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); - if (hookResult?.decision === 'block') { - const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result"); - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook blocked for tool ${dto.toolId}: ${hookReason}`); - const blockMessage = localize('postToolUseHookBlockedContext', "The PostToolUse hook blocked this tool result. Reason: {0}", hookReason); - toolResult.content.push({ kind: 'text', value: '\n\n' + blockMessage + '\n' }); - } - - if (hookResult?.additionalContext) { - // Append additional context from all hooks to the tool result content - for (const context of hookResult.additionalContext) { - toolResult.content.push({ kind: 'text', value: '\n\n' + context + '\n' }); + try { + const schemaUri = createToolSchemaUri(toolId); + const inputJson = JSON.stringify(updatedInput); + const diagnostics = await this._commandService.executeCommand('json.validate', schemaUri, inputJson) || []; + if (diagnostics.length > 0) { + return diagnostics.map(d => d.message).join('; '); } + } catch (e) { + // json extension may not be available; skip validation + this._logService.debug(`[LanguageModelToolsService#_validateUpdatedInput] json.validate command failed, skipping validation: ${toErrorMessage(e)}`); } + + return undefined; } async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { @@ -507,14 +483,25 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo token = source.token; } - // Execute preToolUse hook - returns early if hook denies execution - const { denialResult: hookDenialResult, hookResult: preToolUseHookResult } = await this._executePreToolUseHook(dto, toolData, request, toolInvocation, token); - if (hookDenialResult) { - // Clean up pending tool call if it exists + // Handle preToolUse hook denial + const preToolUseHookResult = dto.preToolUseResult; + if (preToolUseHookResult?.permissionDecision === 'deny') { + const denialResult = this._handlePreToolUseDenial(dto, preToolUseHookResult, toolData, toolInvocation, request); if (pendingToolCallKey) { this._pendingToolCalls.delete(pendingToolCallKey); } - return hookDenialResult; + return denialResult; + } + + // Apply updatedInput from preToolUse hook if provided, after validating against the tool's input schema + if (preToolUseHookResult?.updatedInput) { + const validationError = await this._validateUpdatedInput(dto.toolId, toolData, preToolUseHookResult.updatedInput); + if (validationError) { + this._logService.warn(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} updatedInput from preToolUse hook failed schema validation: ${validationError}`); + } else { + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} input modified by preToolUse hook`); + dto.parameters = preToolUseHookResult.updatedInput; + } } // Fire the event to notify listeners that a tool is being invoked @@ -663,9 +650,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - // Execute postToolUse hook after successful tool execution - await this._executePostToolUseHook(dto, toolResult, token); - this._telemetryService.publicLog2( 'languageModelToolInvoked', { @@ -710,7 +694,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } } - private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IPreToolUseHookResult | undefined, token: CancellationToken): Promise { + private async prepareToolInvocationWithHookResult(tool: IToolEntry, dto: IToolInvocation, hookResult: IExternalPreToolUseHookResult | undefined, token: CancellationToken): Promise { let forceConfirmationReason: string | undefined; if (hookResult?.permissionDecision === 'ask') { const hookMessage = localize('preToolUseHookRequiredConfirmation', "{0} required confirmation", HookType.PreToolUse); @@ -731,7 +715,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * since when the hook returns 'ask' and preparedInvocation was undefined, we create one. */ private async resolveAutoConfirmFromHook( - hookResult: IPreToolUseHookResult | undefined, + hookResult: IExternalPreToolUseHookResult | undefined, tool: IToolEntry, dto: IToolInvocation, preparedInvocation: IPreparedToolInvocation | undefined, @@ -751,17 +735,49 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo } const fullReferenceName = getToolFullReferenceName(tool.data); const hookReason = hookResult.permissionDecisionReason; - const baseMessage = localize('hookRequiresConfirmation.message', "{0} hook confirmation required", HookType.PreToolUse); + const hookNote = hookReason + ? localize('hookRequiresConfirmation.messageWithReason', "{0} hook required confirmation: {1}", HookType.PreToolUse, hookReason) + : localize('hookRequiresConfirmation.message', "{0} hook required confirmation", HookType.PreToolUse); preparedInvocation.confirmationMessages = { ...preparedInvocation.confirmationMessages, title: localize('hookRequiresConfirmation.title', "Use the '{0}' tool?", fullReferenceName), - message: new MarkdownString(hookReason ? `${baseMessage}\n\n${hookReason}` : baseMessage), + message: new MarkdownString(`_${hookNote}_`), allowAutoConfirm: false, }; preparedInvocation.toolSpecificData = { kind: 'input', rawInput: dto.parameters, }; + } else { + // Tool already has its own confirmation - prepend hook note + const hookReason = hookResult.permissionDecisionReason; + const hookNote = hookReason + ? localize('hookRequiresConfirmation.note', "{0} hook required confirmation: {1}", HookType.PreToolUse, hookReason) + : localize('hookRequiresConfirmation.noteNoReason', "{0} hook required confirmation", HookType.PreToolUse); + + const existing = preparedInvocation.confirmationMessages!; + if (preparedInvocation.toolSpecificData?.kind === 'terminal') { + // Terminal tools render message as hover only; use disclaimer for visible text + const existingDisclaimerText = existing.disclaimer + ? (typeof existing.disclaimer === 'string' ? existing.disclaimer : existing.disclaimer.value) + : undefined; + const combinedDisclaimer = existingDisclaimerText + ? `${hookNote}\n\n${existingDisclaimerText}` + : hookNote; + preparedInvocation.confirmationMessages = { + ...existing, + disclaimer: combinedDisclaimer, + allowAutoConfirm: false, + }; + } else { + // Edit/other tools: prepend hook note to the message body + const msgText = typeof existing.message === 'string' ? existing.message : existing.message?.value ?? ''; + preparedInvocation.confirmationMessages = { + ...existing, + message: new MarkdownString(`_${hookNote}_\n\n${msgText}`), + allowAutoConfirm: false, + }; + } } return { autoConfirmed: undefined, preparedInvocation }; } @@ -776,10 +792,12 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo if (tool.impl!.prepareToolInvocation) { const preparePromise = tool.impl!.prepareToolInvocation({ parameters: dto.parameters, + toolCallId: dto.callId, chatRequestId: dto.chatRequestId, chatSessionId: dto.context?.sessionId, chatSessionResource: dto.context?.sessionResource, chatInteractionId: dto.chatInteractionId, + modelId: dto.modelId, forceConfirmationReason: forceConfirmationReason }, token); @@ -811,14 +829,14 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo ...prepared.confirmationMessages, title: localize('defaultToolConfirmation.title', 'Confirm tool execution'), message: localize('defaultToolConfirmation.message', 'Run the \'{0}\' tool?', fullReferenceName), - disclaimer: new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }), + disclaimer: tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }), allowAutoConfirm: false, }; } if (!isEligibleForAutoApproval && prepared?.confirmationMessages?.title) { // Always overwrite the disclaimer if not eligible for auto-approval - prepared.confirmationMessages.disclaimer = new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }); + prepared.confirmationMessages.disclaimer = tool.data.id === toolIdThatCannotBeAutoApproved ? undefined : new MarkdownString(localize('defaultToolConfirmation.disclaimer', 'Auto approval for \'{0}\' is restricted via {1}.', getToolFullReferenceName(tool.data), createMarkdownCommandLink({ title: '`' + ChatConfiguration.EligibleForAutoApproval + '`', id: 'workbench.action.openSettings', arguments: [ChatConfiguration.EligibleForAutoApproval] }, false)), { isTrusted: true }); } if (prepared?.confirmationMessages?.title) { @@ -984,6 +1002,11 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo // Special case, this fetch will call an internal tool 'vscode_fetchWebPage_internal' return true; } + if (toolData.id === toolIdThatCannotBeAutoApproved) { + // Special case, this tool will always require user confirmation as there are multiple options, + // These aren't LM generated instead are generated by extension before agentic loop starts. + return false; + } const eligibilityConfig = this._configurationService.getValue>(ChatConfiguration.EligibleForAutoApproval); if (eligibilityConfig && typeof eligibilityConfig === 'object' && fullReferenceName) { // Direct match @@ -1195,7 +1218,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo * @param fullReferenceNames A list of tool or toolset by their full reference names that are enabled. * @returns A map of tool or toolset instances to their enablement state. */ - toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], _target: string | undefined, model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap { const toolOrToolSetNames = new Set(fullReferenceNames); const result = new Map(); for (const [tool, fullReferenceName] of this.toolsWithFullReferenceName.get()) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts b/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts new file mode 100644 index 00000000000..83a8d6be039 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatConfetti.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../base/browser/dom.js'; + +const confettiColors = [ + '#f44336', '#e91e63', '#9c27b0', '#673ab7', + '#3f51b5', '#2196f3', '#03a9f4', '#00bcd4', + '#009688', '#4caf50', '#8bc34a', '#ffeb3b', + '#ffc107', '#ff9800', '#ff5722' +]; + +let activeOverlay: HTMLElement | undefined; + +/** + * Triggers a confetti animation inside the given container element. + */ +export function triggerConfetti(container: HTMLElement) { + if (activeOverlay) { + return; + } + + const overlay = dom.$('.chat-confetti-overlay'); + overlay.style.position = 'absolute'; + overlay.style.inset = '0'; + overlay.style.pointerEvents = 'none'; + overlay.style.overflow = 'hidden'; + overlay.style.zIndex = '1000'; + container.appendChild(overlay); + activeOverlay = overlay; + + const { width, height } = container.getBoundingClientRect(); + for (let i = 0; i < 250; i++) { + const part = dom.$('.chat-confetti-particle'); + part.style.position = 'absolute'; + part.style.width = `${Math.random() * 8 + 4}px`; + part.style.height = `${Math.random() * 8 + 4}px`; + part.style.backgroundColor = confettiColors[Math.floor(Math.random() * confettiColors.length)]; + part.style.borderRadius = Math.random() > 0.5 ? '50%' : '0'; + part.style.left = `${Math.random() * width}px`; + part.style.top = '-10px'; + part.style.opacity = '1'; + + overlay.appendChild(part); + + const targetX = (Math.random() - 0.5) * width * 0.8; + const targetY = Math.random() * height * 0.8 + height * 0.1; + const rotation = Math.random() * 720 - 360; + const duration = Math.random() * 1000 + 1500; + const delay = Math.random() * 400; + + part.animate([ + { + transform: 'translate(0, 0) rotate(0deg)', + opacity: 1 + }, + { + transform: `translate(${targetX * 0.5}px, ${targetY * 0.5}px) rotate(${rotation * 0.5}deg)`, + opacity: 1, + offset: 0.3 + }, + { + transform: `translate(${targetX}px, ${targetY}px) rotate(${rotation}deg)`, + opacity: 1, + offset: 0.75 + }, + { + transform: `translate(${targetX * 1.1}px, ${targetY + 40}px) rotate(${rotation + 30}deg)`, + opacity: 0 + } + ], { + duration, + delay, + easing: 'linear', + fill: 'forwards' + }); + } + + setTimeout(() => { + overlay.remove(); + activeOverlay = undefined; + }, 3000); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts index ff0da48563e..01559966fdf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatCollapsibleContentPart.ts @@ -49,7 +49,7 @@ export abstract class ChatCollapsibleContentPart extends Disposable implements I private title: IMarkdownString | string, context: IChatContentPartRenderContext, private readonly hoverMessage: IMarkdownString | undefined, - @IHoverService private readonly hoverService: IHoverService, + @IHoverService protected readonly hoverService: IHoverService, ) { super(); this.element = context.element; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts index 464f0791383..a9f8451881a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatHookContentPart.ts @@ -9,7 +9,7 @@ import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IChatHookPart } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; +import { HookType, HOOK_TYPES, HookTypeValue } from '../../../common/promptSyntax/hookSchema.js'; import { ChatTreeItem } from '../../chat.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; @@ -29,16 +29,23 @@ export class ChatHookContentPart extends ChatCollapsibleContentPart implements I const hookTypeLabel = getHookTypeLabel(hookPart.hookType); const isStopped = !!hookPart.stopReason; const isWarning = !!hookPart.systemMessage; + const toolName = hookPart.toolDisplayName; const title = isStopped - ? localize('hook.title.stopped', "Blocked by {0} hook", hookTypeLabel) - : localize('hook.title.warning', "Warning from {0} hook", hookTypeLabel); + ? (toolName + ? localize('hook.title.stoppedWithTool', "Blocked {0} - {1} hook", toolName, hookTypeLabel) + : localize('hook.title.stopped', "Blocked by {0} hook", hookTypeLabel)) + : (toolName + ? localize('hook.title.warningWithTool', "Warning for {0} - {1} hook", toolName, hookTypeLabel) + : localize('hook.title.warning', "Warning from {0} hook", hookTypeLabel)); super(title, context, undefined, hoverService); - this.icon = isStopped ? Codicon.circleSlash : isWarning ? Codicon.warning : Codicon.check; + this.icon = isStopped ? Codicon.error : isWarning ? Codicon.warning : Codicon.check; if (isStopped) { this.domNode.classList.add('chat-hook-outcome-blocked'); + } else if (isWarning) { + this.domNode.classList.add('chat-hook-outcome-warning'); } this.setExpanded(false); @@ -50,7 +57,10 @@ export class ChatHookContentPart extends ChatCollapsibleContentPart implements I if (this.hookPart.stopReason) { const reasonElement = $('.chat-hook-reason', undefined, this.hookPart.stopReason); content.appendChild(reasonElement); - } else if (this.hookPart.systemMessage) { + } + + const isToolHook = this.hookPart.hookType === HookType.PreToolUse || this.hookPart.hookType === HookType.PostToolUse; + if (this.hookPart.systemMessage && (isToolHook || !this.hookPart.stopReason)) { const messageElement = $('.chat-hook-message', undefined, this.hookPart.systemMessage); content.appendChild(messageElement); } @@ -64,6 +74,7 @@ export class ChatHookContentPart extends ChatCollapsibleContentPart implements I } return other.hookType === this.hookPart.hookType && other.stopReason === this.hookPart.stopReason && - other.systemMessage === this.hookPart.systemMessage; + other.systemMessage === this.hookPart.systemMessage && + other.toolDisplayName === this.hookPart.toolDisplayName; } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts index d62abbb822c..f94a9c168cd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatReferencesContentPart.ts @@ -299,7 +299,7 @@ class CollapsibleListDelegate implements IListVirtualDelegate { +export class CollapsibleListRenderer implements IListRenderer { static TEMPLATE_ID = 'chatCollapsibleListRenderer'; readonly templateId: string = CollapsibleListRenderer.TEMPLATE_ID; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts index 25b4bcac405..5e03ac8c247 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -6,30 +6,30 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { $, AnimationFrameScheduler, DisposableResizeObserver } from '../../../../../../base/browser/dom.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Lazy } from '../../../../../../base/common/lazy.js'; +import { IDisposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../../../base/common/observable.js'; import { rcut } from '../../../../../../base/common/strings.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { IChatHookPart, IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IRunSubagentToolInputParams } from '../../../common/tools/builtinTools/runSubagentTool.js'; +import { CodeBlockModelCollection } from '../../../common/widget/codeBlockModelCollection.js'; import { ChatTreeItem } from '../../chat.js'; -import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatCollapsibleContentPart } from './chatCollapsibleContentPart.js'; import { ChatCollapsibleMarkdownContentPart } from './chatCollapsibleMarkdownContentPart.js'; -import { IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; -import { IRunSubagentToolInputParams, RunSubagentTool } from '../../../common/tools/builtinTools/runSubagentTool.js'; -import { autorun } from '../../../../../../base/common/observable.js'; -import { Lazy } from '../../../../../../base/common/lazy.js'; -import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; -import { CollapsibleListPool } from './chatReferencesContentPart.js'; import { EditorPool } from './chatContentCodePools.js'; -import { CodeBlockModelCollection } from '../../../common/widget/codeBlockModelCollection.js'; -import { ChatToolInvocationPart } from './toolInvocationParts/chatToolInvocationPart.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; -import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { CollapsibleListPool } from './chatReferencesContentPart.js'; +import { createThinkingIcon, getToolInvocationIcon } from './chatThinkingContentPart.js'; import './media/chatSubagentContent.css'; +import { ChatToolInvocationPart } from './toolInvocationParts/chatToolInvocationPart.js'; const MAX_TITLE_LENGTH = 100; @@ -51,7 +51,16 @@ interface ILazyMarkdownItem { lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; } -type ILazyItem = ILazyToolItem | ILazyMarkdownItem; +/** + * Represents a lazy hook item (blocked/warning) that will be rendered when expanded. + */ +interface ILazyHookItem { + kind: 'hook'; + lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; + hookPart: IChatHookPart; +} + +type ILazyItem = ILazyToolItem | ILazyMarkdownItem | ILazyHookItem; /** * This is generally copied from ChatThinkingContentPart. We are still experimenting with both UIs so I'm not @@ -79,19 +88,32 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Current tool message for collapsed title (persists even after tool completes) private currentRunningToolMessage: string | undefined; + // Model name used by this subagent for hover tooltip + private modelName: string | undefined; + private readonly _hoverDisposable = this._register(new MutableDisposable()); + // Confirmation auto-expand tracking private toolsWaitingForConfirmation: number = 0; private userManuallyExpanded: boolean = false; private autoExpandedForConfirmation: boolean = false; + /** + * Check if a tool invocation is the parent subagent tool (the tool that spawns a subagent). + * A parent subagent tool has subagent toolSpecificData but no subAgentInvocationId. + */ + private static isParentSubagentTool(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { + return toolInvocation.toolSpecificData?.kind === 'subagent' && !toolInvocation.subAgentInvocationId; + } + /** * Extracts subagent info (description, agentName, prompt) from a tool invocation. */ - private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined } { + private static extractSubagentInfo(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): { description: string; agentName: string | undefined; prompt: string | undefined; modelName: string | undefined } { const defaultDescription = localize('chat.subagent.defaultDescription', 'Running subagent...'); - if (toolInvocation.toolId !== RunSubagentTool.Id) { - return { description: defaultDescription, agentName: undefined, prompt: undefined }; + // Only parent subagent tools contain the full subagent info + if (!ChatSubagentContentPart.isParentSubagentTool(toolInvocation)) { + return { description: defaultDescription, agentName: undefined, prompt: undefined, modelName: undefined }; } // Check toolSpecificData first (works for both live and serialized) @@ -100,6 +122,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen description: toolInvocation.toolSpecificData.description ?? defaultDescription, agentName: toolInvocation.toolSpecificData.agentName, prompt: toolInvocation.toolSpecificData.prompt, + modelName: toolInvocation.toolSpecificData.modelName, }; } @@ -113,10 +136,11 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen description: params?.description ?? defaultDescription, agentName: params?.agentName, prompt: params?.prompt, + modelName: undefined, }; } - return { description: defaultDescription, agentName: undefined, prompt: undefined }; + return { description: defaultDescription, agentName: undefined, prompt: undefined, modelName: undefined }; } constructor( @@ -134,7 +158,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen @IHoverService hoverService: IHoverService, ) { // Extract description, agentName, and prompt from toolInvocation - const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + const { description, agentName, prompt, modelName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); // Build title: "AgentName: description" or "Subagent: description" const prefix = agentName || localize('chat.subagent.prefix', 'Subagent'); @@ -144,6 +168,7 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.description = description; this.agentName = agentName; this.prompt = prompt; + this.modelName = modelName; this.isInitiallyComplete = this.element.isComplete; const node = this.domNode; @@ -201,6 +226,9 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen // Scheduler for coalescing layout operations this.layoutScheduler = this._register(new AnimationFrameScheduler(this.domNode, () => this.performLayout())); + // Set up hover tooltip with model name if available + this.updateHover(); + // Render the prompt section at the start if available (must be after wrapper is initialized) this.renderPromptSection(); @@ -327,6 +355,16 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.setTitleWithWidgets(new MarkdownString(finalLabel), this.instantiationService, this.chatMarkdownAnchorService, this.chatContentMarkdownRenderer); } + private updateHover(): void { + if (!this.modelName || !this._collapseButton) { + return; + } + + this._hoverDisposable.value = this.hoverService.setupDelayedHover(this._collapseButton.element, { + content: localize('chat.subagent.modelTooltip', 'Model: {0}', this.modelName), + }); + } + /** * Tracks a tool invocation's state for: * 1. Updating the title with the current tool message (persists even after completion) @@ -380,7 +418,8 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen * Handles both live and serialized invocations. */ private watchToolCompletion(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { - if (toolInvocation.toolId !== RunSubagentTool.Id) { + // Only watch parent subagent tools for completion + if (!ChatSubagentContentPart.isParentSubagentTool(toolInvocation)) { return; } @@ -400,15 +439,25 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen this.renderResultText(textParts.join('\n')); } + // Update model name from toolSpecificData (set during invoke()) + if (toolInvocation.toolSpecificData?.kind === 'subagent' && toolInvocation.toolSpecificData.modelName) { + this.modelName = toolInvocation.toolSpecificData.modelName; + this.updateHover(); + } + // Mark as inactive when the tool completes this.markAsInactive(); } else if (wasStreaming && state.type !== IChatToolInvocation.StateKind.Streaming) { wasStreaming = false; // Update things that change when tool is done streaming - const { description, agentName, prompt } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); + const { description, agentName, prompt, modelName } = ChatSubagentContentPart.extractSubagentInfo(toolInvocation); this.description = description; this.agentName = agentName; this.prompt = prompt; + if (modelName) { + this.modelName = modelName; + this.updateHover(); + } this.renderPromptSection(); this.updateTitle(); } @@ -547,6 +596,58 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen } } + /** + * Appends a hook item (blocked/warning) to the subagent content part. + */ + public appendHookItem( + factory: () => { domNode: HTMLElement; disposable?: IDisposable }, + hookPart: IChatHookPart + ): void { + if (this.isExpanded() || this.hasExpandedOnce) { + const result = factory(); + this.appendHookItemToDOM(result.domNode, hookPart); + if (result.disposable) { + this._register(result.disposable); + } + } else { + const item: ILazyHookItem = { + kind: 'hook', + lazy: new Lazy(factory), + hookPart, + }; + this.lazyItems.push(item); + } + } + + /** + * Appends a hook item's DOM node to the wrapper. + */ + private appendHookItemToDOM(domNode: HTMLElement, hookPart: IChatHookPart): void { + const itemWrapper = $('.chat-thinking-tool-wrapper'); + const icon = hookPart.stopReason ? Codicon.error : Codicon.warning; + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + itemWrapper.appendChild(domNode); + + // Treat hook items as tool items for visibility purposes + if (!this.hasToolItems) { + this.hasToolItems = true; + if (this.wrapper) { + this.wrapper.style.display = ''; + } + } + + if (this.wrapper) { + if (this.resultContainer) { + this.wrapper.insertBefore(itemWrapper, this.resultContainer); + } else { + this.wrapper.appendChild(itemWrapper); + } + } + this.lastItemWrapper = itemWrapper; + this.layoutScheduler.schedule(); + } + /** * Appends a markdown item's DOM node to the wrapper. */ @@ -665,6 +766,12 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen if (result.disposable) { this._register(result.disposable); } + } else if (item.kind === 'hook') { + const result = item.lazy.value; + this.appendHookItemToDOM(result.domNode, item.hookPart); + if (result.disposable) { + this._register(result.disposable); + } } } @@ -719,10 +826,15 @@ export class ChatSubagentContentPart extends ChatCollapsibleContentPart implemen return true; } + // Match hook parts with the same subAgentInvocationId to keep them grouped in the subagent dropdown + if (other.kind === 'hook' && other.subAgentInvocationId) { + return this.subAgentInvocationId === other.subAgentInvocationId; + } + // Match subagent tool invocations with the same subAgentInvocationId to keep them grouped - if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || other.toolId === RunSubagentTool.Id)) { - // For runSubagent tool, use toolCallId as the effective ID - const otherEffectiveId = other.toolId === RunSubagentTool.Id ? other.toolCallId : other.subAgentInvocationId; + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') && (other.subAgentInvocationId || ChatSubagentContentPart.isParentSubagentTool(other))) { + // For parent subagent tool, use toolCallId as the effective ID + const otherEffectiveId = other.subAgentInvocationId ?? other.toolCallId; // If both have IDs, they must match if (this.subAgentInvocationId && otherEffectiveId) { return this.subAgentInvocationId === otherEffectiveId; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts index c9e3d0c4a96..9125e269fb3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSuggestNextWidget.ts @@ -132,7 +132,7 @@ export class ChatSuggestNextWidget extends Disposable { return false; } const provider = getAgentSessionProvider(c.type); - return provider !== undefined && getAgentCanContinueIn(provider); + return provider !== undefined && getAgentCanContinueIn(provider, c); }); if (showContinueOn && availableContributions.length > 0) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index ce9c70c41d7..e3ddd1312ad 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -718,10 +718,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const timeout = setTimeout(() => cts.cancel(), 5000); try { - let models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); - if (!models.length) { - models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' }); - } + const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); if (!models.length) { this.setFallbackTitle(); return; @@ -765,6 +762,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" - Choose the synonym that best fits the context + PRIORITY RULE - BLOCKED/DENIED CONTENT: + - If any item mentions being "blocked" (e.g. "Tried to use X, but was blocked"), it MUST be reflected in the title + - Blocked content takes priority over all other tool calls + - Use natural phrasing like "Tried to , but was blocked" or "Attempted but was denied" + - If there are both blocked items AND normal tool calls, mention both: e.g. "Tried to run terminal but was blocked, edited file.ts" + RULES FOR TOOL CALLS: 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) @@ -804,6 +807,12 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" - "Searched codebase for error handling" → "Looked up error handling" + EXAMPLES WITH BLOCKED CONTENT: + - "Tried to use Run in Terminal, but was blocked" → "Tried to run command, but was blocked" + - "Tried to use Run in Terminal, but was blocked, Edited config.ts" → "Tried to run command but was blocked, edited config.ts" + - "Tried to use Edit File, but was blocked, Tried to use Run in Terminal, but was blocked" → "Tried to use 2 tools, but was blocked" + - "Used Read File, but received a warning, Edited utils.ts" → "Read file with a warning, edited utils.ts" + EXAMPLES WITH REASONING HEADERS (no tools): - "Analyzing component architecture" → "Considered component architecture" - "Planning refactor strategy" → "Planned refactor strategy" @@ -1160,7 +1169,7 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); } } else { - toolCallLabel = `Invoked \`${toolInvocationId}\``; + toolCallLabel = toolInvocationId; } // Add tool call to extracted titles for LLM title generation @@ -1207,6 +1216,10 @@ export class ChatThinkingContentPart extends ChatCollapsibleContentPart implemen const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number } }; const exitCode = terminalData?.terminalCommandState?.exitCode; icon = exitCode !== undefined && exitCode !== 0 ? Codicon.error : Codicon.terminal; + } else if (content.classList.contains('chat-hook-outcome-blocked')) { + icon = Codicon.error; + } else if (content.classList.contains('chat-hook-outcome-warning')) { + icon = Codicon.warning; } else { icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId) : Codicon.tools; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 3c3fab695f7..7a5ba17dcaa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -5,26 +5,146 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; +import { status } from '../../../../../../base/browser/ui/aria/aria.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize, localize2 } from '../../../../../../nls.js'; +import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatTip } from '../../chatTipService.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { IChatTip, IChatTipService } from '../../chatTipService.js'; const $ = dom.$; export class ChatTipContentPart extends Disposable { public readonly domNode: HTMLElement; + private readonly _onDidHide = this._register(new Emitter()); + public readonly onDidHide = this._onDidHide.event; + + private readonly _renderedContent = this._register(new MutableDisposable()); + + private readonly _inChatTipContextKey: IContextKey; + constructor( tip: IChatTip, - renderer: IMarkdownRenderer, + private readonly _renderer: IMarkdownRenderer, + private readonly _getNextTip: () => IChatTip | undefined, + @IChatTipService private readonly _chatTipService: IChatTipService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IMenuService private readonly _menuService: IMenuService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, ) { super(); this.domNode = $('.chat-tip-widget'); + this.domNode.tabIndex = 0; + this.domNode.setAttribute('role', 'region'); + this.domNode.setAttribute('aria-roledescription', localize('chatTipRoleDescription', "tip")); + + this._inChatTipContextKey = ChatContextKeys.inChatTip.bindTo(this._contextKeyService); + const focusTracker = this._register(dom.trackFocus(this.domNode)); + this._register(focusTracker.onDidFocus(() => this._inChatTipContextKey.set(true))); + this._register(focusTracker.onDidBlur(() => this._inChatTipContextKey.set(false))); + this._register({ dispose: () => this._inChatTipContextKey.reset() }); + + this._renderTip(tip); + + this._register(this._chatTipService.onDidDismissTip(() => { + const nextTip = this._getNextTip(); + if (nextTip) { + this._renderTip(nextTip); + } else { + this._onDidHide.fire(); + } + })); + + this._register(this._chatTipService.onDidDisableTips(() => { + this._onDidHide.fire(); + })); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => { + dom.EventHelper.stop(e, true); + const event = new StandardMouseEvent(dom.getWindow(this.domNode), e); + this._contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions: () => { + const menu = this._menuService.getMenuActions(MenuId.ChatTipContext, this._contextKeyService); + return getFlatContextMenuActions(menu); + }, + }); + })); + } + + hasFocus(): boolean { + return dom.isAncestorOfActiveElement(this.domNode); + } + + focus(): void { + this.domNode.focus(); + } + + private _renderTip(tip: IChatTip): void { + dom.clearNode(this.domNode); this.domNode.appendChild(renderIcon(Codicon.lightbulb)); - const markdownContent = this._register(renderer.render(tip.content)); + const markdownContent = this._renderer.render(tip.content); + this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); + const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip"); + const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value); + const ariaLabel = hasLink + ? localize('chatTipWithAction', "{0} Tab to the action.", textContent) + : textContent; + this.domNode.setAttribute('aria-label', ariaLabel); + status(ariaLabel); } } + +//#region Tip context menu actions + +registerAction2(class DismissTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.dismissTip', + title: localize2('chatTip.dismiss', "Dismiss this tip"), + f1: false, + menu: [{ + id: MenuId.ChatTipContext, + group: 'chatTip', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).dismissTip(); + } +}); + +registerAction2(class DisableTipsAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.disableTips', + title: localize2('chatTip.disableTips', "Disable tips"), + f1: false, + menu: [{ + id: MenuId.ChatTipContext, + group: 'chatTip', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + await accessor.get(IChatTipService).disableTips(); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts index f264985ac3c..79ba3979615 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatToolInputOutputContentPart.ts @@ -26,6 +26,8 @@ import { IDisposableReference } from './chatCollections.js'; import { ChatQueryTitlePart } from './chatConfirmationWidget.js'; import { IChatContentPartRenderContext } from './chatContentParts.js'; import { ChatToolOutputContentSubPart } from './chatToolOutputContentSubPart.js'; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; +import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; export interface IChatCollapsibleIOCodePart { kind: 'code'; @@ -97,6 +99,7 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { @IHoverService hoverService: IHoverService, @IModelService private readonly modelService: IModelService, @ILanguageService private readonly languageService: ILanguageService, + @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, ) { super(); @@ -112,6 +115,7 @@ export class ChatCollapsibleInputOutputContentPart extends Disposable { title, subtitle, )); + renderFileWidgets(titleEl.root, this._instantiationService, this.chatMarkdownAnchorService, this._store); const spacer = document.createElement('span'); spacer.style.flexGrow = '1'; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css index c06e49192d5..3a30dc1e68a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatHookContentPart.css @@ -7,6 +7,17 @@ color: var(--vscode-notificationsWarningIcon-foreground) !important; } +.chat-thinking-box .chat-used-context.chat-hook-outcome-blocked, +.chat-thinking-box .chat-used-context.chat-hook-outcome-warning { + padding: 4px 12px 4px 22px; + margin-bottom: 0; +} + +.chat-thinking-box .chat-used-context.chat-hook-outcome-blocked > .chat-used-context-label .codicon, +.chat-thinking-box .chat-used-context.chat-hook-outcome-warning > .chat-used-context-label .codicon { + display: none; +} + .chat-hook-details { display: flex; flex-direction: column; @@ -14,8 +25,20 @@ padding: 8px 12px; } -.chat-hook-message, .chat-hook-reason { +.chat-hook-reason { + font-size: var(--vscode-chat-font-size-body-s); + padding: 4px 10px; +} + +.chat-hook-message { font-size: var(--vscode-chat-font-size-body-s); padding: 4px 10px; color: var(--vscode-descriptionForeground); } + +/* When both reason and message are shown, add a subtle separator */ +.chat-hook-reason + .chat-hook-message { + border-top: 1px solid var(--vscode-chat-requestBorder, var(--vscode-editorWidget-border)); + margin-top: 2px; + padding-top: 6px; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css index 9a70eec7939..7a9b9f736b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/codeBlockPart.css @@ -21,6 +21,7 @@ line-height: 26px; background-color: var(--vscode-interactive-result-editor-background-color, var(--vscode-editor-background)); border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-medium); z-index: 100; max-width: 70%; text-overflow: ellipsis; @@ -32,7 +33,6 @@ } .interactive-result-code-block .interactive-result-code-block-toolbar > .monaco-toolbar { - border-radius: 3px; right: 10px; } @@ -50,7 +50,6 @@ .interactive-result-code-block .interactive-result-code-block-toolbar:focus-within, .interactive-result-code-block.focused .interactive-result-code-block-toolbar { opacity: 1; - border-radius: 2px; pointer-events: auto; } @@ -79,7 +78,7 @@ .interactive-result-code-block, .interactive-result-code-block .monaco-editor, .interactive-result-code-block .monaco-editor .overflow-guard { - border-radius: 4px; + border-radius: var(--vscode-cornerRadius-medium); } .interactive-result-code-block .interactive-result-vulns { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts index 54776b9e5a3..cf8f4d62ffa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatMcpAppSubPart.ts @@ -179,16 +179,16 @@ export class ChatMcpAppSubPart extends BaseChatToolInvocationSubPart { errorMessage.appendText(localize('mcpAppError', 'Error loading MCP App: {0}', error.message || String(error))); // Use ChatErrorWidget for consistent error styling - const errorWidget = new ChatErrorWidget(ChatErrorLevel.Error, errorMessage, this._markdownRendererService); + const errorWidget = this._register(new ChatErrorWidget(ChatErrorLevel.Error, errorMessage, this._markdownRendererService)); errorNode.appendChild(errorWidget.domNode); // Add retry button const buttonContainer = dom.append(errorNode, dom.$('.chat-buttons-container')); - const retryButton = new Button(buttonContainer, defaultButtonStyles); + const retryButton = this._register(new Button(buttonContainer, defaultButtonStyles)); retryButton.label = localize('retry', 'Retry'); - retryButton.onDidClick(() => { + this._register(retryButton.onDidClick(() => { this._model.retry(); - }); + })); container.appendChild(errorNode); this._errorNode = errorNode; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 38beb93405c..c533f527c54 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -103,12 +103,12 @@ import { ChatMarkdownDecorationsRenderer } from './chatContentParts/chatMarkdown import { ChatEditorOptions } from './chatOptions.js'; import { ChatCodeBlockContentProvider, CodeBlockPart } from './chatContentParts/codeBlockPart.js'; import { autorun, observableValue } from '../../../../../base/common/observable.js'; -import { RunSubagentTool } from '../../common/tools/builtinTools/runSubagentTool.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { IChatTipService } from '../chatTipService.js'; import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; +import { HookType } from '../../common/promptSyntax/hookSchema.js'; const $ = dom.$; @@ -184,6 +184,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer>(); + private _activeTipPart: ChatTipContentPart | undefined; + private readonly _notifiedQuestionCarousels = new WeakSet(); private readonly _questionCarouselToast = this._register(new DisposableStore()); @@ -192,7 +194,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer()); readonly onDidClickFollowup: Event = this._onDidClickFollowup.event; - private readonly _onDidClickRerunWithAgentOrCommandDetection = new Emitter<{ readonly sessionResource: URI; readonly requestId: string }>(); + private readonly _onDidClickRerunWithAgentOrCommandDetection = this._register(new Emitter<{ readonly sessionResource: URI; readonly requestId: string }>()); readonly onDidClickRerunWithAgentOrCommandDetection = this._onDidClickRerunWithAgentOrCommandDetection.event; @@ -301,6 +303,18 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), + ); templateData.value.appendChild(tipPart.domNode); + this._activeTipPart = tipPart; templateData.elementDisposables.add(tipPart); + templateData.elementDisposables.add(tipPart.onDidHide(() => { + tipPart.domNode.remove(); + if (this._activeTipPart === tipPart) { + this._activeTipPart = undefined; + } + })); + templateData.elementDisposables.add({ + dispose: () => { + if (this._activeTipPart === tipPart) { + this._activeTipPart = undefined; + } + } + }); } let inlineSlashCommandRendered = false; @@ -1220,7 +1252,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); @@ -1422,6 +1455,14 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer other.kind === 'hook' && other.hookType === hookPart.hookType); } - return this.renderNoContent(other => other.kind === 'hook' && other.hookType === hookPart.hookType); + + if (hookPart.subAgentInvocationId) { + const subagentPart = this.getSubagentPart(templateData.renderedParts, hookPart.subAgentInvocationId); + if (subagentPart) { + subagentPart.appendHookItem(() => { + const part = this.instantiationService.createInstance(ChatHookContentPart, hookPart, context); + return { domNode: part.domNode, disposable: part }; + }, hookPart); + return this.renderNoContent(other => other.kind === 'hook' && other.hookType === hookPart.hookType && other.subAgentInvocationId === hookPart.subAgentInvocationId); + } + } + + // Only pin preTool/postTool hooks into the thinking part + const shouldPinToThinking = hookPart.hookType === HookType.PreToolUse || hookPart.hookType === HookType.PostToolUse; + if (shouldPinToThinking) { + const hookTitle = hookPart.stopReason + ? (hookPart.toolDisplayName + ? localize('hook.thinking.blocked', "Blocked {0}", hookPart.toolDisplayName) + : localize('hook.thinking.blockedGeneric', "Blocked by hook")) + : (hookPart.toolDisplayName + ? localize('hook.thinking.warning', "Used {0}, but received a warning", hookPart.toolDisplayName) + : localize('hook.thinking.warningGeneric', "Tool call received a warning")); + + let thinkingPart = this.getLastThinkingPart(templateData.renderedParts); + if (!thinkingPart) { + // Create a thinking part if one doesn't exist yet (e.g. hook arrives before/with its tool in the same turn) + const newThinking = this.renderThinkingPart({ kind: 'thinking' }, context, templateData); + if (newThinking instanceof ChatThinkingContentPart) { + thinkingPart = newThinking; + } + } + + if (thinkingPart) { + thinkingPart.appendItem(() => { + const part = this.instantiationService.createInstance(ChatHookContentPart, hookPart, context); + return { domNode: part.domNode, disposable: part }; + }, hookTitle, undefined, templateData.value); + return thinkingPart; + } + } + + const part = this.instantiationService.createInstance(ChatHookContentPart, hookPart, context); + return part; } private renderPullRequestContent(pullRequestContent: IChatPullRequestContent, context: IChatContentPartRenderContext, templateData: IChatListItemTemplate): IChatContentPart | undefined { @@ -2514,3 +2596,30 @@ export class ChatVoteDownButton extends DropdownMenuActionViewItem { }; } } + +/** + * Check if a tool invocation is the parent subagent tool (the tool that spawns a subagent). + * A parent subagent tool has subagent toolSpecificData but no subAgentInvocationId. + */ +function isParentSubagentTool(invocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { + return invocation.toolSpecificData?.kind === 'subagent' && !invocation.subAgentInvocationId; +} + +/** + * Get the subagent invocation ID for grouping tools. + * For parent subagent tools, use their toolCallId. + * For child tools, use their subAgentInvocationId. + */ +function getSubagentId(invocation: IChatToolInvocation | IChatToolInvocationSerialized): string | undefined { + if (isParentSubagentTool(invocation)) { + return invocation.toolCallId; + } + return invocation.subAgentInvocationId; +} + +/** + * Check if a tool invocation is part of a subagent (either parent or child). + */ +function isSubagentToolInvocation(invocation: IChatToolInvocation | IChatToolInvocationSerialized): boolean { + return !!getSubagentId(invocation); +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts index 4e33ef0c176..50b40d59419 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListWidget.ts @@ -789,6 +789,21 @@ export class ChatListWidget extends Disposable { return this._renderer.editorsInUse(); } + /** + * Whether the active tip currently has focus. + */ + hasTipFocus(): boolean { + return this._renderer.hasTipFocus(); + } + + /** + * Focus the active tip, if any. + * @returns Whether a tip was focused. + */ + focusTip(): boolean { + return this._renderer.focusTip(); + } + /** * Get template data for a request ID. */ diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 5648329edf1..13c0fb84ae1 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -66,11 +66,12 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../common import { ILanguageModelToolsService, isToolSet } from '../../common/tools/languageModelToolsService.js'; import { ComputeAutomaticInstructions } from '../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../common/promptSyntax/config/config.js'; -import { IHandOff, PromptHeader, Target } from '../../common/promptSyntax/promptFileParser.js'; +import { IHandOff, PromptHeader } from '../../common/promptSyntax/promptFileParser.js'; import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; import { handleModeSwitch } from '../actions/chatActions.js'; import { ChatTreeItem, IChatAcceptInputOptions, IChatAccessibilityService, IChatCodeBlockInfo, IChatFileTreeInfo, IChatListItemRendererOptions, IChatWidget, IChatWidgetService, IChatWidgetViewContext, IChatWidgetViewModelChangeEvent, IChatWidgetViewOptions, isIChatResourceViewContext, isIChatViewViewContext } from '../chat.js'; import { ChatAttachmentModel } from '../attachments/chatAttachmentModel.js'; +import { IChatAttachmentResolveService } from '../attachments/chatAttachmentResolveService.js'; import { ChatSuggestNextWidget } from './chatContentParts/chatSuggestNextWidget.js'; import { ChatInputPart, IChatInputPartOptions, IChatInputStyles } from './input/chatInputPart.js'; import { IChatListItemTemplate } from './chatListRenderer.js'; @@ -196,13 +197,13 @@ export class ChatWidget extends Disposable implements IChatWidget { private _onDidChangeParsedInput = this._register(new Emitter()); readonly onDidChangeParsedInput = this._onDidChangeParsedInput.event; - private readonly _onWillMaybeChangeHeight = new Emitter(); + private readonly _onWillMaybeChangeHeight = this._register(new Emitter()); readonly onWillMaybeChangeHeight: Event = this._onWillMaybeChangeHeight.event; private _onDidChangeHeight = this._register(new Emitter()); readonly onDidChangeHeight = this._onDidChangeHeight.event; - private readonly _onDidChangeContentHeight = new Emitter(); + private readonly _onDidChangeContentHeight = this._register(new Emitter()); readonly onDidChangeContentHeight: Event = this._onDidChangeContentHeight.event; private _onDidChangeEmptyState = this._register(new Emitter()); @@ -365,7 +366,8 @@ export class ChatWidget extends Disposable implements IChatWidget { @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IChatTodoListService private readonly chatTodoListService: IChatTodoListService, @IWorkspaceContextService private readonly contextService: IWorkspaceContextService, - @ILifecycleService private readonly lifecycleService: ILifecycleService + @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IChatAttachmentResolveService private readonly chatAttachmentResolveService: IChatAttachmentResolveService, ) { super(); @@ -740,6 +742,15 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.input.focusQuestionCarousel(); } + toggleTipFocus(): boolean { + if (this.listWidget.hasTipFocus()) { + this.focusInput(); + return true; + } + + return this.listWidget.focusTip(); + } + hasInputFocus(): boolean { return this.input.hasFocus(); } @@ -2210,6 +2221,9 @@ export class ChatWidget extends Disposable implements IChatWidget { } } } + // Expand directory attachments: extract images as binary entries + const resolvedImageVariables = await this._resolveDirectoryImageAttachments(requestInputs.attachedContext.asArray()); + if (this.viewModel.sessionResource && !options.queue) { // todo@connor4312: move chatAccessibilityService.acceptRequest to a refcount model to handle queue messages this.chatAccessibilityService.acceptRequest(this._viewModel!.sessionResource); @@ -2221,6 +2235,7 @@ export class ChatWidget extends Disposable implements IChatWidget { locationData: this._location.resolveData?.(), parserContext: { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }, attachedContext: requestInputs.attachedContext.asArray(), + resolvedVariables: resolvedImageVariables, noCommandDetection: options?.noCommandDetection, ...this.getModeRequestOptions(), modeInfo: this.input.currentModeInfo, @@ -2228,7 +2243,7 @@ export class ChatWidget extends Disposable implements IChatWidget { queue: options?.queue, }); - if (this.viewModel.sessionResource && !options.queue) { + if (this.viewModel.sessionResource && !options.queue && ChatSendResult.isRejected(result)) { this.chatAccessibilityService.disposeRequest(this.viewModel.sessionResource); } @@ -2245,6 +2260,11 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } + // If this was a queued request that just got dequeued, start the progress sound now + if (options.queue && this.viewModel?.sessionResource) { + this.chatAccessibilityService.acceptRequest(this.viewModel.sessionResource); + } + this._onDidSubmitAgent.fire({ agent: sent.data.agent, slashCommand: sent.data.slashCommand }); this.handleDelegationExitIfNeeded(this._lockedAgent, sent.data.agent); sent.data.responseCompletePromise.then(() => { @@ -2263,6 +2283,26 @@ export class ChatWidget extends Disposable implements IChatWidget { return sent.data.responseCreatedPromise; } + // Resolve images from directory attachments to send as additional variables. + private async _resolveDirectoryImageAttachments(attachments: IChatRequestVariableEntry[]): Promise { + const imagePromises: Promise[] = []; + + for (const attachment of attachments) { + if (attachment.kind === 'directory' && URI.isUri(attachment.value)) { + imagePromises.push( + this.chatAttachmentResolveService.resolveDirectoryImages(attachment.value) + ); + } + } + + if (imagePromises.length === 0) { + return []; + } + + const resolved = await Promise.all(imagePromises); + return resolved.flat(); + } + private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise { if (options.queue) { return true; @@ -2509,7 +2549,7 @@ export class ChatWidget extends Disposable implements IChatWidget { // if not tools to enable are present, we are done if (tools !== undefined && this.input.currentModeKind === ChatModeKind.Agent) { - const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools, Target.VSCode, this.input.selectedLanguageModel.get()?.metadata); + const enablementMap = this.toolsService.toToolAndToolSetEnablementMap(tools, this.input.selectedLanguageModel.get()?.metadata); this.input.selectedToolsModel.set(enablementMap, true); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts new file mode 100644 index 00000000000..8917b4edfa8 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatEditsTree.ts @@ -0,0 +1,636 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { addDisposableListener } from '../../../../../../base/browser/dom.js'; +import { ITreeRenderer, ITreeNode, IObjectTreeElement, ObjectTreeElementCollapseState } from '../../../../../../base/browser/ui/tree/tree.js'; +import { IIdentityProvider, IListVirtualDelegate } from '../../../../../../base/browser/ui/list/list.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { comparePaths } from '../../../../../../base/common/comparers.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { matchesSomeScheme, Schemas } from '../../../../../../base/common/network.js'; +import { basename } from '../../../../../../base/common/path.js'; +import { basenameOrAuthority, dirname, isEqual, isEqualAuthority, isEqualOrParent } from '../../../../../../base/common/resources.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; +import { MenuId } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { FileKind } from '../../../../../../platform/files/common/files.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; +import { ILabelService } from '../../../../../../platform/label/common/label.js'; +import { IOpenEvent, WorkbenchList, WorkbenchObjectTree } from '../../../../../../platform/list/browser/listService.js'; +import { IProductService } from '../../../../../../platform/product/common/productService.js'; +import { IStorageService, StorageScope } from '../../../../../../platform/storage/common/storage.js'; +import { isDark } from '../../../../../../platform/theme/common/theme.js'; +import { IThemeService } from '../../../../../../platform/theme/common/themeService.js'; +import { IResourceLabel, ResourceLabels } from '../../../../../browser/labels.js'; +import { SETTINGS_AUTHORITY } from '../../../../../services/preferences/common/preferences.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; +import { ChatResponseReferencePartStatusKind, IChatContentReference } from '../../../common/chatService/chatService.js'; +import { chatEditingWidgetFileStateContextKey, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; +import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../chatEditing/chatEditingActions.js'; +import { createFileIconThemableTreeContainerScope } from '../../../../files/browser/views/explorerView.js'; +import { CollapsibleListPool, IChatCollapsibleListItem, ICollapsibleListTemplate } from '../chatContentParts/chatReferencesContentPart.js'; +import { IDisposableReference } from '../chatContentParts/chatCollections.js'; + +const $ = dom.$; + +/** + * Represents a folder node in the tree view. + */ +export interface IChatEditsFolderElement { + readonly kind: 'folder'; + readonly uri: URI; + readonly children: IChatCollapsibleListItem[]; +} + +/** + * Union type for elements in the chat edits tree. + */ +export type IChatEditsTreeElement = IChatCollapsibleListItem | IChatEditsFolderElement; + +/** + * Find the common ancestor directory among a set of URIs. + * Returns undefined if the URIs have no common ancestor (different schemes/authorities). + */ +function findCommonAncestorUri(uris: readonly URI[]): URI | undefined { + if (uris.length === 0) { + return undefined; + } + let common = uris[0]; + for (let i = 1; i < uris.length; i++) { + while (!isEqualOrParent(uris[i], common)) { + const parent = dirname(common); + if (isEqual(parent, common)) { + return undefined; // reached filesystem root + } + common = parent; + } + } + return common; +} + +/** + * Convert a flat list of chat edits items into a tree grouped by directory. + * Files at the common ancestor directory are shown at the root level without a folder row. + */ +export function buildEditsTree(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { + // Group files by their directory + const folderMap = new Map(); + const itemsWithoutUri: IChatCollapsibleListItem[] = []; + + for (const item of items) { + if (item.kind === 'reference' && URI.isUri(item.reference)) { + const folderUri = dirname(item.reference); + const key = folderUri.toString(); + let group = folderMap.get(key); + if (!group) { + group = { uri: folderUri, items: [] }; + folderMap.set(key, group); + } + group.items.push(item); + } else { + itemsWithoutUri.push(item); + } + } + + const result: IObjectTreeElement[] = []; + + // Add items without URIs as top-level items (e.g., warnings) + for (const item of itemsWithoutUri) { + result.push({ element: item }); + } + + if (folderMap.size === 0) { + return result; + } + + // Find common ancestor so we can flatten files at the root level + const folderUris = [...folderMap.values()].map(f => f.uri); + const commonAncestor = findCommonAncestorUri(folderUris); + + // Sort folders by path + const sortedFolders = [...folderMap.values()].sort((a, b) => + comparePaths(a.uri.fsPath, b.uri.fsPath) + ); + + // Emit folders first, then root-level files (matching search tree behavior) + const rootFiles: IObjectTreeElement[] = []; + for (const folder of sortedFolders) { + const isAtCommonAncestor = commonAncestor && isEqual(folder.uri, commonAncestor); + if (isAtCommonAncestor) { + // Files at the common ancestor go at the root level, after all folders + for (const item of folder.items) { + rootFiles.push({ element: item }); + } + } else { + const folderElement: IChatEditsFolderElement = { + kind: 'folder', + uri: folder.uri, + children: folder.items, + }; + result.push({ + element: folderElement, + children: folder.items.map(item => ({ element: item as IChatEditsTreeElement })), + collapsible: true, + collapsed: ObjectTreeElementCollapseState.PreserveOrExpanded, + }); + } + } + + // Root-level files come after folders + result.push(...rootFiles); + + return result; +} + +/** + * Convert a flat list into tree elements without grouping (list mode). + */ +export function buildEditsList(items: readonly IChatCollapsibleListItem[]): IObjectTreeElement[] { + return items.map(item => ({ element: item as IChatEditsTreeElement })); +} + +/** + * Delegate for the chat edits tree that returns element heights and template IDs. + */ +export class ChatEditsTreeDelegate implements IListVirtualDelegate { + getHeight(_element: IChatEditsTreeElement): number { + return 22; + } + + getTemplateId(element: IChatEditsTreeElement): string { + if (element.kind === 'folder') { + return ChatEditsFolderRenderer.TEMPLATE_ID; + } + return ChatEditsFileTreeRenderer.TEMPLATE_ID; + } +} + +/** + * Identity provider for the chat edits tree. + * Provides stable string IDs so the tree can preserve collapse/selection state across updates. + */ +export class ChatEditsTreeIdentityProvider implements IIdentityProvider { + getId(element: IChatEditsTreeElement): string { + if (element.kind === 'folder') { + return `folder:${element.uri.toString()}`; + } + if (element.kind === 'warning') { + return `warning:${element.content.value}`; + } + const ref = element.reference; + if (typeof ref === 'string') { + return `ref:${ref}`; + } else if (URI.isUri(ref)) { + return `file:${ref.toString()}`; + } else { + // eslint-disable-next-line local/code-no-in-operator + return `file:${'uri' in ref ? ref.uri.toString() : String(ref)}`; + } + } +} + +interface IChatEditsFolderTemplate { + readonly label: IResourceLabel; + readonly templateDisposables: DisposableStore; +} + +/** + * Renderer for folder elements in the chat edits tree. + */ +export class ChatEditsFolderRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatEditsFolderRenderer'; + readonly templateId = ChatEditsFolderRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + private readonly labelService: ILabelService, + ) { } + + renderTemplate(container: HTMLElement): IChatEditsFolderTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + return { label, templateDisposables }; + } + + renderElement(node: ITreeNode, _index: number, templateData: IChatEditsFolderTemplate): void { + const element = node.element; + if (element.kind !== 'folder') { + return; + } + const relativeLabel = this.labelService.getUriLabel(element.uri, { relative: true }); + templateData.label.setResource( + { resource: element.uri, name: relativeLabel || basename(element.uri.path) }, + { fileKind: FileKind.FOLDER, fileDecorations: undefined } + ); + } + + disposeTemplate(templateData: IChatEditsFolderTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * Tree renderer for file elements in the chat edits tree. + * Adapted from CollapsibleListRenderer to work with ITreeNode. + */ +export class ChatEditsFileTreeRenderer implements ITreeRenderer { + static readonly TEMPLATE_ID = 'chatEditsFileRenderer'; + readonly templateId = ChatEditsFileTreeRenderer.TEMPLATE_ID; + + constructor( + private readonly labels: ResourceLabels, + private readonly menuId: MenuId | undefined, + @IThemeService private readonly themeService: IThemeService, + @IProductService private readonly productService: IProductService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + ) { } + + renderTemplate(container: HTMLElement): ICollapsibleListTemplate { + const templateDisposables = new DisposableStore(); + const label = templateDisposables.add(this.labels.create(container, { supportHighlights: true, supportIcons: true })); + + const fileDiffsContainer = $('.working-set-line-counts'); + const addedSpan = dom.$('.working-set-lines-added'); + const removedSpan = dom.$('.working-set-lines-removed'); + fileDiffsContainer.appendChild(addedSpan); + fileDiffsContainer.appendChild(removedSpan); + label.element.appendChild(fileDiffsContainer); + + let toolbar; + let actionBarContainer; + let contextKeyService; + if (this.menuId) { + actionBarContainer = $('.chat-collapsible-list-action-bar'); + contextKeyService = templateDisposables.add(this.contextKeyService.createScoped(actionBarContainer)); + const scopedInstantiationService = templateDisposables.add(this.instantiationService.createChild(new ServiceCollection([IContextKeyService, contextKeyService]))); + toolbar = templateDisposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, this.menuId, { menuOptions: { shouldForwardArgs: true, arg: undefined } })); + label.element.appendChild(actionBarContainer); + } + + return { templateDisposables, label, toolbar, actionBarContainer, contextKeyService, fileDiffsContainer, addedSpan, removedSpan }; + } + + private getReferenceIcon(data: IChatContentReference): URI | ThemeIcon | undefined { + if (ThemeIcon.isThemeIcon(data.iconPath)) { + return data.iconPath; + } else { + return isDark(this.themeService.getColorTheme().type) && data.iconPath?.dark + ? data.iconPath?.dark + : data.iconPath?.light; + } + } + + renderElement(node: ITreeNode, _index: number, templateData: ICollapsibleListTemplate): void { + const data = node.element; + if (data.kind === 'folder') { + return; + } + + if (data.kind === 'warning') { + templateData.label.setResource({ name: data.content.value }, { icon: Codicon.warning }); + return; + } + + const reference = data.reference; + const icon = this.getReferenceIcon(data); + templateData.label.element.style.display = 'flex'; + let arg: URI | undefined; + // eslint-disable-next-line local/code-no-in-operator + if (typeof reference === 'object' && 'variableName' in reference) { + if (reference.value) { + const uri = URI.isUri(reference.value) ? reference.value : reference.value.uri; + templateData.label.setResource( + { + resource: uri, + name: basenameOrAuthority(uri), + description: `#${reference.variableName}`, + // eslint-disable-next-line local/code-no-in-operator + range: 'range' in reference.value ? reference.value.range : undefined, + }, { icon, title: data.options?.status?.description ?? data.title }); + } else if (reference.variableName.startsWith('kernelVariable')) { + const variable = reference.variableName.split(':')[1]; + const asVariableName = `${variable}`; + const label = `Kernel variable`; + templateData.label.setLabel(label, asVariableName, { title: data.options?.status?.description }); + } else { + templateData.label.setLabel('Unknown variable type: ' + reference.variableName); + } + } else if (typeof reference === 'string') { + templateData.label.setLabel(reference, undefined, { iconPath: URI.isUri(icon) ? icon : undefined, title: data.options?.status?.description ?? data.title }); + } else { + // eslint-disable-next-line local/code-no-in-operator + const uri = 'uri' in reference ? reference.uri : reference; + arg = uri; + if (uri.scheme === 'https' && isEqualAuthority(uri.authority, 'github.com') && uri.path.includes('/tree/')) { + templateData.label.setResource({ resource: uri, name: basename(uri.path) }, { icon: Codicon.github, title: data.title }); + } else if (uri.scheme === this.productService.urlProtocol && isEqualAuthority(uri.authority, SETTINGS_AUTHORITY)) { + const settingId = uri.path.substring(1); + templateData.label.setResource({ resource: uri, name: settingId }, { icon: Codicon.settingsGear, title: localize('setting.hover', "Open setting '{0}'", settingId) }); + } else if (matchesSomeScheme(uri, Schemas.mailto, Schemas.http, Schemas.https)) { + templateData.label.setResource({ resource: uri, name: uri.toString(true) }, { icon: icon ?? Codicon.globe, title: data.options?.status?.description ?? data.title ?? uri.toString(true) }); + } else { + templateData.label.setFile(uri, { + fileKind: FileKind.FILE, + fileDecorations: undefined, + // eslint-disable-next-line local/code-no-in-operator + range: 'range' in reference ? reference.range : undefined, + title: data.options?.status?.description ?? data.title, + }); + } + } + + for (const selector of ['.monaco-icon-suffix-container', '.monaco-icon-name-container']) { + // eslint-disable-next-line no-restricted-syntax + const element = templateData.label.element.querySelector(selector); + if (element) { + if (data.options?.status?.kind === ChatResponseReferencePartStatusKind.Omitted || data.options?.status?.kind === ChatResponseReferencePartStatusKind.Partial) { + element.classList.add('warning'); + } else { + element.classList.remove('warning'); + } + } + } + + if (data.state !== undefined) { + if (templateData.actionBarContainer) { + const diffMeta = data?.options?.diffMeta; + if (diffMeta) { + if (!templateData.fileDiffsContainer || !templateData.addedSpan || !templateData.removedSpan) { + return; + } + templateData.addedSpan.textContent = `+${diffMeta.added}`; + templateData.removedSpan.textContent = `-${diffMeta.removed}`; + templateData.fileDiffsContainer.setAttribute('aria-label', localize('chatEditingSession.fileCounts', '{0} lines added, {1} lines removed', diffMeta.added, diffMeta.removed)); + } + // eslint-disable-next-line no-restricted-syntax + templateData.label.element.querySelector('.monaco-icon-name-container')?.classList.add('modified'); + } + if (templateData.toolbar) { + templateData.toolbar.context = arg; + } + if (templateData.contextKeyService) { + chatEditingWidgetFileStateContextKey.bindTo(templateData.contextKeyService).set(data.state); + } + } + } + + disposeTemplate(templateData: ICollapsibleListTemplate): void { + templateData.templateDisposables.dispose(); + } +} + +/** + * Widget that renders the chat edits file list, supporting both flat list and tree views. + * Manages the lifecycle of the underlying tree or list widget, and handles toggling between modes. + */ +export class ChatEditsListWidget extends Disposable { + private readonly _onDidFocus = this._register(new Emitter()); + readonly onDidFocus: Event = this._onDidFocus.event; + + private readonly _onDidOpen = this._register(new Emitter>()); + readonly onDidOpen: Event> = this._onDidOpen.event; + + private _tree: WorkbenchObjectTree | undefined; + private _list: IDisposableReference> | undefined; + + private readonly _listPool: CollapsibleListPool; + private readonly _widgetDisposables = this._register(new DisposableStore()); + private readonly _chatEditsInTreeView: IContextKey; + + private _currentContainer: HTMLElement | undefined; + private _currentSession: IChatEditingSession | null = null; + private _lastEntries: readonly IChatCollapsibleListItem[] = []; + + get currentSession(): IChatEditingSession | null { + return this._currentSession; + } + + get selectedElements(): URI[] { + const edits: URI[] = []; + if (this._tree) { + for (const element of this._tree.getSelection()) { + if (element && element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + } else if (this._list) { + for (const element of this._list.object.getSelectedElements()) { + if (element.kind === 'reference' && URI.isUri(element.reference)) { + edits.push(element.reference); + } + } + } + return edits; + } + + constructor( + private readonly onDidChangeVisibility: Event, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, + @IThemeService private readonly themeService: IThemeService, + @ILabelService private readonly labelService: ILabelService, + ) { + super(); + + this._listPool = this._register(this.instantiationService.createInstance( + CollapsibleListPool, + this.onDidChangeVisibility, + MenuId.ChatEditingWidgetModifiedFilesToolbar, + { verticalScrollMode: ScrollbarVisibility.Visible }, + )); + + this._chatEditsInTreeView = ChatContextKeys.chatEditsInTreeView.bindTo(contextKeyService); + this._chatEditsInTreeView.set(this._isTreeMode); + + this._register(this.storageService.onDidChangeValue(StorageScope.PROFILE, CHAT_EDITS_VIEW_MODE_STORAGE_KEY, this._store)(() => { + const isTree = this._isTreeMode; + this._chatEditsInTreeView.set(isTree); + if (this._currentContainer) { + this.create(this._currentContainer, this._currentSession); + this.setEntries(this._lastEntries); + } + })); + } + + private get _isTreeMode(): boolean { + return this.storageService.get(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, StorageScope.PROFILE, 'list') === 'tree'; + } + + /** + * Creates the appropriate widget (tree or list) inside the given container. + * Must be called before {@link setEntries}. + */ + create(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this._currentContainer = container; + this._currentSession = chatEditingSession; + this.clear(); + dom.clearNode(container); + + if (this._isTreeMode) { + this._createTree(container, chatEditingSession); + } else { + this._createList(container, chatEditingSession); + } + } + + /** + * Rebuild the widget (e.g. after a view mode toggle). + */ + rebuild(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this.create(container, chatEditingSession); + } + + /** + * Whether the current view mode has changed since the widget was last created. + */ + get needsRebuild(): boolean { + if (this._isTreeMode) { + return !this._tree; + } + return !this._list; + } + + /** + * Update the displayed entries. + */ + setEntries(entries: readonly IChatCollapsibleListItem[]): void { + this._lastEntries = entries; + if (this._tree) { + const treeElements = this._isTreeMode + ? buildEditsTree(entries) + : buildEditsList(entries); + + // Use the file entry count for height, not the tree-expanded count, + // so height stays consistent when toggling between tree and list modes + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + this._tree.layout(height); + this._tree.getHTMLElement().style.height = `${height}px`; + this._tree.setChildren(null, treeElements); + } else if (this._list) { + const maxItemsShown = 6; + const itemsShown = Math.min(entries.length, maxItemsShown); + const height = itemsShown * 22; + const list = this._list.object; + list.layout(height); + list.getHTMLElement().style.height = `${height}px`; + list.splice(0, list.length, entries); + } + } + + /** + * Dispose the current tree or list widget without disposing the outer widget. + */ + clear(): void { + this._widgetDisposables.clear(); + this._tree = undefined; + this._list = undefined; + } + + private _createTree(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + const resourceLabels = this._widgetDisposables.add(this.instantiationService.createInstance(ResourceLabels, { onDidChangeVisibility: this.onDidChangeVisibility })); + const treeContainer = dom.$('.chat-used-context-list'); + this._widgetDisposables.add(createFileIconThemableTreeContainerScope(treeContainer, this.themeService)); + + const tree = this._widgetDisposables.add(this.instantiationService.createInstance( + WorkbenchObjectTree, + 'ChatEditsTree', + treeContainer, + new ChatEditsTreeDelegate(), + [ + new ChatEditsFolderRenderer(resourceLabels, this.labelService), + this.instantiationService.createInstance(ChatEditsFileTreeRenderer, resourceLabels, MenuId.ChatEditingWidgetModifiedFilesToolbar), + ], + { + alwaysConsumeMouseWheel: false, + accessibilityProvider: { + getAriaLabel: (element: IChatEditsTreeElement) => { + if (element.kind === 'folder') { + return this.labelService.getUriLabel(element.uri, { relative: true }); + } + if (element.kind === 'warning') { + return element.content.value; + } + const reference = element.reference; + if (typeof reference === 'string') { + return reference; + } else if (URI.isUri(reference)) { + return this.labelService.getUriBasenameLabel(reference); + // eslint-disable-next-line local/code-no-in-operator + } else if ('uri' in reference) { + return this.labelService.getUriBasenameLabel(reference.uri); + } else { + return ''; + } + }, + getWidgetAriaLabel: () => localize('chatEditsTree', "Changed Files"), + }, + identityProvider: new ChatEditsTreeIdentityProvider(), + verticalScrollMode: ScrollbarVisibility.Visible, + hideTwistiesOfChildlessElements: true, + } + )); + + tree.updateOptions({ enableStickyScroll: false }); + + this._tree = tree; + + this._widgetDisposables.add(tree.onDidChangeFocus(() => { + this._onDidFocus.fire(); + })); + + this._widgetDisposables.add(tree.onDidOpen(e => { + this._onDidOpen.fire(e); + })); + + this._widgetDisposables.add(addDisposableListener(tree.getHTMLElement(), 'click', () => { + this._onDidFocus.fire(); + }, true)); + + dom.append(container, tree.getHTMLElement()); + } + + private _createList(container: HTMLElement, chatEditingSession: IChatEditingSession | null): void { + this._list = this._listPool.get(); + const list = this._list.object; + this._widgetDisposables.add(this._list); + + this._widgetDisposables.add(list.onDidFocus(() => { + this._onDidFocus.fire(); + })); + + this._widgetDisposables.add(list.onDidOpen(async (e) => { + if (e.element) { + this._onDidOpen.fire({ + element: e.element as IChatEditsTreeElement, + editorOptions: e.editorOptions, + sideBySide: e.sideBySide, + browserEvent: e.browserEvent, + }); + } + })); + + this._widgetDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', () => { + this._onDidFocus.fire(); + }, true)); + + dom.append(container, list.getHTMLElement()); + } + + override dispose(): void { + this.clear(); + super.dispose(); + } +} 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 ff765e49c3c..518c735c5d3 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -31,7 +31,6 @@ import { mixin } from '../../../../../../base/common/objects.js'; import { autorun, derived, derivedOpts, IObservable, ISettableObservable, observableFromEvent, observableValue } from '../../../../../../base/common/observable.js'; import { isMacintosh } from '../../../../../../base/common/platform.js'; import { isEqual } from '../../../../../../base/common/resources.js'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; import { assertType } from '../../../../../../base/common/types.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IEditorConstructionOptions } from '../../../../../../editor/browser/config/editorConfiguration.js'; @@ -63,7 +62,6 @@ import { registerAndCreateHistoryNavigationContext } from '../../../../../../pla import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../../../platform/instantiation/common/serviceCollection.js'; import { IKeybindingService } from '../../../../../../platform/keybinding/common/keybinding.js'; -import { WorkbenchList } from '../../../../../../platform/list/browser/listService.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { ObservableMemento, observableMemento } from '../../../../../../platform/observable/common/observableMemento.js'; import { bindContextKey } from '../../../../../../platform/observable/common/platformObservableUtils.js'; @@ -104,17 +102,17 @@ import { DefaultChatAttachmentWidget, ElementChatAttachmentWidget, FileAttachmen import { ChatImplicitContexts } from '../../attachments/chatImplicitContext.js'; import { ImplicitContextAttachmentWidget } from '../../attachments/implicitContextAttachment.js'; import { IChatWidget, ISessionTypePickerDelegate, isIChatResourceViewContext, isIChatViewViewContext, IWorkspacePickerDelegate } from '../../chat.js'; -import { ChatEditingShowChangesAction, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; +import { ChatEditingShowChangesAction, ChatEditsViewAsListActionId, ChatEditsViewAsTreeActionId, ViewAllSessionChangesAction, ViewPreviousEditsAction } from '../../chatEditing/chatEditingActions.js'; import { resizeImage } from '../../chatImageUtils.js'; import { ChatSessionPickerActionItem, IChatSessionPickerDelegate } from '../../chatSessions/chatSessionPickerActionItem.js'; import { SearchableOptionPickerActionItem } from '../../chatSessions/searchableOptionPickerActionItem.js'; import { IChatContextService } from '../../contextContrib/chatContextService.js'; -import { IDisposableReference } from '../chatContentParts/chatCollections.js'; import { ChatQuestionCarouselPart, IChatQuestionCarouselOptions } from '../chatContentParts/chatQuestionCarouselPart.js'; import { IChatContentPartRenderContext } from '../chatContentParts/chatContentParts.js'; -import { CollapsibleListPool, IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; +import { IChatCollapsibleListItem } from '../chatContentParts/chatReferencesContentPart.js'; import { ChatTodoListWidget } from '../chatContentParts/chatTodoListWidget.js'; import { ChatDragAndDrop } from '../chatDragAndDrop.js'; +import { ChatEditsListWidget } from './chatEditsTree.js'; import { ChatFollowups } from './chatFollowups.js'; import { ChatInputPartWidgetController } from './chatInputPartWidgets.js'; import { IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -125,6 +123,7 @@ import { IModePickerDelegate, ModePickerActionItem } from './modePickerActionIte import { SessionTypePickerActionItem } from './sessionTargetPickerActionItem.js'; import { WorkspacePickerActionItem } from './workspacePickerActionItem.js'; import { ChatContextUsageWidget } from '../../widgetHosts/viewPane/chatContextUsageWidget.js'; +import { Target } from '../../../common/promptSyntax/service/promptsService.js'; const $ = dom.$; @@ -419,21 +418,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private _workingSetLinesRemovedSpan = new Lazy(() => dom.$('.working-set-lines-removed')); private readonly _chatEditsActionsDisposables: DisposableStore = this._register(new DisposableStore()); - private readonly _chatEditsDisposables: DisposableStore = this._register(new DisposableStore()); private readonly _renderingChatEdits = this._register(new MutableDisposable()); - private _chatEditsListPool: CollapsibleListPool; - private _chatEditList: IDisposableReference> | undefined; + private readonly _chatEditsListWidget = this._register(new MutableDisposable()); get selectedElements(): URI[] { - const edits = []; - const editsList = this._chatEditList?.object; - const selectedElements = editsList?.getSelectedElements() ?? []; - for (const element of selectedElements) { - if (element.kind === 'reference' && URI.isUri(element.reference)) { - edits.push(element.reference); - } - } - return edits; + return this._chatEditsListWidget.value?.selectedElements ?? []; } private _attemptedWorkingSetEntriesCount: number = 0; @@ -589,8 +578,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.inputEditor.updateOptions(newOptions); })); - this._chatEditsListPool = this._register(this.instantiationService.createInstance(CollapsibleListPool, this._onDidChangeVisibility.event, MenuId.ChatEditingWidgetModifiedFilesToolbar, { verticalScrollMode: ScrollbarVisibility.Visible })); - this._hasFileAttachmentContextKey = ChatContextKeys.hasFileAttachments.bindTo(contextKeyService); this.initSelectedModel(); @@ -678,7 +665,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private initSelectedModel() { const persistedSelection = this.storageService.get(this.getSelectedModelStorageKey(), StorageScope.APPLICATION); - const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, persistedSelection === 'copilot/gpt-4.1'); + const persistedAsDefault = this.storageService.getBoolean(this.getSelectedModelIsDefaultStorageKey(), StorageScope.APPLICATION, true); if (persistedSelection) { const model = this.getModels().find(m => m.identifier === persistedSelection); @@ -1426,7 +1413,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Check if this session type has a customAgentTarget const customAgentTarget = ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); - this.chatSessionHasCustomAgentTarget.set(!!customAgentTarget); + this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); // Handle agent option from session - set initial mode if (customAgentTarget) { @@ -1697,6 +1684,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } + /** + * Shows the context usage details popup and focuses it. + * @returns Whether the details were successfully shown. + */ + showContextUsageDetails(): boolean { + return this.contextUsageWidget?.showDetails() ?? false; + } + /** * Updates the context usage widget based on the current model. */ @@ -2000,7 +1995,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge customAgentTarget: () => { const sessionResource = this._widget?.viewModel?.model.sessionResource; const ctx = sessionResource && this.chatService.getChatSessionFromInternalUri(sessionResource); - return ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType); + return (ctx && this.chatSessionsService.getCustomAgentTargetForSessionType(ctx.chatSessionType)) ?? Target.Undefined; }, }; return this.modeWidget = this.instantiationService.createInstance(ModePickerActionItem, action, delegate, pickerOptions); @@ -2574,8 +2569,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge ); } else { dom.clearNode(this.chatEditingSessionWidgetContainer); - this._chatEditsDisposables.clear(); - this._chatEditList = undefined; + this._chatEditsListWidget.value?.clear(); } }); } @@ -2668,7 +2662,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge }) : undefined, disableWhileRunning: isSessionMenu, buttonConfigProvider: (action) => { - if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID) { + if (action.id === ChatEditingShowChangesAction.ID || action.id === ViewPreviousEditsAction.Id || action.id === ViewAllSessionChangesAction.ID + || action.id === ChatEditsViewAsTreeActionId || action.id === ChatEditsViewAsListActionId) { return { showIcon: true, showLabel: false, isSecondary: true }; } return undefined; @@ -2719,54 +2714,51 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge workingSetContainer.classList.toggle('collapsed', collapsed); })); - if (!this._chatEditList) { - this._chatEditList = this._chatEditsListPool.get(); - const list = this._chatEditList.object; - this._chatEditsDisposables.add(this._chatEditList); - this._chatEditsDisposables.add(list.onDidFocus(() => { - this._onDidFocus.fire(); - })); - this._chatEditsDisposables.add(list.onDidOpen(async (e) => { - if (e.element?.kind === 'reference' && URI.isUri(e.element.reference)) { - const modifiedFileUri = e.element.reference; - const originalUri = e.element.options?.originalUri; - - if (e.element.options?.isDeletion && originalUri) { - await this.editorService.openEditor({ - resource: originalUri, // instead of modified, because modified will not exist - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + if (!this._chatEditsListWidget.value || this._chatEditsListWidget.value.needsRebuild) { + if (!this._chatEditsListWidget.value) { + const widget = this.instantiationService.createInstance(ChatEditsListWidget, this._onDidChangeVisibility.event); + this._chatEditsListWidget.value = widget; + this._register(widget.onDidFocus(() => this._onDidFocus.fire())); + this._register(widget.onDidOpen(async (e) => { + const element = e.element; + if (!element || element.kind === 'folder' || element.kind === 'warning') { return; } + if (element.kind === 'reference' && URI.isUri(element.reference)) { + const modifiedFileUri = element.reference; + const originalUri = element.options?.originalUri; - // If there's a originalUri, open as diff editor - if (originalUri) { - await this.editorService.openEditor({ - original: { resource: originalUri }, - modified: { resource: modifiedFileUri }, + if (element.options?.isDeletion && originalUri) { + await this.editorService.openEditor({ + resource: originalUri, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + if (originalUri) { + await this.editorService.openEditor({ + original: { resource: originalUri }, + modified: { resource: modifiedFileUri }, + options: e.editorOptions + }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); + return; + } + + // Use the widget's current session, not a stale closure + const entry = widget.currentSession?.getEntry(modifiedFileUri); + const pane = await this.editorService.openEditor({ + resource: modifiedFileUri, options: e.editorOptions }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - return; + + if (pane) { + entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); + } } - - const entry = chatEditingSession?.getEntry(modifiedFileUri); - - const pane = await this.editorService.openEditor({ - resource: modifiedFileUri, - options: e.editorOptions - }, e.sideBySide ? SIDE_GROUP : ACTIVE_GROUP); - - if (pane) { - entry?.getEditorIntegration(pane).reveal(true, e.editorOptions.preserveFocus); - } - } - })); - this._chatEditsDisposables.add(addDisposableListener(list.getHTMLElement(), 'click', e => { - if (!this.hasFocus()) { - this._onDidFocus.fire(); - } - }, true)); - dom.append(workingSetContainer, list.getHTMLElement()); + })); + } + this._chatEditsListWidget.value.rebuild(workingSetContainer, chatEditingSession); dom.append(innerContainer, workingSetContainer); } @@ -2779,13 +2771,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // entries, while background chat sessions use session file changes. const allEntries = editEntries.concat(sessionFileEntries); - const maxItemsShown = 6; - const itemsShown = Math.min(allEntries.length, maxItemsShown); - const height = itemsShown * 22; - const list = this._chatEditList!.object; - list.layout(height); - list.getHTMLElement().style.height = `${height}px`; - list.splice(0, list.length, allEntries); + this._chatEditsListWidget.value?.setEntries(allEntries); })); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts index 0c15407e636..bf0b3dfb98c 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatSelectedTools.ts @@ -138,8 +138,7 @@ export class ChatSelectedTools extends Disposable { if (!currentMap && currentMode.kind === ChatModeKind.Agent) { const modeTools = currentMode.customTools?.read(r); if (modeTools) { - const target = currentMode.target?.read(r); - currentMap = ToolEnablementStates.fromMap(this._toolsService.toToolAndToolSetEnablementMap(modeTools, target, lm)); + currentMap = ToolEnablementStates.fromMap(this._toolsService.toToolAndToolSetEnablementMap(modeTools, lm)); } } if (!currentMap) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts index 9e5218f2afb..37a075a3970 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/delegationSessionPickerActionItem.ts @@ -54,7 +54,8 @@ export class DelegationSessionPickerActionItem extends SessionTypePickerActionIt return true; // Always show active session type } - return getAgentCanContinueIn(type); + const contribution = this.chatSessionsService.getChatSessionContribution(type); + return getAgentCanContinueIn(type, contribution); } protected override _getSessionCategory(sessionTypeItem: ISessionTypeItem) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts index 5f012ae67d3..071230853fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -28,7 +28,7 @@ import { IChatAgentService } from '../../../common/participants/chatAgents.js'; import { ChatMode, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { isOrganizationPromptFile } from '../../../common/promptSyntax/utils/promptsServiceUtils.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../../common/constants.js'; -import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; +import { PromptsStorage, Target } from '../../../common/promptSyntax/service/promptsService.js'; import { getOpenChatActionIdForMode } from '../../actions/chatActions.js'; import { IToggleChatModeArgs, ToggleAgentModeActionId } from '../../actions/chatExecuteActions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; @@ -41,11 +41,17 @@ export interface IModePickerDelegate { * When set, the mode picker will show custom agents whose target matches this value. * Custom agents without a target are always shown in all session types. If no agents match the target, shows a default "Agent" option. */ - readonly customAgentTarget?: () => string | undefined; + readonly customAgentTarget?: () => Target; } // TODO: there should be an icon contributed for built-in modes -const builtinDefaultIcon = Codicon.tasklist; +const builtinDefaultIcon = (mode: IChatMode) => { + switch (mode.name.get().toLowerCase()) { + case 'ask': return Codicon.ask; + case 'plan': return Codicon.tasklist; + default: return undefined; + } +}; export class ModePickerActionItem extends ChatInputPickerActionViewItem { constructor( @@ -65,7 +71,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { @IOpenerService openerService: IOpenerService ) { // Get custom agent target (if filtering is enabled) - const customAgentTarget = delegate.customAgentTarget?.(); + const customAgentTarget = delegate.customAgentTarget?.() ?? Target.Undefined; // Category definitions const builtInCategory = { label: localize('built-in', "Built-In"), order: 0 }; @@ -107,7 +113,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { openerService.open(modeResource.get()); } }); - } else if (!customAgentTarget) { + } else if (customAgentTarget === Target.Undefined) { const label = localize('configureToolsFor', "Configure tools for {0} agent", mode.label.get()); toolbarActions.push({ id: `configureTools:${mode.id}`, @@ -165,7 +171,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { ...makeAction(mode, currentMode), tooltip: '', hover: { content: mode.description.get() ?? chatAgentService.getDefaultAgent(ChatAgentLocation.Chat, mode.kind)?.description ?? action.tooltip, position: this.pickerOptions.hoverPosition }, - icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon : undefined), + icon: mode.icon.get() ?? (isModeConsideredBuiltIn(mode, this._productService) ? builtinDefaultIcon(mode) : undefined), category: agentModeDisabledViaPolicy ? policyDisabledCategory : customCategory }; }; @@ -182,8 +188,8 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const modes = chatModeService.getModes(); const currentMode = delegate.currentMode.get(); const filteredCustomModes = modes.custom.filter(mode => { - const target = mode.target?.get(); - return isUserDefinedCustomAgent(mode) && (!target || target === customAgentTarget); + const target = mode.target.get(); + return isUserDefinedCustomAgent(mode) && (target === customAgentTarget); }); // Always include the default "Agent" option first const checked = currentMode.id === ChatMode.Agent.id; @@ -203,7 +209,18 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { const shouldHideEditMode = configurationService.getValue(ChatConfiguration.EditModeHidden) && chatAgentService.hasToolsAgent && currentMode.id !== ChatMode.Edit.id; - const otherBuiltinModes = modes.builtin.filter(mode => mode.id !== ChatMode.Agent.id && !(shouldHideEditMode && mode.id === ChatMode.Edit.id)); + const otherBuiltinModes = modes.builtin.filter(mode => { + if (mode.id === ChatMode.Agent.id) { + return false; + } + if (shouldHideEditMode && mode.id === ChatMode.Edit.id) { + return false; + } + if (mode.id === ChatMode.Ask.id) { + return false; + } + return true; + }); // Filter out 'implement' mode from the dropdown - it's available for handoffs but not user-selectable const customModes = groupBy( modes.custom, @@ -230,7 +247,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { }; const modePickerActionWidgetOptions: Omit = { - actionProvider: customAgentTarget ? actionProviderWithCustomAgentTarget : actionProvider, + actionProvider: customAgentTarget !== Target.Undefined ? actionProviderWithCustomAgentTarget : actionProvider, actionBarActionProvider: { getActions: () => this.getModePickerActionBarActions() }, @@ -267,7 +284,7 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { // Every built-in mode should have an icon. // TODO: this should be provided by the mode itself if (!icon && isModeConsideredBuiltIn(currentMode, this._productService)) { - icon = builtinDefaultIcon; + icon = builtinDefaultIcon(currentMode); } const labelElements = []; @@ -284,21 +301,6 @@ export class ModePickerActionItem extends ChatInputPickerActionViewItem { } } -/** - * Returns true if the mode is the built-in 'implement' mode from the chat extension. - * This mode is hidden from the mode picker but available for handoffs. - */ -export function isBuiltinImplementMode(mode: IChatMode, productService: IProductService): boolean { - if (mode.name.get().toLowerCase() !== 'implement') { - return false; - } - if (mode.source?.storage !== PromptsStorage.extension) { - return false; - } - const chatExtensionId = productService.defaultChatAgent?.chatExtensionId; - return !!chatExtensionId && mode.source.extensionId.value === chatExtensionId; -} - function isModeConsideredBuiltIn(mode: IChatMode, productService: IProductService): boolean { if (mode.isBuiltin) { return true; diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts index 725f52f8092..c14a5416cda 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/sessionTargetPickerActionItem.ts @@ -18,10 +18,11 @@ import { IKeybindingService } from '../../../../../../platform/keybinding/common import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { IChatSessionsService } from '../../../common/chatSessionsService.js'; -import { AgentSessionProviders, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; +import { AgentSessionProviders, backgroundAgentDisplayName, getAgentSessionProvider, getAgentSessionProviderDescription, getAgentSessionProviderIcon, getAgentSessionProviderName, isFirstPartyAgentSessionProvider } from '../../agentSessions/agentSessions.js'; import { ChatInputPickerActionViewItem, IChatInputPickerOptions } from './chatInputPickerActionItem.js'; import { ISessionTypePickerDelegate } from '../../chat.js'; import { IActionProvider } from '../../../../../../base/browser/ui/dropdown/dropdown.js'; +import { autorun } from '../../../../../../base/common/observable.js'; export interface ISessionTypeItem { type: AgentSessionProviders; @@ -100,10 +101,19 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { super(action, sessionTargetPickerOptions, pickerOptions, actionWidgetService, keybindingService, contextKeyService, telemetryService); - this._updateAgentSessionItems(); this._register(this.chatSessionsService.onDidChangeAvailability(() => { this._updateAgentSessionItems(); })); + + // Re-render when the background agent display name changes via experiment + // Note: autorun runs immediately, so this also handles initial population + this._register(autorun(reader => { + backgroundAgentDisplayName.read(reader); + this._updateAgentSessionItems(); + if (this.element) { + this.renderLabel(this.element); + } + })); } protected _run(sessionTypeItem: ISessionTypeItem): void { @@ -153,6 +163,10 @@ export class SessionTypePickerActionItem extends ChatInputPickerActionViewItem { const contributions = this.chatSessionsService.getAllChatSessionContributions(); for (const contribution of contributions) { + if (contribution.isReadOnly) { + continue; // Read-only sessions are not interactive and should not appear in session target picker + } + const agentSessionType = getAgentSessionProvider(contribution.type); if (!agentSessionType) { continue; 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 952b37142f1..4de68fd4633 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -300,20 +300,39 @@ border-color: var(--vscode-textBlockQuote-border); } +.interactive-item-container .value .rendered-markdown strong { + font-weight: 600; +} + .interactive-item-container .value .rendered-markdown table { width: 100%; text-align: left; margin-bottom: 16px; + border-radius: var(--vscode-cornerRadius-medium); + overflow: hidden; + border-collapse: separate; + border-spacing: 0; + border: 1px solid var(--vscode-chat-requestBorder); } -.interactive-item-container .value .rendered-markdown table, .interactive-item-container .value .rendered-markdown table td, .interactive-item-container .value .rendered-markdown table th { border: 1px solid var(--vscode-chat-requestBorder); - border-collapse: collapse; + border-top: none; + border-left: none; padding: 4px 6px; } +.interactive-item-container .value .rendered-markdown table td:last-child, +.interactive-item-container .value .rendered-markdown table th:last-child { + border-right: none; +} + +.interactive-item-container .value .rendered-markdown table tr:last-child td, +.interactive-item-container .value .rendered-markdown table tr:last-child th { + border-bottom: none; +} + .interactive-item-container .value .rendered-markdown a, .interactive-item-container .value .interactive-session-followups, .interactive-item-container .value .rendered-markdown a code { @@ -609,6 +628,10 @@ padding-inline-start: 28px; } +.interactive-item-container .value .rendered-markdown li { + margin: 4px 0; +} + /* NOTE- We want to dedent codeblocks in lists specifically to give them the full width. No more elegant way to do this, these values have to be updated for changes to the rules above, or to support more deeply nested lists. */ .interactive-item-container .value .rendered-markdown ul .interactive-result-code-block { @@ -1239,14 +1262,14 @@ have to be updated for changes to the rules above, or to support more deeply nes } .interactive-session .interactive-input-part > .chat-todo-list-widget-container .chat-todo-list-widget .monaco-scrollable-element .monaco-list-rows .monaco-list-row { - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .interactive-input-part.compact .chat-input-container { display: flex; justify-content: space-between; padding-bottom: 0; - border-radius: 2px; + border-radius: var(--vscode-cornerRadius-small); } .interactive-session .interactive-input-and-side-toolbar { @@ -2101,6 +2124,12 @@ have to be updated for changes to the rules above, or to support more deeply nes display: none; } +/* Tree view: remove twistie indent for leaf (non-collapsible) file rows */ +.interactive-session .chat-editing-session-list .monaco-tl-twistie:not(.collapsible) { + width: 0; + padding-right: 0; +} + .interactive-session .chat-summary-list .monaco-list .monaco-list-row { border-radius: 4px; } diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts index 05f0f2ab499..aa6c0db52aa 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -13,6 +13,9 @@ import { IObservable, observableValue } from '../../../../../../base/common/obse import { localize } from '../../../../../../nls.js'; import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { ChatContextUsageDetails, IChatContextUsageData } from './chatContextUsageDetails.js'; @@ -113,10 +116,17 @@ export class ChatContextUsageWidget extends Disposable { private currentData: IChatContextUsageData | undefined; + private static readonly _OPENED_STORAGE_KEY = 'chat.contextUsage.hasBeenOpened'; + private static readonly _HOVER_ID = 'chat.contextUsage'; + + private readonly _contextUsageOpenedKey: IContextKey; + constructor( @IHoverService private readonly hoverService: IHoverService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); @@ -131,49 +141,72 @@ export class ChatContextUsageWidget extends Disposable { this.progressIndicator = new CircularProgressIndicator(); iconContainer.appendChild(this.progressIndicator.domNode); + // Track context usage opened state + this._contextUsageOpenedKey = ChatContextKeys.contextUsageHasBeenOpened.bindTo(this.contextKeyService); + + // Restore persisted state + if (this.storageService.getBoolean(ChatContextUsageWidget._OPENED_STORAGE_KEY, StorageScope.WORKSPACE, false)) { + this._contextUsageOpenedKey.set(true); + } + // Set up hover - will be configured when data is available this.setupHover(); } + /** + * Shows the sticky context usage details hover and records that the user + * has opened it. Returns `true` if the details were shown. + */ + showDetails(): boolean { + const details = this._createDetails(); + if (!details) { + return false; + } + this.hoverService.showInstantHover( + { ...this._hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, + true + ); + this._markOpened(); + return true; + } + + private readonly _hoverOptions: Omit = { + id: ChatContextUsageWidget._HOVER_ID, + appearance: { showPointer: true, compact: true }, + persistence: { hideOnHover: false }, + trapFocus: true + }; + + private _createDetails(): ChatContextUsageDetails | undefined { + if (!this._isVisible.get() || !this.currentData) { + return undefined; + } + if (!this._contextUsageDetails.value) { + this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); + } + this._contextUsageDetails.value.update(this.currentData); + return this._contextUsageDetails.value; + } + + private _markOpened(): void { + this._contextUsageOpenedKey.set(true); + this.storageService.store(ChatContextUsageWidget._OPENED_STORAGE_KEY, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + private setupHover(): void { this._hoverDisposable.clear(); const store = new DisposableStore(); this._hoverDisposable.value = store; - const createDetails = (): ChatContextUsageDetails | undefined => { - if (!this._isVisible.get() || !this.currentData) { - return undefined; - } - this._contextUsageDetails.value = this.instantiationService.createInstance(ChatContextUsageDetails); - this._contextUsageDetails.value.update(this.currentData); - return this._contextUsageDetails.value; - }; - - const hoverOptions: Omit = { - appearance: { showPointer: true, compact: true }, - persistence: { hideOnHover: false }, - trapFocus: true - }; - store.add(this.hoverService.setupDelayedHover(this.domNode, () => ({ - ...hoverOptions, - content: createDetails()?.domNode ?? '' + ...this._hoverOptions, + content: this._createDetails()?.domNode ?? '' }))); - const showStickyHover = () => { - const details = createDetails(); - if (details) { - this.hoverService.showInstantHover( - { ...hoverOptions, content: details.domNode, target: this.domNode, persistence: { hideOnHover: false, sticky: true } }, - true - ); - } - }; - // Show sticky + focused hover on click store.add(addDisposableListener(this.domNode, EventType.CLICK, e => { e.stopPropagation(); - showStickyHover(); + this.showDetails(); })); // Show sticky + focused hover on keyboard activation (Space/Enter) @@ -181,7 +214,7 @@ export class ChatContextUsageWidget extends Disposable { const evt = new StandardKeyboardEvent(e); if (evt.equals(KeyCode.Space) || evt.equals(KeyCode.Enter)) { e.preventDefault(); - showStickyHover(); + this.showDetails(); } })); } 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 6c7ae754e48..c11c625bc9c 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewPane.ts @@ -65,7 +65,9 @@ import { IAgentSession } from '../../agentSessions/agentSessionsModel.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; interface IChatViewPaneState extends Partial { + /** @deprecated */ sessionId?: string; + sessionResource?: URI; sessionsSidebarWidth?: number; } @@ -128,7 +130,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { lifecycleService.startupKind !== StartupKind.ReloadedWindow && this.configurationService.getValue(ChatConfiguration.RestoreLastPanelSession) === false ) { - this.viewState.sessionId = undefined; // clear persisted session on fresh start + // clear persisted session on fresh start + this.viewState.sessionId = undefined; + this.viewState.sessionResource = undefined; } this.sessionsViewerVisible = false; // will be updated from layout code this.sessionsViewerSidebarWidth = Math.max(ChatViewPane.SESSIONS_SIDEBAR_MIN_WIDTH, this.viewState.sessionsSidebarWidth ?? ChatViewPane.SESSIONS_SIDEBAR_DEFAULT_WIDTH); @@ -267,6 +271,10 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { return this.chatService.transferredSessionResource; } + if (this.viewState.sessionResource) { + return this.viewState.sessionResource; + } + return this.viewState.sessionId ? LocalChatSessionUri.forSession(this.viewState.sessionId) : undefined; } @@ -675,7 +683,9 @@ export class ChatViewPane extends ViewPane implements IViewWelcomeDelegate { if (model) { await this.updateWidgetLockState(model.sessionResource); // Update widget lock state based on session type - this.viewState.sessionId = model.sessionId; // remember as model to restore in view state + // remember as model to restore in view state + this.viewState.sessionId = model.sessionId; + this.viewState.sessionResource = model.sessionResource; } this._widget.setModel(model); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts index 53769e1c43c..0cbc75d1883 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatViewTitleControl.ts @@ -59,6 +59,8 @@ export class ChatViewTitleControl extends Disposable { } private registerActions(): void { + const that = this; + this._register(registerAction2(class extends Action2 { constructor() { super({ @@ -76,7 +78,7 @@ export class ChatViewTitleControl extends Disposable { async run(accessor: ServicesAccessor): Promise { const instantiationService = accessor.get(IInstantiationService); - const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker); + const agentSessionsPicker = instantiationService.createInstance(AgentSessionsPicker, that.titleLabel.value?.element); await agentSessionsPicker.pickAgentSession(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css index 7884cf2c512..f57aca15300 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/media/chatViewPane.css @@ -100,7 +100,7 @@ } .agent-sessions-new-button-container { - padding: 8px 12px; + padding: 8px; } } @@ -127,7 +127,7 @@ } .agent-session-section { - padding: 0 12px 0 20px; + padding: 0 6px 0 14px; } /* Right position: symmetric padding */ @@ -138,7 +138,7 @@ } .agent-session-section { - padding: 0 12px 0 8px; + padding: 0 6px 0 8px; } } diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a9b4206c5e5..94bb20e75af 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -41,6 +41,7 @@ export namespace ChatContextKeys { export const inChatSession = new RawContextKey('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") }); export const inChatEditor = new RawContextKey('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") }); export const inChatTodoList = new RawContextKey('inChatTodoList', false, { type: 'boolean', description: localize('inChatTodoList', "True when focus is in the chat todo list.") }); + export const inChatTip = new RawContextKey('inChatTip', false, { type: 'boolean', description: localize('inChatTip', "True when focus is in a chat tip.") }); export const inChatTerminalToolOutput = new RawContextKey('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") }); export const chatModeKind = new RawContextKey('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") }); export const chatModeName = new RawContextKey('chatModeName', '', { type: 'string', description: localize('chatModeName', "The name of the current chat mode (e.g. 'Plan' for custom modes).") }); @@ -119,7 +120,11 @@ export namespace ChatContextKeys { export const hasMultipleAgentSessionsSelected = new RawContextKey('agentSessionHasMultipleSelected', false, { type: 'boolean', description: localize('agentSessionHasMultipleSelected', "True when multiple agent sessions are selected.") }); export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); + export const chatEditsInTreeView = new RawContextKey('chatEditsInTreeView', false, { type: 'boolean', description: localize('chatEditsInTreeView', "True when the chat edits working set is displayed as a tree.") }); + export const isKatexMathElement = new RawContextKey('chatIsKatexMathElement', false, { type: 'boolean', description: localize('chatIsKatexMathElement', "True when focusing a KaTeX math element.") }); + + export const contextUsageHasBeenOpened = new RawContextKey('chatContextUsageHasBeenOpened', false, { type: 'boolean', description: localize('chatContextUsageHasBeenOpened', "True when the user has opened the context window usage details.") }); } export namespace ChatContextKeyExprs { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index bc51edb1440..f4d5712527a 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -19,8 +19,8 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IChatAgentService } from './participants/chatAgents.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; -import { IHandOff } from './promptSyntax/promptFileParser.js'; -import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; +import { IHandOff, isTarget } from './promptSyntax/promptFileParser.js'; +import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage, Target } from './promptSyntax/service/promptsService.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { isString } from '../../../../base/common/types.js'; @@ -45,7 +45,7 @@ export class ChatModeService extends Disposable implements IChatModeService { private readonly agentModeDisabledByPolicy: IContextKey; private readonly _customModeInstances = new Map(); - private readonly _onDidChangeChatModes = new Emitter(); + private readonly _onDidChangeChatModes = this._register(new Emitter()); public readonly onDidChangeChatModes = this._onDidChangeChatModes.event; constructor( @@ -121,7 +121,7 @@ export class ChatModeService extends Disposable implements IChatModeService { argumentHint: cachedMode.argumentHint, agentInstructions: cachedMode.modeInstructions ?? { content: cachedMode.body ?? '', toolReferences: [] }, handOffs: cachedMode.handOffs, - target: cachedMode.target, + target: cachedMode.target ?? Target.Undefined, visibility: cachedMode.visibility ?? { userInvokable: true, agentInvokable: cachedMode.infer !== false }, agents: cachedMode.agents, source: reviveChatModeSource(cachedMode.source) ?? { storage: PromptsStorage.local } @@ -248,7 +248,7 @@ export interface IChatModeData { readonly handOffs?: readonly IHandOff[]; readonly uri?: URI; readonly source?: IChatModeSourceData; - readonly target?: string; + readonly target?: Target; readonly visibility?: ICustomAgentVisibility; readonly agents?: readonly string[]; readonly infer?: boolean; // deprecated, only available in old cached data @@ -269,7 +269,7 @@ export interface IChatMode { readonly modeInstructions?: IObservable; readonly uri?: IObservable; readonly source?: IAgentSource; - readonly target?: IObservable; + readonly target: IObservable; readonly visibility?: IObservable; readonly agents?: IObservable; } @@ -302,7 +302,7 @@ function isCachedChatModeData(data: unknown): data is IChatModeData { (mode.handOffs === undefined || Array.isArray(mode.handOffs)) && (mode.uri === undefined || (typeof mode.uri === 'object' && mode.uri !== null)) && (mode.source === undefined || isChatModeSourceData(mode.source)) && - (mode.target === undefined || typeof mode.target === 'string') && + (mode.target === undefined || isTarget(mode.target)) && (mode.visibility === undefined || isCustomAgentVisibility(mode.visibility)) && (mode.agents === undefined || Array.isArray(mode.agents)); } @@ -316,7 +316,7 @@ export class CustomChatMode implements IChatMode { private readonly _modelObservable: ISettableObservable; private readonly _argumentHintObservable: ISettableObservable; private readonly _handoffsObservable: ISettableObservable; - private readonly _targetObservable: ISettableObservable; + private readonly _targetObservable: ISettableObservable; private readonly _visibilityObservable: ISettableObservable; private readonly _agentsObservable: ISettableObservable; private _source: IAgentSource; @@ -371,7 +371,7 @@ export class CustomChatMode implements IChatMode { return this._source; } - get target(): IObservable { + get target(): IObservable { return this._targetObservable; } @@ -483,6 +483,7 @@ export class BuiltinChatMode implements IChatMode { public readonly label: IObservable; public readonly description: IObservable; public readonly icon: IObservable; + public readonly target: IObservable; constructor( public readonly kind: ChatModeKind, @@ -494,6 +495,7 @@ export class BuiltinChatMode implements IChatMode { this.label = constObservable(label); this.description = observableValue('description', description); this.icon = constObservable(icon); + this.target = constObservable(Target.Undefined); } public get isBuiltin(): boolean { @@ -505,10 +507,6 @@ export class BuiltinChatMode implements IChatMode { return this.kind; } - get target(): IObservable { - return observableValue('target', undefined); - } - /** * Getters are not json-stringified */ diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index c56b97b6918..47c0fc70084 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -434,7 +434,11 @@ export interface IChatHookPart { stopReason?: string; /** Warning/system message from the hook, shown to the user */ systemMessage?: string; + /** Display name of the tool that was affected by the hook */ + toolDisplayName?: string; metadata?: { readonly [key: string]: unknown }; + /** If set, this hook was executed within a subagent invocation and should be grouped with it. */ + subAgentInvocationId?: string; } export interface IChatTerminalToolInvocationData { @@ -852,6 +856,24 @@ export interface IChatSubagentToolInvocationData { agentName?: string; prompt?: string; result?: string; + modelName?: string; +} + +/** + * Progress type for external tool invocation updates from extensions. + * When isComplete is false, creates or updates a tool invocation. + * When isComplete is true, completes an existing tool invocation. + */ +export interface IChatExternalToolInvocationUpdate { + kind: 'externalToolInvocationUpdate'; + toolCallId: string; + toolName: string; + isComplete: boolean; + errorMessage?: string; + invocationMessage?: string | IMarkdownString; + pastTenseMessage?: string | IMarkdownString; + toolSpecificData?: IChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatTodoListContent | IChatSubagentToolInvocationData; + subagentInvocationId?: string; } export interface IChatTodoListContent { @@ -950,7 +972,8 @@ export type IChatProgress = | IChatElicitationRequestSerialized | IChatMcpServersStarting | IChatMcpServersStartingSerialized - | IChatHookPart; + | IChatHookPart + | IChatExternalToolInvocationUpdate; export interface IChatFollowup { kind: 'reply'; @@ -1281,6 +1304,7 @@ export interface IChatSendRequestOptions { // eslint-disable-next-line @typescript-eslint/no-explicit-any rejectedConfirmationData?: any[]; attachedContext?: IChatRequestVariableEntry[]; + resolvedVariables?: IChatRequestVariableEntry[]; /** The target agent ID can be specified with this property instead of using @ in 'message' */ agentId?: string; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 33519705f82..0e894f0b498 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -52,7 +52,6 @@ import { ILanguageModelToolsService } from '../tools/languageModelToolsService.j import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; -import { IHooksExecutionService } from '../hooks/hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; @@ -905,7 +904,7 @@ export class ChatService extends Disposable implements IChatService { let detectedAgent: IChatAgentData | undefined; let detectedCommand: IChatAgentCommand | undefined; - // Collect hooks from hooks.json files + // Collect hooks from hook .json files let collectedHooks: IChatRequestHooks | undefined; try { collectedHooks = await this.promptsService.getHooks(token); @@ -913,10 +912,6 @@ export class ChatService extends Disposable implements IChatService { this.logService.warn('[ChatService] Failed to collect hooks:', error); } - if (collectedHooks) { - store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks)); - } - const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); @@ -954,6 +949,12 @@ export class ChatService extends Disposable implements IChatService { variableData = { variables: this.prepareContext(request.attachedContext) }; model.updateRequest(request, variableData); + // Merge resolved variables (e.g. images from directories) for the + // agent request only - they are not stored on the request model. + if (options?.resolvedVariables?.length) { + variableData = { variables: [...variableData.variables, ...options.resolvedVariables] }; + } + const promptTextResult = getPromptText(request.message); variableData = updateRanges(variableData, promptTextResult.diff); // TODO bit of a hack message = promptTextResult.message; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 45bfafb6564..839510819e7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -15,6 +15,7 @@ import { IChatAgentAttachmentCapabilities, IChatAgentRequest } from './participa import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatModel, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; import { IChatProgress, IChatService, IChatSessionTiming } from './chatService/chatService.js'; +import { Target } from './promptSyntax/service/promptsService.js'; export const enum ChatSessionStatus { Failed = 0, @@ -84,13 +85,14 @@ export interface IChatSessionsExtensionPoint { readonly capabilities?: IChatAgentAttachmentCapabilities; readonly commands?: IChatSessionCommandContribution[]; readonly canDelegate?: boolean; + readonly isReadOnly?: boolean; /** * When set, the chat session will show a filtered mode picker with custom agents * that have a matching `target` property. This enables contributed chat sessions * to reuse the standard agent/mode dropdown with filtered custom agents. * Custom agents without a `target` property are also shown in all filtered lists */ - readonly customAgentTarget?: string; + readonly customAgentTarget?: Target; } export interface IChatSessionItem { @@ -183,16 +185,19 @@ export interface IChatSession extends IDisposable { ) => Promise; } -export interface IChatSessionItemProvider { - readonly chatSessionType: string; - readonly onDidChangeChatSessionItems: Event; - provideChatSessionItems(token: CancellationToken): Promise; -} - export interface IChatSessionContentProvider { provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; } +export interface IChatSessionItemController { + + readonly onDidChangeChatSessionItems: Event; + + get items(): readonly IChatSessionItem[]; + + refresh(token: CancellationToken): Promise; +} + /** * Event fired when session options need to be sent to the extension. * Extends IWaitUntil to allow listeners to register async work that will be awaited. @@ -214,7 +219,7 @@ export interface IChatSessionsService { getChatSessionContribution(chatSessionType: string): IChatSessionsExtensionPoint | undefined; - registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable; + registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable; activateChatSessionItemProvider(chatSessionType: string): Promise; getAllChatSessionContributions(): IChatSessionsExtensionPoint[]; @@ -224,11 +229,16 @@ export interface IChatSessionsService { getInputPlaceholderForSessionType(chatSessionType: string): string | undefined; /** - * Get the list of chat session items grouped by session type. + * Get the list of current chat session items grouped by session type. * @param providerTypeFilter If specified, only returns items from the given providers. If undefined, returns items from all providers. */ getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; + /** + * Forces the controllers to refresh their session items, optionally filtered by provider type. + */ + refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise; + reportInProgress(chatSessionType: string, count: number): void; getInProgress(): { displayName: string; count: number }[]; @@ -259,9 +269,9 @@ export interface IChatSessionsService { /** * Get the customAgentTarget for a specific session type. - * When set, the mode picker should show filtered custom agents matching this target. + * When the Target is not `Target.Undefined`, the mode picker should show filtered custom agents matching this target. */ - getCustomAgentTargetForSessionType(chatSessionType: string): string | undefined; + getCustomAgentTargetForSessionType(chatSessionType: string): Target; onDidChangeOptionGroups: Event; diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 8270d7cf9e4..a5eec8a6ad8 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,6 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', AgentEnabled = 'chat.agent.enabled', + PlanAgentDefaultModel = 'chat.planAgent.defaultModel', RequestQueueingEnabled = 'chat.requestQueuing.enabled', RequestQueueingDefaultAction = 'chat.requestQueuing.defaultAction', AgentStatusEnabled = 'chat.agentsControl.enabled', diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts deleted file mode 100644 index 763c71bba65..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ /dev/null @@ -1,123 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * External hook types - types that cross the process boundary to/from spawned hook commands. - * - * "External" means these types define the contract between VS Code and the external hook - * command process. - * - * Examples: - * - IPreToolUseCommandInput: sent TO the spawned command via stdin - * - IPreToolUseCommandOutput: received FROM the spawned command via stdout - * - * Internal types (in hooksTypes.ts) are used within VS Code. - */ - -import { URI } from '../../../../../base/common/uri.js'; - -//#region Common Hook Types - -/** - * Common properties added to all hook command inputs. - */ -export interface IHookCommandInput { - readonly timestamp: string; - readonly cwd: URI; - readonly sessionId: string; - readonly hookEventName: string; - readonly transcript_path?: URI; -} - -/** - * Common output fields that can be present in any hook command result. - * These fields control execution flow and user feedback. - */ -export interface IHookCommandOutput { - /** - * If set, stops processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Message shown to the user. - */ - readonly systemMessage?: string; -} - -export const enum HookCommandResultKind { - Success = 1, - Error = 2 -} - -/** - * Raw result from spawning a hook command. - * This is the low-level result before semantic processing. - */ -export interface IHookCommandResult { - readonly kind: HookCommandResultKind; - /** - * For success, this is stdout (parsed as JSON if valid, otherwise string). - * For errors, this is stderr. - */ - readonly result: string | object; -} - -//#endregion - -//#region PreToolUse Hook Types - -/** - * Tool-specific command input fields for preToolUse hook. - * These are mixed with IHookCommandInput at runtime. - */ -export interface IPreToolUseCommandInput { - readonly tool_name: string; - readonly tool_input: unknown; - readonly tool_use_id: string; -} - -/** - * External command output for preToolUse hook. - * Extends common output with hookSpecificOutput wrapper. - */ -export interface IPreToolUseCommandOutput extends IHookCommandOutput { - readonly hookSpecificOutput?: { - readonly hookEventName?: string; - readonly permissionDecision?: 'allow' | 'deny'; - readonly permissionDecisionReason?: string; - readonly additionalContext?: string; - }; -} - -//#endregion - -//#region PostToolUse Hook Types - -/** - * Tool-specific command input fields for postToolUse hook. - * These are mixed with IHookCommandInput at runtime. - */ -export interface IPostToolUseCommandInput { - readonly tool_name: string; - readonly tool_input: unknown; - readonly tool_response: string; - readonly tool_use_id: string; -} - -/** - * External command output for postToolUse hook. - * Extends common output with decision control fields. - */ -export interface IPostToolUseCommandOutput extends IHookCommandOutput { - readonly decision?: 'block'; - readonly reason?: string; - readonly hookSpecificOutput?: { - readonly hookEventName?: string; - readonly additionalContext?: string; - }; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts deleted file mode 100644 index c560f697503..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ /dev/null @@ -1,518 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { URI, isUriComponents } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; -import { - HookCommandResultKind, - IHookCommandInput, - IHookCommandResult, - IPostToolUseCommandInput, - IPreToolUseCommandInput -} from './hooksCommandTypes.js'; -import { - commonHookOutputValidator, - IHookResult, - IPostToolUseCallerInput, - IPostToolUseHookResult, - IPreToolUseCallerInput, - IPreToolUseHookResult, - postToolUseOutputValidator, - PreToolUsePermissionDecision, - preToolUseOutputValidator -} from './hooksTypes.js'; - -export const hooksOutputChannelId = 'hooksExecution'; -const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); - -export interface IHooksExecutionOptions { - readonly input?: unknown; - readonly token?: CancellationToken; -} - -export interface IHookExecutedEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly input: unknown; - readonly results: readonly IHookResult[]; - readonly durationMs: number; -} - -/** - * Callback interface for hook execution proxies. - * MainThreadHooks implements this to forward calls to the extension host. - */ -export interface IHooksExecutionProxy { - runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; -} - -export const IHooksExecutionService = createDecorator('hooksExecutionService'); - -export interface IHooksExecutionService { - _serviceBrand: undefined; - - /** - * Fires when a hook has finished executing. - */ - readonly onDidExecuteHook: Event; - - /** - * Called by mainThreadHooks when extension host is ready - */ - setProxy(proxy: IHooksExecutionProxy): void; - - /** - * Register hooks for a session. Returns a disposable that unregisters them. - */ - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; - - /** - * Get hooks registered for a session. - */ - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; - - /** - * Execute hooks of the given type for the given session - */ - executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; - - /** - * Execute preToolUse hooks with typed input and validated output. - * The execution service builds the full hook input from the caller input plus session context. - * Returns a combined result with common fields and permission decision. - */ - executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise; - - /** - * Execute postToolUse hooks with typed input and validated output. - * Called after a tool completes successfully. The execution service builds the full hook input - * from the caller input plus session context. - * Returns a combined result with decision and additional context. - */ - executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise; -} - -/** - * Keys that should be redacted when logging hook input. - */ -const redactedInputKeys = ['toolArgs']; - -export class HooksExecutionService extends Disposable implements IHooksExecutionService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidExecuteHook = this._register(new Emitter()); - readonly onDidExecuteHook: Event = this._onDidExecuteHook.event; - - private _proxy: IHooksExecutionProxy | undefined; - private readonly _sessionHooks = new Map(); - /** Stored transcript path per session (keyed by session URI string). */ - private readonly _sessionTranscriptPaths = new Map(); - private _channelRegistered = false; - private _requestCounter = 0; - - constructor( - @ILogService private readonly _logService: ILogService, - @IOutputService private readonly _outputService: IOutputService, - ) { - super(); - } - - setProxy(proxy: IHooksExecutionProxy): void { - this._proxy = proxy; - } - - private _ensureOutputChannel(): void { - if (this._channelRegistered) { - return; - } - Registry.as(Extensions.OutputChannels).registerChannel({ - id: hooksOutputChannelId, - label: hooksOutputChannelLabel, - log: false - }); - this._channelRegistered = true; - } - - private _log(requestId: number, hookType: HookTypeValue, message: string): void { - this._ensureOutputChannel(); - const channel = this._outputService.getChannel(hooksOutputChannelId); - if (channel) { - channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`); - } - } - - private _redactForLogging(input: object): object { - const result: Record = { ...input }; - for (const key of redactedInputKeys) { - if (Object.hasOwn(result, key)) { - result[key] = '...'; - } - } - return result; - } - - /** - * JSON.stringify replacer that converts URI / UriComponents values to their string form. - */ - private readonly _uriReplacer = (_key: string, value: unknown): unknown => { - if (URI.isUri(value)) { - return value.fsPath; - } - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - }; - - private async _runSingleHook( - requestId: number, - hookType: HookTypeValue, - hookCommand: IHookCommand, - sessionResource: URI, - callerInput: unknown, - transcriptPath: URI | undefined, - token: CancellationToken - ): Promise { - // Build the common hook input properties. - // URI values are kept as URI objects through the RPC boundary, and converted - // to filesystem paths on the extension host side during JSON serialization. - const commonInput: IHookCommandInput = { - timestamp: new Date().toISOString(), - cwd: hookCommand.cwd ?? URI.file(''), - sessionId: sessionResource.toString(), - hookEventName: hookType, - ...(transcriptPath ? { transcript_path: transcriptPath } : undefined), - }; - - // Merge common properties with caller-specific input - const fullInput = !!callerInput && typeof callerInput === 'object' - ? { ...commonInput, ...callerInput } - : commonInput; - - const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer); - this._log(requestId, hookType, `Running: ${hookCommandJson}`); - const inputForLog = this._redactForLogging(fullInput); - this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`); - - const sw = StopWatch.create(); - try { - const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token); - const result = this._toInternalResult(commandResult); - this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed())); - return result; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); - return this._createErrorResult(errMessage); - } - } - - private _createErrorResult(errorMessage: string): IHookResult { - return { - output: errorMessage, - success: false, - }; - } - - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { - if (commandResult.kind !== HookCommandResultKind.Success) { - return this._createErrorResult( - typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result) - ); - } - - // For string results, no common fields to extract - if (typeof commandResult.result !== 'object') { - return { - output: commandResult.result, - success: true, - }; - } - - // Extract and validate common fields - const validationResult = commonHookOutputValidator.validate(commandResult.result); - const commonFields = validationResult.error ? {} : validationResult.content; - - // Extract only known hook-specific fields for output - const resultObj = commandResult.result as Record; - const hookOutput = this._extractHookSpecificOutput(resultObj); - - return { - stopReason: commonFields.stopReason, - messageForUser: commonFields.systemMessage, - output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, - success: true, - }; - } - - /** - * Extract hook-specific output fields, excluding common fields. - */ - private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['stopReason', 'systemMessage']); - const output: Record = {}; - for (const [key, value] of Object.entries(result)) { - if (value !== undefined && !commonFields.has(key)) { - output[key] = value; - } - } - - return output; - } - - private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void { - const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success' : 'Error'; - const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); - const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]'; - if (hasOutput) { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`); - this._log(requestId, hookType, `Output: ${resultStr}`); - } else { - this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`); - } - } - - /** - * Extract `transcript_path` from hook input if present. - * The caller (e.g. SessionStart) may include it as a URI in the input object. - */ - private _extractTranscriptPath(input: unknown): URI | undefined { - if (typeof input !== 'object' || input === null) { - return undefined; - } - const transcriptPath = (input as Record)['transcriptPath']; - if (URI.isUri(transcriptPath)) { - return transcriptPath; - } - if (isUriComponents(transcriptPath)) { - return URI.revive(transcriptPath); - } - return undefined; - } - - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { - const key = sessionResource.toString(); - this._sessionHooks.set(key, hooks); - return toDisposable(() => { - this._sessionHooks.delete(key); - this._sessionTranscriptPaths.delete(key); - }); - } - - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { - return this._sessionHooks.get(sessionResource.toString()); - } - - async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { - const sw = StopWatch.create(); - const results: IHookResult[] = []; - - try { - if (!this._proxy) { - return results; - } - - const sessionKey = sessionResource.toString(); - - // Extract and store transcript_path from input when present (e.g. SessionStart) - const inputTranscriptPath = this._extractTranscriptPath(options?.input); - if (inputTranscriptPath) { - this._sessionTranscriptPaths.set(sessionKey, inputTranscriptPath); - } - - const hooks = this.getHooksForSession(sessionResource); - if (!hooks) { - return results; - } - - const hookCommands = hooks[hookType]; - if (!hookCommands || hookCommands.length === 0) { - return results; - } - - const transcriptPath = this._sessionTranscriptPaths.get(sessionKey); - - const requestId = this._requestCounter++; - const token = options?.token ?? CancellationToken.None; - - this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); - this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); - - for (const hookCommand of hookCommands) { - const result = await this._runSingleHook(requestId, hookType, hookCommand, sessionResource, options?.input, transcriptPath, token); - results.push(result); - - // If stopReason is set, stop processing remaining hooks - if (result.stopReason) { - this._log(requestId, hookType, `Stopping: ${result.stopReason}`); - break; - } - } - - return results; - } finally { - this._onDidExecuteHook.fire({ - hookType, - sessionResource, - input: options?.input, - results, - durationMs: Math.round(sw.elapsed()), - }); - } - } - - async executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise { - const toolSpecificInput: IPreToolUseCommandInput = { - tool_name: input.toolName, - tool_input: input.toolInput, - tool_use_id: input.toolCallId, - }; - - const results = await this.executeHook(HookType.PreToolUse, sessionResource, { - input: toolSpecificInput, - token: token ?? CancellationToken.None, - }); - - // Run all hooks and collapse results. Most restrictive decision wins: deny > ask > allow. - // Collect all additionalContext strings from every hook. - const allAdditionalContext: string[] = []; - let mostRestrictiveDecision: PreToolUsePermissionDecision | undefined; - let winningResult: IHookResult | undefined; - let winningReason: string | undefined; - - for (const result of results) { - if (result.success && typeof result.output === 'object' && result.output !== null) { - const validationResult = preToolUseOutputValidator.validate(result.output); - if (!validationResult.error) { - const hookSpecificOutput = validationResult.content.hookSpecificOutput; - if (hookSpecificOutput) { - // Validate hookEventName if present - must match the hook type - if (hookSpecificOutput.hookEventName !== undefined && hookSpecificOutput.hookEventName !== HookType.PreToolUse) { - this._logService.warn(`[HooksExecutionService] preToolUse hook returned invalid hookEventName '${hookSpecificOutput.hookEventName}', expected '${HookType.PreToolUse}'`); - continue; - } - - // Collect additionalContext from every hook - if (hookSpecificOutput.additionalContext) { - allAdditionalContext.push(hookSpecificOutput.additionalContext); - } - - // Track the most restrictive decision: deny > ask > allow - const decision = hookSpecificOutput.permissionDecision; - if (decision && this._isMoreRestrictive(decision, mostRestrictiveDecision)) { - mostRestrictiveDecision = decision; - winningResult = result; - winningReason = hookSpecificOutput.permissionDecisionReason; - } - } - } else { - this._logService.warn(`[HooksExecutionService] preToolUse hook output validation failed: ${validationResult.error.message}`); - } - } - } - - if (!mostRestrictiveDecision || !winningResult) { - return undefined; - } - - return { - ...winningResult, - permissionDecision: mostRestrictiveDecision, - permissionDecisionReason: winningReason, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } - - /** - * Returns true if `candidate` is more restrictive than `current`. - * Restriction order: deny > ask > allow. - */ - private _isMoreRestrictive(candidate: PreToolUsePermissionDecision, current: PreToolUsePermissionDecision | undefined): boolean { - const order: Record = { 'deny': 2, 'ask': 1, 'allow': 0 }; - return current === undefined || order[candidate] > order[current]; - } - - async executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise { - // Check if there are PostToolUse hooks registered before doing any work stringifying tool results - const hooks = this.getHooksForSession(sessionResource); - const hookCommands = hooks?.[HookType.PostToolUse]; - if (!hookCommands || hookCommands.length === 0) { - return undefined; - } - - // Lazily render tool response text only when hooks are registered - const toolResponseText = input.getToolResponseText(); - - const toolSpecificInput: IPostToolUseCommandInput = { - tool_name: input.toolName, - tool_input: input.toolInput, - tool_response: toolResponseText, - tool_use_id: input.toolCallId, - }; - - const results = await this.executeHook(HookType.PostToolUse, sessionResource, { - input: toolSpecificInput, - token: token ?? CancellationToken.None, - }); - - // Run all hooks and collapse results. Block is the most restrictive decision. - // Collect all additionalContext strings from every hook. - const allAdditionalContext: string[] = []; - let hasBlock = false; - let blockReason: string | undefined; - let blockResult: IHookResult | undefined; - - for (const result of results) { - if (result.success && typeof result.output === 'object' && result.output !== null) { - const validationResult = postToolUseOutputValidator.validate(result.output); - if (!validationResult.error) { - const validated = validationResult.content; - - // Validate hookEventName if present - if (validated.hookSpecificOutput?.hookEventName !== undefined && validated.hookSpecificOutput.hookEventName !== HookType.PostToolUse) { - this._logService.warn(`[HooksExecutionService] postToolUse hook returned invalid hookEventName '${validated.hookSpecificOutput.hookEventName}', expected '${HookType.PostToolUse}'`); - continue; - } - - // Collect additionalContext from every hook - if (validated.hookSpecificOutput?.additionalContext) { - allAdditionalContext.push(validated.hookSpecificOutput.additionalContext); - } - - // Track the first block decision (most restrictive) - if (validated.decision === 'block' && !hasBlock) { - hasBlock = true; - blockReason = validated.reason; - blockResult = result; - } - } else { - this._logService.warn(`[HooksExecutionService] postToolUse hook output validation failed: ${validationResult.error.message}`); - } - } - } - - // Return combined result if there's a block decision or any additional context - if (!hasBlock && allAdditionalContext.length === 0) { - return undefined; - } - - const baseResult = blockResult ?? results[0]; - return { - ...baseResult, - decision: hasBlock ? 'block' : undefined, - reason: blockReason, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } -} diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts deleted file mode 100644 index e562a1096c0..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ /dev/null @@ -1,131 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Internal hook types - types used within VS Code's hooks execution service. - * - * "Internal" means these types are used by VS Code code only - they never cross the - * process boundary to external hook commands. They use camelCase for field names. - * - * Examples: - * - IPreToolUseCallerInput: provided by VS Code callers (e.g., LanguageModelToolsService) - * - IPreToolUseHookResult: returned TO VS Code callers after processing command output - * - * External types (in hooksCommandTypes.ts) define the contract with spawned commands. - */ - -import { vEnum, vObj, vOptionalProp, vString } from '../../../../../base/common/validation.js'; - -//#region Common Hook Types - -/** - * Semantic hook result with common fields extracted and defaults applied. - * This is what callers receive from executeHook. - */ -export interface IHookResult { - /** - * If set, the agent should stop processing entirely after this hook. - * The message is shown to the user but not to the agent. - */ - readonly stopReason?: string; - /** - * Message shown to the user. - * (Mapped from `systemMessage` in command output.) - */ - readonly messageForUser?: string; - /** - * The hook's output (hook-specific fields only). - * For errors, this is the error message string. - */ - readonly output: unknown; - /** - * Whether the hook command executed successfully (exit code 0). - */ - readonly success: boolean; -} - -export const commonHookOutputValidator = vObj({ - stopReason: vOptionalProp(vString()), - systemMessage: vOptionalProp(vString()), -}); - -//#endregion - -//#region PreToolUse Hook Types - -/** - * Input provided by VS Code callers when invoking the preToolUse hook. - */ -export interface IPreToolUseCallerInput { - readonly toolName: string; - readonly toolInput: unknown; - readonly toolCallId: string; -} - -export const preToolUseOutputValidator = vObj({ - hookSpecificOutput: vOptionalProp(vObj({ - hookEventName: vOptionalProp(vString()), - permissionDecision: vEnum('allow', 'deny', 'ask'), - permissionDecisionReason: vOptionalProp(vString()), - additionalContext: vOptionalProp(vString()), - })), -}); - -/** - * Valid permission decisions for preToolUse hooks. - * - 'allow': Auto-approve the tool execution (skip user confirmation) - * - 'deny': Deny the tool execution - * - 'ask': Always require user confirmation (never auto-approve) - */ -export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; - -/** - * Result from preToolUse hooks with permission decision fields. - * Returned to VS Code callers. Represents the collapsed result of all hooks. - */ -export interface IPreToolUseHookResult extends IHookResult { - readonly permissionDecision?: PreToolUsePermissionDecision; - readonly permissionDecisionReason?: string; - readonly additionalContext?: string[]; -} - -//#endregion - -//#region PostToolUse Hook Types - -/** - * Input provided by VS Code callers when invoking the postToolUse hook. - * The toolResponse is a lazy getter that renders the tool result content to a string. - * It is only called if there are PostToolUse hooks registered. - */ -export interface IPostToolUseCallerInput { - readonly toolName: string; - readonly toolInput: unknown; - readonly getToolResponseText: () => string; - readonly toolCallId: string; -} - -export const postToolUseOutputValidator = vObj({ - decision: vOptionalProp(vEnum('block')), - reason: vOptionalProp(vString()), - hookSpecificOutput: vOptionalProp(vObj({ - hookEventName: vOptionalProp(vString()), - additionalContext: vOptionalProp(vString()), - })), -}); - -export type PostToolUseDecision = 'block'; - -/** - * Result from postToolUse hooks with decision fields. - * Returned to VS Code callers. Represents the collapsed result of all hooks. - */ -export interface IPostToolUseHookResult extends IHookResult { - readonly decision?: PostToolUseDecision; - readonly reason?: string; - readonly additionalContext?: string[]; -} - -//#endregion diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index d3366a2de9a..4067c77f7e7 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -176,6 +176,7 @@ export interface ILanguageModelChatMetadata { readonly tooltip?: string; readonly detail?: string; readonly multiplier?: string; + readonly multiplierNumeric?: number; readonly family: string; readonly maxInputTokens: number; readonly maxOutputTokens: number; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index e5f6b11c795..7f9170a04f2 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -29,8 +29,10 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExternalToolInvocationUpdate, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; +import { ChatToolInvocation } from './chatProgressTypes/chatToolInvocation.js'; +import { ToolDataSource, IToolData } from '../tools/languageModelToolsService.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; import { IChatAgentCommand, IChatAgentData, IChatAgentResult, IChatAgentService, UserSelectedTools, reviveSerializedAgent } from '../participants/chatAgents.js'; @@ -287,7 +289,7 @@ export interface IChatResponseModel { setVoteDownReason(reason: ChatAgentVoteDownReason | undefined): void; setUsage(usage: IChatUsage): void; setEditApplied(edit: IChatTextEditGroup, editCount: number): boolean; - updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask, quiet?: boolean): void; + updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask | IChatExternalToolInvocationUpdate, quiet?: boolean): void; /** * Adopts any partially-undo {@link response} as the {@link entireResponse}. * Only valid when {@link isComplete}. This is needed because otherwise an @@ -689,7 +691,7 @@ export class Response extends AbstractResponse implements IDisposable { this._updateRepr(true); } - updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask, quiet?: boolean): void { + updateContent(progress: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatTask | IChatExternalToolInvocationUpdate, quiet?: boolean): void { if (progress.kind === 'clearToPreviousToolInvocation') { if (progress.reason === ChatResponseClearToPreviousToolInvocationReason.CopyrightContentRetry) { this.clearToPreviousToolInvocation(localize('copyrightContentRetry', "Response cleared due to possible match to public code, retrying with modified prompt.")); @@ -793,6 +795,9 @@ export class Response extends AbstractResponse implements IDisposable { }); this._responseParts.push(progress); this._updateRepr(quiet); + } else if (progress.kind === 'externalToolInvocationUpdate') { + this._handleExternalToolInvocationUpdate(progress); + this._updateRepr(quiet); } else { this._responseParts.push(progress); this._updateRepr(quiet); @@ -826,6 +831,63 @@ export class Response extends AbstractResponse implements IDisposable { this._responseParts.push({ kind: 'notebookEditGroup', uri, edits: [edits], done, isExternalEdit }); } + private _handleExternalToolInvocationUpdate(progress: IChatExternalToolInvocationUpdate): void { + // Look for existing invocation in the response parts + const existingInvocation = this._responseParts.findLast( + (part): part is ChatToolInvocation => part.kind === 'toolInvocation' && part.toolCallId === progress.toolCallId + ); + + if (existingInvocation) { + if (progress.isComplete) { + existingInvocation.didExecuteTool({ + content: [], + toolResultMessage: progress.pastTenseMessage, + toolResultError: progress.errorMessage, + }); + } + if (progress.toolSpecificData !== undefined) { + existingInvocation.toolSpecificData = progress.toolSpecificData; + } + return; + } + + // Create a new external tool invocation + const toolData: IToolData = { + id: progress.toolName, + source: ToolDataSource.External, + displayName: progress.toolName, + modelDescription: progress.toolName, + }; + + const invocation = new ChatToolInvocation( + { + invocationMessage: progress.invocationMessage, + pastTenseMessage: progress.pastTenseMessage, + toolSpecificData: progress.toolSpecificData, + }, + toolData, + progress.toolCallId, + progress.subagentInvocationId, + undefined, // parameters + {}, + undefined // chatRequestId + ); + + if (progress.isComplete) { + // Already completed on first push + invocation.didExecuteTool({ + content: [], + toolResultMessage: progress.pastTenseMessage, + toolResultError: progress.errorMessage, + }); + if (progress.toolSpecificData !== undefined) { + invocation.toolSpecificData = progress.toolSpecificData; + } + } + + this._responseParts.push(invocation); + } + protected override _updateRepr(quiet?: boolean) { super._updateRepr(); if (!this._onDidChangeValue) { @@ -1088,6 +1150,9 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel if (part.kind === 'confirmation' && !part.isUsed) { return part.title; } + if (part.kind === 'questionCarousel' && !part.isUsed) { + return localize('waitingAnswer', "Answer questions to continue..."); + } if (part.kind === 'elicitation2' && part.state.read(r) === ElicitationState.Pending) { const title = part.title; return isMarkdownString(title) ? title.value : title; @@ -1147,7 +1212,7 @@ export class ChatResponseModel extends Disposable implements IChatResponseModel /** * Apply a progress update to the actual response content. */ - updateContent(responsePart: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit, quiet?: boolean) { + updateContent(responsePart: IChatProgressResponseContent | IChatTextEdit | IChatNotebookEdit | IChatExternalToolInvocationUpdate, quiet?: boolean) { this._response.updateContent(responsePart, quiet); } diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts index 3bb3abaaafe..cecb075bd83 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionStore.ts @@ -19,9 +19,10 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IUserDataProfilesService } from '../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, isEmptyWorkspaceIdentifier, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { Dto } from '../../../../services/extensions/common/proxyIdentifier.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; +import { IWorkspaceEditingService } from '../../../../services/workspaces/common/workspaceEditing.js'; import { awaitStatsForSession } from '../chat.js'; import { IChatSessionStats, IChatSessionTiming, ResponseModelState } from '../chatService/chatService.js'; import { ChatAgentLocation } from '../constants.js'; @@ -36,7 +37,7 @@ const ChatIndexStorageKey = 'chat.ChatSessionStore.index'; const ChatTransferIndexStorageKey = 'ChatSessionStore.transferIndex'; export class ChatSessionStore extends Disposable { - private readonly storageRoot: URI; + private storageRoot: URI; private readonly previousEmptyWindowStorageRoot: URI | undefined; private readonly transferredSessionStorageRoot: URI; @@ -55,6 +56,7 @@ export class ChatSessionStore extends Disposable { @ILifecycleService private readonly lifecycleService: ILifecycleService, @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IWorkspaceEditingService private readonly workspaceEditingService: IWorkspaceEditingService, ) { super(); @@ -71,6 +73,12 @@ export class ChatSessionStore extends Disposable { this.transferredSessionStorageRoot = joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'transferredChatSessions'); + // Listen to workspace transitions to migrate chat sessions + this._register(this.workspaceEditingService.onDidEnterWorkspace(event => { + const transitionPromise = this.storeQueue.queue(() => this.handleWorkspaceTransition(event.oldWorkspace, event.newWorkspace)); + event.join(transitionPromise); + })); + this._register(this.lifecycleService.onWillShutdown(e => { this.shuttingDown = true; if (!this.storeTask) { @@ -84,6 +92,91 @@ export class ChatSessionStore extends Disposable { })); } + private async handleWorkspaceTransition(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const wasEmptyWindow = isEmptyWorkspaceIdentifier(oldWorkspace); + const isNewWorkspaceEmpty = isEmptyWorkspaceIdentifier(newWorkspace); + const oldWorkspaceId = oldWorkspace.id; + const newWorkspaceId = newWorkspace.id; + + this.logService.info(`ChatSessionStore: Workspace transition from ${oldWorkspaceId} to ${newWorkspaceId}`); + + // Determine the old storage location based on the old workspace + const oldStorageRoot = wasEmptyWindow ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, oldWorkspaceId, 'chatSessions'); + + // Determine the new storage location based on the new workspace + const newStorageRoot = isNewWorkspaceEmpty ? + joinPath(this.userDataProfilesService.defaultProfile.globalStorageHome, 'emptyWindowChatSessions') : + joinPath(this.environmentService.workspaceStorageHome, newWorkspaceId, 'chatSessions'); + + // If the storage roots are identical, there is nothing to migrate + if (oldStorageRoot.toString() === newStorageRoot.toString()) { + this.storageRoot = newStorageRoot; + return; + } + + // Update storage root for the new workspace + this.storageRoot = newStorageRoot; + + // Migrate session files from old to new location + await this.migrateSessionsToNewWorkspace(oldStorageRoot, wasEmptyWindow, isNewWorkspaceEmpty); + } + + private async migrateSessionsToNewWorkspace(oldStorageRoot: URI, wasEmptyWindow: boolean, isNewWorkspaceEmpty: boolean): Promise { + try { + // Check if old storage location exists + const oldStorageExists = await this.fileService.exists(oldStorageRoot); + if (!oldStorageExists) { + this.logService.info(`ChatSessionStore: Old storage location does not exist, skipping migration`); + return; + } + + // Read all session files from old location + const oldDirectory = await this.fileService.resolve(oldStorageRoot); + if (!oldDirectory.children) { + this.logService.info(`ChatSessionStore: No children in old storage location, skipping migration`); + return; + } + + this.logService.info(`ChatSessionStore: Found ${oldDirectory.children.length} files in old storage location`); + + // Copy each file to the new location + let migratedCount = 0; + for (const child of oldDirectory.children) { + if (!child.isDirectory && (child.name.endsWith('.json') || child.name.endsWith('.jsonl'))) { + const oldFilePath = child.resource; + const newFilePath = joinPath(this.storageRoot, child.name); + + try { + await this.fileService.copy(oldFilePath, newFilePath, false); + migratedCount++; + } catch (e) { + if (toFileOperationResult(e) === FileOperationResult.FILE_MOVE_CONFLICT) { + // File already exists at target - skip as a no-op + this.logService.trace(`ChatSessionStore: Session file ${child.name} already exists at target, skipping`); + } else { + this.reportError('sessionMigration', `Error migrating chat session file ${child.name}`, e); + } + } + } + } + + this.logService.info(`ChatSessionStore: Copied ${migratedCount} chat session files from ${wasEmptyWindow ? 'empty window' : oldStorageRoot.toString()} to ${isNewWorkspaceEmpty ? 'empty window' : this.storageRoot.toString()} (originals preserved at old location)`); + + // Clear the index cache and flush it to the new storage scope + this.indexCache = undefined; + try { + await this.flushIndex(); + } catch (e) { + this.reportError('migrateWorkspace', 'Error flushing chat session index after workspace migration', e); + } + + } catch (e) { + this.reportError('migrateWorkspace', 'Error migrating chat sessions to new workspace', e); + } + } + async storeSessions(sessions: ChatModel[]): Promise { if (this.shuttingDown) { // Don't start this task if we missed the chance to block shutdown diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index ded5eba1b22..e0dd7ca927a 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -153,7 +153,7 @@ export interface IChatAgentRequest { editedFileEvents?: IChatAgentEditedFileEvent[]; /** * Collected hooks configuration for this request. - * Contains all hooks defined in hooks.json files, organized by hook type. + * Contains all hooks defined in hooks .json files, organized by hook type. */ hooks?: IChatRequestHooks; /** @@ -271,7 +271,7 @@ export class ChatAgentService extends Disposable implements IChatAgentService { private _agents = new Map(); - private readonly _onDidChangeAgents = new Emitter(); + private readonly _onDidChangeAgents = this._register(new Emitter()); readonly onDidChangeAgents: Event = this._onDidChangeAgents.event; private readonly _agentsContextKeys = new Set(); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index c56b279455d..7a603df5251 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -19,7 +19,7 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry, toPromptTextVariableEntry, PromptFileVariableKind, IPromptTextVariableEntry, ChatRequestToolReferenceEntry, toToolVariableEntry } from '../attachments/chatVariableEntries.js'; import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; -import { isPromptOrInstructionsFile } from './config/promptFileLocations.js'; +import { isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; import { PromptsType } from './promptTypes.js'; import { ParsedPromptFile } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; @@ -131,8 +131,14 @@ export class ComputeAutomaticInstructions { } const applyTo = parsedFile.header?.applyTo; + const paths = parsedFile.header?.paths; - if (!applyTo) { + // Claude rules files use `paths` (defaulting to '**' when omitted), + // regular instruction files use `applyTo` (skipped when omitted) + const isClaudeRules = isInClaudeRulesFolder(uri); + const pattern = isClaudeRules ? (paths?.join(', ') ?? '**') : applyTo; + + if (!pattern) { this._logService.trace(`[InstructionsContextComputer] No 'applyTo' found: ${uri}`); continue; } @@ -143,18 +149,18 @@ export class ComputeAutomaticInstructions { continue; } - const match = this._matches(context.files, applyTo); + const match = this._matches(context.files, pattern); if (match) { this._logService.trace(`[InstructionsContextComputer] Match for ${uri} with ${match.pattern}${match.file ? ` for file ${match.file}` : ''}`); const reason = !match.file ? localize('instruction.file.reason.allFiles', 'Automatically attached as pattern is **') : - localize('instruction.file.reason.specificFile', 'Automatically attached as pattern {0} matches {1}', applyTo, this._labelService.getUriLabel(match.file, { relative: true })); + localize('instruction.file.reason.specificFile', 'Automatically attached as pattern {0} matches {1}', pattern, this._labelService.getUriLabel(match.file, { relative: true })); variables.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, reason, true)); telemetryEvent.applyingInstructionsCount++; } else { - this._logService.trace(`[InstructionsContextComputer] No match for ${uri} with ${applyTo}`); + this._logService.trace(`[InstructionsContextComputer] No match for ${uri} with ${pattern}`); } } } @@ -209,6 +215,21 @@ export class ComputeAutomaticInstructions { } } + /** + * Combines the `applyTo` and `paths` attributes into a single comma-separated + * pattern string that can be matched by {@link _matches}. + * Used for the instructions list XML output where both should be shown. + */ + private _getApplyToPattern(applyTo: string | undefined, paths: readonly string[] | undefined): string | undefined { + if (applyTo) { + return applyTo; + } + if (paths && paths.length > 0) { + return paths.join(', '); + } + return undefined; + } + private _matches(files: ResourceSet, applyToPattern: string): { pattern: string; file?: URI } | undefined { const patterns = splitGlobAware(applyToPattern, ','); const patterMatches = (pattern: string): { pattern: string; file?: URI } | undefined => { @@ -279,13 +300,14 @@ export class ComputeAutomaticInstructions { if (parsedFile) { entries.push(''); if (parsedFile.header) { - const { description, applyTo } = parsedFile.header; + const { description, applyTo, paths } = parsedFile.header; if (description) { entries.push(`${description}`); } entries.push(`${getFilePath(uri)}`); - if (applyTo) { - entries.push(`${applyTo}`); + const applyToPattern = this._getApplyToPattern(applyTo, paths); + if (applyToPattern) { + entries.push(`${applyToPattern}`); } } else { entries.push(`${getFilePath(uri)}`); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 81f81c0d3d6..186fee36470 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { URI } from '../../../../../../base/common/uri.js'; -import { basename, dirname } from '../../../../../../base/common/path.js'; +import { posix } from '../../../../../../base/common/path.js'; import { PromptsType } from '../promptTypes.js'; import { PromptsStorage } from '../service/promptsService.js'; +const { basename, dirname } = posix; + /** * File extension for the reusable prompt files. */ @@ -49,9 +51,9 @@ export const CLAUDE_MD_FILENAME = 'CLAUDE.md'; export const CLAUDE_LOCAL_MD_FILENAME = 'CLAUDE.local.md'; /** - * Default hook file name (case insensitive). + * Claude configuration folder name. */ -export const HOOKS_FILENAME = 'hooks.json'; +export const CLAUDE_CONFIG_FOLDER = '.claude'; /** * Copilot custom instructions file name. @@ -79,6 +81,16 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes'; */ export const AGENTS_SOURCE_FOLDER = '.github/agents'; +/** + * Claude agents folder. + */ +export const CLAUDE_AGENTS_SOURCE_FOLDER = '.claude/agents'; + +/** + * Claude rules folder. + */ +export const CLAUDE_RULES_SOURCE_FOLDER = '.claude/rules'; + /** * Hooks folder. */ @@ -154,6 +166,8 @@ export const DEFAULT_SKILL_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ */ export const DEFAULT_INSTRUCTIONS_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: CLAUDE_RULES_SOURCE_FOLDER, source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/' + CLAUDE_RULES_SOURCE_FOLDER, source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, ]; /** @@ -168,13 +182,15 @@ export const DEFAULT_PROMPT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ */ export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: CLAUDE_AGENTS_SOURCE_FOLDER, source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, ]; /** * Default hook file paths. + * Entries can be either a directory or a specific file path (.json) */ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ - { path: '.github/hooks/hooks.json', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '.github/hooks', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, { path: '.claude/settings.local.json', source: PromptFileSource.ClaudeWorkspaceLocal, storage: PromptsStorage.local }, { path: '.claude/settings.json', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, { path: '~/.claude/settings.json', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, @@ -185,11 +201,25 @@ export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ */ function isInAgentsFolder(fileUri: URI): boolean { const dir = dirname(fileUri.path); - return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir === AGENTS_SOURCE_FOLDER; + return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); +} + +/** + * Helper function to check if a file is inside the .claude/rules/ folder (including subfolders). + * Claude rules files (.md) in this folder are treated as instruction files. + */ +export function isInClaudeRulesFolder(fileUri: URI): boolean { + const path = fileUri.path; + return path.includes('/' + CLAUDE_RULES_SOURCE_FOLDER + '/'); } /** * Gets the prompt file type from the provided path. + * + * Note: This function assumes the URI is already known to be a prompt file + * (e.g., from a configured prompt source folder). It does not validate that + * arbitrary URIs are prompt files - for example, any .json file will return + * PromptsType.hook regardless of its location. */ export function getPromptFileType(fileUri: URI): PromptsType | undefined { const filename = basename(fileUri.path); @@ -216,17 +246,16 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } - // Check if it's a hooks.json file (case insensitive) - if (filename.toLowerCase() === HOOKS_FILENAME.toLowerCase()) { - return PromptsType.hook; + // Check if it's a .md file inside the .claude/rules/ folder (including subfolders) + // These are treated as instruction files + if (filename.endsWith('.md') && filename !== 'README.md' && isInClaudeRulesFolder(fileUri)) { + return PromptsType.instructions; } - // Check if it's a settings.local.json or settings.json file in a .claude folder - if (filename.toLowerCase() === 'settings.local.json' || filename.toLowerCase() === 'settings.json') { - const dir = dirname(fileUri.path); - if (dir.endsWith('/.claude') || dir === '.claude') { - return PromptsType.hook; - } + // Any .json file is treated as a hook file. + // The caller is responsible for only passing URIs from valid prompt source folders. + if (filename.toLowerCase().endsWith('.json')) { + return PromptsType.hook; } return undefined; @@ -250,7 +279,7 @@ export function getPromptFileExtension(type: PromptsType): string { case PromptsType.skill: return SKILL_FILENAME; case PromptsType.hook: - return HOOKS_FILENAME; + return '.json'; default: throw new Error('Unknown prompt type'); } @@ -308,6 +337,11 @@ export function getCleanPromptName(fileUri: URI): string { return basename(fileUri.path, '.md'); } + // For .md files in .claude/rules/ folder, treat them as instruction files + if (fileName.endsWith('.md') && fileName !== 'README.md' && isInClaudeRulesFolder(fileUri)) { + return basename(fileUri.path, '.md'); + } + // because we now rely on the `prompt` language ID that can be explicitly // set for any document in the editor, any file can be a "prompt" file, so // to account for that, we return the full file name including the file diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index ba37834afa7..c159acfa4c3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -16,6 +16,7 @@ export const CLAUDE_HOOK_TYPE_MAP: Record = { 'UserPromptSubmit': HookType.UserPromptSubmit, 'PreToolUse': HookType.PreToolUse, 'PostToolUse': HookType.PostToolUse, + 'PreCompact': HookType.PreCompact, 'SubagentStart': HookType.SubagentStart, 'SubagentStop': HookType.SubagentStop, 'Stop': HookType.Stop, @@ -52,6 +53,20 @@ export function getClaudeHookTypeName(hookType: HookType): string | undefined { return getHookTypeToClaudeNameMap().get(hookType); } +/** + * Result of parsing Claude hooks file. + */ +export interface IParseClaudeHooksResult { + /** + * The parsed hooks by type. + */ + readonly hooks: Map; + /** + * Whether all hooks from this file were disabled via `disableAllHooks: true`. + */ + readonly disabledAllHooks: boolean; +} + /** * Parses hooks from a Claude settings.json file. * Claude format: @@ -69,23 +84,31 @@ export function getClaudeHookTypeName(hookType: HookType): string | undefined { * "PreToolUse": [{ "type": "command", "command": "..." }] * } * } + * + * If the file has `disableAllHooks: true` at the top level, all hooks are filtered out. */ export function parseClaudeHooks( json: unknown, workspaceRootUri: URI | undefined, userHome: string -): Map { +): IParseClaudeHooksResult { const result = new Map(); if (!json || typeof json !== 'object') { - return result; + return { hooks: result, disabledAllHooks: false }; } const root = json as Record; + + // Check for disableAllHooks property at the top level + if (root.disableAllHooks === true) { + return { hooks: result, disabledAllHooks: true }; + } + const hooks = root.hooks; if (!hooks || typeof hooks !== 'object') { - return result; + return { hooks: result, disabledAllHooks: false }; } const hooksObj = hooks as Record; @@ -139,7 +162,7 @@ export function parseClaudeHooks( } } - return result; + return { hooks: result, disabledAllHooks: false }; } /** @@ -157,7 +180,5 @@ function resolveClaudeCommand( return undefined; } - // Add type if missing for resolveHookCommand - const normalized = { ...raw, type: 'command' }; - return resolveHookCommand(normalized, workspaceRootUri, userHome); + return resolveHookCommand(raw, workspaceRootUri, userHome); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index cf33b6e5efc..6bdf4afdc89 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -30,7 +30,7 @@ export interface IResolvedHookEntry { * Supported hook file formats. */ export enum HookSourceFormat { - /** GitHub Copilot hooks.json format */ + /** GitHub Copilot hooks .json format */ Copilot = 'copilot', /** Claude settings.json / settings.local.json format */ Claude = 'claude', @@ -48,11 +48,6 @@ export function getHookSourceFormat(fileUri: URI): HookSourceFormat { return HookSourceFormat.Claude; } - // Copilot format: hooks.json (typically in .github/hooks/) - if (filename === 'hooks.json') { - return HookSourceFormat.Copilot; - } - // Default to Copilot format return HookSourceFormat.Copilot; } @@ -66,7 +61,7 @@ export function isReadOnlyHookSource(format: HookSourceFormat): boolean { } /** - * Parses hooks from a Copilot hooks.json file (our native format). + * Parses hooks from a Copilot hooks .json file (our native format). */ export function parseCopilotHooks( json: unknown, @@ -116,6 +111,18 @@ export function parseCopilotHooks( return result; } +/** + * Result of parsing hooks from a file. + */ +export interface IParseHooksFromFileResult { + readonly format: HookSourceFormat; + readonly hooks: Map; + /** + * Whether all hooks from this file were disabled via `disableAllHooks: true`. + */ + readonly disabledAllHooks: boolean; +} + /** * Parses hooks from any supported format, auto-detecting the format from the file URI. */ @@ -124,22 +131,61 @@ export function parseHooksFromFile( json: unknown, workspaceRootUri: URI | undefined, userHome: string -): { format: HookSourceFormat; hooks: Map } { +): IParseHooksFromFileResult { const format = getHookSourceFormat(fileUri); let hooks: Map; + let disabledAllHooks = false; switch (format) { - case HookSourceFormat.Claude: - hooks = parseClaudeHooks(json, workspaceRootUri, userHome); + case HookSourceFormat.Claude: { + const result = parseClaudeHooks(json, workspaceRootUri, userHome); + hooks = result.hooks; + disabledAllHooks = result.disabledAllHooks; break; + } case HookSourceFormat.Copilot: default: hooks = parseCopilotHooks(json, workspaceRootUri, userHome); break; } - return { format, hooks }; + return { format, hooks, disabledAllHooks }; +} + +/** + * Parses hooks from a file, ignoring the `disableAllHooks` flag. + * Used by diagnostics to show which hooks are hidden when `disableAllHooks: true` is set. + */ +export function parseHooksIgnoringDisableAll( + fileUri: URI, + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): IParseHooksFromFileResult { + const format = getHookSourceFormat(fileUri); + + let hooks: Map; + + switch (format) { + case HookSourceFormat.Claude: { + // Strip `disableAllHooks` before parsing so the hooks are still extracted + if (json && typeof json === 'object') { + const { disableAllHooks: _, ...rest } = json as Record; + const result = parseClaudeHooks(rest, workspaceRootUri, userHome); + hooks = result.hooks; + } else { + hooks = new Map(); + } + break; + } + case HookSourceFormat.Copilot: + default: + hooks = parseCopilotHooks(json, workspaceRootUri, userHome); + break; + } + + return { format, hooks, disabledAllHooks: true }; } /** @@ -153,3 +199,16 @@ export function getHookSourceFormatLabel(format: HookSourceFormat): string { return 'GitHub Copilot'; } } + +/** + * Builds a new hook entry object in the appropriate format for the given source format. + * - Copilot format: `{ type: 'command', command: '' }` + * - Claude format: `{ matcher: '', hooks: [{ type: 'command', command: '' }] }` + */ +export function buildNewHookEntry(format: HookSourceFormat): Record { + const commandEntry = { type: 'command', command: '' }; + if (format === HookSourceFormat.Claude) { + return { matcher: '', hooks: [commandEntry] }; + } + return commandEntry; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts index 9655530d7df..ccf368ed54a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -9,16 +9,17 @@ import { URI } from '../../../../../base/common/uri.js'; import { joinPath } from '../../../../../base/common/resources.js'; import { isAbsolute } from '../../../../../base/common/path.js'; import { untildify } from '../../../../../base/common/labels.js'; -import * as platform from '../../../../../base/common/platform.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; /** - * Enum of available hook types that can be configured in hooks.json + * Enum of available hook types that can be configured in hooks .json */ export enum HookType { SessionStart = 'SessionStart', UserPromptSubmit = 'UserPromptSubmit', PreToolUse = 'PreToolUse', PostToolUse = 'PostToolUse', + PreCompact = 'PreCompact', SubagentStart = 'SubagentStart', SubagentStop = 'SubagentStop', Stop = 'Stop', @@ -36,7 +37,7 @@ export const HOOK_TYPES = [ { id: HookType.SessionStart, label: nls.localize('hookType.sessionStart.label', "Session Start"), - description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins or when resuming an existing session.") + description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins.") }, { id: HookType.UserPromptSubmit, @@ -53,6 +54,11 @@ export const HOOK_TYPES = [ label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") }, + { + id: HookType.PreCompact, + label: nls.localize('hookType.preCompact.label', "Pre-Compact"), + description: nls.localize('hookType.preCompact.description', "Executed before the agent compacts the conversation context.") + }, { id: HookType.SubagentStart, label: nls.localize('hookType.subagentStart.label', "Subagent Start"), @@ -86,7 +92,7 @@ export interface IHookCommand { /** Resolved working directory URI. */ readonly cwd?: URI; readonly env?: Record; - readonly timeoutSec?: number; + readonly timeout?: number; /** Original JSON field name that provided the windows command. */ readonly windowsSource?: 'windows' | 'powershell'; /** Original JSON field name that provided the linux command. */ @@ -104,6 +110,7 @@ export interface IChatRequestHooks { readonly [HookType.UserPromptSubmit]?: readonly IHookCommand[]; readonly [HookType.PreToolUse]?: readonly IHookCommand[]; readonly [HookType.PostToolUse]?: readonly IHookCommand[]; + readonly [HookType.PreCompact]?: readonly IHookCommand[]; readonly [HookType.SubagentStart]?: readonly IHookCommand[]; readonly [HookType.SubagentStop]?: readonly IHookCommand[]; readonly [HookType.Stop]?: readonly IHookCommand[]; @@ -157,10 +164,10 @@ const hookCommandSchema: IJSONSchema = { additionalProperties: { type: 'string' }, description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') }, - timeoutSec: { + timeout: { type: 'number', default: 30, - description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 30).') + description: nls.localize('hook.timeout', 'Maximum execution time in seconds (default: 30).') } } }; @@ -184,7 +191,7 @@ export const hookFileSchema: IJSONSchema = { properties: { SessionStart: { ...hookArraySchema, - description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins or when resuming an existing session. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') + description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') }, UserPromptSubmit: { ...hookArraySchema, @@ -198,6 +205,10 @@ export const hookFileSchema: IJSONSchema = { ...hookArraySchema, description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') }, + PreCompact: { + ...hookArraySchema, + description: nls.localize('hookFile.preCompact', 'Executed before the agent compacts the conversation context. Use to save conversation state, export important information, or prepare for context reduction.') + }, SubagentStart: { ...hookArraySchema, description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') @@ -229,7 +240,7 @@ export const hookFileSchema: IJSONSchema = { { type: 'command', command: '${2:./scripts/validate.sh}', - timeoutSec: 15 + timeout: 15 } ] } @@ -246,7 +257,7 @@ export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks'; /** * Glob pattern for hook files. */ -export const HOOK_FILE_GLOB = 'hooks/hooks.json'; +export const HOOK_FILE_GLOB = '.github/hooks/*.json'; /** * Normalizes a raw hook type identifier to the canonical HookType enum value. @@ -267,7 +278,7 @@ export function toHookType(rawHookTypeId: string): HookType | undefined { * - powershell -> windows * This is an internal helper - use resolveHookCommand for the full resolution. */ -function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeoutSec?: number } | undefined { +function normalizeHookCommand(raw: Record): { command?: string; windows?: string; linux?: string; osx?: string; windowsSource?: 'windows' | 'powershell'; linuxSource?: 'linux' | 'bash'; osxSource?: 'osx' | 'bash'; cwd?: string; env?: Record; timeout?: number } | undefined { if (raw.type !== 'command') { return undefined; } @@ -302,36 +313,37 @@ function normalizeHookCommand(raw: Record): { command?: string; ...(osxSource && { osxSource }), ...(typeof raw.cwd === 'string' && { cwd: raw.cwd }), ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), - ...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }), + ...(typeof raw.timeout !== 'number' && typeof raw.timeoutSec === 'number' && { timeout: raw.timeoutSec }), + ...(typeof raw.timeout === 'number' && { timeout: raw.timeout }), }; } /** - * Gets a label for the current platform. + * Gets a label for the given platform. */ -export function getCurrentPlatformLabel(): string { - if (platform.isWindows) { +export function getPlatformLabel(os: OperatingSystem): string { + if (os === OperatingSystem.Windows) { return 'Windows'; - } else if (platform.isMacintosh) { + } else if (os === OperatingSystem.Macintosh) { return 'macOS'; - } else if (platform.isLinux) { + } else if (os === OperatingSystem.Linux) { return 'Linux'; } return ''; } /** - * Resolves the effective command for the current platform. + * Resolves the effective command for the given platform. * This applies OS-specific overrides (windows, linux, osx) to get the actual command that will be executed. * Similar to how launch.json handles platform-specific configurations in debugAdapter.ts. */ -export function resolveEffectiveCommand(hook: IHookCommand): string | undefined { - // Select the platform-specific override based on the current OS - if (platform.isWindows && hook.windows) { +export function resolveEffectiveCommand(hook: IHookCommand, os: OperatingSystem): string | undefined { + // Select the platform-specific override based on the OS + if (os === OperatingSystem.Windows && hook.windows) { return hook.windows; - } else if (platform.isMacintosh && hook.osx) { + } else if (os === OperatingSystem.Macintosh && hook.osx) { return hook.osx; - } else if (platform.isLinux && hook.linux) { + } else if (os === OperatingSystem.Linux && hook.linux) { return hook.linux; } @@ -342,45 +354,45 @@ export function resolveEffectiveCommand(hook: IHookCommand): string | undefined /** * Checks if the hook is using a platform-specific command override. */ -export function isUsingPlatformOverride(hook: IHookCommand): boolean { - if (platform.isWindows && hook.windows) { +export function isUsingPlatformOverride(hook: IHookCommand, os: OperatingSystem): boolean { + if (os === OperatingSystem.Windows && hook.windows) { return true; - } else if (platform.isMacintosh && hook.osx) { + } else if (os === OperatingSystem.Macintosh && hook.osx) { return true; - } else if (platform.isLinux && hook.linux) { + } else if (os === OperatingSystem.Linux && hook.linux) { return true; } return false; } /** - * Gets the source shell type for the effective command on the current platform. + * Gets the source shell type for the effective command on the given platform. * Returns 'powershell' if the Windows command came from a powershell field, * 'bash' if the Linux/macOS command came from a bash field, * or undefined for default shell handling. */ -export function getEffectiveCommandSource(hook: IHookCommand): 'powershell' | 'bash' | undefined { - if (platform.isWindows && hook.windows && hook.windowsSource === 'powershell') { +export function getEffectiveCommandSource(hook: IHookCommand, os: OperatingSystem): 'powershell' | 'bash' | undefined { + if (os === OperatingSystem.Windows && hook.windows && hook.windowsSource === 'powershell') { return 'powershell'; - } else if (platform.isMacintosh && hook.osx && hook.osxSource === 'bash') { + } else if (os === OperatingSystem.Macintosh && hook.osx && hook.osxSource === 'bash') { return 'bash'; - } else if (platform.isLinux && hook.linux && hook.linuxSource === 'bash') { + } else if (os === OperatingSystem.Linux && hook.linux && hook.linuxSource === 'bash') { return 'bash'; } return undefined; } /** - * Gets the original JSON field key name for the current platform's command. + * Gets the original JSON field key name for the given platform's command. * Returns the actual field name from the JSON (e.g., 'bash' instead of 'osx' if bash was used). * This is used for editor focus to highlight the correct field. */ -export function getEffectiveCommandFieldKey(hook: IHookCommand): string { - if (platform.isWindows && hook.windows) { +export function getEffectiveCommandFieldKey(hook: IHookCommand, os: OperatingSystem): string { + if (os === OperatingSystem.Windows && hook.windows) { return hook.windowsSource ?? 'windows'; - } else if (platform.isMacintosh && hook.osx) { + } else if (os === OperatingSystem.Macintosh && hook.osx) { return hook.osxSource ?? 'osx'; - } else if (platform.isLinux && hook.linux) { + } else if (os === OperatingSystem.Linux && hook.linux) { return hook.linuxSource ?? 'linux'; } return 'command'; @@ -388,18 +400,18 @@ export function getEffectiveCommandFieldKey(hook: IHookCommand): string { /** * Formats a hook command for display. - * Resolves OS-specific overrides to show the effective command for the current platform. + * Resolves OS-specific overrides to show the effective command for the given platform. * If using a platform-specific override, includes the platform as a prefix badge. */ -export function formatHookCommandLabel(hook: IHookCommand): string { - const command = resolveEffectiveCommand(hook) ?? ''; +export function formatHookCommandLabel(hook: IHookCommand, os: OperatingSystem): string { + const command = resolveEffectiveCommand(hook, os); if (!command) { return ''; } // Add platform badge if using platform-specific override - if (isUsingPlatformOverride(hook)) { - const platformLabel = getCurrentPlatformLabel(); + if (isUsingPlatformOverride(hook, os)) { + const platformLabel = getPlatformLabel(os); return `[${platformLabel}] ${command}`; } @@ -445,6 +457,6 @@ export function resolveHookCommand(raw: Record, workspaceRootUr ...(normalized.osxSource && { osxSource: normalized.osxSource }), ...(cwdUri && { cwd: cwdUri }), ...(normalized.env && { env: normalized.env }), - ...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }), + ...(normalized.timeout !== undefined && { timeout: normalized.timeout }), }; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index f229164fb33..bd4ecc7de0f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -11,12 +11,12 @@ import { localize } from '../../../../../../nls.js'; import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; +import { parseCommaSeparatedList, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; import { Selection } from '../../../../../../editor/common/core/selection.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { isGithubTarget, MARKERS_OWNER_ID } from './promptValidator.js'; +import { getTarget, isVSCodeOrDefaultTarget, MARKERS_OWNER_ID } from './promptValidator.js'; import { IMarkerData, IMarkerService } from '../../../../../../platform/markers/common/markers.js'; import { CodeActionKind } from '../../../../../../editor/contrib/codeAction/common/types.js'; @@ -106,16 +106,26 @@ export class PromptCodeActionProvider implements CodeActionProvider { } private getUpdateToolsCodeActions(promptFile: ParsedPromptFile, promptType: PromptsType, model: ITextModel, range: Range, result: CodeAction[]): void { - const toolsAttr = promptFile.header?.getAttribute(PromptHeaderAttributes.tools); - if (toolsAttr?.value.type !== 'array' || !toolsAttr.value.range.containsRange(range)) { + if (!promptFile.header) { return; } - if (isGithubTarget(promptType, promptFile.header?.target)) { - // GitHub Copilot custom agents use a fixed set of tool names that are not deprecated + const toolsAttr = promptFile.header.getAttribute(PromptHeaderAttributes.tools); + if (!toolsAttr || !toolsAttr.value.range.containsRange(range)) { return; } - - const values = toolsAttr.value.items; + const target = getTarget(promptType, promptFile.header); + if (!isVSCodeOrDefaultTarget(target)) { + // GitHub Copilot and Claude custom agents use a fixed set of tool names that are not deprecated + return; + } + let value = toolsAttr.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type !== 'array') { + return; + } + const values = value.items; const deprecatedNames = new Lazy(() => this.languageModelToolsService.getDeprecatedFullReferenceNames()); const edits: TextEdit[] = []; for (const item of values) { @@ -164,7 +174,7 @@ export class PromptCodeActionProvider implements CodeActionProvider { if (edits.length && result.length === 0 || edits.length > 1) { result.push( - this.createCodeAction(model, toolsAttr.value.range, + this.createCodeAction(model, value.range, localize('updateAllToolNames', "Update all tool names"), edits.map(edit => asWorkspaceTextEdit(model, edit)) ) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts index aa7474566aa..3fdf4aa385e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts @@ -8,7 +8,7 @@ import { DocumentSemanticTokensProvider, ProviderResult, SemanticTokens, Semanti import { ITextModel } from '../../../../../../editor/common/model.js'; import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { isGithubTarget } from './promptValidator.js'; +import { getTarget, isVSCodeOrDefaultTarget } from './promptValidator.js'; export class PromptDocumentSemanticTokensProvider implements DocumentSemanticTokensProvider { /** @@ -32,9 +32,9 @@ export class PromptDocumentSemanticTokensProvider implements DocumentSemanticTok if (!promptAST.body) { return undefined; } - - if (isGithubTarget(promptType, promptAST.header?.target)) { - // In GitHub Copilot mode, we don't provide variable semantic tokens to tool references + const target = getTarget(promptType, promptAST.header ?? model.uri); + if (!isVSCodeOrDefaultTarget(target)) { + // variables syntax is only support for VS Code and default targets, not for GitHub Copilot or Claude custom agents return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 56165a933ed..9035e6bf404 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -13,11 +13,13 @@ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../langua import { ILanguageModelToolsService } from '../../tools/languageModelToolsService.js'; import { IChatModeService } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService } from '../service/promptsService.js'; +import { IPromptsService, Target } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { IHeaderAttribute, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, getValidAttributeNames, isGithubTarget, knownGithubCopilotTools } from './promptValidator.js'; +import { ClaudeHeaderAttributes, IArrayValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, claudeRulesAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; import { localize } from '../../../../../../nls.js'; +import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; + export class PromptHeaderAutocompletion implements CompletionItemProvider { /** @@ -106,8 +108,8 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { const suggestions: CompletionItem[] = []; - const isGitHubTarget = isGithubTarget(promptType, header.target); - const attributesToPropose = new Set(getValidAttributeNames(promptType, false, isGitHubTarget)); + const target = getTarget(promptType, header); + const attributesToPropose = new Set(getValidAttributeNames(promptType, false, target)); for (const attr of header.attributes) { attributesToPropose.delete(attr.key); } @@ -115,9 +117,9 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (colonPosition) { return key; } - const valueSuggestions = this.getValueSuggestions(promptType, key); + const valueSuggestions = this.getValueSuggestions(promptType, key, target); if (valueSuggestions.length > 0) { - return `${key}: \${0:${valueSuggestions[0]}}`; + return `${key}: \${0:${valueSuggestions[0].name}}`; } else { return `${key}: \$0`; } @@ -127,7 +129,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { for (const attribute of attributesToPropose) { const item: CompletionItem = { label: attribute, - documentation: getAttributeDescription(attribute, promptType), + documentation: getAttributeDescription(attribute, promptType, target), kind: CompletionItemKind.Property, insertText: getInsertText(attribute), insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, @@ -146,15 +148,14 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { colonPosition: Position, promptType: PromptsType, ): Promise { - const suggestions: CompletionItem[] = []; - const attribute = header.attributes.find(attr => attr.range.containsPosition(position)); + const posLineNumber = position.lineNumber; + const attribute = header.attributes.find(({ range }) => range.startLineNumber <= posLineNumber && posLineNumber <= range.endLineNumber); if (!attribute) { return undefined; } - - const isGitHubTarget = isGithubTarget(promptType, header.target); - if (!getValidAttributeNames(promptType, true, isGitHubTarget).includes(attribute.key)) { + const target = getTarget(promptType, header); + if (!getValidAttributeNames(promptType, true, target).includes(attribute.key)) { return undefined; } @@ -162,38 +163,58 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (attribute.key === PromptHeaderAttributes.model) { if (attribute.value.type === 'array') { // if the position is inside the tools metadata, we provide tool name completions - const getValues = async () => this.getModelNames(promptType === PromptsType.agent); - return this.provideArrayCompletions(model, position, attribute, getValues); + const getValues = async () => { + if (target === Target.Claude) { + return knownClaudeTools; + } else { + return this.getModelNames(promptType === PromptsType.agent); + } + }; + return this.provideArrayCompletions(model, position, attribute.value, getValues); } } - if (attribute.key === PromptHeaderAttributes.tools) { - if (attribute.value.type === 'array') { + if (attribute.key === PromptHeaderAttributes.tools || attribute.key === ClaudeHeaderAttributes.disallowedTools) { + let value = attribute.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type === 'array') { // if the position is inside the tools metadata, we provide tool name completions - const getValues = async () => isGitHubTarget ? knownGithubCopilotTools : Array.from(this.languageModelToolsService.getFullReferenceNames()); - return this.provideArrayCompletions(model, position, attribute, getValues); + const getValues = async () => { + if (target === Target.GitHubCopilot) { + // for GitHub Copilot agent files, we only suggest the known set of tools that are supported by GitHub Copilot, instead of all tools that the user has defined, because many tools won't work with GitHub Copilot and it would be frustrating for users to select a tool that doesn't work + return knownGithubCopilotTools; + } else if (target === Target.Claude) { + return knownClaudeTools; + } else { + return Array.from(this.languageModelToolsService.getFullReferenceNames()).map(name => ({ name })); + } + }; + return this.provideArrayCompletions(model, position, value, getValues); } } } - if (promptType === PromptsType.agent) { - if (attribute.key === PromptHeaderAttributes.agents && !isGitHubTarget) { - if (attribute.value.type === 'array') { - return this.provideArrayCompletions(model, position, attribute, async () => (await this.promptsService.getCustomAgents(CancellationToken.None)).map(agent => agent.name)); - } + if (attribute.key === PromptHeaderAttributes.agents) { + if (attribute.value.type === 'array') { + return this.provideArrayCompletions(model, position, attribute.value, async () => { + return await this.promptsService.getCustomAgents(CancellationToken.None); + }); } } const lineContent = model.getLineContent(attribute.range.startLineNumber); const whilespaceAfterColon = (lineContent.substring(colonPosition.column).match(/^\s*/)?.[0].length) ?? 0; - const values = this.getValueSuggestions(promptType, attribute.key); - for (const value of values) { + const entries = this.getValueSuggestions(promptType, attribute.key, target); + for (const entry of entries) { const item: CompletionItem = { - label: value, + label: entry.name, + documentation: entry.description, kind: CompletionItemKind.Value, - insertText: whilespaceAfterColon === 0 ? ` ${value}` : value, + insertText: whilespaceAfterColon === 0 ? ` ${entry.name}` : entry.name, range: new Range(position.lineNumber, colonPosition.column + whilespaceAfterColon + 1, position.lineNumber, model.getLineMaxColumn(position.lineNumber)), }; suggestions.push(item); } - if (attribute.key === PromptHeaderAttributes.handOffs && (promptType === PromptsType.agent)) { + if (attribute.key === PromptHeaderAttributes.handOffs) { const value = [ '', ' - label: Start Implementation', @@ -212,11 +233,27 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return { suggestions }; } - private getValueSuggestions(promptType: string, attribute: string): string[] { + private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): IValueEntry[] { + if (target === Target.Claude) { + const attributeDesc = promptType === PromptsType.instructions ? claudeRulesAttributes[attribute] : claudeAgentAttributes[attribute]; + if (attributeDesc) { + if (attributeDesc.enums) { + return attributeDesc.enums; + } else if (attributeDesc.defaults) { + return attributeDesc.defaults.map(value => ({ name: value })); + } + } + return []; + } switch (attribute) { case PromptHeaderAttributes.applyTo: if (promptType === PromptsType.instructions) { - return [`'**'`, `'**/*.ts, **/*.js'`, `'**/*.php'`, `'**/*.py'`]; + return [ + { name: `'**'` }, + { name: `'**/*.ts, **/*.js'` }, + { name: `'**/*.php'` }, + { name: `'**/*.py'` } + ]; } break; case PromptHeaderAttributes.agent: @@ -224,21 +261,24 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { if (promptType === PromptsType.prompt) { // Get all available agents (builtin + custom) const agents = this.chatModeService.getModes(); - const suggestions: string[] = []; + const suggestions: IValueEntry[] = []; for (const agent of Iterable.concat(agents.builtin, agents.custom)) { - suggestions.push(agent.name.get()); + suggestions.push({ name: agent.name.get(), description: agent.label.get() }); } return suggestions; } break; case PromptHeaderAttributes.target: if (promptType === PromptsType.agent) { - return ['vscode', 'github-copilot']; + return [{ name: 'vscode' }, { name: 'github-copilot' }]; } break; case PromptHeaderAttributes.tools: if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { - return ['[]', `['search', 'edit', 'fetch']`]; + return [ + { name: '[]' }, + { name: `['search', 'edit', 'web']` } + ]; } break; case PromptHeaderAttributes.model: @@ -248,58 +288,68 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { break; case PromptHeaderAttributes.infer: if (promptType === PromptsType.agent) { - return ['true', 'false']; + return [ + { name: 'true' }, + { name: 'false' } + ]; } break; case PromptHeaderAttributes.agents: if (promptType === PromptsType.agent) { - return ['["*"]']; + return [{ name: '["*"]' }]; } break; case PromptHeaderAttributes.userInvokable: if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return ['true', 'false']; + return [{ name: 'true' }, { name: 'false' }]; } break; case PromptHeaderAttributes.disableModelInvocation: if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return ['true', 'false']; + return [{ name: 'true' }, { name: 'false' }]; } break; } return []; } - private getModelNames(agentModeOnly: boolean): string[] { + private getModelNames(agentModeOnly: boolean): IValueEntry[] { const result = []; for (const model of this.languageModelsService.getLanguageModelIds()) { const metadata = this.languageModelsService.lookupLanguageModel(model); if (metadata && metadata.isUserSelectable !== false) { if (!agentModeOnly || ILanguageModelChatMetadata.suitableForAgentMode(metadata)) { - result.push(ILanguageModelChatMetadata.asQualifiedName(metadata)); + result.push({ + name: ILanguageModelChatMetadata.asQualifiedName(metadata), + description: metadata.tooltip + }); } } } return result; } - private async provideArrayCompletions(model: ITextModel, position: Position, agentsAttr: IHeaderAttribute, getValues: () => Promise): Promise { - if (agentsAttr.value.type !== 'array') { - return undefined; - } - const getSuggestions = async (toolRange: Range) => { + private async provideArrayCompletions(model: ITextModel, position: Position, arrayValue: IArrayValue, getValues: () => Promise>): Promise { + const getSuggestions = async (toolRange: Range, currentItem?: IValue) => { const suggestions: CompletionItem[] = []; - const toolNames = await getValues(); - for (const toolName of toolNames) { + const entries = await getValues(); + const quotePreference = getQuotePreference(arrayValue, model); + const existingValues = new Set(arrayValue.items.filter(item => item !== currentItem).filter(item => item.type === 'string').map(item => item.value)); + for (const entry of entries) { + const entryName = entry.name; + if (existingValues.has(entryName)) { + continue; + } let insertText: string; if (!toolRange.isEmpty()) { const firstChar = model.getValueInRange(toolRange).charCodeAt(0); - insertText = firstChar === CharCode.SingleQuote ? `'${toolName}'` : firstChar === CharCode.DoubleQuote ? `"${toolName}"` : toolName; + insertText = firstChar === CharCode.SingleQuote ? `'${entryName}'` : firstChar === CharCode.DoubleQuote ? `"${entryName}"` : entryName; } else { - insertText = `'${toolName}'`; + insertText = formatArrayValue(entryName, quotePreference); } suggestions.push({ - label: toolName, + label: entryName, + documentation: entry.description, kind: CompletionItemKind.Value, filterText: insertText, insertText: insertText, @@ -309,18 +359,18 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return { suggestions }; }; - for (const toolNameNode of agentsAttr.value.items) { - if (toolNameNode.range.containsPosition(position)) { - // if the position is inside a tool range, we provide tool name completions - return await getSuggestions(toolNameNode.range); + for (const item of arrayValue.items) { + if (item.range.containsPosition(position)) { + // if the position is inside a item range, we provide item completions + return await getSuggestions(item.range, item); } } const prefix = model.getValueInRange(new Range(position.lineNumber, 1, position.lineNumber, position.column)); - if (prefix.match(/[,[]\s*$/)) { + if (prefix.match(/[:,[]\s*$/)) { // if the position is after a comma or bracket return await getSuggestions(new Range(position.lineNumber, position.column, position.lineNumber, position.column)); } return undefined; - } + } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 07b7cfd336e..c1df4ed4ab0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -14,9 +14,9 @@ import { ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/languageModelToolsService.js'; import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { IPromptsService } from '../service/promptsService.js'; -import { IHeaderAttribute, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, isGithubTarget } from './promptValidator.js'; +import { IPromptsService, Target } from '../service/promptsService.js'; +import { ClaudeHeaderAttributes, IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { getAttributeDescription, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptValidator.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -48,40 +48,44 @@ export class PromptHoverProvider implements HoverProvider { } const promptAST = this.promptsService.getParsedPromptFile(model); + const target = getTarget(promptType, promptAST.header ?? model.uri); + if (promptAST.header?.range.containsPosition(position)) { - return this.provideHeaderHover(position, promptType, promptAST.header); + return this.provideHeaderHover(position, promptType, promptAST.header, target); } if (promptAST.body?.range.containsPosition(position)) { - return this.provideBodyHover(position, promptAST.body); + return this.provideBodyHover(position, promptAST.body, target); } return undefined; } - private async provideBodyHover(position: Position, body: PromptBody): Promise { + private async provideBodyHover(position: Position, body: PromptBody, target: Target): Promise { for (const ref of body.variableReferences) { if (ref.range.containsPosition(position)) { const toolName = ref.name; - return this.getToolHoverByName(toolName, ref.range); + + return this.getToolHoverByName(toolName, ref.range, target); } } return undefined; } - private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader): Promise { + private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader, target: Target): Promise { for (const attribute of header.attributes) { if (attribute.range.containsPosition(position)) { - const description = getAttributeDescription(attribute.key, promptType); + const description = getAttributeDescription(attribute.key, promptType, target); if (description) { switch (attribute.key) { case PromptHeaderAttributes.model: - return this.getModelHover(attribute, position, description, promptType === PromptsType.agent && isGithubTarget(promptType, header.target)); + return this.getModelHover(attribute, position, description, target); case PromptHeaderAttributes.tools: - return this.getToolHover(attribute, position, description); + case ClaudeHeaderAttributes.disallowedTools: + return this.getToolHover(attribute, position, description, target); case PromptHeaderAttributes.agent: case PromptHeaderAttributes.mode: return this.getAgentHover(attribute, position, description); case PromptHeaderAttributes.handOffs: - return this.getHandsOffHover(attribute, position, promptType === PromptsType.agent && isGithubTarget(promptType, header.target)); + return this.getHandsOffHover(attribute, position, target); case PromptHeaderAttributes.infer: return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invokable` and `disable-model-invocation` instead.'), attribute.range); default: @@ -93,11 +97,15 @@ export class PromptHoverProvider implements HoverProvider { return undefined; } - private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string): Hover | undefined { - if (node.value.type === 'array') { - for (const toolName of node.value.items) { + private getToolHover(node: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { + let value = node.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type === 'array') { + for (const toolName of value.items) { if (toolName.type === 'string' && toolName.range.containsPosition(position)) { - const description = this.getToolHoverByName(toolName.value, toolName.range); + const description = this.getToolHoverByName(toolName.value, toolName.range, target); if (description) { return description; } @@ -107,7 +115,14 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(baseMessage, node.range); } - private getToolHoverByName(toolName: string, range: Range): Hover | undefined { + private getToolHoverByName(toolName: string, range: Range, target: Target): Hover | undefined { + if (target === Target.Claude) { + const description = knownClaudeTools.find(tool => tool.name === toolName)?.description; + if (description) { + return this.createHover(description, range); + } + return undefined; + } const tool = this.languageModelToolsService.getToolByFullReferenceName(toolName); if (tool !== undefined) { if (isToolSet(tool)) { @@ -131,16 +146,31 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(lines.join('\n'), range); } - private getModelHover(node: IHeaderAttribute, position: Position, baseMessage: string, isGitHubTarget: boolean): Hover | undefined { - if (isGitHubTarget) { + private getModelHover(node: IHeaderAttribute, position: Position, baseMessage: string, target: Target): Hover | undefined { + if (target === Target.GitHubCopilot) { return this.createHover(baseMessage + '\n\n' + localize('promptHeader.agent.model.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), node.range); } const modelHoverContent = (modelName: string): Hover | undefined => { + const lines: string[] = []; + lines.push(baseMessage + '\n'); + + if (target === Target.Claude) { + const claudeModel = knownClaudeModels.find(model => model.name === modelName); + if (!claudeModel) { + return this.createHover(lines.join('\n'), node.range); + } + if (claudeModel.modelEquivalent) { + lines.push(localize('claudeModelEquivalent', 'Claude model `{0}` maps to the following model:\n', modelName)); + modelName = claudeModel.modelEquivalent; + } else { + lines.push(claudeModel.description); + return this.createHover(lines.join('\n'), node.range); + } + } + const result = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); if (result) { const meta = result.metadata; - const lines: string[] = []; - lines.push(baseMessage + '\n'); lines.push(localize('modelName', '- Name: {0}', meta.name)); lines.push(localize('modelFamily', '- Family: {0}', meta.family)); lines.push(localize('modelVendor', '- Vendor: {0}', meta.vendor)); @@ -202,10 +232,10 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(lines.join('\n'), agentAttribute.range); } - private getHandsOffHover(attribute: IHeaderAttribute, position: Position, isGitHubTarget: boolean): Hover | undefined { - const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent)!; - if (isGitHubTarget) { - return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), attribute.range); + private getHandsOffHover(attribute: IHeaderAttribute, position: Position, target: Target): Hover | undefined { + const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent, target)!; + if (!isVSCodeOrDefaultTarget(target)) { + return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used in GitHub Copilot or Claude targets.'), attribute.range); } return this.createHover(handoffsBaseMessage, attribute.range); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 33d61b4a232..802e88203dd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -16,16 +16,18 @@ import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType } from '../promptTypes.js'; -import { GithubPromptHeaderAttributes, IArrayValue, IHeaderAttribute, IStringValue, ParsedPromptFile, PromptHeader, PromptHeaderAttributes, Target } from '../promptFileParser.js'; +import { GithubPromptHeaderAttributes, IArrayValue, IHeaderAttribute, IStringValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { IPromptsService } from '../service/promptsService.js'; +import { IPromptsService, Target } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { dirname } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -41,8 +43,9 @@ export class PromptValidator { public async validate(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { promptAST.header?.errors.forEach(error => report(toMarker(error.message, error.range, MarkerSeverity.Error))); - await this.validateHeader(promptAST, promptType, report); - await this.validateBody(promptAST, promptType, report); + const target = getTarget(promptType, promptAST.header ?? promptAST.uri); + await this.validateHeader(promptAST, promptType, target, report); + await this.validateBody(promptAST, target, report); await this.validateFileName(promptAST, promptType, report); await this.validateSkillFolderName(promptAST, promptType, report); } @@ -88,7 +91,7 @@ export class PromptValidator { } } - private async validateBody(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateBody(promptAST: ParsedPromptFile, target: Target, report: (markers: IMarkerData) => void): Promise { const body = promptAST.body; if (!body) { return; @@ -118,13 +121,10 @@ export class PromptValidator { } } - const isGitHubTarget = isGithubTarget(promptType, promptAST.header?.target); - // Validate variable references (tool or toolset names) - if (body.variableReferences.length && !isGitHubTarget) { + if (body.variableReferences.length && isVSCodeOrDefaultTarget(target)) { const headerTools = promptAST.header?.tools; - const headerTarget = promptAST.header?.target; - const headerToolsMap = headerTools ? this.languageModelToolsService.toToolAndToolSetEnablementMap(headerTools, headerTarget, undefined) : undefined; + const headerToolsMap = headerTools ? this.languageModelToolsService.toToolAndToolSetEnablementMap(headerTools, undefined) : undefined; const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); @@ -156,27 +156,30 @@ export class PromptValidator { await Promise.all(fileReferenceChecks); } - private async validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise { + private async validateHeader(promptAST: ParsedPromptFile, promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): Promise { const header = promptAST.header; if (!header) { return; } const attributes = header.attributes; - const isGitHubTarget = isGithubTarget(promptType, header.target); - this.checkForInvalidArguments(attributes, promptType, isGitHubTarget, report); + this.checkForInvalidArguments(attributes, promptType, target, report); - this.validateName(attributes, isGitHubTarget, report); + this.validateName(attributes, report); this.validateDescription(attributes, report); this.validateArgumentHint(attributes, report); switch (promptType) { case PromptsType.prompt: { const agent = this.validateAgent(attributes, report); - this.validateTools(attributes, agent?.kind ?? ChatModeKind.Agent, header.target, report); + this.validateTools(attributes, agent?.kind ?? ChatModeKind.Agent, target, report); this.validateModel(attributes, agent?.kind ?? ChatModeKind.Agent, report); break; } case PromptsType.instructions: - this.validateApplyTo(attributes, report); + if (target === Target.Claude) { + this.validatePaths(attributes, report); + } else { + this.validateApplyTo(attributes, report); + } this.validateExcludeAgent(attributes, report); break; @@ -185,11 +188,13 @@ export class PromptValidator { this.validateInfer(attributes, report); this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); - this.validateTools(attributes, ChatModeKind.Agent, header.target, report); - if (!isGitHubTarget) { + this.validateTools(attributes, ChatModeKind.Agent, target, report); + if (isVSCodeOrDefaultTarget(target)) { this.validateModel(attributes, ChatModeKind.Agent, report); this.validateHandoffs(attributes, report); await this.validateAgentsAttribute(attributes, header, report); + } else if (target === Target.Claude) { + this.validateClaudeAttributes(attributes, report); } break; } @@ -198,23 +203,24 @@ export class PromptValidator { this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); break; - } } - private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, isGitHubTarget: boolean, report: (markers: IMarkerData) => void): void { - const validAttributeNames = getValidAttributeNames(promptType, true, isGitHubTarget); - const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, true))); + private checkForInvalidArguments(attributes: IHeaderAttribute[], promptType: PromptsType, target: Target, report: (markers: IMarkerData) => void): void { + const validAttributeNames = getValidAttributeNames(promptType, true, target); + const validGithubCopilotAttributeNames = new Lazy(() => new Set(getValidAttributeNames(promptType, false, Target.GitHubCopilot))); for (const attribute of attributes) { if (!validAttributeNames.includes(attribute.key)) { - const supportedNames = new Lazy(() => getValidAttributeNames(promptType, false, isGitHubTarget).sort().join(', ')); + const supportedNames = new Lazy(() => getValidAttributeNames(promptType, false, target).sort().join(', ')); switch (promptType) { case PromptsType.prompt: report(toMarker(localize('promptValidator.unknownAttribute.prompt', "Attribute '{0}' is not supported in prompt files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); break; case PromptsType.agent: - if (isGitHubTarget) { + if (target === Target.GitHubCopilot) { report(toMarker(localize('promptValidator.unknownAttribute.github-agent', "Attribute '{0}' is not supported in custom GitHub Copilot agent files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); + } else if (target === Target.Claude) { + // ignore for now as we don't have a full list of supported attributes for claude target } else { if (validGithubCopilotAttributeNames.value.has(attribute.key)) { report(toMarker(localize('promptValidator.ignoredAttribute.vscode-agent', "Attribute '{0}' is ignored when running locally in VS Code.", attribute.key), attribute.range, MarkerSeverity.Info)); @@ -224,7 +230,11 @@ export class PromptValidator { } break; case PromptsType.instructions: - report(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); + if (target === Target.Claude) { + report(toMarker(localize('promptValidator.unknownAttribute.rules', "Attribute '{0}' is not supported in rules files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); + } else { + report(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); + } break; case PromptsType.skill: report(toMarker(localize('promptValidator.unknownAttribute.skill', "Attribute '{0}' is not supported in skill files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning)); @@ -236,7 +246,7 @@ export class PromptValidator { - private validateName(attributes: IHeaderAttribute[], isGitHubTarget: boolean, report: (markers: IMarkerData) => void): void { + private validateName(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void { const nameAttribute = attributes.find(attr => attr.key === PromptHeaderAttributes.name); if (!nameAttribute) { return; @@ -334,6 +344,30 @@ export class PromptValidator { } } + private validateClaudeAttributes(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): void { + // vaidate all claude-specific attributes that have enum values + for (const claudeAttributeName in claudeAgentAttributes) { + const claudeAttribute = claudeAgentAttributes[claudeAttributeName]; + const enumValues = claudeAttribute.enums; + if (enumValues) { + const attribute = attributes.find(attr => attr.key === claudeAttributeName); + if (!attribute) { + continue; + } + if (attribute.value.type !== 'string') { + report(toMarker(localize('promptValidator.claude.attributeMustBeString', "The '{0}' attribute must be a string.", claudeAttributeName), attribute.value.range, MarkerSeverity.Error)); + continue; + } else { + const modelName = attribute.value.value.trim(); + if (enumValues.every(model => model.name !== modelName)) { + const validValues = enumValues.map(model => model.name).join(', '); + report(toMarker(localize('promptValidator.claude.attributeNotFound', "Unknown value '{0}', valid: {1}.", modelName, validValues), attribute.value.range, MarkerSeverity.Warning)); + } + } + } + } + } + private findModelByName(modelName: string): ILanguageModelChatMetadata | undefined { const metadataAndId = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); if (metadataAndId && metadataAndId.metadata.isUserSelectable !== false) { @@ -386,7 +420,7 @@ export class PromptValidator { return undefined; } - private validateTools(attributes: IHeaderAttribute[], agentKind: ChatModeKind, target: string | undefined, report: (markers: IMarkerData) => void): undefined { + private validateTools(attributes: IHeaderAttribute[], agentKind: ChatModeKind, target: Target, report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.tools); if (!attribute) { return; @@ -394,21 +428,22 @@ export class PromptValidator { if (agentKind !== ChatModeKind.Agent) { report(toMarker(localize('promptValidator.toolsOnlyInAgent', "The 'tools' attribute is only supported when using agents. Attribute will be ignored."), attribute.range, MarkerSeverity.Warning)); } - - switch (attribute.value.type) { - case 'array': - if (target === Target.GitHubCopilot) { - // no validation for github-copilot target - } else { - this.validateVSCodeTools(attribute.value, target, report); - } - break; - default: - report(toMarker(localize('promptValidator.toolsMustBeArrayOrMap', "The 'tools' attribute must be an array."), attribute.value.range, MarkerSeverity.Error)); + let value = attribute.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type !== 'array') { + report(toMarker(localize('promptValidator.toolsMustBeArrayOrMap', "The 'tools' attribute must be an array or a comma separated string."), attribute.value.range, MarkerSeverity.Error)); + return; + } + if (target === Target.GitHubCopilot || target === Target.Claude) { + // no validation for github-copilot target and claude + } else { + this.validateVSCodeTools(value, report); } } - private validateVSCodeTools(valueItem: IArrayValue, target: string | undefined, report: (markers: IMarkerData) => void) { + private validateVSCodeTools(valueItem: IArrayValue, report: (markers: IMarkerData) => void) { if (valueItem.items.length > 0) { const available = new Set(this.languageModelToolsService.getFullReferenceNames()); const deprecatedNames = this.languageModelToolsService.getDeprecatedFullReferenceNames(); @@ -463,6 +498,36 @@ export class PromptValidator { } } + private validatePaths(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.paths); + if (!attribute) { + return; + } + if (attribute.value.type !== 'array') { + report(toMarker(localize('promptValidator.pathsMustBeArray', "The 'paths' attribute must be an array of glob patterns."), attribute.value.range, MarkerSeverity.Error)); + return; + } + for (const item of attribute.value.items) { + if (item.type !== 'string') { + report(toMarker(localize('promptValidator.eachPathMustBeString', "Each entry in the 'paths' attribute must be a string."), item.range, MarkerSeverity.Error)); + continue; + } + const pattern = item.value.trim(); + if (pattern.length === 0) { + report(toMarker(localize('promptValidator.pathMustBeNonEmpty', "Path entries must be non-empty glob patterns."), item.range, MarkerSeverity.Error)); + continue; + } + try { + const globPattern = parse(pattern); + if (isEmptyPattern(globPattern)) { + report(toMarker(localize('promptValidator.pathMustBeValidGlob', "'{0}' is not a valid glob pattern.", pattern), item.range, MarkerSeverity.Error)); + } + } catch (_error) { + report(toMarker(localize('promptValidator.pathMustBeValidGlob', "'{0}' is not a valid glob pattern.", pattern), item.range, MarkerSeverity.Error)); + } + } + } + private validateExcludeAgent(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.excludeAgent); if (!attribute) { @@ -638,9 +703,16 @@ const recommendedAttributeNames: Record = { [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; -export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, isGitHubTarget: boolean): string[] { - if (isGitHubTarget && promptType === PromptsType.agent) { - return githubCopilotAgentAttributeNames; +export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, target: Target): string[] { + if (target === Target.Claude) { + if (promptType === PromptsType.instructions) { + return Object.keys(claudeRulesAttributes); + } + return Object.keys(claudeAgentAttributes); + } else if (target === Target.GitHubCopilot) { + if (promptType === PromptsType.agent) { + return githubCopilotAgentAttributeNames; + } } return includeNonRecommended ? allAttributeNames[promptType] : recommendedAttributeNames[promptType]; } @@ -649,7 +721,15 @@ export function isNonRecommendedAttribute(attributeName: string): boolean { return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer; } -export function getAttributeDescription(attributeName: string, promptType: PromptsType): string | undefined { +export function getAttributeDescription(attributeName: string, promptType: PromptsType, target: Target): string | undefined { + if (target === Target.Claude) { + if (promptType === PromptsType.agent) { + return claudeAgentAttributes[attributeName]?.description; + } + if (promptType === PromptsType.instructions) { + return claudeRulesAttributes[attributeName]?.description; + } + } switch (promptType) { case PromptsType.instructions: switch (attributeName) { @@ -724,11 +804,170 @@ export function getAttributeDescription(attributeName: string, promptType: Promp // The list of tools known to be used by GitHub Copilot custom agents export const knownGithubCopilotTools = [ - SpecedToolAliases.execute, SpecedToolAliases.read, SpecedToolAliases.edit, SpecedToolAliases.search, SpecedToolAliases.agent, + { name: SpecedToolAliases.execute, description: localize('githubCopilot.execute', 'Execute commands') }, + { name: SpecedToolAliases.read, description: localize('githubCopilot.read', 'Read files') }, + { name: SpecedToolAliases.edit, description: localize('githubCopilot.edit', 'Edit files') }, + { name: SpecedToolAliases.search, description: localize('githubCopilot.search', 'Search files') }, + { name: SpecedToolAliases.agent, description: localize('githubCopilot.agent', 'Use subagents') }, ]; -export function isGithubTarget(promptType: PromptsType, target: string | undefined): boolean { - return promptType === PromptsType.agent && target === Target.GitHubCopilot; +export interface IValueEntry { + readonly name: string; + readonly description?: string; +} + +export const knownClaudeTools = [ + { name: 'Bash', description: localize('claude.bash', 'Execute shell commands'), toolEquivalent: [SpecedToolAliases.execute] }, + { name: 'Edit', description: localize('claude.edit', 'Make targeted file edits'), toolEquivalent: ['edit/editNotebook', 'edit/editFiles'] }, + { name: 'Glob', description: localize('claude.glob', 'Find files by pattern'), toolEquivalent: ['search/fileSearch'] }, + { name: 'Grep', description: localize('claude.grep', 'Search file contents with regex'), toolEquivalent: ['search/textSearch'] }, + { name: 'Read', description: localize('claude.read', 'Read file contents'), toolEquivalent: ['read/readFile', 'read/getNotebookSummary'] }, + { name: 'Write', description: localize('claude.write', 'Create/overwrite files'), toolEquivalent: ['edit/createDirectory', 'edit/createFile', 'edit/createJupyterNotebook'] }, + { name: 'WebFetch', description: localize('claude.webFetch', 'Fetch URL content'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'WebSearch', description: localize('claude.webSearch', 'Perform web searches'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'Task', description: localize('claude.task', 'Run subagents for complex tasks'), toolEquivalent: [SpecedToolAliases.agent] }, + { name: 'Skill', description: localize('claude.skill', 'Execute skills'), toolEquivalent: [] }, + { name: 'LSP', description: localize('claude.lsp', 'Code intelligence (requires plugin)'), toolEquivalent: [] }, + { name: 'NotebookEdit', description: localize('claude.notebookEdit', 'Modify Jupyter notebooks'), toolEquivalent: ['edit/editNotebook'] }, + { name: 'AskUserQuestion', description: localize('claude.askUserQuestion', 'Ask multiple-choice questions'), toolEquivalent: ['vscode/askQuestions'] }, + { name: 'MCPSearch', description: localize('claude.mcpSearch', 'Searches for MCP tools when tool search is enabled'), toolEquivalent: [] } +]; + +export const knownClaudeModels = [ + { name: 'sonnet', description: localize('claude.sonnet', 'Latest Claude Sonnet'), modelEquivalent: 'Claude Sonnet 4.5 (copilot)' }, + { name: 'opus', description: localize('claude.opus', 'Latest Claude Opus'), modelEquivalent: 'Claude Opus 4.6 (copilot)' }, + { name: 'haiku', description: localize('claude.haiku', 'Latest Claude Haiku, fast for simple tasks'), modelEquivalent: 'Claude Haiku 4.5 (copilot)' }, + { name: 'inherit', description: localize('claude.inherit', 'Inherit model from parent agent or prompt'), modelEquivalent: undefined }, +]; + +export function mapClaudeModels(claudeModelNames: readonly string[]): readonly string[] { + const result = []; + for (const name of claudeModelNames) { + const claudeModel = knownClaudeModels.find(model => model.name === name); + if (claudeModel && claudeModel.modelEquivalent) { + result.push(claudeModel.modelEquivalent); + } + } + return result; +} + +/** + * Maps Claude tool names to their VS Code tool equivalents. + */ +export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { + const result: string[] = []; + for (const name of claudeToolNames) { + const claudeTool = knownClaudeTools.find(tool => tool.name === name); + if (claudeTool) { + result.push(...claudeTool.toolEquivalent); + } + } + return result; +} + +export const claudeAgentAttributes: Record = { + 'name': { + type: 'string', + description: localize('attribute.name', "Unique identifier using lowercase letters and hyphens (required)"), + }, + 'description': { + type: 'string', + description: localize('attribute.description', "When to delegate to this subagent (required)"), + }, + 'tools': { + type: 'array', + description: localize('attribute.tools', "Array of tools the subagent can use. Inherits all tools if omitted"), + defaults: ['Read, Edit, Bash'], + items: knownClaudeTools + }, + 'disallowedTools': { + type: 'array', + description: localize('attribute.disallowedTools', "Tools to deny, removed from inherited or specified list"), + defaults: ['Write, Edit, Bash'], + items: knownClaudeTools + }, + 'model': { + type: 'string', + description: localize('attribute.model', "Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit."), + defaults: ['sonnet', 'opus', 'haiku', 'inherit'], + enums: knownClaudeModels + }, + 'permissionMode': { + type: 'string', + description: localize('attribute.permissionMode', "Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan."), + defaults: ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan'], + enums: [ + { name: 'default', description: localize('claude.permissionMode.default', 'Standard behavior: prompts for permission on first use of each tool.') }, + { name: 'acceptEdits', description: localize('claude.permissionMode.acceptEdits', 'Automatically accepts file edit permissions for the session.') }, + { name: 'plan', description: localize('claude.permissionMode.plan', 'Plan Mode: Claude can analyze but not modify files or execute commands.') }, + { name: 'delegate', description: localize('claude.permissionMode.delegate', 'Coordination-only mode for agent team leads. Only available when an agent team is active.') }, + { name: 'dontAsk', description: localize('claude.permissionMode.dontAsk', 'Auto-denies tools unless pre-approved via /permissions or permissions.allow rules.') }, + { name: 'bypassPermissions', description: localize('claude.permissionMode.bypassPermissions', 'Skips all permission prompts (requires safe environment like containers).') } + ] + }, + 'skills': { + type: 'array', + description: localize('attribute.skills', "Skills to load into the subagent's context at startup."), + }, + 'mcpServers': { + type: 'array', + description: localize('attribute.mcpServers', "MCP servers available to this subagent."), + }, + 'hooks': { + type: 'object', + description: localize('attribute.hooks', "Lifecycle hooks scoped to this subagent."), + }, + 'memory': { + type: 'string', + description: localize('attribute.memory', "Persistent memory scope: user, project, or local. Enables cross-session learning."), + defaults: ['user', 'project', 'local'], + enums: [ + { name: 'user', description: localize('claude.memory.user', "Remember learnings across all projects.") }, + { name: 'project', description: localize('claude.memory.project', "The subagent's knowledge is project-specific and shareable via version control.") }, + { name: 'local', description: localize('claude.memory.local', "The subagent's knowledge is project-specific but should not be checked into version control.") } + ] + } +}; + +/** + * Attributes supported in Claude rules files (`.claude/rules/*.md`). + * Claude rules use `paths` instead of `applyTo` for glob patterns. + */ +export const claudeRulesAttributes: Record = { + 'description': { + type: 'string', + description: localize('attribute.rules.description', "A description of what this rule covers, used to provide context about when it applies."), + }, + 'paths': { + type: 'array', + description: localize('attribute.rules.paths', "Array of glob patterns that describe for which files the rule applies. Based on these patterns, the file is automatically included in the prompt when the context contains a file that matches.\nExample: `['src/**/*.ts', 'test/**']`"), + }, +}; + +export function isVSCodeOrDefaultTarget(target: Target): boolean { + return target === Target.VSCode || target === Target.Undefined; +} + +export function getTarget(promptType: PromptsType, header: PromptHeader | URI): Target { + const uri = header instanceof URI ? header : header.uri; + if (promptType === PromptsType.agent) { + const parentDir = dirname(uri); + if (parentDir.path.endsWith(`/${CLAUDE_AGENTS_SOURCE_FOLDER}`)) { + return Target.Claude; + } + if (!(header instanceof URI)) { + const target = header.target; + if (target === Target.GitHubCopilot || target === Target.VSCode) { + return target; + } + } + return Target.Undefined; + } else if (promptType === PromptsType.instructions) { + if (isInClaudeRulesFolder(uri)) { + return Target.Claude; + } + } + return Target.Undefined; } function toMarker(message: string, range: Range, severity = MarkerSeverity.Error): IMarkerData { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index ef5406557e2..778cf3a8245 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -9,6 +9,7 @@ import { splitLinesIncludeSeparators } from '../../../../../base/common/strings. import { URI } from '../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError, Position as YamlPosition } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; +import { Target } from './service/promptsService.js'; export class PromptFileParser { constructor() { @@ -32,7 +33,7 @@ export class PromptFileParser { } // range starts on the line after the ---, and ends at the beginning of the line that has the closing --- const range = new Range(2, 1, headerEndLine + 1, 1); - header = new PromptHeader(range, linesWithEOL); + header = new PromptHeader(range, uri, linesWithEOL); } if (bodyStartLine < linesWithEOL.length) { // range starts on the line after the ---, and ends at the beginning of line after the last line @@ -68,6 +69,7 @@ export namespace PromptHeaderAttributes { export const mode = 'mode'; export const model = 'model'; export const applyTo = 'applyTo'; + export const paths = 'paths'; export const tools = 'tools'; export const handOffs = 'handoffs'; export const advancedOptions = 'advancedOptions'; @@ -87,15 +89,18 @@ export namespace GithubPromptHeaderAttributes { export const mcpServers = 'mcp-servers'; } -export enum Target { - VSCode = 'vscode', - GitHubCopilot = 'github-copilot' +export namespace ClaudeHeaderAttributes { + export const disallowedTools = 'disallowedTools'; +} + +export function isTarget(value: unknown): value is Target { + return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined; } export class PromptHeader { private _parsed: ParsedHeader | undefined; - constructor(public readonly range: Range, private readonly linesWithEOL: string[]) { + constructor(public readonly range: Range, public readonly uri: URI, private readonly linesWithEOL: string[]) { } private get _parsedHeader(): ParsedHeader { @@ -186,6 +191,15 @@ export class PromptHeader { return this.getStringAttribute(PromptHeaderAttributes.applyTo); } + /** + * Gets the 'paths' attribute from the header. + * The `paths` field supports a list of glob patterns that scope the instruction + * to specific files (used by Claude rules). Returns a string array or undefined. + */ + public get paths(): readonly string[] | undefined { + return this.getStringOrStringArrayAttribute(PromptHeaderAttributes.paths); + } + public get argumentHint(): string | undefined { return this.getStringAttribute(PromptHeaderAttributes.argumentHint); } @@ -207,25 +221,18 @@ export class PromptHeader { if (!toolsAttribute) { return undefined; } - if (toolsAttribute.value.type === 'array') { + let value = toolsAttribute.value; + if (value.type === 'string') { + value = parseCommaSeparatedList(value); + } + if (value.type === 'array') { const tools: string[] = []; - for (const item of toolsAttribute.value.items) { + for (const item of value.items) { if (item.type === 'string' && item.value) { tools.push(item.value); } } return tools; - } else if (toolsAttribute.value.type === 'object') { - const tools: string[] = []; - const collectLeafs = ({ key, value }: { key: IStringValue; value: IValue }) => { - if (value.type === 'boolean') { - tools.push(key.value); - } else if (value.type === 'object') { - value.properties.forEach(collectLeafs); - } - }; - toolsAttribute.value.properties.forEach(collectLeafs); - return tools; } return undefined; } @@ -477,3 +484,76 @@ export interface IBodyVariableReference { readonly range: Range; readonly offset: number; } + +/** + * Parses a comma-separated list of values into an array of strings. + * Values can be unquoted or quoted (single or double quotes). + * + * @param input A string containing comma-separated values + * @returns An IArrayValue containing the parsed values and their ranges + */ +export function parseCommaSeparatedList(stringValue: IStringValue): IArrayValue { + const result: IStringValue[] = []; + const input = stringValue.value; + const positionOffset = stringValue.range.getStartPosition(); + let pos = 0; + const isWhitespace = (char: string): boolean => char === ' ' || char === '\t'; + + while (pos < input.length) { + // Skip leading whitespace + while (pos < input.length && isWhitespace(input[pos])) { + pos++; + } + + if (pos >= input.length) { + break; + } + + const startPos = pos; + let value = ''; + let endPos: number; + + const char = input[pos]; + if (char === '"' || char === `'`) { + // Quoted string + const quote = char; + pos++; // Skip opening quote + + while (pos < input.length && input[pos] !== quote) { + value += input[pos]; + pos++; + } + endPos = pos + 1; // Include closing quote in the range + + if (pos < input.length) { + pos++; + } + + } else { + // Unquoted string - read until comma or end + const startPos = pos; + while (pos < input.length && input[pos] !== ',') { + value += input[pos]; + pos++; + } + value = value.trimEnd(); + endPos = startPos + value.length; + } + + result.push({ type: 'string', value: value, range: new Range(positionOffset.lineNumber, positionOffset.column + startPos, positionOffset.lineNumber, positionOffset.column + endPos) }); + + // Skip whitespace after value + while (pos < input.length && isWhitespace(input[pos])) { + pos++; + } + + // Skip comma if present + if (pos < input.length && input[pos] === ',') { + pos++; + } + } + + return { type: 'array', items: result, range: stringValue.range }; +} + + diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 4d588cc1cfd..8c4d0cbc58a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -54,8 +54,8 @@ export function getLanguageIdForPromptsType(type: PromptsType): string { case PromptsType.skill: return SKILL_LANGUAGE_ID; case PromptsType.hook: - // Hooks use JSON syntax with schema validation - return 'json'; + // Hooks use JSONC syntax with schema validation + return 'jsonc'; default: throw new Error(`Unknown prompt type: ${type}`); } @@ -71,7 +71,7 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u return PromptsType.agent; case SKILL_LANGUAGE_ID: return PromptsType.skill; - // Note: hook uses 'json' language ID which is shared, so we don't map it here + // Note: hook uses 'jsonc' language ID which is shared, so we don't map it here default: return undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index d5f9eb6b483..f59d436d4fd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -137,6 +137,13 @@ export function isCustomAgentVisibility(obj: unknown): obj is ICustomAgentVisibi return typeof v.userInvokable === 'boolean' && typeof v.agentInvokable === 'boolean'; } +export enum Target { + VSCode = 'vscode', + GitHubCopilot = 'github-copilot', + Claude = 'claude', + Undefined = 'undefined', +} + export interface ICustomAgent { /** * URI of a custom agent file. @@ -169,9 +176,9 @@ export interface ICustomAgent { readonly argumentHint?: string; /** - * Target metadata in the prompt header. + * Target of the agent: Copilot, VSCode, Claude, or undefined if not specified. */ - readonly target?: string; + readonly target: Target; /** * What visibility the agent has (user invokable, subagent invokable). @@ -266,7 +273,8 @@ export type PromptFileSkipReason = | 'name-mismatch' | 'duplicate-name' | 'parse-error' - | 'disabled'; + | 'disabled' + | 'all-hooks-disabled'; /** * Result of discovering a single prompt file. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index aecd757949d..7d4cb5cc352 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -6,6 +6,7 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError } from '../../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { parse as parseJSONC } from '../../../../../../base/common/json.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ResourceMap, ResourceSet } from '../../../../../../base/common/map.js'; import { basename, dirname, isEqual, joinPath } from '../../../../../../base/common/resources.js'; @@ -27,17 +28,18 @@ import { ITelemetryService } from '../../../../../../platform/telemetry/common/t import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { IVariableReference } from '../../chatModes.js'; import { PromptsConfig } from '../config/config.js'; -import { AGENT_MD_FILENAME, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAUDE_MD_FILENAME, getCleanPromptName, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; import { parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; +import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; /** * Error thrown when a skill file is missing the required name attribute. @@ -426,8 +428,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - if (type !== PromptsType.skill) { - // no user source folders for skills + if (type !== PromptsType.skill && type !== PromptsType.hook) { + // no user source folders for skills and hooks const userHome = this.userDataService.currentProfile.promptsHome; result.push({ uri: userHome, storage: PromptsStorage.user, type }); } @@ -566,17 +568,25 @@ export class PromptsService extends Disposable implements IPromptsService { } satisfies IAgentInstructions; const name = ast.header?.name ?? promptPath.name ?? getCleanPromptName(uri); + const target = getTarget(PromptsType.agent, ast.header ?? uri); const source: IAgentSource = IAgentSource.fromPromptPath(promptPath); if (!ast.header) { - return { uri, name, agentInstructions, source, visibility: { userInvokable: true, agentInvokable: true } }; + return { uri, name, agentInstructions, source, target, visibility: { userInvokable: true, agentInvokable: true } }; } const visibility = { userInvokable: ast.header.userInvokable !== false, agentInvokable: ast.header.infer === true || ast.header.disableModelInvocation !== true, } satisfies ICustomAgentVisibility; - const { description, model, tools, handOffs, argumentHint, target, agents } = ast.header; + let model = ast.header.model; + if (target === Target.Claude && model) { + model = mapClaudeModels(model); + } + let { description, tools, handOffs, argumentHint, agents } = ast.header; + if (target === Target.Claude && tools) { + tools = mapClaudeTools(tools); + } return { uri, name, description, model, tools, handOffs, argumentHint, target, visibility, agents, agentInstructions, source }; }) ); @@ -709,11 +719,11 @@ export class PromptsService extends Disposable implements IPromptsService { } const results: IResolvedAgentFile[] = []; const userHome = await this.pathService.userHome(); - const userClaudeFolder = joinPath(userHome, '.claude'); + const userClaudeFolder = joinPath(userHome, CLAUDE_CONFIG_FOLDER); await Promise.all([ this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, undefined, AgentFileType.claudeMd, token, results), // in workspace roots this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_LOCAL_MD_FILENAME, undefined, AgentFileType.claudeMd, token, results), // CLAUDE.local in workspace roots - this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, '.claude', AgentFileType.claudeMd, token, results), // in workspace/.claude folders + this.fileLocator.findFilesInWorkspaceRoots(CLAUDE_MD_FILENAME, CLAUDE_CONFIG_FOLDER, AgentFileType.claudeMd, token, results), // in workspace/.claude folders this.fileLocator.findFilesInRoots([userClaudeFolder], CLAUDE_MD_FILENAME, AgentFileType.claudeMd, token, results) // in ~/.claude folder ]); return results.sort((a, b) => a.uri.toString().localeCompare(b.uri.toString())); @@ -1012,6 +1022,7 @@ export class PromptsService extends Disposable implements IPromptsService { [HookType.UserPromptSubmit]: [], [HookType.PreToolUse]: [], [HookType.PostToolUse]: [], + [HookType.PreCompact]: [], [HookType.SubagentStart]: [], [HookType.SubagentStop]: [], [HookType.Stop]: [], @@ -1020,10 +1031,16 @@ export class PromptsService extends Disposable implements IPromptsService { for (const hookFile of hookFiles) { try { const content = await this.fileService.readFile(hookFile.uri); - const json = JSON.parse(content.value.toString()); + const json = parseJSONC(content.value.toString()); - // Use format-aware parsing that handles Copilot, Claude, and Cursor formats - const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + // Use format-aware parsing that handles Copilot and Claude formats + const { format, hooks, disabledAllHooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + + // Skip files that have all hooks disabled + if (disabledAllHooks) { + this.logger.trace(`[PromptsService] Skipping hook file with disableAllHooks: ${hookFile.uri}`); + continue; + } for (const [hookType, { hooks: commands }] of hooks) { for (const command of commands) { @@ -1294,6 +1311,14 @@ export class PromptsService extends Disposable implements IPromptsService { private async getHookDiscoveryInfo(token: CancellationToken): Promise { const files: IPromptFileDiscoveryResult[] = []; + // Get user home for tilde expansion + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + // Get workspace root for resolving relative cwd paths + const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); for (const promptPath of hookFiles) { const uri = promptPath.uri; @@ -1302,9 +1327,9 @@ export class PromptsService extends Disposable implements IPromptsService { const name = basename(uri); try { - // Try to parse the JSON to validate it + // Try to parse the JSON to validate it (supports JSONC with comments) const content = await this.fileService.readFile(uri); - const json = JSON.parse(content.value.toString()); + const json = parseJSONC(content.value.toString()); // Validate it's an object if (!json || typeof json !== 'object') { @@ -1320,6 +1345,21 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } + // Use format-aware parsing to check for disabledAllHooks + const { disabledAllHooks } = parseHooksFromFile(uri, json, workspaceRootUri, userHome); + + if (disabledAllHooks) { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'all-hooks-disabled', + name, + extensionId + }); + continue; + } + // File is valid files.push({ uri, storage, status: 'loaded', name, extensionId }); } catch (e) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts new file mode 100644 index 00000000000..ba398b6754a --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptEditHelper.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { IArrayValue } from '../promptFileParser.js'; + +const isSimpleNameRegex = /^[\w\/\.-]+$/; + +export function formatArrayValue(name: string, quotePreference?: QuotePreference) { + switch (quotePreference) { + case '\'': + return `'${name}'`; + case '"': + return `"${name}"`; + } + return isSimpleNameRegex.test(name) ? name : `'${name}'`; +} + +export type QuotePreference = '\'' | '\"' | ''; + +export function getQuotePreference(arrayValue: IArrayValue, model: ITextModel): QuotePreference { + const firstStringItem = arrayValue.items.find(item => item.type === 'string' && isSimpleNameRegex.test(item.value)); + const firstChar = firstStringItem ? model.getValueInRange(firstStringItem.range).charAt(0) : undefined; + if (firstChar === `'` || firstChar === `"`) { + return firstChar; + } + return ''; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 38146a5809d..106a6fe1f82 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -7,12 +7,12 @@ import { URI } from '../../../../../../base/common/uri.js'; import { isAbsolute } from '../../../../../../base/common/path.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; import * as nls from '../../../../../../nls.js'; -import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { FileOperationError, FileOperationResult, IFileService } from '../../../../../../platform/files/common/files.js'; import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../config/config.js'; import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, DEFAULT_HOOK_FILE_PATHS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, HOOKS_SOURCE_FOLDER } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -88,8 +88,8 @@ export class PromptFilesLocator { // Filter to only user storage locations const result = absoluteLocations.filter(loc => loc.storage === PromptsStorage.user); - // Also include the VS Code user data prompts folder (for all types except skills) - if (type !== PromptsType.skill) { + // Also include the VS Code user data prompts folder for certain types + if (type === PromptsType.agent || type === PromptsType.instructions || type === PromptsType.prompt) { const userDataPromptsHome = this.userDataService.currentProfile.promptsHome; return [ ...result, @@ -113,22 +113,28 @@ export class PromptFilesLocator { private getSourceFoldersSync(type: PromptsType, userHome: URI): readonly URI[] { const result: URI[] = []; const { folders } = this.workspaceService.getWorkspace(); - const defaultFolders = getPromptFileDefaultLocations(type); + const defaultFileOrFolders = getPromptFileDefaultLocations(type); - for (const sourceFolder of defaultFolders) { - let folderPath: URI; - if (sourceFolder.storage === PromptsStorage.local) { + const getFolderUri = (type: PromptsType, fileOrFolderPath: URI): URI => { + // For hooks, the paths are sometimes file paths, so get the parent directory in that case + if (type === PromptsType.hook && fileOrFolderPath.path.toLowerCase().endsWith('.json')) { + return dirname(fileOrFolderPath); + } + return fileOrFolderPath; + }; + + for (const sourceFileOrFolder of defaultFileOrFolders) { + let fileOrFolderPath: URI; + if (sourceFileOrFolder.storage === PromptsStorage.local) { for (const workspaceFolder of folders) { - folderPath = joinPath(workspaceFolder.uri, sourceFolder.path); - // For hooks, the paths are file paths, so get the parent directory - result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); + fileOrFolderPath = joinPath(workspaceFolder.uri, sourceFileOrFolder.path); + result.push(getFolderUri(type, fileOrFolderPath)); } - } else if (sourceFolder.storage === PromptsStorage.user) { + } else if (sourceFileOrFolder.storage === PromptsStorage.user) { // For tilde paths, strip the ~/ prefix before joining with userHome - const relativePath = isTildePath(sourceFolder.path) ? sourceFolder.path.substring(2) : sourceFolder.path; - folderPath = joinPath(userHome, relativePath); - // For hooks, the paths are file paths, so get the parent directory - result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); + const relativePath = isTildePath(sourceFileOrFolder.path) ? sourceFileOrFolder.path.substring(2) : sourceFileOrFolder.path; + fileOrFolderPath = joinPath(userHome, relativePath); + result.push(getFolderUri(type, fileOrFolderPath)); } } @@ -202,11 +208,36 @@ export class PromptFilesLocator { /** * Gets the hook source folders for creating new hooks. - * Returns only the Copilot hooks folder (.github/hooks) since Claude paths are read-only. + * Returns folders from config, excluding user storage and Claude paths (which are read-only). */ public async getHookSourceFolders(): Promise { - const { folders } = this.workspaceService.getWorkspace(); - return folders.map(folder => joinPath(folder.uri, HOOKS_SOURCE_FOLDER)); + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, PromptsType.hook); + + // Ignore claude folders since they aren't first-class supported, so we don't want to create invalid formats + // Check for .claude as an actual path segment (starts with ".claude/" or contains "/.claude/") + const allowedHookFolders = configuredLocations.filter(loc => + !loc.path.startsWith('.claude/') && !loc.path.includes('/.claude/') + ); + + // Convert to absolute URIs + const result = new ResourceSet(); + const absoluteLocations = this.toAbsoluteLocations(PromptsType.hook, allowedHookFolders, userHome); + + for (const location of absoluteLocations) { + // For hook configs, entries are directories unless the path ends with .json (specific file) + // Default entries have filePattern, user entries don't but are still directories + const isSpecificFile = location.uri.path.endsWith('.json'); + if (isSpecificFile) { + // It's a specific file path (like .github/hooks/hooks.json), use parent directory + result.add(dirname(location.uri)); + } else { + // It's a directory path (like .github/hooks or .github/books) + result.add(location.uri); + } + } + + return [...result]; } /** @@ -342,20 +373,7 @@ export class PromptFilesLocator { private getLocalParentFolders(type: PromptsType): readonly { parent: URI; filePattern?: string }[] { const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); - if (type === PromptsType.agent) { - configuredLocations.push(...DEFAULT_AGENT_SOURCE_FOLDERS); - } else if (type === PromptsType.hook) { - configuredLocations.push(...DEFAULT_HOOK_FILE_PATHS); - } - const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, undefined); - - // For hooks, the paths are file paths (e.g., '.github/hooks/hooks.json'), not folder paths. - // We need to watch the parent directories of these files. - if (type === PromptsType.hook) { - return absoluteLocations.map((location) => ({ parent: dirname(location.uri) })); - } - return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri)); } @@ -444,13 +462,19 @@ export class PromptFilesLocator { /** * Uses the file service to resolve the provided location and return either the file at the location of files in the directory. + * For claude rules folders (.claude/rules), this searches recursively to support subdirectories. */ private async resolveFilesAtLocation(location: URI, type: PromptsType, token: CancellationToken): Promise { if (type === PromptsType.skill) { return this.findAgentSkillsInFolder(location, token); } + // Claude rules folders support subdirectories, so search recursively + const recursive = type === PromptsType.instructions && isInClaudeRulesFolder(joinPath(location, 'dummy.md')); try { const info = await this.fileService.resolve(location); + if (token.isCancellationRequested) { + return []; + } if (info.isFile) { return [info.resource]; } else if (info.isDirectory && info.children) { @@ -458,11 +482,20 @@ export class PromptFilesLocator { for (const child of info.children) { if (child.isFile) { result.push(child.resource); + } else if (recursive && child.isDirectory) { + // Recursively search subdirectories for claude rules + const subFiles = await this.resolveFilesAtLocation(child.resource, type, token); + result.push(...subFiles); } } return result; } - } catch (error) { + } catch (e) { + if (e instanceof FileOperationError && e.fileOperationResult === FileOperationResult.FILE_NOT_FOUND) { + // ignore + } else { + this.logService.error(`Failed to resolve files at location: ${location.toString()}`, e); + } } return []; } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index a71ce6ba3ed..d8784ef6dd7 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -7,38 +7,39 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js import { Codicon } from '../../../../../../base/common/codicons.js'; import { Event } from '../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; -import { generateUuid } from '../../../../../../base/common/uuid.js'; import { IJSONSchema, IJSONSchemaMap } from '../../../../../../base/common/jsonSchema.js'; import { Disposable, DisposableStore } from '../../../../../../base/common/lifecycle.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { generateUuid } from '../../../../../../base/common/uuid.js'; import { localize } from '../../../../../../nls.js'; import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; -import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; -import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; +import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; import { ILanguageModelsService } from '../../languageModels.js'; +import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; +import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; +import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; +import { IChatRequestHooks } from '../../promptSyntax/hookSchema.js'; +import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, + isToolSet, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, - isToolSet, ToolDataSource, ToolProgress, VSCodeToolReference, } from '../languageModelToolsService.js'; -import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; -import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. @@ -60,6 +61,9 @@ export class RunSubagentTool extends Disposable implements IToolImpl { readonly onDidUpdateToolData: Event; + /** Hack to port data between prepare/invoke */ + private readonly _resolvedModels = new Map(); + constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, @@ -143,29 +147,30 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeTools = invocation.userSelectedTools; let modeInstructions: IChatRequestModeInstructions | undefined; let subagent: ICustomAgent | undefined; + let resolvedModelName: string | undefined; const subAgentName = args.agentName; if (subAgentName) { subagent = await this.getSubAgentByName(subAgentName); if (subagent) { - // Use mode-specific model if available - const modeModelQualifiedNames = subagent.model; - if (modeModelQualifiedNames) { - // Find the actual model identifier from the qualified name(s) - outer: for (const qualifiedName of modeModelQualifiedNames) { - const lmByQualifiedName = this.languageModelsService.lookupLanguageModelByQualifiedName(qualifiedName); - if (lmByQualifiedName?.identifier) { - modeModelId = lmByQualifiedName.identifier; - break outer; - } - } + // Check the pre-resolved model cache from prepareToolInvocation + const cached = this._resolvedModels.get(invocation.callId); + if (cached) { + this._resolvedModels.delete(invocation.callId); + modeModelId = cached.modeModelId; + resolvedModelName = cached.resolvedModelName; + } else { + // Fallback: resolve the model here if prepare didn't cache it + const resolved = this.resolveSubagentModel(subagent, invocation.modelId); + modeModelId = resolved.modeModelId; + resolvedModelName = resolved.resolvedModelName; } // Use mode-specific tools if available const modeCustomTools = subagent.tools; if (modeCustomTools) { // Convert the mode's custom tools (array of qualified names) to UserSelectedTools format - const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, subagent.target, undefined); + const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, undefined); // Convert enablement map to UserSelectedTools (Record) modeTools = {}; for (const [tool, enabled] of enablementMap) { @@ -185,6 +190,16 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } else { throw new Error(`Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); } + } else { + // No subagent name - clean up any cached entry and resolve model name from main model + const cached = this._resolvedModels.get(invocation.callId); + if (cached) { + this._resolvedModels.delete(invocation.callId); + resolvedModelName = cached.resolvedModelName; + } else { + const resolvedModelMetadata = modeModelId ? this.languageModelsService.lookupLanguageModel(modeModelId) : undefined; + resolvedModelName = resolvedModelMetadata?.name; + } } // Track whether we should collect markdown (after the last tool invocation) @@ -208,6 +223,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } else { model.acceptResponseProgress(request, part); } + } else if (part.kind === 'hook') { + model.acceptResponseProgress(request, { ...part, subAgentInvocationId }); } else if (part.kind === 'markdownContent') { if (inEdit) { model.acceptResponseProgress(request, { kind: 'markdownContent', content: new MarkdownString('\n```\n\n') }); @@ -230,6 +247,14 @@ export class RunSubagentTool extends Disposable implements IToolImpl { const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined); // agents can not call subagents await computer.collect(variableSet, token); + // Collect hooks from hook .json files + let collectedHooks: IChatRequestHooks | undefined; + try { + collectedHooks = await this.promptsService.getHooks(token); + } catch (error) { + this.logService.warn('[ChatService] Failed to collect hooks:', error); + } + // Build the agent request const agentRequest: IChatAgentRequest = { sessionResource: invocation.context.sessionResource, @@ -244,6 +269,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { userSelectedTools: modeTools, modeInstructions, parentRequestId: invocation.chatRequestId, + hooks: collectedHooks, + hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), }; // Subscribe to tool invocations to clear markdown parts when a tool is invoked @@ -274,6 +301,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Store result in toolSpecificData for serialization if (invocation.toolSpecificData?.kind === 'subagent') { invocation.toolSpecificData.result = resultText; + invocation.toolSpecificData.modelName = resolvedModelName; } // Return result with toolMetadata containing subAgentInvocationId for trajectory tracking @@ -286,6 +314,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { subAgentInvocationId, description: args.description, agentName: agentRequest.subAgentName, + modelName: resolvedModelName, } }; @@ -303,11 +332,53 @@ export class RunSubagentTool extends Disposable implements IToolImpl { return agents.find(agent => agent.name === name); } + /** + * Resolves the model to be used by a subagent, applying multiplier-based + * fallback to avoid using a more expensive model than the main agent. + */ + private resolveSubagentModel(subagent: ICustomAgent | undefined, mainModelId: string | undefined): { modeModelId: string | undefined; resolvedModelName: string | undefined } { + let modeModelId = mainModelId; + + if (subagent) { + const modeModelQualifiedNames = subagent.model; + if (modeModelQualifiedNames) { + // Find the actual model identifier from the qualified name(s) + outer: for (const qualifiedName of modeModelQualifiedNames) { + const lmByQualifiedName = this.languageModelsService.lookupLanguageModelByQualifiedName(qualifiedName); + if (lmByQualifiedName?.identifier) { + modeModelId = lmByQualifiedName.identifier; + break outer; + } + } + } + + // If the subagent's model has a larger multiplier than the main agent's model, + // fall back to the main agent's model to avoid using a more expensive model. + if (modeModelId && modeModelId !== mainModelId) { + const mainModelMetadata = mainModelId ? this.languageModelsService.lookupLanguageModel(mainModelId) : undefined; + const subagentModelMetadata = this.languageModelsService.lookupLanguageModel(modeModelId); + const mainMultiplier = mainModelMetadata?.multiplierNumeric; + const subagentMultiplier = subagentModelMetadata?.multiplierNumeric; + if (mainMultiplier !== undefined && subagentMultiplier !== undefined && subagentMultiplier > mainMultiplier) { + this.logService.warn(`[RunSubagentTool] Subagent '${subagent.name}' requested model '${subagentModelMetadata?.name}' (multiplier: ${subagentMultiplier}) which has a larger multiplier than the main agent model '${mainModelMetadata?.name}' (multiplier: ${mainMultiplier}). Falling back to the main agent model.`); + modeModelId = mainModelId; + } + } + } + + const resolvedModelMetadata = modeModelId ? this.languageModelsService.lookupLanguageModel(modeModelId) : undefined; + return { modeModelId, resolvedModelName: resolvedModelMetadata?.name }; + } + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const args = context.parameters as IRunSubagentToolInputParams; const subagent = args.agentName ? await this.getSubAgentByName(args.agentName) : undefined; + // Resolve the model early and cache it for invoke() + const resolved = this.resolveSubagentModel(subagent, context.modelId); + this._resolvedModels.set(context.toolCallId, resolved); + return { invocationMessage: args.description, toolSpecificData: { @@ -315,6 +386,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { description: args.description, agentName: subagent?.name, prompt: args.prompt, + modelName: resolved.resolvedModelName, }, }; } diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 3f187cf003f..9153ccc9fac 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -162,6 +162,16 @@ export namespace ToolDataSource { } } +/** + * Pre-tool-use hook result passed from the extension when the hook was executed externally. + */ +export interface IExternalPreToolUseHookResult { + permissionDecision?: 'allow' | 'deny' | 'ask'; + permissionDecisionReason?: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updatedInput?: Record; +} + export interface IToolInvocation { callId: string; toolId: string; @@ -184,6 +194,8 @@ export interface IToolInvocation { userSelectedTools?: UserSelectedTools; /** The label of the custom button selected by the user during confirmation, if custom buttons were used. */ selectedCustomButton?: string; + /** Pre-tool-use hook result passed from the extension, if the hook was already executed externally. */ + preToolUseResult?: IExternalPreToolUseHookResult; } export interface IToolInvocationContext { @@ -200,12 +212,14 @@ export function isToolInvocationContext(obj: any): obj is IToolInvocationContext export interface IToolInvocationPreparationContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any parameters: any; + toolCallId: string; chatRequestId?: string; /** @deprecated Use {@link chatSessionResource} instead */ chatSessionId?: string; chatSessionResource: URI | undefined; chatInteractionId?: string; - /** If set, tells the tool that it should include confirmmation messages. */ + modelId?: string; + /** If set, tells the tool that it should include confirmation messages. */ forceConfirmationReason?: string; } @@ -571,15 +585,10 @@ export interface ILanguageModelToolsService { /** * Gets the enablement maps based on the given set of references. * @param fullReferenceNames The full reference names of the tools and tool sets to enable. - * @param target Optional target to filter tools by. * @param model Optional language model metadata to filter tools by. * If undefined is passed, all tools will be returned, even if normally disabled. */ - toToolAndToolSetEnablementMap( - fullReferenceNames: readonly string[], - target: string | undefined, - model: ILanguageModelChatMetadata | undefined, - ): IToolAndToolSetEnablementMap; + toToolAndToolSetEnablementMap(fullReferenceNames: readonly string[], model: ILanguageModelChatMetadata | undefined): IToolAndToolSetEnablementMap; toFullReferenceNames(map: IToolAndToolSetEnablementMap): string[]; toToolReferences(variableReferences: readonly IVariableReference[]): ChatRequestToolReferenceEntry[]; diff --git a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts index 9aca92e5856..d756c702087 100644 --- a/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/accessibility/chatResponseAccessibleView.test.ts @@ -4,15 +4,19 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../editor/common/core/range.js'; import { Location } from '../../../../../../editor/common/languages.js'; -import { getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ChatResponseAccessibleView, getToolSpecificDataDescription, getResultDetailsDescription, getToolInvocationA11yDescription } from '../../../browser/accessibility/chatResponseAccessibleView.js'; +import { IChatWidget, IChatWidgetService } from '../../../browser/chat.js'; import { IChatExtensionsContent, IChatPullRequestContent, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolResourcesInvocationData } from '../../../common/chatService/chatService.js'; suite('ChatResponseAccessibleView', () => { - ensureNoDisposablesAreLeakedInTestSuite(); + const store = ensureNoDisposablesAreLeakedInTestSuite(); suite('getToolSpecificDataDescription', () => { test('returns empty string for undefined', () => { @@ -388,4 +392,47 @@ suite('ChatResponseAccessibleView', () => { assert.ok(result.includes('test.ts')); }); }); + + suite('getProvider', () => { + test('prefers the latest response when focus is on a queued request', () => { + const instantiationService = store.add(new TestInstantiationService()); + const responseItem = { + response: { value: [{ kind: 'markdownContent', content: new MarkdownString('Response content') }] }, + model: { onDidChange: Event.None }, + setVote: () => undefined + }; + const queuedRequest = { message: 'Queued request' }; + const items = [responseItem, queuedRequest]; + let focusedItem: unknown = queuedRequest; + + const widget = { + hasInputFocus: () => true, + focusResponseItem: () => { focusedItem = queuedRequest; }, + getFocus: () => focusedItem, + focus: (item: unknown) => { focusedItem = item; }, + viewModel: { getItems: () => items } + } as unknown as IChatWidget; + + const widgetService = { + _serviceBrand: undefined, + lastFocusedWidget: widget, + onDidAddWidget: Event.None, + onDidBackgroundSession: Event.None, + reveal: async () => true, + revealWidget: async () => widget, + getAllWidgets: () => [widget], + getWidgetByInputUri: () => widget, + openSession: async () => widget, + getWidgetBySessionResource: () => widget + } as unknown as IChatWidgetService; + + instantiationService.stub(IChatWidgetService, widgetService); + + const accessibleView = new ChatResponseAccessibleView(); + const provider = instantiationService.invokeFunction(accessor => accessibleView.getProvider(accessor)); + assert.ok(provider); + store.add(provider); + assert.ok(provider.provideContent().includes('Response content')); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index 064114c7882..f7fb12e4c80 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -12,7 +12,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { AgentSessionsModel, IAgentSession, isAgentSession, isAgentSessionsModel, isLocalAgentSessionItem } from '../../../browser/agentSessions/agentSessionsModel.js'; import { AgentSessionsFilter } from '../../../browser/agentSessions/agentSessionsFilter.js'; -import { ChatSessionStatus, IChatSessionItem, IChatSessionItemProvider, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; +import { ChatSessionStatus, IChatSessionItemController, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { MockChatSessionsService } from '../../common/mockChatSessionsService.js'; import { TestLifecycleService, workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; @@ -22,7 +22,8 @@ import { MenuId } from '../../../../../../platform/actions/common/actions.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { AgentSessionProviders, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; +import { AgentSessionProviders, getAgentCanContinueIn, getAgentSessionProviderIcon, getAgentSessionProviderName } from '../../../browser/agentSessions/agentSessions.js'; + suite('AgentSessions', () => { @@ -60,22 +61,25 @@ suite('AgentSessions', () => { assert.strictEqual(viewModel.sessions.length, 0); }); - test('should resolve sessions from providers', async () => { + test('should resolve sessions from controllers', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const chatSessionType = 'test-type'; + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1', { - label: 'Test Session 1' - }), - makeSimpleSessionItem('session-2', { - label: 'Test Session 2' - }) - ] + refresh: async () => { }, + get items() { + return [ + makeSimpleSessionItem('session-1', { + label: 'Test Session 1' + }), + makeSimpleSessionItem('session-2', { + label: 'Test Session 2' + }) + ]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(chatSessionType, controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -88,26 +92,26 @@ suite('AgentSessions', () => { }); }); - test('should resolve sessions from multiple providers', async () => { + test('should resolve sessions from multiple controllers', async () => { return runWithFakedTimers({}, async () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', + const controller1: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', + const controller2: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-2'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-2')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); + mockChatSessionsService.registerChatSessionItemController('type-1', controller1); + mockChatSessionsService.registerChatSessionItemController('type-2', controller2); viewModel = createViewModel(); @@ -121,13 +125,13 @@ suite('AgentSessions', () => { test('should fire onWillResolve and onDidResolve events', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] + refresh: async () => { }, + get items() { return []; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); let willResolveFired = false; @@ -152,15 +156,15 @@ suite('AgentSessions', () => { test('should fire onDidChangeSessions event after resolving', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); let sessionsChangedFired = false; @@ -179,11 +183,11 @@ suite('AgentSessions', () => { const created = Date.now(); const lastRequestEnded = created + 1000; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-1'), label: 'Test Session', description: new MarkdownString('**Bold** description'), @@ -192,11 +196,11 @@ suite('AgentSessions', () => { iconPath: ThemeIcon.fromId('check'), timing: { created, lastRequestStarted: created, lastRequestEnded }, changes: { files: 1, insertions: 10, deletions: 5 } - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -218,24 +222,24 @@ suite('AgentSessions', () => { test('should handle resolve with specific provider', async () => { return runWithFakedTimers({}, async () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', + const controller1: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', + const controller2: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-2'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-2')]; + } }; - disposables.add(mockChatSessionsService.registerChatSessionItemProvider(provider1)); - disposables.add(mockChatSessionsService.registerChatSessionItemProvider(provider2)); + disposables.add(mockChatSessionsService.registerChatSessionItemController('type-1', controller1)); + disposables.add(mockChatSessionsService.registerChatSessionItemController('type-2', controller2)); viewModel = createViewModel(); @@ -250,26 +254,26 @@ suite('AgentSessions', () => { }); }); - test('should handle resolve with multiple specific providers', async () => { + test('should handle resolve with multiple specific controllers', async () => { return runWithFakedTimers({}, async () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', + const controller1: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', + const controller2: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-2'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-2')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); + mockChatSessionsService.registerChatSessionItemController('type-1', controller1); + mockChatSessionsService.registerChatSessionItemController('type-2', controller2); viewModel = createViewModel(); @@ -281,21 +285,22 @@ suite('AgentSessions', () => { test('should respond to onDidChangeItemsProviders event', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const chatSessionType = 'test-type'; + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(chatSessionType, controller); viewModel = createViewModel(); const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); // Trigger event - this should automatically call resolve - mockChatSessionsService.fireDidChangeItemsProviders(provider); + mockChatSessionsService.fireDidChangeItemsProviders({ chatSessionType }); // Wait for the sessions to be resolved await sessionsChangedPromise; @@ -306,15 +311,15 @@ suite('AgentSessions', () => { test('should respond to onDidChangeAvailability event', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); @@ -331,15 +336,15 @@ suite('AgentSessions', () => { test('should respond to onDidChangeSessionItems event', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); const sessionsChangedPromise = Event.toPromise(viewModel.onDidChangeSessions); @@ -356,15 +361,15 @@ suite('AgentSessions', () => { test('should maintain provider reference in session view model', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -376,13 +381,13 @@ suite('AgentSessions', () => { test('should handle empty provider results', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] + refresh: async () => { }, + get items() { return []; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -393,35 +398,37 @@ suite('AgentSessions', () => { test('should handle sessions with different statuses', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { - id: 'session-failed', - resource: URI.parse('test://session-failed'), - label: 'Failed Session', - status: ChatSessionStatus.Failed, - timing: makeNewSessionTiming() - }, - { - id: 'session-completed', - resource: URI.parse('test://session-completed'), - label: 'Completed Session', - status: ChatSessionStatus.Completed, - timing: makeNewSessionTiming() - }, - { - id: 'session-inprogress', - resource: URI.parse('test://session-inprogress'), - label: 'In Progress Session', - status: ChatSessionStatus.InProgress, - timing: makeNewSessionTiming() - } - ] + refresh: async () => { }, + get items() { + return [ + { + id: 'session-failed', + resource: URI.parse('test://session-failed'), + label: 'Failed Session', + status: ChatSessionStatus.Failed, + timing: makeNewSessionTiming() + }, + { + id: 'session-completed', + resource: URI.parse('test://session-completed'), + label: 'Completed Session', + status: ChatSessionStatus.Completed, + timing: makeNewSessionTiming() + }, + { + id: 'session-inprogress', + resource: URI.parse('test://session-inprogress'), + label: 'In Progress Session', + status: ChatSessionStatus.InProgress, + timing: makeNewSessionTiming() + } + ]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -436,20 +443,20 @@ suite('AgentSessions', () => { test('should replace sessions on re-resolve', async () => { return runWithFakedTimers({}, async () => { let sessionCount = 1; + let _items: IChatSessionItem[] = []; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - const sessions: IChatSessionItem[] = []; + refresh: async () => { + _items = []; for (let i = 0; i < sessionCount; i++) { - sessions.push(makeSimpleSessionItem(`session-${i + 1}`)); + _items.push(makeSimpleSessionItem(`session-${i + 1}`)); } - return sessions; - } + }, + get items() { return _items; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -463,20 +470,20 @@ suite('AgentSessions', () => { test('should handle local agent session type specially', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: localChatSessionType, + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ id: 'local-session', resource: LocalChatSessionUri.forSession('local-session'), label: 'Local Session', timing: makeNewSessionTiming() - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(localChatSessionType, controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -490,19 +497,19 @@ suite('AgentSessions', () => { return runWithFakedTimers({}, async () => { const resource = URI.parse('custom://my-session/path'); - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: resource, label: 'Test Session', timing: makeNewSessionTiming() - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = createViewModel(); await viewModel.resolve(undefined); @@ -514,20 +521,20 @@ suite('AgentSessions', () => { test('should throttle multiple rapid resolve calls', async () => { return runWithFakedTimers({}, async () => { - let providerCallCount = 0; + let controllerCallCount = 0; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - providerCallCount++; - return [ - makeSimpleSessionItem('session-1'), - ]; + refresh: async () => { controllerCallCount++; }, + get items() { + return [makeSimpleSessionItem('session-1')]; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); + // Registering calls a refresh initially + assert.strictEqual(controllerCallCount, 1); + viewModel = createViewModel(); // Make multiple rapid resolve calls @@ -539,67 +546,65 @@ suite('AgentSessions', () => { await Promise.all(resolvePromises); - // Should only call provider once due to throttling - assert.strictEqual(providerCallCount, 1); + // Should only call controller once more due to throttling + assert.strictEqual(controllerCallCount, 2); assert.strictEqual(viewModel.sessions.length, 1); }); }); - test('should not preserve sessions from non-resolved providers', async () => { + test('should not preserve sessions from non-resolved controllers', async () => { return runWithFakedTimers({}, async () => { - let provider1CallCount = 0; - let provider2CallCount = 0; + let controller1CallCount = 0; + let controller2CallCount = 0; + let _items1: IChatSessionItem[] = []; + let _items2: IChatSessionItem[] = []; - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', + const controller1: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - provider1CallCount++; - return [ - { - resource: URI.parse('test://session-1'), - label: `Session 1 (call ${provider1CallCount})`, - timing: makeNewSessionTiming() - } - ]; - } + refresh: async () => { + controller1CallCount++; + _items1 = [{ + resource: URI.parse('test://session-1'), + label: `Session 1 (call ${controller1CallCount})`, + timing: makeNewSessionTiming() + }]; + }, + get items() { return _items1; } }; - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', + const controller2: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { - provider2CallCount++; - return [ - { - resource: URI.parse('test://session-2'), - label: `Session 2 (call ${provider2CallCount})`, - timing: makeNewSessionTiming() - } - ]; - } + refresh: async () => { + controller2CallCount++; + _items2 = [{ + resource: URI.parse('test://session-2'), + label: `Session 2 (call ${controller2CallCount})`, + timing: makeNewSessionTiming() + }]; + }, + get items() { return _items2; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); + mockChatSessionsService.registerChatSessionItemController('type-1', controller1); + mockChatSessionsService.registerChatSessionItemController('type-2', controller2); viewModel = createViewModel(); // First resolve all await viewModel.resolve(undefined); assert.strictEqual(viewModel.sessions.length, 2); - assert.strictEqual(provider1CallCount, 1); - assert.strictEqual(provider2CallCount, 1); + assert.strictEqual(controller1CallCount, 2); // One from registration and one from resolve + assert.strictEqual(controller2CallCount, 2); // One from registration and one from resolve // Now resolve only type-2 await viewModel.resolve('type-2'); // Should still have only one session assert.strictEqual(viewModel.sessions.length, 1); - // Provider 1 should not be called again - assert.strictEqual(provider1CallCount, 1); - // Provider 2 should be called again - assert.strictEqual(provider2CallCount, 2); + // Controller 1 should not be called again + assert.strictEqual(controller1CallCount, 2); + // Controller 2 should be called again + assert.strictEqual(controller2CallCount, 3); }); }); @@ -607,33 +612,35 @@ suite('AgentSessions', () => { return runWithFakedTimers({}, async () => { let resolveCount = 0; const resolvedProviders: (string | undefined)[] = []; + let _items1: IChatSessionItem[] = []; + let _items2: IChatSessionItem[] = []; - const provider1: IChatSessionItemProvider = { - chatSessionType: 'type-1', + const controller1: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { + refresh: async () => { resolveCount++; resolvedProviders.push('type-1'); - return [makeSimpleSessionItem('session-1'),]; - } + _items1 = [makeSimpleSessionItem('session-1')]; + }, + get items() { return _items1; } }; - const provider2: IChatSessionItemProvider = { - chatSessionType: 'type-2', + const controller2: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { + refresh: async () => { resolveCount++; resolvedProviders.push('type-2'); - return [{ + _items2 = [{ resource: URI.parse('test://session-2'), label: 'Session 2', timing: makeNewSessionTiming() }]; - } + }, + get items() { return _items2; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); - mockChatSessionsService.registerChatSessionItemProvider(provider2); + mockChatSessionsService.registerChatSessionItemController('type-1', controller1); + mockChatSessionsService.registerChatSessionItemController('type-2', controller2); viewModel = createViewModel(); @@ -835,7 +842,7 @@ suite('AgentSessions', () => { assert.strictEqual(filter.exclude(session2), false); }); - test('should filter out multiple excluded providers', () => { + test('should filter out multiple excluded controllers', () => { const storageService = instantiationService.get(IStorageService); const filter = disposables.add(instantiationService.createInstance( AgentSessionsFilter, @@ -1039,13 +1046,13 @@ suite('AgentSessions', () => { }); test('should register provider filter actions', () => { - const provider1: IChatSessionItemProvider = { - chatSessionType: 'custom-type-1', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] + refresh: async () => { }, + get items() { return []; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider1); + mockChatSessionsService.registerChatSessionItemController('custom-type-1', controller); const filter = disposables.add(instantiationService.createInstance( AgentSessionsFilter, @@ -1063,15 +1070,16 @@ suite('AgentSessions', () => { { filterMenuId: MenuId.ViewTitle } )); - const provider: IChatSessionItemProvider = { - chatSessionType: 'new-type', + const chatSessionType = 'new-type'; + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [] + refresh: async () => { }, + get items() { return []; } }; // Register provider after filter creation - mockChatSessionsService.registerChatSessionItemProvider(provider); - mockChatSessionsService.fireDidChangeItemsProviders(provider); + mockChatSessionsService.registerChatSessionItemController(chatSessionType, controller); + mockChatSessionsService.fireDidChangeItemsProviders({ chatSessionType }); // Filter should work with new provider const session = createSession({ providerType: 'new-type' }); @@ -1241,15 +1249,15 @@ suite('AgentSessions', () => { test('should archive and unarchive sessions', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1269,15 +1277,15 @@ suite('AgentSessions', () => { test('should fire onDidChangeSessions when archiving', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1295,15 +1303,15 @@ suite('AgentSessions', () => { test('should not fire onDidChangeSessions when archiving with same value', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1324,20 +1332,20 @@ suite('AgentSessions', () => { test('should preserve archived state from provider', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-1'), label: 'Test Session', archived: true, timing: makeNewSessionTiming() - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1349,20 +1357,20 @@ suite('AgentSessions', () => { test('should override provider archived state with user preference', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-1'), label: 'Test Session', archived: true, timing: makeNewSessionTiming() - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1411,19 +1419,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2026, 1 /* February */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-1'), label: 'Session 1', timing: futureSessionTiming, - }, - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1442,15 +1450,15 @@ suite('AgentSessions', () => { test('should fire onDidChangeSessions when marking as read', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1470,15 +1478,15 @@ suite('AgentSessions', () => { test('should not fire onDidChangeSessions when marking as read with same value', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1499,15 +1507,15 @@ suite('AgentSessions', () => { test('should preserve read state after re-resolve', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1532,19 +1540,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://old-session'), label: 'Old Session', timing: oldSessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1563,19 +1571,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2026, 1 /* February */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://new-session'), label: 'New Session', timing: newSessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1595,19 +1603,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2026, 1 /* February */, 1), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-with-endtime'), label: 'Session With EndTime', timing: sessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1627,19 +1635,19 @@ suite('AgentSessions', () => { lastRequestEnded: undefined, }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-no-endtime'), label: 'Session Without EndTime', timing: sessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1658,19 +1666,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2026, 1 /* February */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://new-session'), label: 'New Session', timing: newSessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1697,19 +1705,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2026, 1 /* February */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://new-session'), label: 'New Session', timing: newSessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1742,19 +1750,19 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2026, 1 /* February */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://new-session'), label: 'New Session', timing: newSessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1784,19 +1792,20 @@ suite('AgentSessions', () => { lastRequestEnded: Date.UTC(2025, 10 /* November */, 2), }; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const chatSessionType = 'test-type'; + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://old-session'), label: 'Old Session', timing: oldSessionTiming, - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(chatSessionType, controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1842,21 +1851,22 @@ suite('AgentSessions', () => { test('should track status transitions', async () => { return runWithFakedTimers({}, async () => { let sessionStatus = ChatSessionStatus.InProgress; + let _items: IChatSessionItem[] = []; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { + _items = [{ resource: URI.parse('test://session-1'), label: 'Test Session', status: sessionStatus, timing: makeNewSessionTiming() - } - ] + }]; + }, + get items() { return _items; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1872,21 +1882,21 @@ suite('AgentSessions', () => { test('should clean up state tracking for removed sessions', async () => { return runWithFakedTimers({}, async () => { let includeSessions = true; + let _items: IChatSessionItem[] = []; - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => { + refresh: async () => { if (includeSessions) { - return [ - makeSimpleSessionItem('session-1'), - ]; + _items = [makeSimpleSessionItem('session-1')]; + } else { + _items = []; } - return []; - } + }, + get items() { return _items; } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1939,6 +1949,16 @@ suite('AgentSessions', () => { assert.strictEqual(icon.id, Codicon.cloud.id); }); + test('should return correct name for Growth provider', () => { + const name = getAgentSessionProviderName(AgentSessionProviders.Growth); + assert.strictEqual(name, 'Growth'); + }); + + test('should return correct icon for Growth provider', () => { + const icon = getAgentSessionProviderIcon(AgentSessionProviders.Growth); + assert.strictEqual(icon.id, Codicon.lightbulb.id); + }); + test('should handle Local provider type in model', async () => { return runWithFakedTimers({}, async () => { const instantiationService = disposables.add(workbenchInstantiationService(undefined, disposables)); @@ -1946,15 +1966,15 @@ suite('AgentSessions', () => { instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); - const provider: IChatSessionItemProvider = { - chatSessionType: AgentSessionProviders.Local, + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(AgentSessionProviders.Local, controller); const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -1973,15 +1993,15 @@ suite('AgentSessions', () => { instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); - const provider: IChatSessionItemProvider = { - chatSessionType: AgentSessionProviders.Background, + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(AgentSessionProviders.Background, controller); const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -2000,15 +2020,15 @@ suite('AgentSessions', () => { instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); - const provider: IChatSessionItemProvider = { - chatSessionType: AgentSessionProviders.Cloud, + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController(AgentSessionProviders.Cloud, controller); const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -2028,20 +2048,20 @@ suite('AgentSessions', () => { instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); const customIcon = ThemeIcon.fromId('beaker'); - const provider: IChatSessionItemProvider = { - chatSessionType: 'custom-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - { + refresh: async () => { }, + get items() { + return [{ resource: URI.parse('test://session-1'), label: 'Test Session', iconPath: customIcon, timing: makeNewSessionTiming() - } - ] + }]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('custom-type', controller); const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -2058,15 +2078,15 @@ suite('AgentSessions', () => { instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); - const provider: IChatSessionItemProvider = { - chatSessionType: 'custom-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('custom-type', controller); const viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); await viewModel.resolve(undefined); @@ -2077,6 +2097,25 @@ suite('AgentSessions', () => { }); }); + suite('AgentSessionsViewModel - getAgentCanContinueIn', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('should return false when contribution.isReadOnly is true', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: true }); + assert.strictEqual(result, false); + }); + + test('should return true for Cloud when contribution is not read-only', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Cloud, { type: 'test', name: 'test', displayName: 'Test', description: 'test', isReadOnly: false }); + assert.strictEqual(result, true); + }); + + test('should return false for Growth provider', () => { + const result = getAgentCanContinueIn(AgentSessionProviders.Growth); + assert.strictEqual(result, false); + }); + }); + suite('AgentSessionsViewModel - Cancellation and Lifecycle', () => { const disposables = new DisposableStore(); let mockChatSessionsService: MockChatSessionsService; @@ -2100,15 +2139,15 @@ suite('AgentSessions', () => { test('should not resolve if lifecycle will shutdown', async () => { return runWithFakedTimers({}, async () => { - const provider: IChatSessionItemProvider = { - chatSessionType: 'test-type', + const controller: IChatSessionItemController = { onDidChangeChatSessionItems: Event.None, - provideChatSessionItems: async () => [ - makeSimpleSessionItem('session-1'), - ] + refresh: async () => { }, + get items() { + return [makeSimpleSessionItem('session-1')]; + } }; - mockChatSessionsService.registerChatSessionItemProvider(provider); + mockChatSessionsService.registerChatSessionItemController('test-type', controller); viewModel = disposables.add(instantiationService.createInstance(AgentSessionsModel)); // Set willShutdown to true diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts index b99a9c3138d..ea79ad2f353 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionsDataSource.test.ts @@ -54,21 +54,21 @@ suite('sessionDateFromNow', () => { const ONE_DAY = 24 * 60 * 60 * 1000; - test('returns "1 day ago" for yesterday', () => { + test('returns "1 day" for yesterday', () => { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); // Time in the middle of yesterday const yesterday = startOfToday - ONE_DAY / 2; - assert.strictEqual(sessionDateFromNow(yesterday), '1 day ago'); + assert.strictEqual(sessionDateFromNow(yesterday), '1 day'); }); - test('returns "2 days ago" for two days ago', () => { + test('returns "2 days" for two days ago', () => { const now = Date.now(); const startOfToday = new Date(now).setHours(0, 0, 0, 0); const startOfYesterday = startOfToday - ONE_DAY; // Time in the middle of two days ago const twoDaysAgo = startOfYesterday - ONE_DAY / 2; - assert.strictEqual(sessionDateFromNow(twoDaysAgo), '2 days ago'); + assert.strictEqual(sessionDateFromNow(twoDaysAgo), '2 days'); }); test('returns fromNow result for today', () => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts similarity index 89% rename from src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts rename to src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts index 40547235409..3cbc1cccf03 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsController.test.ts @@ -14,7 +14,7 @@ import { runWithFakedTimers } from '../../../../../../base/test/common/timeTrave import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; -import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/localAgentSessionsProvider.js'; +import { LocalAgentsSessionsController } from '../../../browser/agentSessions/localAgentSessionsController.js'; import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; import { ChatRequestQueueKind, IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; @@ -280,7 +280,7 @@ function createMockChatModel(options: { } as unknown as IChatModel; } -suite('LocalAgentsSessionsProvider', () => { +suite('LocalAgentsSessionsController', () => { const disposables = new DisposableStore(); let mockChatService: MockChatService; let mockChatSessionsService: MockChatSessionsService; @@ -300,38 +300,39 @@ suite('LocalAgentsSessionsProvider', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createProvider(): LocalAgentsSessionsProvider { - return disposables.add(instantiationService.createInstance(LocalAgentsSessionsProvider)); + function createController(): LocalAgentsSessionsController { + return disposables.add(instantiationService.createInstance(LocalAgentsSessionsController)); } test('should have correct session type', () => { - const provider = createProvider(); - assert.strictEqual(provider.chatSessionType, localChatSessionType); + const controller = createController(); + assert.strictEqual(controller.chatSessionType, localChatSessionType); }); test('should register itself with chat sessions service', async () => { - const provider = createProvider(); + const controller = createController(); - const providerResults = await mockChatSessionsService.getChatSessionItems(undefined, CancellationToken.None); - assert.strictEqual(providerResults.length, 1); - assert.strictEqual(providerResults[0].chatSessionType, provider.chatSessionType); + const controllerResults = await mockChatSessionsService.getChatSessionItems(undefined, CancellationToken.None); + assert.strictEqual(controllerResults.length, 1); + assert.strictEqual(controllerResults[0].chatSessionType, controller.chatSessionType); }); test('should provide empty sessions when no live or history sessions', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); mockChatService.setLiveSessionItems([]); mockChatService.setHistorySessionItems([]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 0); }); }); test('should provide live session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('test-session'); const mockModel = createMockChatModel({ @@ -350,7 +351,8 @@ suite('LocalAgentsSessionsProvider', () => { lastResponseState: ResponseModelState.Complete }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Test Session'); assert.strictEqual(sessions[0].resource.toString(), sessionResource.toString()); @@ -359,7 +361,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should provide history session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-session'); @@ -373,7 +375,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming() }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'History Session'); }); @@ -381,7 +384,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should not duplicate sessions in history and live', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('duplicate-session'); const mockModel = createMockChatModel({ @@ -407,7 +410,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming() }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Live Session'); }); @@ -416,7 +420,7 @@ suite('LocalAgentsSessionsProvider', () => { suite('Session Status', () => { test('should return InProgress status when request in progress', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('in-progress-session'); const mockModel = createMockChatModel({ @@ -435,7 +439,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming() }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.InProgress); }); @@ -443,7 +448,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should return Completed status when last response is complete', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('completed-session'); const mockModel = createMockChatModel({ @@ -465,7 +470,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming(), }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -473,7 +479,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should return Success status when last response was canceled', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('canceled-session'); const mockModel = createMockChatModel({ @@ -494,7 +500,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming(), }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -502,7 +509,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should return Failed status when last response has error', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('error-session'); const mockModel = createMockChatModel({ @@ -523,7 +530,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming(), }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); }); @@ -533,7 +541,7 @@ suite('LocalAgentsSessionsProvider', () => { suite('Session Statistics', () => { test('should return statistics for sessions with modified entries', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('stats-session'); const mockModel = createMockChatModel({ @@ -572,7 +580,8 @@ suite('LocalAgentsSessionsProvider', () => { } }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.ok(sessions[0].changes); const changes = sessions[0].changes as { files: number; insertions: number; deletions: number }; @@ -584,7 +593,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should not return statistics for sessions without modified entries', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('no-stats-session'); const mockModel = createMockChatModel({ @@ -612,7 +621,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming() }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].changes, undefined); }); @@ -622,7 +632,7 @@ suite('LocalAgentsSessionsProvider', () => { suite('Session Timing', () => { test('should use model timestamp for created when model exists', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('timing-session'); const modelTimestamp = Date.now() - 5000; @@ -642,7 +652,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming({ created: modelTimestamp }) }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); @@ -650,7 +661,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should use lastMessageDate for created when model does not exist', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-timing'); const lastMessageDate = Date.now() - 10000; @@ -665,7 +676,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming({ created: lastMessageDate }) }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); @@ -673,7 +685,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should set lastRequestEnded from last response completedAt', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('endtime-session'); const completedAt = Date.now() - 1000; @@ -694,7 +706,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming({ lastRequestEnded: completedAt }) }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); @@ -704,7 +717,7 @@ suite('LocalAgentsSessionsProvider', () => { suite('Session Icon', () => { test('should use Codicon.chatSparkle as icon', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('icon-session'); const mockModel = createMockChatModel({ @@ -722,7 +735,8 @@ suite('LocalAgentsSessionsProvider', () => { timing: createTestTiming() }]); - const sessions = await provider.provideChatSessionItems(CancellationToken.None); + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); }); @@ -732,7 +746,7 @@ suite('LocalAgentsSessionsProvider', () => { suite('Events', () => { test('should fire onDidChangeChatSessionItems when model progress changes', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('progress-session'); const mockModel = createMockChatModel({ @@ -745,7 +759,7 @@ suite('LocalAgentsSessionsProvider', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -758,7 +772,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should fire onDidChangeChatSessionItems when model request status changes', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('status-change-session'); const mockModel = createMockChatModel({ @@ -771,7 +785,7 @@ suite('LocalAgentsSessionsProvider', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -784,7 +798,7 @@ suite('LocalAgentsSessionsProvider', () => { test('should clean up model listeners when model is removed via chatModels observable', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('cleanup-session'); const mockModel = createMockChatModel({ @@ -802,7 +816,7 @@ suite('LocalAgentsSessionsProvider', () => { // The onDidChangeChatSessionItems from registerModelListeners cleanup should fire once // but after that, title changes should NOT fire onDidChangeChatSessionItems let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts new file mode 100644 index 00000000000..0105ff47128 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/chatAttachmentResolveService.test.ts @@ -0,0 +1,202 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IFileService, IFileStatWithMetadata } from '../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ChatAttachmentResolveService } from '../../browser/attachments/chatAttachmentResolveService.js'; +import { createFileStat } from '../../../../test/common/workbenchTestServices.js'; +import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; + +suite('ChatAttachmentResolveService', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let service: ChatAttachmentResolveService; + + /** + * Map from directory URI string to children, simulating a file tree. + * Populated per-test to control the mock directory structure. + */ + let directoryTree: Map; + + /** + * Set of file URI strings that should be treated as valid images + * by the mocked resolveImageEditorAttachContext. + */ + let imageFileUris: Set; + + setup(() => { + instantiationService = testDisposables.add(new TestInstantiationService()); + directoryTree = new Map(); + imageFileUris = new Set(); + + // Stub IFileService with resolve() that uses the directoryTree map + instantiationService.stub(IFileService, { + resolve: async (resource: URI): Promise => { + const children = directoryTree.get(resource.toString()); + if (children !== undefined) { + return createFileStat(resource, false, false, true, false, children); + } + // Treat as a file + return createFileStat(resource, false, true, false); + } + }); + + instantiationService.stub(IEditorService, {}); + instantiationService.stub(ITextModelService, {}); + instantiationService.stub(IExtensionService, {}); + instantiationService.stub(IDialogService, {}); + + service = instantiationService.createInstance(ChatAttachmentResolveService); + + // Override resolveImageEditorAttachContext to avoid DOM dependencies (canvas, Image, etc.) + // and return a predictable image entry for files in the imageFileUris set. + service.resolveImageEditorAttachContext = async (resource: URI): Promise => { + if (imageFileUris.has(resource.toString())) { + return { + id: resource.toString(), + name: resource.path.split('/').pop()!, + value: new Uint8Array([1, 2, 3]), + kind: 'image', + }; + } + return undefined; + }; + }); + + test('returns empty array for empty directory', async () => { + const dirUri = URI.file('/test/empty-dir'); + directoryTree.set(dirUri.toString(), []); + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('returns image entries for image files in directory', async () => { + const dirUri = URI.file('/test/images-dir'); + const pngUri = URI.file('/test/images-dir/photo.png'); + const jpgUri = URI.file('/test/images-dir/photo.jpg'); + const txtUri = URI.file('/test/images-dir/readme.txt'); + + directoryTree.set(dirUri.toString(), [ + { resource: pngUri, isFile: true, isDirectory: false }, + { resource: jpgUri, isFile: true, isDirectory: false }, + { resource: txtUri, isFile: true, isDirectory: false }, + ]); + imageFileUris.add(pngUri.toString()); + imageFileUris.add(jpgUri.toString()); + + const result = await service.resolveDirectoryImages(dirUri); + assert.strictEqual(result.length, 2); + assert.ok(result.every(e => e.kind === 'image')); + const names = result.map(e => e.name).sort(); + assert.deepStrictEqual(names, ['photo.jpg', 'photo.png']); + }); + + test('ignores non-image files', async () => { + const dirUri = URI.file('/test/text-dir'); + const txtUri = URI.file('/test/text-dir/file.txt'); + const tsUri = URI.file('/test/text-dir/index.ts'); + + directoryTree.set(dirUri.toString(), [ + { resource: txtUri, isFile: true, isDirectory: false }, + { resource: tsUri, isFile: true, isDirectory: false }, + ]); + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('recursively discovers images in subdirectories', async () => { + const rootUri = URI.file('/test/root'); + const subDirUri = URI.file('/test/root/subdir'); + const deepDirUri = URI.file('/test/root/subdir/deep'); + + const rootPng = URI.file('/test/root/logo.png'); + const subPng = URI.file('/test/root/subdir/banner.webp'); + const deepJpg = URI.file('/test/root/subdir/deep/photo.jpeg'); + const deepTxt = URI.file('/test/root/subdir/deep/notes.txt'); + + directoryTree.set(rootUri.toString(), [ + { resource: rootPng, isFile: true, isDirectory: false }, + { resource: subDirUri, isFile: false, isDirectory: true }, + ]); + directoryTree.set(subDirUri.toString(), [ + { resource: subPng, isFile: true, isDirectory: false }, + { resource: deepDirUri, isFile: false, isDirectory: true }, + ]); + directoryTree.set(deepDirUri.toString(), [ + { resource: deepJpg, isFile: true, isDirectory: false }, + { resource: deepTxt, isFile: true, isDirectory: false }, + ]); + + imageFileUris.add(rootPng.toString()); + imageFileUris.add(subPng.toString()); + imageFileUris.add(deepJpg.toString()); + + const result = await service.resolveDirectoryImages(rootUri); + assert.strictEqual(result.length, 3); + assert.ok(result.every(e => e.kind === 'image')); + const names = result.map(e => e.name).sort(); + assert.deepStrictEqual(names, ['banner.webp', 'logo.png', 'photo.jpeg']); + }); + + test('handles unreadable directory gracefully', async () => { + const dirUri = URI.file('/test/unreadable'); + // Override resolve to throw for this URI + instantiationService.stub(IFileService, { + resolve: async (resource: URI): Promise => { + if (resource.toString() === dirUri.toString()) { + throw new Error('Permission denied'); + } + return createFileStat(resource, false, true, false); + } + }); + // Re-create service with the new stub + service = instantiationService.createInstance(ChatAttachmentResolveService); + service.resolveImageEditorAttachContext = async (resource: URI): Promise => { + if (imageFileUris.has(resource.toString())) { + return { + id: resource.toString(), + name: resource.path.split('/').pop()!, + value: new Uint8Array([1, 2, 3]), + kind: 'image', + }; + } + return undefined; + }; + + const result = await service.resolveDirectoryImages(dirUri); + assert.deepStrictEqual(result, []); + }); + + test('handles mixed directory with images and non-images', async () => { + const dirUri = URI.file('/test/mixed'); + const gifUri = URI.file('/test/mixed/animation.gif'); + const jsUri = URI.file('/test/mixed/script.js'); + const bmpUri = URI.file('/test/mixed/icon.bmp'); + + directoryTree.set(dirUri.toString(), [ + { resource: gifUri, isFile: true, isDirectory: false }, + { resource: jsUri, isFile: true, isDirectory: false }, + { resource: bmpUri, isFile: true, isDirectory: false }, + ]); + imageFileUris.add(gifUri.toString()); + imageFileUris.add(bmpUri.toString()); + // bmp is NOT in CHAT_ATTACHABLE_IMAGE_MIME_TYPES (only png, jpg, jpeg, gif, webp) + // so it should be skipped by the regex even though it would resolve successfully + + const result = await service.resolveDirectoryImages(dirUri); + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].name, 'animation.gif'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 5f57af4bf8d..0243dcfdd0d 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../../base/common/lifecycle.js'; import { waitForState } from '../../../../../../base/common/observable.js'; import { isEqual } from '../../../../../../base/common/resources.js'; @@ -25,6 +26,7 @@ import { IWorkbenchAssignmentService } from '../../../../../services/assignment/ import { NullWorkbenchAssignmentService } from '../../../../../services/assignment/test/common/nullAssignmentService.js'; import { nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { TestWorkerService } from '../../../../inlineChat/test/browser/testWorkerService.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; @@ -45,7 +47,6 @@ import { IChatVariablesService } from '../../../common/attachments/chatVariables import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; -import { IHooksExecutionService } from '../../../common/hooks/hooksExecutionService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; @@ -88,14 +89,14 @@ suite('ChatEditingService', function () { collection.set(IMcpService, new TestMcpService()); collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); - collection.set(IHooksExecutionService, new class extends mock() { - override registerHooks() { return Disposable.None; } - }); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; } }); + collection.set(IWorkspaceEditingService, new class extends mock() { + override readonly onDidEnterWorkspace = Event.None; + }); collection.set(INotebookService, new class extends mock() { override getNotebookTextModel(_uri: URI): NotebookTextModel | undefined { return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 453f76531c1..3ee8fdae9f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -4,14 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter, Event } from '../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { ICommandEvent, ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; -import { ChatTipService } from '../../browser/chatTipService.js'; +import { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; +import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; +import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatModeKind } from '../../common/constants.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; +import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { override contextMatchesRules(): boolean { @@ -25,6 +36,10 @@ suite('ChatTipService', () => { let instantiationService: TestInstantiationService; let contextKeyService: MockContextKeyServiceWithRulesMatching; let configurationService: TestConfigurationService; + let commandExecutedEmitter: Emitter; + let storageService: InMemoryStorageService; + let mockInstructionFiles: IResolvedAgentFile[]; + let mockPromptInstructionFiles: IPromptPath[]; function createProductService(hasCopilot: boolean): IProductService { return { @@ -36,15 +51,31 @@ suite('ChatTipService', () => { function createService(hasCopilot: boolean = true, tipsEnabled: boolean = true): ChatTipService { instantiationService.stub(IProductService, createProductService(hasCopilot)); configurationService.setUserConfiguration('chat.tips.enabled', tipsEnabled); - return instantiationService.createInstance(ChatTipService); + return testDisposables.add(instantiationService.createInstance(ChatTipService)); } setup(() => { instantiationService = testDisposables.add(new TestInstantiationService()); contextKeyService = new MockContextKeyServiceWithRulesMatching(); configurationService = new TestConfigurationService(); + commandExecutedEmitter = testDisposables.add(new Emitter()); + storageService = testDisposables.add(new InMemoryStorageService()); + mockInstructionFiles = []; + mockPromptInstructionFiles = []; instantiationService.stub(IContextKeyService, contextKeyService); instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IStorageService, storageService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ICommandService, { + onDidExecuteCommand: commandExecutedEmitter.event, + onWillExecuteCommand: testDisposables.add(new Emitter()).event, + } as Partial as ICommandService); + instantiationService.stub(IPromptsService, { + listAgentInstructions: async () => mockInstructionFiles, + listPromptFiles: async () => mockPromptInstructionFiles, + onDidChangeCustomAgents: Event.None, + } as Partial as IPromptsService); + instantiationService.stub(ILanguageModelToolsService, testDisposables.add(new MockLanguageModelToolsService())); }); test('returns a tip for new requests with timestamp after service creation', () => { @@ -137,4 +168,449 @@ suite('ChatTipService', () => { const tip = service.getNextTip('new-request', now + 1000, contextKeyService); assert.ok(tip, 'New request should get a tip after multiple old requests'); }); + + test('dismissTip excludes the dismissed tip and allows a new one', () => { + const service = createService(); + const now = Date.now(); + + // Get a tip + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1); + + // Dismiss it + service.dismissTip(); + + // Next call should return a different tip (since the dismissed one is excluded) + const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + if (tip2) { + assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be shown again'); + } + // tip2 may be undefined if it was the only eligible tip — that's also valid + }); + + test('dismissTip fires onDidDismissTip event', () => { + const service = createService(); + const now = Date.now(); + + service.getNextTip('request-1', now + 1000, contextKeyService); + + let fired = false; + testDisposables.add(service.onDidDismissTip(() => { fired = true; })); + service.dismissTip(); + + assert.ok(fired, 'onDidDismissTip should fire'); + }); + + test('disableTips fires onDidDisableTips event', async () => { + const service = createService(); + const now = Date.now(); + + service.getNextTip('request-1', now + 1000, contextKeyService); + + let fired = false; + testDisposables.add(service.onDidDisableTips(() => { fired = true; })); + await service.disableTips(); + + assert.ok(fired, 'onDidDisableTips should fire'); + }); + + test('disableTips resets state so re-enabling works', async () => { + const service = createService(); + const now = Date.now(); + + // Show a tip + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1); + + // Disable tips + await service.disableTips(); + + // Re-enable tips + configurationService.setUserConfiguration('chat.tips.enabled', true); + + // Should be able to get a tip again on a new request + const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); + assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); + }); + + function createMockPromptsService( + agentInstructions: IResolvedAgentFile[] = [], + promptInstructions: IPromptPath[] = [], + options?: { onDidChangeCustomAgents?: Event; listPromptFiles?: (_type: PromptsType) => Promise }, + ): Partial { + return { + listAgentInstructions: async () => agentInstructions, + listPromptFiles: options?.listPromptFiles ?? (async (_type: PromptsType) => promptInstructions), + onDidChangeCustomAgents: options?.onDidChangeCustomAgents ?? Event.None, + }; + } + + function createMockToolsService(): MockLanguageModelToolsService { + return testDisposables.add(new MockLanguageModelToolsService()); + } + + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { + const tip: ITipDefinition = { + id: 'tip.undoChanges', + message: 'test', + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before command is executed'); + + commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after command is executed'); + }); + + test('excludes tip.customInstructions when copilot-instructions.md exists in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([{ uri: { path: '/.github/copilot-instructions.md' }, realPath: undefined, type: AgentFileType.copilotInstructionsMd } as IResolvedAgentFile]) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when copilot-instructions.md exists'); + }); + + test('does not exclude tip.customInstructions when only AGENTS.md exists', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([{ uri: { path: '/AGENTS.md' }, realPath: undefined, type: AgentFileType.agentsMd } as IResolvedAgentFile]) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when only AGENTS.md exists'); + }); + + test('excludes tip.customInstructions when .instructions.md files exist in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [{ uri: URI.file('/.github/instructions/coding.instructions.md'), storage: PromptsStorage.local, type: PromptsType.instructions }]) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when .instructions.md files exist'); + }); + + test('does not exclude tip.customInstructions when no instruction files exist', async () => { + const tip: ITipDefinition = { + id: 'tip.customInstructions', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.instructions, agentFileType: AgentFileType.copilotInstructionsMd, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no instruction files exist'); + }); + + test('excludes tip.agentMode when agent mode has been used in workspace', () => { + const tip: ITipDefinition = { + id: 'tip.agentMode', + message: 'test', + excludeWhenModesUsed: [ChatModeKind.Agent], + }; + + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); + + tracker.recordCurrentMode(contextKeyService); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after agent mode has been recorded'); + }); + + test('excludes tip.planMode when Plan mode has been used in workspace', () => { + const tip: ITipDefinition = { + id: 'tip.planMode', + message: 'test', + excludeWhenModesUsed: ['Plan'], + }; + + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Plan'); + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); + + tracker.recordCurrentMode(contextKeyService); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after Plan mode has been recorded'); + }); + + test('persists command exclusions to workspace storage across tracker instances', () => { + const tip: ITipDefinition = { + id: 'tip.undoChanges', + message: 'test', + excludeWhenCommandsExecuted: ['workbench.action.chat.restoreCheckpoint'], + }; + + const tracker1 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); + assert.strictEqual(tracker1.isExcluded(tip), true); + + // Second tracker reads from storage — should be excluded immediately + const tracker2 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted exclusion from workspace storage'); + }); + + test('persists mode exclusions to workspace storage across tracker instances', () => { + const tip: ITipDefinition = { + id: 'tip.agentMode', + message: 'test', + excludeWhenModesUsed: [ChatModeKind.Agent], + }; + + contextKeyService.createKey(ChatContextKeys.chatModeKind.key, ChatModeKind.Agent); + contextKeyService.createKey(ChatContextKeys.chatModeName.key, 'Agent'); + + const tracker1 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + tracker1.recordCurrentMode(contextKeyService); + assert.strictEqual(tracker1.isExcluded(tip), true); + + // Second tracker reads from storage — should be excluded immediately + const tracker2 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted mode exclusion from workspace storage'); + }); + + test('excludes tip when tracked tool has been invoked', () => { + const mockToolsService = createMockToolsService(); + const tip: ITipDefinition = { + id: 'tip.mermaid', + message: 'test', + excludeWhenToolsInvoked: ['renderMermaidDiagram'], + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before tool is invoked'); + + mockToolsService.fireOnDidInvokeTool({ toolId: 'renderMermaidDiagram', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after tool is invoked'); + }); + + test('persists tool exclusions to workspace storage across tracker instances', () => { + const mockToolsService = createMockToolsService(); + const tip: ITipDefinition = { + id: 'tip.subagents', + message: 'test', + excludeWhenToolsInvoked: ['runSubagent'], + }; + + const tracker1 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + mockToolsService, + new NullLogService(), + )); + + mockToolsService.fireOnDidInvokeTool({ toolId: 'runSubagent', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); + assert.strictEqual(tracker1.isExcluded(tip), true); + + // Second tracker reads from storage — should be excluded immediately + const tracker2 = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted tool exclusion from workspace storage'); + }); + + test('excludes tip.skill when skill files exist in workspace', async () => { + const tip: ITipDefinition = { + id: 'tip.skill', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [{ uri: URI.file('/.github/skills/my-skill.skill.md'), storage: PromptsStorage.local, type: PromptsType.skill }]) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when skill files exist'); + }); + + test('does not exclude tip.skill when no skill files exist', async () => { + const tip: ITipDefinition = { + id: 'tip.skill', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.skill }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Wait for the async file check to complete + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); + }); + + test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { + const agentChangeEmitter = testDisposables.add(new Emitter()); + let agentFiles: IPromptPath[] = []; + + const tip: ITipDefinition = { + id: 'tip.customAgent', + message: 'test', + excludeWhenPromptFilesExist: { promptType: PromptsType.agent, excludeUntilChecked: true }, + }; + + const tracker = testDisposables.add(new TipEligibilityTracker( + [tip], + { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, + storageService, + createMockPromptsService([], [], { + onDidChangeCustomAgents: agentChangeEmitter.event, + listPromptFiles: async () => agentFiles, + }) as IPromptsService, + createMockToolsService(), + new NullLogService(), + )); + + // Initial check: no agent files, but excludeUntilChecked means excluded first + await new Promise(r => setTimeout(r, 0)); + assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded after initial check finds no files'); + + // Simulate agent files appearing + agentFiles = [{ uri: URI.file('/.github/agents/my-agent.agent.md'), storage: PromptsStorage.local, type: PromptsType.agent }]; + agentChangeEmitter.fire(); + await new Promise(r => setTimeout(r, 0)); + + assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after onDidChangeCustomAgents fires and agent files exist'); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts index 617a4ae433c..d75ce8adbb8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; +import { buildNewHookEntry, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; /** * Helper to extract the selected text from content using a selection range. @@ -652,4 +653,73 @@ suite('hookUtils', () => { }); }); }); + + suite('findHookCommandSelection with buildNewHookEntry', () => { + + test('finds command in Copilot-format generated JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const content = JSON.stringify({ hooks: { SessionStart: [entry] } }, null, '\t'); + const result = findHookCommandSelection(content, 'SessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + }); + + test('finds command in Claude-format generated JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { PreToolUse: [entry] } }, null, '\t'); + const result = findHookCommandSelection(content, 'PreToolUse', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + }); + + test('finds command when appending Claude entry to existing hooks', () => { + const entry1 = buildNewHookEntry(HookSourceFormat.Claude); + const entry2 = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { PreToolUse: [entry1, entry2] } }, null, '\t'); + + const result0 = findHookCommandSelection(content, 'PreToolUse', 0, 'command'); + const result1 = findHookCommandSelection(content, 'PreToolUse', 1, 'command'); + assert.ok(result0); + assert.ok(result1); + assert.strictEqual(getSelectedText(content, result0), ''); + assert.strictEqual(getSelectedText(content, result1), ''); + // Second entry should be on a later line + assert.ok(result1.startLineNumber > result0.startLineNumber); + }); + + test('Claude format JSON has correct structure', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const content = JSON.stringify({ hooks: { SubagentStart: [entry] } }, null, '\t'); + const parsed = JSON.parse(content); + assert.deepStrictEqual(parsed, { + hooks: { + SubagentStart: [ + { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + } + ] + } + }); + }); + + test('Copilot format JSON has correct structure', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const content = JSON.stringify({ hooks: { SubagentStart: [entry] } }, null, '\t'); + const parsed = JSON.parse(content); + assert.deepStrictEqual(parsed, { + hooks: { + SubagentStart: [ + { + type: 'command', + command: '' + } + ] + } + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index cd2fa76b689..36a6634e564 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -19,7 +19,7 @@ import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../ import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IChatModeService } from '../../../../common/chatModes.js'; import { PromptHeaderAutocompletion } from '../../../../common/promptSyntax/languageProviders/promptHeaderAutocompletion.js'; -import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; @@ -75,6 +75,7 @@ suite('PromptHeaderAutocompletion', () => { }, uri: URI.parse('myFs://.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -97,9 +98,9 @@ suite('PromptHeaderAutocompletion', () => { completionProvider = instaService.createInstance(PromptHeaderAutocompletion); }); - async function getCompletions(content: string, promptType: PromptsType) { + async function getCompletions(content: string, promptType: PromptsType, uri?: URI) { const languageId = getLanguageIdForPromptsType(promptType); - const uri = URI.parse('test:///test' + getPromptFileExtension(promptType)); + uri ??= URI.parse('test:///test' + getPromptFileExtension(promptType)); const model = disposables.add(createTextModel(content, languageId, undefined, uri)); // get the completion location from the '|' marker const lineColumnMarkerRange = model.findNextMatch('|', new Position(1, 1), false, false, '', false)?.range; @@ -207,7 +208,6 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.agent); // GPT 4 is excluded because it has agentMode: false assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'MAE 4 (olama)', result: `model: ['MAE 4 (olama)', 'MAE 4 (olama)']` }, { label: 'MAE 4.1 (copilot)', result: `model: ['MAE 4 (olama)', 'MAE 4.1 (copilot)']` }, ].sort(sortByLabel)); }); @@ -222,16 +222,16 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.agent); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'agent', result: `tools: ['agent']` }, - { label: 'execute', result: `tools: ['execute']` }, - { label: 'read', result: `tools: ['read']` }, - { label: 'tool1', result: `tools: ['tool1']` }, - { label: 'tool2', result: `tools: ['tool2']` }, - { label: 'vscode', result: `tools: ['vscode']` }, + { label: 'agent', result: `tools: [agent]` }, + { label: 'execute', result: `tools: [execute]` }, + { label: 'read', result: `tools: [read]` }, + { label: 'tool1', result: `tools: [tool1]` }, + { label: 'tool2', result: `tools: [tool2]` }, + { label: 'vscode', result: `tools: [vscode]` }, ].sort(sortByLabel)); }); - test('complete tool names inside tools array with existing entries', async () => { + test('complete tool names inside tools array with existing single quoted entries', async () => { const content = [ '---', 'description: "Test"', @@ -243,13 +243,48 @@ suite('PromptHeaderAutocompletion', () => { assert.deepStrictEqual(actual.sort(sortByLabel), [ { label: 'agent', result: `tools: ['read', 'agent']` }, { label: 'execute', result: `tools: ['read', 'execute']` }, - { label: 'read', result: `tools: ['read', 'read']` }, { label: 'tool1', result: `tools: ['read', 'tool1']` }, { label: 'tool2', result: `tools: ['read', 'tool2']` }, { label: 'vscode', result: `tools: ['read', 'vscode']` }, ].sort(sortByLabel)); }); + test('complete tool names inside tools array with existing double quoted entries', async () => { + const content = [ + '---', + 'description: "Test"', + `tools: ["read", "tool1", |]`, + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'agent', result: `tools: ["read", "tool1", "agent"]` }, + { label: 'execute', result: `tools: ["read", "tool1", "execute"]` }, + { label: 'tool2', result: `tools: ["read", "tool1", "tool2"]` }, + { label: 'vscode', result: `tools: ["read", "tool1", "vscode"]` }, + ].sort(sortByLabel)); + }); + + test('complete tool names inside tools array with existing unquoted entries', async () => { + const content = [ + '---', + 'description: "Test"', + `tools: [read, "tool1", |]`, + '---', + ].join('\n'); + + //uses the first entry to determine quote preference, so the new entry should be unquoted + + const actual = await getCompletions(content, PromptsType.agent); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'agent', result: `tools: [read, "tool1", agent]` }, + { label: 'execute', result: `tools: [read, "tool1", execute]` }, + { label: 'tool2', result: `tools: [read, "tool1", tool2]` }, + { label: 'vscode', result: `tools: [read, "tool1", vscode]` }, + ].sort(sortByLabel)); + }); + test('complete tool names inside tools array with existing entries 2', async () => { const content = [ '---', @@ -262,7 +297,6 @@ suite('PromptHeaderAutocompletion', () => { assert.deepStrictEqual(actual.sort(sortByLabel), [ { label: 'agent', result: `tools: ['read', 'agent']` }, { label: 'execute', result: `tools: ['read', 'execute']` }, - { label: 'read', result: `tools: ['read', 'read']` }, { label: 'tool1', result: `tools: ['read', 'tool1']` }, { label: 'tool2', result: `tools: ['read', 'tool2']` }, { label: 'vscode', result: `tools: ['read', 'vscode']` }, @@ -279,7 +313,7 @@ suite('PromptHeaderAutocompletion', () => { const actual = await getCompletions(content, PromptsType.agent); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'agent1', result: `agents: ['agent1']` }, + { label: 'agent1', result: `agents: [agent1]` }, ].sort(sortByLabel)); }); @@ -329,6 +363,155 @@ suite('PromptHeaderAutocompletion', () => { }); }); + suite('claude agent header completions', () => { + // Claude agents are identified by their URI being under .claude/agents/ + const claudeAgentUri = URI.parse('test:///.claude/agents/security-reviewer.agent.md'); + + test('complete attribute names', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + '|', + '---', + 'You are a senior security engineer.', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'disallowedTools', result: 'disallowedTools: ${0:Write, Edit, Bash}' }, + { label: 'hooks', result: 'hooks: $0' }, + { label: 'mcpServers', result: 'mcpServers: $0' }, + { label: 'memory', result: 'memory: ${0:user}' }, + { label: 'model', result: 'model: ${0:sonnet}' }, + { label: 'permissionMode', result: 'permissionMode: ${0:default}' }, + { label: 'skills', result: 'skills: $0' }, + { label: 'tools', result: 'tools: ${0:Read, Edit, Bash}' }, + ].sort(sortByLabel)); + }); + + test('complete attribute names excludes already present ones', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: Edit', + '|', + '---', + 'You are a senior security engineer.', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + // 'tools' should not appear since it is already in the header + const labels = actual.map(a => a.label).sort(); + assert.ok(!labels.includes('tools'), 'tools should not be suggested when already present'); + assert.ok(!labels.includes('name'), 'name should not be suggested when already present'); + assert.ok(!labels.includes('description'), 'description should not be suggested when already present'); + }); + + test('complete model attribute value with claude enum values', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'model: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.sort(sortByLabel), [ + { label: 'haiku', result: 'model: haiku' }, + { label: 'inherit', result: 'model: inherit' }, + { label: 'opus', result: 'model: opus' }, + { label: 'sonnet', result: 'model: sonnet' }, + ].sort(sortByLabel)); + }); + + test('complete tools with comma-separated values', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: Edit, |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + const labels = actual.map(a => a.label).sort(); + assert.deepStrictEqual(labels, [ + 'AskUserQuestion', 'Bash', 'Glob', 'Grep', + 'LSP', 'MCPSearch', 'NotebookEdit', 'Read', 'Skill', + 'Task', 'WebFetch', 'WebSearch', 'Write' + ].sort()); + }); + + test('complete tools inside array syntax', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: [|]', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + const labels = actual.map(a => a.label).sort(); + assert.deepStrictEqual(labels, [ + 'AskUserQuestion', 'Bash', 'Edit', 'Glob', 'Grep', + 'LSP', 'MCPSearch', 'NotebookEdit', 'Read', 'Skill', + 'Task', 'WebFetch', 'WebSearch', 'Write' + ].sort()); + // Array items without quotes should use the name directly + assert.deepStrictEqual(actual.find(a => a.label === 'Edit')?.result, `tools: [Edit]`); + }); + + test('complete tools inside array with existing entries', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: [Edit, |]`, + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.find(a => a.label === 'Read')?.result, `tools: [Edit, Read]`); + assert.deepStrictEqual(actual.find(a => a.label === 'Bash')?.result, `tools: [Edit, Bash]`); + }); + + test('complete disallowedTools with comma-separated values', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'disallowedTools: |', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + const labels = actual.map(a => a.label).sort(); + assert.deepStrictEqual(labels, [ + 'AskUserQuestion', 'Bash', 'Edit', 'Glob', 'Grep', + 'LSP', 'MCPSearch', 'NotebookEdit', 'Read', 'Skill', + 'Task', 'WebFetch', 'WebSearch', 'Write' + ].sort()); + }); + + test('complete disallowedTools inside array syntax', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'disallowedTools: [Bash, |]', + '---', + ].join('\n'); + + const actual = await getCompletions(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(actual.find(a => a.label === 'Write')?.result, `disallowedTools: [Bash, Write]`); + assert.deepStrictEqual(actual.find(a => a.label === 'Edit')?.result, `disallowedTools: [Bash, Edit]`); + }); + }); + suite('prompt header completions', () => { test('complete model attribute name', async () => { const content = [ diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index ebff561a2c0..476b8011ad0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -18,7 +18,7 @@ import { ChatAgentLocation, ChatConfiguration } from '../../../../common/constan import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../../../common/tools/languageModelToolsService.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../../../common/languageModels.js'; import { PromptHoverProvider } from '../../../../common/promptSyntax/languageProviders/promptHovers.js'; -import { IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { createTextModel } from '../../../../../../../editor/test/common/testTextModel.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -55,6 +55,10 @@ suite('PromptHoverProvider', () => { const testModels: ILanguageModelChatMetadata[] = [ { id: 'mae-4', name: 'MAE 4', vendor: 'olama', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, { id: 'mae-4.1', name: 'MAE 4.1', vendor: 'copilot', version: '1.0', family: 'mae', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 8192, maxOutputTokens: 1024, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: { [ChatAgentLocation.Chat]: true } } satisfies ILanguageModelChatMetadata, + // Claude model equivalents + { id: 'claude-sonnet-4.5', name: 'Claude Sonnet 4.5', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata, + { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata, + { id: 'claude-haiku-4.5', name: 'Claude Haiku 4.5', vendor: 'copilot', version: '1.0', family: 'claude', modelPickerCategory: undefined, extension: new ExtensionIdentifier('a.b'), isUserSelectable: true, maxInputTokens: 200000, maxOutputTokens: 8192, capabilities: { agentMode: true, toolCalling: true }, isDefaultForLocation: {} } satisfies ILanguageModelChatMetadata, ]; instaService.stub(ILanguageModelsService, { @@ -74,6 +78,7 @@ suite('PromptHoverProvider', () => { name: 'BeastMode', agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -88,9 +93,11 @@ suite('PromptHoverProvider', () => { hoverProvider = instaService.createInstance(PromptHoverProvider); }); - async function getHover(content: string, line: number, column: number, promptType: PromptsType): Promise { + async function getHover(content: string, line: number, column: number, promptType: PromptsType, options?: { claudeAgent?: boolean }): Promise { const languageId = getLanguageIdForPromptsType(promptType); - const uri = URI.parse('test:///test' + getPromptFileExtension(promptType)); + const ext = getPromptFileExtension(promptType); + const path = options?.claudeAgent ? `/.claude/agents/test${ext}` : `/test${ext}`; + const uri = URI.parse('test://' + path); const model = disposables.add(createTextModel(content, languageId, undefined, uri)); const position = new Position(line, column); const hover = await hoverProvider.provideHover(model, position, CancellationToken.None); @@ -168,7 +175,7 @@ suite('PromptHoverProvider', () => { const expected = [ 'Possible handoff actions when the agent has completed its task.', '', - 'Note: This attribute is not used when target is github-copilot.' + 'Note: This attribute is not used in GitHub Copilot or Claude targets.' ].join('\n'); assert.strictEqual(hover, expected); }); @@ -495,4 +502,281 @@ suite('PromptHoverProvider', () => { assert.strictEqual(hover, undefined); }); }); + + suite('claude agent hovers', () => { + // Helper that creates a hover in a Claude agent file (URI under .claude/agents/) + async function getClaudeHover(content: string, line: number, column: number): Promise { + return getHover(content, line, column, PromptsType.agent, { claudeAgent: true }); + } + + test('hover on name attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 2, 1); + assert.strictEqual(hover, 'Unique identifier using lowercase letters and hyphens (required)'); + }); + + test('hover on description attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 3, 1); + assert.strictEqual(hover, 'When to delegate to this subagent (required)'); + }); + + test('hover on tools attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + 'tools: Edit, Grep, AskUserQuestion, WebFetch', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Array of tools the subagent can use. Inherits all tools if omitted'); + }); + + test('hover on individual Claude tool shows tool description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code', + `tools: ['Edit', 'Grep', 'WebFetch']`, + '---', + ].join('\n'); + // Hover on 'Edit' tool + const hoverEdit = await getClaudeHover(content, 4, 10); + assert.strictEqual(hoverEdit, 'Make targeted file edits'); + + // Hover on 'Grep' tool + const hoverGrep = await getClaudeHover(content, 4, 17); + assert.strictEqual(hoverGrep, 'Search file contents with regex'); + + // Hover on 'WebFetch' tool + const hoverFetch = await getClaudeHover(content, 4, 27); + assert.strictEqual(hoverFetch, 'Fetch URL content'); + }); + + test('hover on model attribute shows Claude description', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code', + 'model: opus', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `opus` maps to the following model:', + '', + '- Name: Claude Opus 4.6', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on model attribute with sonnet value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `sonnet` maps to the following model:', + '', + '- Name: Claude Sonnet 4.5', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on model attribute with haiku value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: haiku', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `haiku` maps to the following model:', + '', + '- Name: Claude Haiku 4.5', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on model attribute with inherit value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: inherit', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + const expected = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Inherit model from parent agent or prompt' + ].join('\n'); + assert.strictEqual(hover, expected); + }); + + test('hover on disallowedTools attribute shows Claude description', async () => { + const content = [ + '---', + 'name: read-only-agent', + 'description: Read-only analysis agent', + `disallowedTools: ['Write', 'Edit', 'Bash']`, + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Tools to deny, removed from inherited or specified list'); + }); + + test('hover on individual disallowedTools value shows tool description', async () => { + const content = [ + '---', + 'name: read-only-agent', + 'description: Read-only', + `disallowedTools: ['Bash', 'Write']`, + '---', + ].join('\n'); + // Hover on 'Bash' tool value + const hoverBash = await getClaudeHover(content, 4, 20); + assert.strictEqual(hoverBash, 'Execute shell commands'); + + // Hover on 'Write' tool value + const hoverWrite = await getClaudeHover(content, 4, 28); + assert.strictEqual(hoverWrite, 'Create/overwrite files'); + }); + + test('hover on permissionMode attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'permissionMode: acceptEdits', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan.'); + }); + + test('hover on memory attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'memory: project', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Persistent memory scope: user, project, or local. Enables cross-session learning.'); + }); + + test('hover on skills attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'skills: ["code-review"]', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Skills to load into the subagent\'s context at startup.'); + }); + + test('hover on hooks attribute shows Claude description', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'hooks: {}', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + assert.strictEqual(hover, 'Lifecycle hooks scoped to this subagent.'); + }); + + test('hover on handoffs attribute in Claude agent shows not-used note', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'handoffs:', + ' - label: Test', + ' agent: Default', + ' prompt: Test', + '---', + ].join('\n'); + const hover = await getClaudeHover(content, 4, 1); + // handoffs is not a Claude attribute, so no hover should appear + assert.strictEqual(hover, undefined); + }); + + test('full example: hover on each attribute of a Claude agent', async () => { + // Realistic Claude agent file as user provided + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: ['Edit', 'Grep', 'AskUserQuestion', 'WebFetch']`, + 'model: opus', + '---', + 'You are a senior security engineer.', + ].join('\n'); + + // Hover on name (line 2) + const nameHover = await getClaudeHover(content, 2, 1); + assert.strictEqual(nameHover, 'Unique identifier using lowercase letters and hyphens (required)'); + + // Hover on description (line 3) + const descHover = await getClaudeHover(content, 3, 1); + assert.strictEqual(descHover, 'When to delegate to this subagent (required)'); + + // Hover on tools attribute key (line 4, column 1) + const toolsHover = await getClaudeHover(content, 4, 1); + assert.strictEqual(toolsHover, 'Array of tools the subagent can use. Inherits all tools if omitted'); + + // Hover on 'AskUserQuestion' tool value (line 4) + const askHover = await getClaudeHover(content, 4, 28); + assert.strictEqual(askHover, 'Ask multiple-choice questions'); + + // Hover on model value 'opus' (line 5) + const modelHover = await getClaudeHover(content, 5, 1); + const expectedModelHover = [ + 'Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit.', + '', + 'Claude model `opus` maps to the following model:', + '', + '- Name: Claude Opus 4.6', + '- Family: claude', + '- Vendor: copilot' + ].join('\n'); + assert.strictEqual(modelHover, expectedModelHover); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index fafa0b4fdb7..071f767b5d1 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -25,7 +25,7 @@ import { getPromptFileExtension } from '../../../../common/promptSyntax/config/p import { PromptValidator } from '../../../../common/promptSyntax/languageProviders/promptValidator.js'; import { PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -import { ICustomAgent, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockChatModeService } from '../../../common/mockChatModeService.js'; import { MockPromptsService } from '../../../common/promptSyntax/service/mockPromptsService.js'; @@ -135,6 +135,7 @@ suite('PromptValidator', () => { name: 'BeastMode', agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -154,6 +155,7 @@ suite('PromptValidator', () => { tools: ['tool1', 'tool2'], agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; promptsService.setCustomModes([customMode]); @@ -206,7 +208,7 @@ suite('PromptValidator', () => { ); }); - test('tools must be array', async () => { + test('tools must be array or string', async () => { const content = [ '---', 'description: "Test"', @@ -214,8 +216,7 @@ suite('PromptValidator', () => { '---', ].join('\n'); const markers = await validate(content, PromptsType.agent); - assert.strictEqual(markers.length, 1); - assert.deepStrictEqual(markers.map(m => m.message), [`The 'tools' attribute must be an array.`]); + assert.strictEqual(markers.length, 0); }); test('model as string array - valid', async () => { @@ -1853,4 +1854,383 @@ suite('PromptValidator', () => { }); + suite('claude rules', () => { + + // Helper URI for Claude rules — file must be under .claude/rules/ for target detection + const claudeRulesUri = URI.parse('myFs://test/.claude/rules/my-rule.md'); + + test('valid claude rules with paths attribute', async () => { + const content = [ + '---', + 'description: "TypeScript rules"', + `paths: ['**/*.ts', '**/*.tsx']`, + '---', + 'Always use strict mode.', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.deepStrictEqual(markers, []); + }); + + test('valid claude rules without paths attribute', async () => { + const content = [ + '---', + 'description: "General rules"', + '---', + 'Follow coding guidelines.', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.deepStrictEqual(markers, []); + }); + + test('claude rules paths must be an array', async () => { + const content = [ + '---', + 'description: "Rules"', + 'paths: "**/*.ts"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'paths' attribute must be an array of glob patterns.`); + }); + + test('claude rules paths entries must be strings', async () => { + const content = [ + '---', + 'description: "Rules"', + `paths: [123, '**/*.ts']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Each entry in the 'paths' attribute must be a string.`); + }); + + test('claude rules paths entries must be non-empty', async () => { + const content = [ + '---', + 'description: "Rules"', + `paths: ['']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `Path entries must be non-empty glob patterns.`); + }); + + test('claude rules with unknown attribute shows warning', async () => { + const content = [ + '---', + 'description: "Rules"', + 'applyTo: "**/*.ts"', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.ok(markers[0].message.includes(`Attribute 'applyTo' is not supported in rules files.`)); + }); + + test('claude rules with multiple validation errors', async () => { + const content = [ + '---', + 'description: ""', + `paths: ['', 123]`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, claudeRulesUri); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'description' attribute should not be empty.` }, + { severity: MarkerSeverity.Error, message: `Path entries must be non-empty glob patterns.` }, + { severity: MarkerSeverity.Error, message: `Each entry in the 'paths' attribute must be a string.` }, + ] + ); + }); + + test('claude rules in subdirectory', async () => { + const subDirUri = URI.parse('myFs://test/.claude/rules/sub/deep-rule.md'); + const content = [ + '---', + 'description: "Nested rules"', + `paths: ['src/**/*.ts']`, + '---', + 'Nested rule content.', + ].join('\n'); + const markers = await validate(content, PromptsType.instructions, subDirUri); + assert.deepStrictEqual(markers, []); + }); + }); + + suite('claude agents', () => { + + // Helper URI for Claude agents — file must be under .claude/agents/ for target detection + const claudeAgentUri = URI.parse('myFs://test/.claude/agents/test.agent.md'); + + test('valid Claude agent with all common attributes', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: ['Edit', 'Grep', 'AskUserQuestion', 'WebFetch']`, + 'model: opus', + 'permissionMode: delegate', + '---', + 'You are a senior security engineer.', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('valid Claude agent with minimal attributes', async () => { + const content = [ + '---', + 'name: helper', + 'description: A simple helper agent', + '---', + 'You help with tasks.', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('Claude agent with valid model values', async () => { + // Each known Claude model should be valid + for (const modelName of ['sonnet', 'opus', 'haiku', 'inherit']) { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `model: ${modelName}`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], `Model '${modelName}' should be valid`); + } + }); + + test('Claude agent with unknown model value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: gpt-4', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown value 'gpt-4', valid: sonnet, opus, haiku, inherit.`); + }); + + test('Claude agent with non-string model value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: 123', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'model' attribute must be a string.`); + }); + + test('Claude agent with valid permissionMode values', async () => { + for (const mode of ['default', 'acceptEdits', 'plan', 'delegate', 'dontAsk', 'bypassPermissions']) { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `permissionMode: ${mode}`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], `permissionMode '${mode}' should be valid`); + } + }); + + test('Claude agent with unknown permissionMode value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + 'permissionMode: allowAll', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown value 'allowAll', valid: default, acceptEdits, plan, delegate, dontAsk, bypassPermissions.`); + }); + + test('Claude agent with non-string permissionMode value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + 'permissionMode: true', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'permissionMode' attribute must be a string.`); + }); + + test('Claude agent with valid memory values', async () => { + for (const mem of ['user', 'project', 'local']) { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `memory: ${mem}`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], `memory '${mem}' should be valid`); + } + }); + + test('Claude agent with unknown memory value', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: sonnet', + 'permissionMode: default', + 'memory: global', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `Unknown value 'global', valid: user, project, local.`); + }); + + test('Claude agent with empty name shows error', async () => { + const content = [ + '---', + 'name: ""', + 'description: Test', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'name' attribute must not be empty.`); + }); + + test('Claude agent with empty description shows error', async () => { + const content = [ + '---', + 'name: test-agent', + 'description: ""', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Error); + assert.strictEqual(markers[0].message, `The 'description' attribute should not be empty.`); + }); + + test('Claude agent with unknown attributes does not warn', async () => { + // Claude target ignores unknown attributes since we don't have a full list + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'customAttribute: someValue', + 'anotherCustom: 123', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], 'Unknown attributes should be silently ignored for Claude agents'); + }); + + test('Claude agent tools are not validated against VS Code tool registry', async () => { + // Claude tool names (Edit, Grep, etc.) don't exist in VS Code's tool registry + // but should not produce warnings for Claude target + const content = [ + '---', + 'name: test-agent', + 'description: Test', + `tools: ['Edit', 'Grep', 'UnknownClaudeTool', 'WebFetch']`, + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], 'Claude tools should not be validated against VS Code registry'); + }); + + test('Claude agent with comma-separated tools string', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code', + 'tools: Edit, Grep, AskUserQuestion, WebFetch', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, [], 'Comma-separated tools string should be valid for Claude'); + }); + + test('Claude agent does not validate handoffs or agents attributes', async () => { + // handoffs and agents are VS Code-specific; they shouldn't be validated for Claude + const content = [ + '---', + 'name: test-agent', + 'description: Test', + 'model: opus', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('Claude agent full realistic example', async () => { + const content = [ + '---', + 'name: security-reviewer', + 'description: Reviews code for security vulnerabilities', + `tools: ['Edit', 'Grep', 'AskUserQuestion', 'WebFetch']`, + 'model: opus', + 'permissionMode: delegate', + 'memory: project', + '---', + 'You are a senior security engineer.', + 'Review the code for common vulnerabilities.', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual(markers, []); + }); + + test('Claude agent with multiple validation errors', async () => { + const content = [ + '---', + 'name: ""', + 'description: ""', + 'model: unknown-model', + 'permissionMode: invalid-mode', + '---', + ].join('\n'); + const markers = await validate(content, PromptsType.agent, claudeAgentUri); + assert.deepStrictEqual( + markers.map(m => ({ severity: m.severity, message: m.message })), + [ + { severity: MarkerSeverity.Error, message: `The 'name' attribute must not be empty.` }, + { severity: MarkerSeverity.Error, message: `The 'description' attribute should not be empty.` }, + { severity: MarkerSeverity.Warning, message: `Unknown value 'unknown-model', valid: sonnet, opus, haiku, inherit.` }, + { severity: MarkerSeverity.Warning, message: `Unknown value 'invalid-mode', valid: default, acceptEdits, plan, delegate, dontAsk, bypassPermissions.` }, + ] + ); + }); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 0c19f89c566..75d099cd9b7 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -8,12 +8,12 @@ import { Barrier } from '../../../../../../base/common/async.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { CancellationError, isCancellationError } from '../../../../../../base/common/errors.js'; -import { Event } from '../../../../../../base/common/event.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAccessibilityService } from '../../../../../../platform/accessibility/common/accessibility.js'; import { TestAccessibilityService } from '../../../../../../platform/accessibility/test/common/testAccessibilityService.js'; import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationChangeEvent } from '../../../../../../platform/configuration/common/configuration.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { ContextKeyService } from '../../../../../../platform/contextkey/browser/contextKeyService.js'; @@ -33,10 +33,6 @@ import { ILanguageModelToolsConfirmationService } from '../../../common/tools/la import { MockLanguageModelToolsConfirmationService } from '../../common/tools/mockLanguageModelToolsConfirmationService.js'; import { runWithFakedTimers } from '../../../../../../base/test/common/timeTravelScheduler.js'; import { ILanguageModelChatMetadata } from '../../../common/languageModels.js'; -import { IHookResult, IPostToolUseCallerInput, IPostToolUseHookResult, IPreToolUseCallerInput, IPreToolUseHookResult } from '../../../common/hooks/hooksTypes.js'; -import { IHooksExecutionService, IHooksExecutionOptions, IHooksExecutionProxy } from '../../../common/hooks/hooksExecutionService.js'; -import { HookTypeValue, IChatRequestHooks } from '../../../common/promptSyntax/hookSchema.js'; -import { IDisposable } from '../../../../../../base/common/lifecycle.js'; // --- Test helpers to reduce repetition and improve readability --- @@ -64,30 +60,6 @@ class TestTelemetryService implements Partial { } } -class MockHooksExecutionService implements IHooksExecutionService { - readonly _serviceBrand: undefined; - readonly onDidExecuteHook = Event.None; - public preToolUseHookResult: IPreToolUseHookResult | undefined = undefined; - public postToolUseHookResult: IPostToolUseHookResult | undefined = undefined; - public lastPreToolUseInput: IPreToolUseCallerInput | undefined = undefined; - public lastPostToolUseInput: IPostToolUseCallerInput | undefined = undefined; - - setProxy(_proxy: IHooksExecutionProxy): void { } - registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } - getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; } - executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { - return Promise.resolve([]); - } - async executePreToolUseHook(_sessionResource: URI, input: IPreToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPreToolUseInput = input; - return this.preToolUseHookResult; - } - async executePostToolUseHook(_sessionResource: URI, input: IPostToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPostToolUseInput = input; - return this.postToolUseHookResult; - } -} - function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { const toolData: IToolData = { id, @@ -146,7 +118,7 @@ interface TestToolsServiceOptions { accessibilityService?: IAccessibilityService; accessibilitySignalService?: Partial; telemetryService?: Partial; - hooksExecutionService?: MockHooksExecutionService; + commandService?: Partial; /** Called after configurationService is created but before the service is instantiated */ configureServices?: (config: TestConfigurationService) => void; } @@ -170,7 +142,6 @@ function createTestToolsService(store: ReturnType { // Test with enabled tool { const fullReferenceNames = ['tool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 1, 'Expected 1 tool to be enabled'); assert.strictEqual(result1.get(tool1), true, 'tool1 should be enabled'); @@ -751,7 +725,7 @@ suite('LanguageModelToolsService', () => { // Test with multiple enabled tools { const fullReferenceNames = ['my.extension/extTool1RefName', 'mcpToolSetRefName/*', 'internalToolSetRefName/internalToolSetTool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 4, 'Expected 4 tools to be enabled'); assert.strictEqual(result1.get(extTool1), true, 'extTool1 should be enabled'); @@ -764,7 +738,7 @@ suite('LanguageModelToolsService', () => { } // Test with all enabled tools, redundant names { - const result1 = service.toToolAndToolSetEnablementMap(allFullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(allFullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 12, 'Expected 12 tools to be enabled'); // +4 including the vscode, execute, read, agent toolsets @@ -775,7 +749,7 @@ suite('LanguageModelToolsService', () => { // Test with no enabled tools { const fullReferenceNames: string[] = []; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 0, 'Expected 0 tools to be enabled'); @@ -785,7 +759,7 @@ suite('LanguageModelToolsService', () => { // Test with unknown tool { const fullReferenceNames: string[] = ['unknownToolRefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 0, 'Expected 0 tools to be enabled'); @@ -795,7 +769,7 @@ suite('LanguageModelToolsService', () => { // Test with legacy tool names { const fullReferenceNames: string[] = ['extTool1RefName', 'mcpToolSetRefName', 'internalToolSetTool1RefName']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 4, 'Expected 4 tools to be enabled'); assert.strictEqual(result1.get(extTool1), true, 'extTool1 should be enabled'); @@ -810,7 +784,7 @@ suite('LanguageModelToolsService', () => { // Test with tool in user tool set { const fullReferenceNames = ['Tool2 Display Name']; - const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined, undefined); + const result1 = service.toToolAndToolSetEnablementMap(fullReferenceNames, undefined); assert.strictEqual(result1.size, numOfTools, `Expected ${numOfTools} tools and tool sets`); assert.strictEqual([...result1.entries()].filter(([_, enabled]) => enabled).length, 2, 'Expected 1 tool and user tool set to be enabled'); assert.strictEqual(result1.get(tool2), true, 'tool2 should be enabled'); @@ -837,7 +811,7 @@ suite('LanguageModelToolsService', () => { // Test enabling the tool set const enabledNames = [toolData1].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(toolData1), true, 'individual tool should be enabled'); @@ -897,7 +871,7 @@ suite('LanguageModelToolsService', () => { // Test enabling the tool set const enabledNames = [toolSet, toolData1].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(toolData1), true, 'individual tool should be enabled'); assert.strictEqual(result.get(toolData2), false); @@ -932,7 +906,7 @@ suite('LanguageModelToolsService', () => { // Test with non-existent tool names const enabledNames = [toolData, unregisteredToolData].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(toolData), true, 'existing tool should be enabled'); // Non-existent tools should not appear in the result map @@ -981,7 +955,7 @@ suite('LanguageModelToolsService', () => { // Test 1: Using legacy tool reference name should enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolName'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -990,7 +964,7 @@ suite('LanguageModelToolsService', () => { // Test 2: Using another legacy tool reference name should also work { - const result = service.toToolAndToolSetEnablementMap(['deprecatedToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['deprecatedToolName'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via another legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -999,7 +973,7 @@ suite('LanguageModelToolsService', () => { // Test 3: Using legacy toolset name should enable the entire toolset { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled when set is enabled via legacy name'); @@ -1009,7 +983,7 @@ suite('LanguageModelToolsService', () => { // Test 4: Using deprecated toolset name should also work { - const result = service.toToolAndToolSetEnablementMap(['deprecatedToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['deprecatedToolSet'], undefined); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via another legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled when set is enabled via legacy name'); @@ -1019,7 +993,7 @@ suite('LanguageModelToolsService', () => { // Test 5: Mix of current and legacy names { - const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolSet'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled via current name'); assert.strictEqual(result.get(toolSetWithLegacy), true, 'toolset should be enabled via legacy name'); assert.strictEqual(result.get(toolInSet), true, 'tool in set should be enabled'); @@ -1030,7 +1004,7 @@ suite('LanguageModelToolsService', () => { // Test 6: Using legacy names and current names together (redundant but should work) { - const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolName', 'deprecatedToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['newToolRef', 'oldToolName', 'deprecatedToolName'], undefined); assert.strictEqual(result.get(toolWithLegacy), true, 'tool should be enabled (redundant legacy names should not cause issues)'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1056,7 +1030,7 @@ suite('LanguageModelToolsService', () => { // Test 1: Using the full legacy name should enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet/oldToolName'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet/oldToolName'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool should be enabled via full legacy name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1065,7 +1039,7 @@ suite('LanguageModelToolsService', () => { // Test 2: Using just the orphaned toolset name should also enable the tool { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool should be enabled via orphaned toolset name'); const fullReferenceNames = service.toFullReferenceNames(result); @@ -1085,7 +1059,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(anotherToolFromOrphanedSet)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'first tool should be enabled via orphaned toolset name'); assert.strictEqual(result.get(anotherToolFromOrphanedSet), true, 'second tool should also be enabled via orphaned toolset name'); @@ -1106,7 +1080,7 @@ suite('LanguageModelToolsService', () => { store.add(service.registerToolData(unrelatedTool)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); assert.strictEqual(result.get(toolWithOrphanedToolSet), true, 'tool from oldToolSet should be enabled'); assert.strictEqual(result.get(anotherToolFromOrphanedSet), true, 'another tool from oldToolSet should be enabled'); assert.strictEqual(result.get(unrelatedTool), false, 'tool from different toolset should NOT be enabled'); @@ -1134,7 +1108,7 @@ suite('LanguageModelToolsService', () => { store.add(newToolSetWithSameName.addTool(toolInRecreatedSet)); { - const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(['oldToolSet'], undefined); // Now 'oldToolSet' should enable BOTH the recreated toolset AND the tools with legacy names pointing to oldToolSet assert.strictEqual(result.get(newToolSetWithSameName), true, 'recreated toolset should be enabled'); assert.strictEqual(result.get(toolInRecreatedSet), true, 'tool in recreated set should be enabled'); @@ -1224,7 +1198,7 @@ suite('LanguageModelToolsService', () => { { const toolNames = ['custom-agent', 'shell']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(service.executeToolSet), true, 'execute should be enabled'); assert.strictEqual(result.get(service.agentToolSet), true, 'agent should be enabled'); @@ -1239,7 +1213,7 @@ suite('LanguageModelToolsService', () => { } { const toolNames = ['github/*', 'playwright/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1255,7 +1229,7 @@ suite('LanguageModelToolsService', () => { { // the speced names should work and not be altered const toolNames = ['github/create_branch', 'playwright/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1271,7 +1245,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github/github-mcp-server/*', 'microsoft/playwright-mcp/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1286,7 +1260,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github/github-mcp-server/create_branch', 'microsoft/playwright-mcp/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1302,7 +1276,7 @@ suite('LanguageModelToolsService', () => { { // using the latest MCP full names should also work const toolNames = ['io.github.github/github-mcp-server/*', 'com.microsoft/playwright-mcp/*']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpToolSet), true, 'githubMcpToolSet should be enabled'); assert.strictEqual(result.get(playwrightMcpToolSet), true, 'playwrightMcpToolSet should be enabled'); @@ -1318,7 +1292,7 @@ suite('LanguageModelToolsService', () => { { // using the latest MCP full names should also work const toolNames = ['io.github.github/github-mcp-server/create_branch', 'com.microsoft/playwright-mcp/browser_click']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); assert.strictEqual(result.get(playwrightMcpTool1), true, 'playwrightMcpTool1 should be enabled'); @@ -1334,7 +1308,7 @@ suite('LanguageModelToolsService', () => { { // using the old MCP full names should also work const toolNames = ['github-mcp-server/create_branch']; - const result = service.toToolAndToolSetEnablementMap(toolNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(toolNames, undefined); assert.strictEqual(result.get(githubMcpTool1), true, 'githubMcpTool1 should be enabled'); const fullReferenceNames = service.toFullReferenceNames(result).sort(); @@ -1738,7 +1712,6 @@ suite('LanguageModelToolsService', () => { instaService1.stub(IAccessibilityService, testAccessibilityService1); instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService1.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService1.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService)); const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', { @@ -1780,7 +1753,6 @@ suite('LanguageModelToolsService', () => { instaService2.stub(IAccessibilityService, testAccessibilityService2); instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService2.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService2.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService)); const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', { @@ -1823,7 +1795,6 @@ suite('LanguageModelToolsService', () => { instaService3.stub(IAccessibilityService, testAccessibilityService3); instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService); instaService3.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService3.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService)); const tool3 = registerToolForTest(testService3, store, 'offTool', { @@ -2150,7 +2121,7 @@ suite('LanguageModelToolsService', () => { // Enable the MCP toolset { const enabledNames = [mcpToolSet].map(t => service.getFullReferenceName(t)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(mcpToolSet), true, 'MCP toolset should be enabled'); // Ensure the toolset is in the map assert.strictEqual(result.get(mcpTool), true, 'MCP tool should be enabled when its toolset is enabled'); // Ensure the tool is in the map @@ -2161,7 +2132,7 @@ suite('LanguageModelToolsService', () => { // Enable a tool from the MCP toolset { const enabledNames = [mcpTool].map(t => service.getFullReferenceName(t, mcpToolSet)); - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, undefined); + const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined); assert.strictEqual(result.get(mcpToolSet), false, 'MCP toolset should be disabled'); // Ensure the toolset is in the map assert.strictEqual(result.get(mcpTool), true, 'MCP tool should be enabled'); // Ensure the tool is in the map @@ -2593,7 +2564,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); const tool = registerToolForTest(testService, store, 'gitCommitTool', { @@ -2632,7 +2602,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2672,7 +2641,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2715,7 +2683,6 @@ suite('LanguageModelToolsService', () => { }, store); instaService.stub(IChatService, chatService); instaService.stub(ILanguageModelToolsConfirmationService, new MockLanguageModelToolsConfirmationService()); - instaService.stub(IHooksExecutionService, new MockHooksExecutionService()); const testService = store.add(instaService.createInstance(LanguageModelToolsService)); // Tool that was previously namespaced under extension but is now internal @@ -2864,7 +2831,7 @@ suite('LanguageModelToolsService', () => { // Provide model metadata for gpt-4 family const modelMetadata = { id: 'gpt-4-turbo', vendor: 'openai', family: 'gpt-4', version: '1.0' } as ILanguageModelChatMetadata; const enabledNames = ['gpt4ToolRef', 'anyModelToolRef', 'claudeToolRef']; - const result = service.toToolAndToolSetEnablementMap(enabledNames, undefined, modelMetadata); + const result = service.toToolAndToolSetEnablementMap(enabledNames, modelMetadata); // gpt4Tool should be enabled (model matches) assert.strictEqual(result.get(gpt4Tool), true, 'gpt4Tool should be enabled'); @@ -3565,6 +3532,52 @@ suite('LanguageModelToolsService', () => { assert.ok(toolIds.includes('multiSetTool'), 'Tool should be permitted if it belongs to at least one permitted toolset'); }); + test('isPermitted allows internal tools with canBeReferencedInPrompt=false when agent mode is disabled (issue #292935)', () => { + // Disable agent mode + configurationService.setUserConfiguration(ChatConfiguration.AgentEnabled, false); + + // Create internal infrastructure tool that explicitly cannot be referenced in prompts + const infrastructureTool: IToolData = { + id: 'infrastructureToolInternal', + toolReferenceName: 'infrastructureToolRef', + modelDescription: 'Infrastructure Tool', + displayName: 'Infrastructure Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: false, + }; + store.add(service.registerToolData(infrastructureTool)); + + // Create internal tool with canBeReferencedInPrompt=true (should be blocked) + const referencableTool: IToolData = { + id: 'referencableTool', + toolReferenceName: 'referencableToolRef', + modelDescription: 'Referencable Tool', + displayName: 'Referencable Tool', + source: ToolDataSource.Internal, + canBeReferencedInPrompt: true, + }; + store.add(service.registerToolData(referencableTool)); + + // Create internal tool with canBeReferencedInPrompt=undefined (should be blocked) + const undefinedTool: IToolData = { + id: 'undefinedTool', + toolReferenceName: 'undefinedToolRef', + modelDescription: 'Undefined Tool', + displayName: 'Undefined Tool', + source: ToolDataSource.Internal, + // canBeReferencedInPrompt is undefined + }; + store.add(service.registerToolData(undefinedTool)); + + // Get tools - only the infrastructure tool should be available + const tools = Array.from(service.getTools(undefined)); + const toolIds = tools.map(t => t.id); + + assert.ok(toolIds.includes('infrastructureToolInternal'), 'Internal infrastructure tool with canBeReferencedInPrompt=false should be permitted when agent mode is disabled'); + assert.ok(!toolIds.includes('referencableTool'), 'Internal tool with canBeReferencedInPrompt=true should NOT be permitted when agent mode is disabled'); + assert.ok(!toolIds.includes('undefinedTool'), 'Internal tool with canBeReferencedInPrompt=undefined should NOT be permitted when agent mode is disabled'); + }); + suite('ToolSet when clause filtering (issue #291154)', () => { test('ToolSet.getTools filters tools by when clause', () => { // Create a context key for testing @@ -3805,27 +3818,16 @@ suite('LanguageModelToolsService', () => { }); suite('preToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; let hookService: LanguageModelToolsService; let hookChatService: MockChatService; setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); + const setup = createTestToolsService(store); hookService = setup.service; hookChatService = setup.chatService; }); test('when hook denies, tool returns error and creates cancelled invocation', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - success: true, - permissionDecision: 'deny', - permissionDecisionReason: 'Destructive operations require approval', - }; - const tool = registerToolForTest(hookService, store, 'hookDenyTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'should not run' }] }) }); @@ -3833,8 +3835,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }); + dto.preToolUseResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Destructive operations require approval', + }; + const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test' }), + dto, async () => 0, CancellationToken.None ); @@ -3857,12 +3865,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook allows, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - success: true, - permissionDecision: 'allow', - }; - const tool = registerToolForTest(hookService, store, 'hookAllowTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3870,8 +3872,13 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-allow', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + }; + const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-allow' }), + dto, async () => 0, CancellationToken.None ); @@ -3882,8 +3889,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns undefined, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = undefined; - const tool = registerToolForTest(hookService, store, 'hookUndefinedTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3900,38 +3905,7 @@ suite('LanguageModelToolsService', () => { assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); }); - test('hook receives correct input parameters', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - success: true, - permissionDecision: 'allow', - }; - - const tool = registerToolForTest(hookService, store, 'hookInputTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) - }); - - stubGetSession(hookChatService, 'hook-test-input', { requestId: 'req1' }); - - await hookService.invokeTool( - tool.makeDto({ param1: 'value1', param2: 42 }, { sessionId: 'hook-test-input' }), - async () => 0, - CancellationToken.None - ); - - assert.ok(mockHooksService.lastPreToolUseInput); - assert.strictEqual(mockHooksService.lastPreToolUseInput.toolName, 'hookInputTool'); - assert.deepStrictEqual(mockHooksService.lastPreToolUseInput.toolInput, { param1: 'value1', param2: 42 }); - }); - test('when hook denies, tool invoke is never called', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - success: true, - permissionDecision: 'deny', - permissionDecisionReason: 'Operation not allowed', - }; - let invokeCalled = false; const tool = registerToolForTest(hookService, store, 'hookNeverInvokeTool', { invoke: async () => { @@ -3943,8 +3917,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: unknown } = {}; stubGetSession(hookChatService, 'hook-test-no-invoke', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }); + dto.preToolUseResult = { + permissionDecision: 'deny', + permissionDecisionReason: 'Operation not allowed', + }; + await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-no-invoke' }), + dto, async () => 0, CancellationToken.None ); @@ -3953,13 +3933,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns ask, tool is not auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - success: true, - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user confirmation', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAskTool', { invoke: async () => { @@ -3978,9 +3951,15 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-ask', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-ask' }); + dto.preToolUseResult = { + permissionDecision: 'ask', + permissionDecisionReason: 'Requires user confirmation', + }; + // Start invocation - it should wait for confirmation const invokePromise = hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-ask' }), + dto, async () => 0, CancellationToken.None ); @@ -4001,12 +3980,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns allow, tool is auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - success: true, - permissionDecision: 'allow', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAutoApproveTool', { invoke: async () => { @@ -4025,9 +3998,14 @@ suite('LanguageModelToolsService', () => { const capture: { invocation?: ChatToolInvocation } = {}; stubGetSession(hookChatService, 'hook-test-auto-approve', { requestId: 'req1', capture }); + const dto = tool.makeDto({ test: 1 }, { sessionId: 'hook-test-auto-approve' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + }; + // Invoke the tool - it should auto-approve due to hook const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'hook-test-auto-approve' }), + dto, async () => 0, CancellationToken.None ); @@ -4037,155 +4015,147 @@ suite('LanguageModelToolsService', () => { assert.strictEqual(result.content[0].kind, 'text'); assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'success'); }); - }); - suite('postToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; - let hookService: LanguageModelToolsService; - let hookChatService: MockChatService; + test('when hook returns updatedInput, tool is invoked with replaced parameters', async () => { + let receivedParameters: Record | undefined; - setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService + const tool = registerToolForTest(hookService, store, 'hookUpdatedInputTool', { + invoke: async (dto) => { + receivedParameters = dto.parameters; + return { content: [{ kind: 'text', value: 'done' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm?', + message: 'Confirm action', + allowAutoConfirm: true + } + }) }); - hookService = setup.service; - hookChatService = setup.chatService; - }); - test('when hook blocks, block context is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - success: true, - decision: 'block', - reason: 'Lint errors detected', + stubGetSession(hookChatService, 'hook-test-updated-input', { requestId: 'req1' }); + + const dto = tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { safeCommand: 'echo hello' }, }; - const tool = registerToolForTest(hookService, store, 'postHookBlockTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-block', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block' }), - async () => 0, - CancellationToken.None - ); - - // Original content should still be present - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - - // Block context should be appended wrapped in XML tags - assert.ok(result.content.length >= 2, 'Block context should be appended'); - const blockPart = result.content[1] as IToolResultTextPart; - assert.strictEqual(blockPart.kind, 'text'); - assert.ok(blockPart.value.includes(''), 'Block text should have opening tag'); - assert.ok(blockPart.value.includes(''), 'Block text should have closing tag'); - assert.ok(blockPart.value.includes('Lint errors detected'), 'Block text should include the reason'); - - // Should NOT set toolResultError - assert.strictEqual(result.toolResultError, undefined); - }); - - test('when hook returns additionalContext, it is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - success: true, - additionalContext: ['Consider running tests after this change'], - }; - - const tool = registerToolForTest(hookService, store, 'postHookContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-context', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-context' }), - async () => 0, - CancellationToken.None - ); - - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - - assert.ok(result.content.length >= 2, 'Additional context should be appended'); - const contextPart = result.content[1] as IToolResultTextPart; - assert.strictEqual(contextPart.kind, 'text'); - assert.ok(contextPart.value.includes(''), 'Context text should have opening tag'); - assert.ok(contextPart.value.includes(''), 'Context text should have closing tag'); - assert.ok(contextPart.value.includes('Consider running tests after this change')); - }); - - test('when hook returns undefined, tool result is unchanged', async () => { - mockHooksService.postToolUseHookResult = undefined; - - const tool = registerToolForTest(hookService, store, 'postHookNoopTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original output' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-noop', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-noop' }), - async () => 0, - CancellationToken.None - ); - - assert.strictEqual(result.content.length, 1); - assert.strictEqual(result.content[0].kind, 'text'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original output'); - }); - - test('hook receives correct input including tool response text', async () => { - mockHooksService.postToolUseHookResult = undefined; - - const tool = registerToolForTest(hookService, store, 'postHookInputTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'file contents here' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-input', { requestId: 'req1' }); - await hookService.invokeTool( - tool.makeDto({ param1: 'value1' }, { sessionId: 'post-hook-input' }), + dto, async () => 0, CancellationToken.None ); - assert.ok(mockHooksService.lastPostToolUseInput); - assert.strictEqual(mockHooksService.lastPostToolUseInput.toolName, 'postHookInputTool'); - assert.deepStrictEqual(mockHooksService.lastPostToolUseInput.toolInput, { param1: 'value1' }); - assert.strictEqual(typeof mockHooksService.lastPostToolUseInput.getToolResponseText, 'function'); + assert.deepStrictEqual(receivedParameters, { safeCommand: 'echo hello' }); }); - test('when hook blocks with both decision and additionalContext, both are appended', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - success: true, - decision: 'block', - reason: 'Security issue found', - additionalContext: ['Please review the file permissions'], + test('when hook returns updatedInput that fails schema validation, original parameters are kept', async () => { + const mockCommandService = { + executeCommand: async (commandId: string) => { + if (commandId === 'json.validate') { + return [{ message: 'Missing required property "command"', range: [{ line: 0, character: 0 }, { line: 0, character: 1 }], severity: 'Error' }]; + } + return undefined; + } }; - const tool = registerToolForTest(hookService, store, 'postHookBlockContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original' }] }) + const setup = createTestToolsService(store, { + commandService: mockCommandService as ICommandService, }); - stubGetSession(hookChatService, 'post-hook-block-ctx', { requestId: 'req1' }); + let receivedParameters: Record | undefined; - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block-ctx' }), + const tool = registerToolForTest(setup.service, store, 'hookValidationFailTool', { + invoke: async (dto) => { + receivedParameters = dto.parameters; + return { content: [{ kind: 'text', value: 'done' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm?', + message: 'Confirm action', + allowAutoConfirm: true + } + }) + }, { + inputSchema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + } + }); + + stubGetSession(setup.chatService, 'hook-test-validation-fail', { requestId: 'req1' }); + + const dto = tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { invalidField: 'wrong' }, + }; + + await setup.service.invokeTool( + dto, async () => 0, CancellationToken.None ); - // Original + block message + additional context = 3 parts - assert.ok(result.content.length >= 3, 'Should have original, block message, and additional context'); - assert.strictEqual((result.content[0] as IToolResultTextPart).value, 'original'); - assert.ok((result.content[1] as IToolResultTextPart).value.includes('Security issue found')); - assert.ok((result.content[2] as IToolResultTextPart).value.includes('Please review the file permissions')); + // Original parameters should be kept since validation failed + assert.deepStrictEqual(receivedParameters, { command: 'original' }); + }); + + test('when hook returns updatedInput that passes schema validation, parameters are replaced', async () => { + const mockCommandService = { + executeCommand: async (commandId: string) => { + if (commandId === 'json.validate') { + return []; // no diagnostics = valid + } + return undefined; + } + }; + + const setup = createTestToolsService(store, { + commandService: mockCommandService as ICommandService, + }); + + let receivedParameters: Record | undefined; + + const tool = registerToolForTest(setup.service, store, 'hookValidationPassTool', { + invoke: async (dto) => { + receivedParameters = dto.parameters; + return { content: [{ kind: 'text', value: 'done' }] }; + }, + prepareToolInvocation: async () => ({ + confirmationMessages: { + title: 'Confirm?', + message: 'Confirm action', + allowAutoConfirm: true + } + }) + }, { + inputSchema: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + } + }); + + stubGetSession(setup.chatService, 'hook-test-validation-pass', { requestId: 'req1' }); + + const dto = tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }); + dto.preToolUseResult = { + permissionDecision: 'allow', + updatedInput: { command: 'safe-command' }, + }; + + await setup.service.invokeTool( + dto, + async () => 0, + CancellationToken.None + ); + + // Updated parameters should be applied since validation passed + assert.deepStrictEqual(receivedParameters, { command: 'safe-command' }); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 4473ad266aa..393726f0c60 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -145,12 +145,12 @@ suite('ChatSubagentContentPart', () => { source: ToolDataSource.Internal, toolId: options.toolId ?? RunSubagentTool.Id, toolCallId: toolCallId, - subAgentInvocationId: options.subAgentInvocationId ?? 'test-subagent-id', + subAgentInvocationId: options.subAgentInvocationId, state: observableValue('state', stateValue), kind: 'toolInvocation', toJSON: () => createMockSerializedToolInvocation({ toolId: options.toolId ?? RunSubagentTool.Id, - subAgentInvocationId: options.subAgentInvocationId ?? 'test-subagent-id', + subAgentInvocationId: options.subAgentInvocationId, toolSpecificData: options.toolSpecificData, isComplete: stateType === IChatToolInvocation.StateKind.Completed }) @@ -183,7 +183,7 @@ suite('ChatSubagentContentPart', () => { toolCallId: options.subAgentInvocationId ?? 'test-tool-call-id', toolId: options.toolId ?? RunSubagentTool.Id, source: ToolDataSource.Internal, - subAgentInvocationId: options.subAgentInvocationId ?? 'test-subagent-id', + subAgentInvocationId: options.subAgentInvocationId, kind: 'toolInvocationSerialized' }; } @@ -245,7 +245,7 @@ suite('ChatSubagentContentPart', () => { ): ChatSubagentContentPart { const part = store.add(instantiationService.createInstance( ChatSubagentContentPart, - idOverride ?? toolInvocation.subAgentInvocationId!, + idOverride ?? toolInvocation.subAgentInvocationId ?? toolInvocation.toolCallId, toolInvocation, context, mockMarkdownRenderer, @@ -483,7 +483,6 @@ suite('ChatSubagentContentPart', () => { const toolInvocation = createMockToolInvocation({ toolId: RunSubagentTool.Id, toolCallId: sharedToolCallId, - subAgentInvocationId: 'call-abc' }); const context = createMockRenderContext(false); @@ -492,7 +491,6 @@ suite('ChatSubagentContentPart', () => { const otherInvocation = createMockToolInvocation({ toolId: RunSubagentTool.Id, toolCallId: sharedToolCallId, - subAgentInvocationId: 'call-abc' }); const result = part.hasSameContent(otherInvocation, [], context.element); @@ -1364,4 +1362,96 @@ suite('ChatSubagentContentPart', () => { 'Title should still include tool message after completion'); }); }); + + suite('Model name tooltip', () => { + test('should set up hover with model name from serialized toolSpecificData', () => { + const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; + mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { + setupDelayedHoverCalls.push({ element, content: typeof options.content === 'string' ? options.content : '' }); + return { dispose: () => { } }; + }; + + const serializedInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Completed task', + agentName: 'TestAgent', + prompt: 'Do the thing', + result: 'Done', + modelName: 'GPT-4o' + } + }); + const context = createMockRenderContext(true); + + createPart(serializedInvocation, context); + + // Should have set up a hover with the model name + const modelHover = setupDelayedHoverCalls.find(c => c.content.includes('GPT-4o')); + assert.ok(modelHover, 'Should set up hover with model name'); + }); + + test('should not set up hover when no model name is available', () => { + const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; + mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { + setupDelayedHoverCalls.push({ element, content: typeof options.content === 'string' ? options.content : '' }); + return { dispose: () => { } }; + }; + + const serializedInvocation = createMockSerializedToolInvocation({ + toolSpecificData: { + kind: 'subagent', + description: 'Completed task', + agentName: 'TestAgent', + prompt: 'Do the thing', + result: 'Done', + // no modelName + } + }); + const context = createMockRenderContext(true); + + createPart(serializedInvocation, context); + + // Should not have set up any hover with model info + const modelHover = setupDelayedHoverCalls.find(c => c.content.includes('Model:')); + assert.strictEqual(modelHover, undefined, 'Should not set up model hover when no model name'); + }); + + test('should set up hover when tool completes and toolSpecificData has modelName', () => { + const setupDelayedHoverCalls: { element: HTMLElement; content: string }[] = []; + mockHoverService.setupDelayedHover = (element: HTMLElement, options: { content: string }) => { + setupDelayedHoverCalls.push({ element, content: typeof options.content === 'string' ? options.content : '' }); + return { dispose: () => { } }; + }; + + const toolSpecificData: IChatSubagentToolInvocationData = { + kind: 'subagent', + description: 'Working on task', + agentName: 'TestAgent', + prompt: 'Do stuff', + }; + + const toolInvocation = createMockToolInvocation({ + toolSpecificData, + stateType: IChatToolInvocation.StateKind.Executing, + }); + const context = createMockRenderContext(false); + + createPart(toolInvocation, context); + + // No model hover initially (no modelName yet) + const initialHover = setupDelayedHoverCalls.find(c => c.content.includes('Model:')); + assert.strictEqual(initialHover, undefined, 'Should not have model hover initially'); + + // Simulate invoke() setting modelName on toolSpecificData + toolSpecificData.modelName = 'Claude Sonnet 4'; + + // Simulate tool completion + const state = toolInvocation.state as ReturnType>; + state.set(createState(IChatToolInvocation.StateKind.Completed), undefined); + + // Should now have a hover with the model name + const modelHover = setupDelayedHoverCalls.find(c => c.content.includes('Claude Sonnet 4')); + assert.ok(modelHover, 'Should set up hover with model name after completion'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts new file mode 100644 index 00000000000..82a8283135d --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/widget/input/chatEditsTree.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../../base/common/uri.js'; +import { DisposableStore } from '../../../../../../../base/common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; +import { TestConfigurationService } from '../../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ContextKeyService } from '../../../../../../../platform/contextkey/browser/contextKeyService.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../../platform/storage/common/storage.js'; +import { workbenchInstantiationService } from '../../../../../../test/browser/workbenchTestServices.js'; +import { IChatCollapsibleListItem } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; +import { buildEditsList, buildEditsTree, ChatEditsListWidget, ChatEditsTreeIdentityProvider, IChatEditsFolderElement } from '../../../../browser/widget/input/chatEditsTree.js'; +import { CHAT_EDITS_VIEW_MODE_STORAGE_KEY } from '../../../../browser/chatEditing/chatEditingActions.js'; +import { ModifiedFileEntryState, IChatEditingSession } from '../../../../common/editing/chatEditingService.js'; +import { Event } from '../../../../../../../base/common/event.js'; + +function makeFileItem(path: string, added = 0, removed = 0): IChatCollapsibleListItem { + return { + reference: URI.file(path), + state: ModifiedFileEntryState.Modified, + kind: 'reference', + options: { + status: undefined, + diffMeta: { added, removed }, + } + }; +} + +suite('ChatEditsTree', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('buildEditsList', () => { + test('wraps items as flat tree elements', () => { + const items = [ + makeFileItem('/src/a.ts'), + makeFileItem('/src/b.ts'), + ]; + const result = buildEditsList(items); + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].children, undefined); + assert.strictEqual(result[1].children, undefined); + }); + + test('returns empty array for empty input', () => { + assert.deepStrictEqual(buildEditsList([]), []); + }); + }); + + suite('buildEditsTree', () => { + test('groups files by directory', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/src/b.ts'), + makeFileItem('/project/lib/c.ts'), + ]; + const result = buildEditsTree(items); + + // Should have 2 folder elements + assert.strictEqual(result.length, 2); + + const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); + assert.strictEqual(folders.length, 2); + + // Each folder should have children + for (const r of result) { + assert.ok(r.children); + assert.ok(r.collapsible); + } + }); + + test('skips folder grouping for single file in single folder', () => { + const items = [makeFileItem('/project/src/a.ts')]; + const result = buildEditsTree(items); + + // Single file should not be wrapped in a folder + assert.strictEqual(result.length, 1); + assert.notStrictEqual(result[0].element.kind, 'folder'); + }); + + test('still groups when there are multiple folders even with single files', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/lib/b.ts'), + ]; + const result = buildEditsTree(items); + + assert.strictEqual(result.length, 2); + const folders = result.map(r => r.element).filter((e): e is IChatEditsFolderElement => e.kind === 'folder'); + assert.strictEqual(folders.length, 2); + }); + + test('handles items without URIs as top-level elements', () => { + const warning: IChatCollapsibleListItem = { + kind: 'warning', + content: { value: 'Something went wrong' }, + }; + const items: IChatCollapsibleListItem[] = [ + warning, + makeFileItem('/src/a.ts'), + ]; + const result = buildEditsTree(items); + + // Warning at top level + single file at root (common ancestor is /src/) + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].element.kind, 'warning'); + assert.strictEqual(result[1].element.kind, 'reference'); + }); + + test('flattens files at common ancestor and shows subfolders', () => { + const items = [ + makeFileItem('/project/root/hello.py'), + makeFileItem('/project/root/README.md'), + makeFileItem('/project/root/test.py'), + makeFileItem('/project/root/js/test2.js'), + ]; + const result = buildEditsTree(items); + + // Common ancestor is /project/root/ — files there go to root level, + // js/ becomes a folder node + const rootFiles = result.filter(r => r.element.kind === 'reference'); + const folders = result.filter(r => r.element.kind === 'folder'); + assert.strictEqual(rootFiles.length, 3, 'three files at root level'); + assert.strictEqual(folders.length, 1, 'one subfolder'); + assert.strictEqual((folders[0].element as IChatEditsFolderElement).children.length, 1); + + // Folders should come before files (like search) + const firstFolderIndex = result.findIndex(r => r.element.kind === 'folder'); + const firstFileIndex = result.findIndex(r => r.element.kind === 'reference'); + assert.ok(firstFolderIndex < firstFileIndex, 'folders should appear before files'); + }); + + test('all files in same directory produces no folder row', () => { + const items = [ + makeFileItem('/project/src/a.ts'), + makeFileItem('/project/src/b.ts'), + makeFileItem('/project/src/c.ts'), + ]; + const result = buildEditsTree(items); + + // All files in the same directory — common ancestor is /project/src/ + // No folder row needed + assert.strictEqual(result.length, 3); + assert.ok(result.every(r => r.element.kind === 'reference')); + }); + }); + + suite('ChatEditsTreeIdentityProvider', () => { + test('provides stable IDs for folders', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const folder: IChatEditsFolderElement = { + kind: 'folder', + uri: URI.file('/src'), + children: [], + }; + const id = provider.getId(folder); + assert.strictEqual(id, `folder:${URI.file('/src').toString()}`); + }); + + test('provides stable IDs for file references', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item = makeFileItem('/src/a.ts'); + const id = provider.getId(item); + assert.strictEqual(id, `file:${URI.file('/src/a.ts').toString()}`); + }); + + test('same element produces same ID', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item1 = makeFileItem('/src/a.ts'); + const item2 = makeFileItem('/src/a.ts'); + assert.strictEqual(provider.getId(item1), provider.getId(item2)); + }); + + test('different elements produce different IDs', () => { + const provider = new ChatEditsTreeIdentityProvider(); + const item1 = makeFileItem('/src/a.ts'); + const item2 = makeFileItem('/src/b.ts'); + assert.notStrictEqual(provider.getId(item1), provider.getId(item2)); + }); + }); + + suite('ChatEditsListWidget lifecycle', () => { + let store: DisposableStore; + let storageService: IStorageService; + let widget: ChatEditsListWidget; + + setup(() => { + store = new DisposableStore(); + const instaService = workbenchInstantiationService({ + contextKeyService: () => store.add(new ContextKeyService(new TestConfigurationService)), + }, store); + store.add(instaService); + + storageService = instaService.get(IStorageService); + widget = store.add(instaService.createInstance(ChatEditsListWidget, Event.None)); + }); + + teardown(() => { + store.dispose(); + }); + + test.skip('storage listener fires after clear', () => { + // Stub create to avoid DOM/widget side effects in tests + let createCallCount = 0; + const origCreate = widget.create.bind(widget); + widget.create = (c, s) => { + createCallCount++; + // Update stored refs without actually building widgets + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + assert.strictEqual(createCallCount, 1); + + // Simulate session switch + widget.clear(); + + // Toggle view mode — storage listener must still fire after clear() + createCallCount = 0; + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + assert.strictEqual(createCallCount, 1, 'storage listener should trigger create after clear()'); + + widget.create = origCreate; + }); + + test.skip('currentSession is updated on rebuild', () => { + // Stub create + widget.create = (c, s) => { + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + assert.strictEqual(widget.currentSession, null); + + const mockSession = {} as IChatEditingSession; + widget.rebuild(container, mockSession); + assert.strictEqual(widget.currentSession, mockSession); + }); + + test.skip('setEntries replays after view mode toggle', () => { + // Stub create and track setEntries calls + widget.create = (c, s) => { + (widget as unknown as { _currentContainer: HTMLElement | undefined })._currentContainer = c; + (widget as unknown as { _currentSession: IChatEditingSession | null })._currentSession = s; + }; + + const container = document.createElement('div'); + widget.create(container, null); + + const entries = [makeFileItem('/src/a.ts'), makeFileItem('/src/b.ts')]; + widget.setEntries(entries); + + const setEntriesCalls: readonly IChatCollapsibleListItem[][] = []; + const origSetEntries = widget.setEntries.bind(widget); + widget.setEntries = (e) => { + (setEntriesCalls as IChatCollapsibleListItem[][]).push([...e]); + origSetEntries(e); + }; + + // Toggle to tree mode — should replay entries + storageService.store(CHAT_EDITS_VIEW_MODE_STORAGE_KEY, 'tree', StorageScope.PROFILE, StorageTarget.USER); + assert.strictEqual(setEntriesCalls.length, 1, 'setEntries should have been replayed'); + assert.strictEqual(setEntriesCalls[0].length, 2, 'should have replayed the 2 entries'); + + widget.setEntries = origSetEntries; + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index 5f1d2940776..75f22e0c69f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -19,7 +19,7 @@ import { TestStorageService } from '../../../../test/common/workbenchTestService import { IChatAgentService } from '../../common/participants/chatAgents.js'; import { ChatMode, ChatModeService } from '../../common/chatModes.js'; import { ChatModeKind } from '../../common/constants.js'; -import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { IAgentSource, ICustomAgent, IPromptsService, PromptsStorage, Target } from '../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js'; class TestChatAgentService implements Partial { @@ -119,6 +119,7 @@ suite('ChatModeService', () => { tools: ['tool1', 'tool2'], agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -156,6 +157,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -175,6 +177,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Findable mode body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -200,6 +203,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Initial body', toolReferences: [] }, model: ['gpt-4'], source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -244,6 +248,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Mode 1 body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; @@ -254,6 +259,7 @@ suite('ChatModeService', () => { tools: [], agentInstructions: { content: 'Mode 2 body', toolReferences: [] }, source: workspaceSource, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 6ea83f12ce5..601e1389741 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -32,6 +32,7 @@ import { NullWorkbenchAssignmentService } from '../../../../../services/assignme import { IExtensionService, nullExtensionDescription } from '../../../../../services/extensions/common/extensions.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; +import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { InMemoryTestFileService, mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; @@ -174,6 +175,7 @@ suite('ChatService', () => { instantiationService.stub(IChatService, new MockChatService()); instantiationService.stub(IEnvironmentService, { workspaceStorageHome: URI.file('/test/path/to/workspaceStorage') }); instantiationService.stub(ILifecycleService, { onWillShutdown: Event.None }); + instantiationService.stub(IWorkspaceEditingService, { onDidEnterWorkspace: Event.None }); instantiationService.stub(IChatEditingService, new class extends mock() { override startOrContinueGlobalEditingSession(): IChatEditingSession { return { diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts deleted file mode 100644 index c50fc0efda4..00000000000 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ /dev/null @@ -1,894 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import assert from 'assert'; -import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; -import { URI } from '../../../../../base/common/uri.js'; -import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { HookCommandResultKind, IHookCommandResult } from '../../common/hooks/hooksCommandTypes.js'; -import { HooksExecutionService, IHooksExecutionProxy } from '../../common/hooks/hooksExecutionService.js'; -import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; -import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js'; - -function cmd(command: string): IHookCommand { - return { type: 'command', command, cwd: URI.file('/') }; -} - -function createMockOutputService(): IOutputService { - const mockChannel: Partial = { - append: () => { }, - }; - return { - _serviceBrand: undefined, - getChannel: () => mockChannel as IOutputChannel, - } as unknown as IOutputService; -} - -suite('HooksExecutionService', () => { - const store = ensureNoDisposablesAreLeakedInTestSuite(); - - let service: HooksExecutionService; - const sessionUri = URI.file('/test/session'); - - setup(() => { - service = store.add(new HooksExecutionService(new NullLogService(), createMockOutputService())); - }); - - suite('registerHooks', () => { - test('registers hooks for a session', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - }); - - test('returns disposable that unregisters hooks', () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - const disposable = service.registerHooks(sessionUri, hooks); - - assert.strictEqual(service.getHooksForSession(sessionUri), hooks); - - disposable.dispose(); - - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - - test('different sessions have independent hooks', () => { - const session1 = URI.file('/test/session1'); - const session2 = URI.file('/test/session2'); - const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] }; - const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] }; - - store.add(service.registerHooks(session1, hooks1)); - store.add(service.registerHooks(session2, hooks2)); - - assert.strictEqual(service.getHooksForSession(session1), hooks1); - assert.strictEqual(service.getHooksForSession(session2), hooks2); - }); - }); - - suite('getHooksForSession', () => { - test('returns undefined for unregistered session', () => { - assert.strictEqual(service.getHooksForSession(sessionUri), undefined); - }); - }); - - suite('executeHook', () => { - test('returns empty array when no proxy set', async () => { - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks registered for session', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('returns empty array when no hooks of requested type', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PostToolUse, sessionUri); - assert.deepStrictEqual(results, []); - }); - - test('executes hook commands via proxy and returns semantic results', async () => { - const proxy = createMockProxy((cmd) => ({ - kind: HookCommandResultKind.Success, - result: `executed: ${cmd.command}` - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' }); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].success, true); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].output, 'executed: echo test'); - }); - - test('executes multiple hook commands in order', async () => { - const executedCommands: string[] = []; - const proxy = createMockProxy((cmd) => { - executedCommands.push(cmd.command ?? ''); - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { - [HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')] - }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 3); - assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); - }); - - test('wraps proxy errors in error result', async () => { - const proxy = createMockProxy(() => { - throw new Error('proxy failed'); - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].success, false); - assert.strictEqual(results[0].output, 'proxy failed'); - // Error results still have default common fields - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes cancellation token to proxy', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const cts = store.add(new CancellationTokenSource()); - await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); - - assert.strictEqual(receivedToken, cts.token); - }); - - test('uses CancellationToken.None when no token provided', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(receivedToken, CancellationToken.None); - }); - - test('extracts common fields from successful result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - stopReason: 'User requested stop', - systemMessage: 'Warning: hook triggered', - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].success, true); - assert.strictEqual(results[0].stopReason, 'User requested stop'); - assert.strictEqual(results[0].messageForUser, 'Warning: hook triggered'); - // Hook-specific fields are in output with wrapper - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('uses defaults when no common fields present', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].stopReason, undefined); - assert.strictEqual(results[0].messageForUser, undefined); - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles error results from command', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Error, - result: 'command failed with error' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].success, false); - assert.strictEqual(results[0].output, 'command failed with error'); - // Defaults are still applied - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes through hook-specific output fields for non-preToolUse hooks', async () => { - // Stop hooks return different fields (decision, reason) than preToolUse hooks - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Please run the tests' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.Stop]: [cmd('check-stop')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.Stop, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].success, true); - // Hook-specific fields should be in output, not undefined - assert.deepStrictEqual(results[0].output, { - decision: 'block', - reason: 'Please run the tests' - }); - }); - - test('passes input to proxy', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const testInput = { foo: 'bar', nested: { value: 123 } }; - await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); - - // Input includes caller properties merged with common hook properties - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['foo'], 'bar'); - assert.deepStrictEqual(input['nested'], { value: 123 }); - // Common properties are also present - assert.strictEqual(typeof input['timestamp'], 'string'); - assert.strictEqual(input['hookEventName'], HookType.PreToolUse); - }); - }); - - suite('executePreToolUseHook', () => { - test('returns allow result when hook allows', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - permissionDecisionReason: 'Tool is safe' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - assert.strictEqual(result.permissionDecisionReason, 'Tool is safe'); - }); - - test('returns ask result when hook requires confirmation', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user approval' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Requires user approval'); - }); - - test('deny takes priority over ask and allow', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - // First hook returns allow, second returns ask, third returns deny - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow' } } - }; - } else if (callCount === 2) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'ask' } } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'Blocked' } } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2'), cmd('hook3')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'deny'); - assert.strictEqual(result.permissionDecisionReason, 'Blocked'); - }); - - test('ask takes priority over allow', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - // First hook returns allow, second returns ask - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow' } } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'ask', permissionDecisionReason: 'Need confirmation' } } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Need confirmation'); - }); - - test('ignores results with wrong hookEventName', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - // First hook returns allow but with wrong hookEventName - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', // Wrong hook type - permissionDecision: 'deny' - } - } - }; - } else { - // Second hook returns allow with correct hookEventName - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'allow' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - // The deny with wrong hookEventName should be ignored - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - }); - - test('allows results without hookEventName (optional field)', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - // No hookEventName - should be accepted - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - }); - }); - - suite('executePostToolUseHook', () => { - test('returns undefined when no hooks configured', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.strictEqual(result, undefined); - }); - - test('returns block decision when hook blocks', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Lint errors found' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.decision, 'block'); - assert.strictEqual(result.reason, 'Lint errors found'); - }); - - test('returns additionalContext from hookSpecificOutput', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'File was modified successfully' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.additionalContext, ['File was modified successfully']); - assert.strictEqual(result.decision, undefined); - }); - - test('block takes priority and collects all additionalContext', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Tests failed' - } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - additionalContext: 'Extra context from second hook' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.decision, 'block'); - assert.strictEqual(result.reason, 'Tests failed'); - assert.deepStrictEqual(result.additionalContext, ['Extra context from second hook']); - }); - - test('ignores results with wrong hookEventName', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - additionalContext: 'Should be ignored' - } - } - }; - } else { - return { - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - additionalContext: 'Correct context' - } - } - }; - } - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook1'), cmd('hook2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'test-tool', toolInput: {}, getToolResponseText: () => 'tool output', toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.deepStrictEqual(result.additionalContext, ['Correct context']); - }); - - test('passes tool response text as string to external command', async () => { - let receivedInput: unknown; - const proxy = createMockProxy((_cmd, input) => { - receivedInput = input; - return { kind: HookCommandResultKind.Success, result: {} }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executePostToolUseHook( - sessionUri, - { toolName: 'my-tool', toolInput: { arg: 'val' }, getToolResponseText: () => 'file contents here', toolCallId: 'call-42' } - ); - - assert.ok(typeof receivedInput === 'object' && receivedInput !== null); - const input = receivedInput as Record; - assert.strictEqual(input['tool_name'], 'my-tool'); - assert.deepStrictEqual(input['tool_input'], { arg: 'val' }); - assert.strictEqual(input['tool_response'], 'file contents here'); - assert.strictEqual(input['tool_use_id'], 'call-42'); - assert.strictEqual(input['hookEventName'], HookType.PostToolUse); - }); - - test('does not call getter when no PostToolUse hooks registered', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - // Register hooks only for PreToolUse, not PostToolUse - const hooks = { [HookType.PreToolUse]: [cmd('hook')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - let getterCalled = false; - const result = await service.executePostToolUseHook( - sessionUri, - { - toolName: 'test-tool', - toolInput: {}, - getToolResponseText: () => { getterCalled = true; return ''; }, - toolCallId: 'call-1' - } - ); - - assert.strictEqual(result, undefined); - assert.strictEqual(getterCalled, false); - }); - }); - - suite('preToolUse smoke tests — input → output', () => { - test('single hook: allow', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - permissionDecisionReason: 'Trusted tool', - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('lint-check')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'readFile', toolInput: { path: '/src/index.ts' }, toolCallId: 'call-1' }; - const result = await service.executePreToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason, additionalContext: result?.additionalContext }), - JSON.stringify({ permissionDecision: 'allow', permissionDecisionReason: 'Trusted tool', additionalContext: undefined }) - ); - }); - - test('single hook: deny', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'deny', - permissionDecisionReason: 'Path is outside workspace', - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('path-guard')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'writeFile', toolInput: { path: '/etc/passwd' }, toolCallId: 'call-2' }; - const result = await service.executePreToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'Path is outside workspace' }) - ); - }); - - test('multiple hooks: deny wins over allow and ask', async () => { - // Three hooks return allow, ask, deny (in that order). - // deny must win regardless of ordering. - let callCount = 0; - const decisions = ['allow', 'ask', 'deny'] as const; - const proxy = createMockProxy(() => { - const decision = decisions[callCount++]; - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `hook-${callCount}` } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2'), cmd('h3')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'runCommand', toolInput: { cmd: 'rm -rf /' }, toolCallId: 'call-3' } - ); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'deny', permissionDecisionReason: 'hook-3' }) - ); - }); - - test('multiple hooks: ask wins over allow', async () => { - let callCount = 0; - const decisions = ['allow', 'ask'] as const; - const proxy = createMockProxy(() => { - const decision = decisions[callCount++]; - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: decision, permissionDecisionReason: `reason-${decision}` } } - }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('h1'), cmd('h2')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePreToolUseHook( - sessionUri, - { toolName: 'exec', toolInput: {}, toolCallId: 'call-4' } - ); - - assert.deepStrictEqual( - JSON.stringify({ permissionDecision: result?.permissionDecision, permissionDecisionReason: result?.permissionDecisionReason }), - JSON.stringify({ permissionDecision: 'ask', permissionDecisionReason: 'reason-ask' }) - ); - }); - }); - - suite('postToolUse smoke tests — input → output', () => { - test('single hook: block', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - decision: 'block', - reason: 'Lint errors found' - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('lint')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'writeFile', toolInput: { path: 'foo.ts' }, getToolResponseText: () => 'wrote 42 bytes', toolCallId: 'call-5' }; - const result = await service.executePostToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: 'block', reason: 'Lint errors found', additionalContext: undefined }) - ); - }); - - test('single hook: additionalContext only', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - additionalContext: 'Tests still pass after this edit' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('test-runner')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const input = { toolName: 'editFile', toolInput: {}, getToolResponseText: () => 'ok', toolCallId: 'call-6' }; - const result = await service.executePostToolUseHook(sessionUri, input); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: undefined, reason: undefined, additionalContext: ['Tests still pass after this edit'] }) - ); - }); - - test('multiple hooks: block wins and all hooks run', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { kind: HookCommandResultKind.Success, result: { decision: 'block', reason: 'Tests failed' } }; - } - return { kind: HookCommandResultKind.Success, result: { hookSpecificOutput: { additionalContext: 'context from second hook' } } }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PostToolUse]: [cmd('test'), cmd('lint')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 'writeFile', toolInput: {}, getToolResponseText: () => 'data', toolCallId: 'call-7' } - ); - - assert.deepStrictEqual( - JSON.stringify({ decision: result?.decision, reason: result?.reason, additionalContext: result?.additionalContext }), - JSON.stringify({ decision: 'block', reason: 'Tests failed', additionalContext: ['context from second hook'] }) - ); - }); - - test('no hooks registered → undefined (getter never called)', async () => { - const proxy = createMockProxy(); - service.setProxy(proxy); - - // Register PreToolUse only — no PostToolUse - store.add(service.registerHooks(sessionUri, { [HookType.PreToolUse]: [cmd('h')] })); - - let getterCalled = false; - const result = await service.executePostToolUseHook( - sessionUri, - { toolName: 't', toolInput: {}, getToolResponseText: () => { getterCalled = true; return ''; }, toolCallId: 'c' } - ); - - assert.strictEqual(result, undefined); - assert.strictEqual(getterCalled, false); - }); - }); - - function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookCommandResult): IHooksExecutionProxy { - return { - runHookCommand: async (hookCommand, input, token) => { - if (handler) { - return handler(hookCommand, input, token); - } - return { kind: HookCommandResultKind.Success, result: 'mock result' }; - } - }; - } -}); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index c08c536df20..097866e2e4d 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -12,7 +12,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { IChatAgentAttachmentCapabilities } from '../../common/participants/chatAgents.js'; import { IChatModel } from '../../common/model/chatModel.js'; import { IChatService } from '../../common/chatService/chatService.js'; -import { IChatSession, IChatSessionContentProvider, IChatSessionItem, IChatSessionItemProvider, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { IChatSession, IChatSessionContentProvider, IChatSessionItemController, IChatSessionItem, IChatSessionOptionsWillNotifyExtensionEvent, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; +import { Target } from '../../common/promptSyntax/service/promptsService.js'; export class MockChatSessionsService implements IChatSessionsService { _serviceBrand: undefined; @@ -40,7 +41,7 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onRequestNotifyExtension = new AsyncEmitter(); readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; - private sessionItemProviders = new Map(); + private sessionItemControllers = new Map }>(); private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); @@ -49,8 +50,8 @@ export class MockChatSessionsService implements IChatSessionsService { private onChange = () => { }; // For testing: allow triggering events - fireDidChangeItemsProviders(provider: IChatSessionItemProvider): void { - this._onDidChangeItemsProviders.fire(provider); + fireDidChangeItemsProviders(event: { chatSessionType: string }): void { + this._onDidChangeItemsProviders.fire(event); } fireDidChangeSessionItems(chatSessionType: string): void { @@ -65,11 +66,11 @@ export class MockChatSessionsService implements IChatSessionsService { this._onDidChangeInProgress.fire(); } - registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable { - this.sessionItemProviders.set(provider.chatSessionType, provider); + registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable { + this.sessionItemControllers.set(chatSessionType, { controller, initialRefresh: controller.refresh(CancellationToken.None) }); return { dispose: () => { - this.sessionItemProviders.delete(provider.chatSessionType); + this.sessionItemControllers.delete(chatSessionType); } }; } @@ -107,13 +108,26 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.inputPlaceholder; } - getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { - return Promise.all(Array.from(this.sessionItemProviders.values()) - .filter(provider => !providersToResolve || providersToResolve.includes(provider.chatSessionType)) - .map(async provider => ({ - chatSessionType: provider.chatSessionType, - items: await provider.provideChatSessionItems(token), - }))); + getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise> { + return Promise.all( + Array.from(this.sessionItemControllers.entries()) + .filter(([chatSessionType]) => !providerTypeFilter || providerTypeFilter.includes(chatSessionType)) + .map(async ([chatSessionType, controllerEntry]) => { + await controllerEntry.initialRefresh; // ensure initial refresh is done + return ({ + chatSessionType: chatSessionType, + items: controllerEntry.controller.items + }); + })); + } + + async refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise { + await Promise.all( + Array.from(this.sessionItemControllers.entries()) + .filter(([chatSessionType]) => !providerTypeFilter || providerTypeFilter.includes(chatSessionType)) + .map(async ([_chatSessionType, controllerEntry]) => { + await controllerEntry.controller.refresh(token); + })); } reportInProgress(chatSessionType: string, count: number): void { @@ -187,8 +201,8 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.capabilities; } - getCustomAgentTargetForSessionType(chatSessionType: string): string | undefined { - return this.contributions.find(c => c.type === chatSessionType)?.customAgentTarget; + getCustomAgentTargetForSessionType(chatSessionType: string): Target { + return this.contributions.find(c => c.type === chatSessionType)?.customAgentTarget ?? Target.Undefined; } getContentProviderSchemes(): string[] { diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts index cce617aabe1..081a235b906 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatSessionStore.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; @@ -15,9 +17,10 @@ import { IStorageService } from '../../../../../../platform/storage/common/stora import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; import { IUserDataProfilesService, toUserDataProfile } from '../../../../../../platform/userDataProfile/common/userDataProfile.js'; -import { IWorkspaceContextService, WorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { IAnyWorkspaceIdentifier, IWorkspaceContextService, WorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; import { TestWorkspace, Workspace } from '../../../../../../platform/workspace/test/common/testWorkspace.js'; import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; +import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; import { InMemoryTestFileService, TestContextService, TestLifecycleService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatModel, ISerializableChatData3 } from '../../../common/model/chatModel.js'; import { ChatSessionStore, IChatTransfer } from '../../../common/model/chatSessionStore.js'; @@ -40,10 +43,27 @@ function createMockChatModel(sessionResource: URI, options?: { customTitle?: str return model as unknown as ChatModel; } +class MockWorkspaceEditingService extends Disposable implements Partial { + private readonly _onDidEnterWorkspace = this._register(new Emitter()); + readonly onDidEnterWorkspace = this._onDidEnterWorkspace.event; + + fireWorkspaceTransition(oldWorkspace: IAnyWorkspaceIdentifier, newWorkspace: IAnyWorkspaceIdentifier): Promise { + const promises: Promise[] = []; + const event: IDidEnterWorkspaceEvent = { + oldWorkspace, + newWorkspace, + join: (promise: Promise) => promises.push(promise) + }; + this._onDidEnterWorkspace.fire(event); + return Promise.all(promises).then(() => { }); + } +} + suite('ChatSessionStore', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); let instantiationService: TestInstantiationService; + let mockWorkspaceEditingService: MockWorkspaceEditingService; function createChatSessionStore(isEmptyWindow: boolean = false): ChatSessionStore { const workspace = isEmptyWindow ? new Workspace('empty-window-id', []) : TestWorkspace; @@ -61,6 +81,8 @@ suite('ChatSessionStore', () => { instantiationService.stub(ILifecycleService, testDisposables.add(new TestLifecycleService())); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(IConfigurationService, new TestConfigurationService()); + mockWorkspaceEditingService = testDisposables.add(new MockWorkspaceEditingService()); + instantiationService.stub(IWorkspaceEditingService, mockWorkspaceEditingService as unknown as IWorkspaceEditingService); }); test('hasSessions returns false when no sessions exist', () => { @@ -374,4 +396,70 @@ suite('ChatSessionStore', () => { assert.strictEqual(result.toString(), session2Resource.toString()); }); }); + + suite('workspace migration', () => { + test('migration is triggered when onDidEnterWorkspace fires', async () => { + const fileService = instantiationService.get(IFileService) as InMemoryTestFileService; + + // Create store with empty window + const store = createChatSessionStore(true); + const model = testDisposables.add(createMockChatModel(LocalChatSessionUri.forSession('session-1'))); + + // Store a session in empty window + await store.storeSessions([model]); + assert.strictEqual(store.hasSessions(), true); + + // Get the file path for the session in empty window storage + const emptyWindowStorageRoot = store.getChatStorageFolder(); + const sessionFile = URI.joinPath(emptyWindowStorageRoot, 'session-1.json'); + const fileExists = await fileService.exists(sessionFile); + assert.strictEqual(fileExists, true, 'Session file should exist in empty window storage'); + + // Simulate workspace transition via the onDidEnterWorkspace event + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'empty-window-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: TestWorkspace.id, uri: URI.file('/test/folder') }; + + // Fire the workspace transition event - migration happens synchronously via join() + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + // Verify file was copied to new location + const newStorageRoot = store.getChatStorageFolder(); + const migratedSessionFile = URI.joinPath(newStorageRoot, 'session-1.json'); + const migratedFileExists = await fileService.exists(migratedSessionFile); + assert.strictEqual(migratedFileExists, true, 'Session file should be migrated to workspace storage'); + }); + + test('migration handles non-existent old storage location gracefully', async () => { + // Create store with a workspace + const store = createChatSessionStore(false); + + // Simulate workspace transition from a non-existent workspace + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'non-existent-workspace-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: 'new-workspace-id' }; + + // Fire the workspace transition event - should not crash + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + // Store should work normally + assert.strictEqual(store.hasSessions(), false); + }); + + test('storage root is updated after workspace transition', async () => { + // Create store with empty window + const store = createChatSessionStore(true); + + const initialStorageRoot = store.getChatStorageFolder(); + assert.ok(initialStorageRoot.path.includes('emptyWindowChatSessions'), 'Initial storage should be empty window location'); + + // Simulate workspace transition - use proper identifier types + // Empty workspace only has 'id', single folder has 'uri' property too + const oldWorkspace: IAnyWorkspaceIdentifier = { id: 'empty-window-id' }; + const newWorkspace: IAnyWorkspaceIdentifier = { id: 'new-workspace-id', uri: URI.file('/test/folder') }; + + await mockWorkspaceEditingService.fireWorkspaceTransition(oldWorkspace, newWorkspace); + + const newStorageRoot = store.getChatStorageFolder(); + assert.ok(newStorageRoot.path.includes('new-workspace-id'), 'Storage root should be updated to new workspace location'); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 79195b6d4ff..b53e9e8fd1b 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -29,7 +29,7 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../t import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; -import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../common/promptSyntax/service/promptsServiceImpl.js'; @@ -69,10 +69,11 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS, true); - testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true }); + testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true, [CLAUDE_RULES_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { '.claude/skills': true }); + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { [AGENTS_SOURCE_FOLDER]: true }); instaService.stub(IConfigurationService, testConfigService); instaService.stub(IUserDataProfileService, new TestUserDataProfileService()); @@ -550,6 +551,177 @@ suite('ComputeAutomaticInstructions', () => { }); }); + suite('claude rules', () => { + test('should collect claude rules files as instructions', async () => { + const rootFolderName = 'claude-rules-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/rules/code-style.md`, + contents: [ + 'Code style guidelines', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + // Claude rules without paths default to '**', so they are always auto-attached + assert.ok(paths.includes(`${rootFolder}/.claude/rules/code-style.md`), 'Should include rules without paths as they default to **'); + }); + + test('should match claude rules with paths attribute', async () => { + const rootFolderName = 'claude-rules-paths-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/rules/api-rules.md`, + contents: [ + '---', + 'paths:', + ' - "src/api/**/*.ts"', + '---', + 'API development rules', + ] + }, + { + path: `${rootFolder}/.claude/rules/frontend-rules.md`, + contents: [ + '---', + 'paths:', + ' - "src/frontend/**/*.tsx"', + '---', + 'Frontend rules', + ] + }, + { + path: `${rootFolder}/src/api/handler.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/api/handler.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder}/.claude/rules/api-rules.md`), 'Should match API rules via paths'); + assert.ok(!paths.includes(`${rootFolder}/.claude/rules/frontend-rules.md`), 'Should not match frontend rules'); + }); + + test('should collect claude rules from subdirectories', async () => { + const rootFolderName = 'claude-rules-subdir-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/rules/frontend/react.md`, + contents: [ + '---', + 'paths:', + ' - "**/*.tsx"', + '---', + 'React guidelines', + ] + }, + { + path: `${rootFolder}/.claude/rules/backend/api.md`, + contents: [ + '---', + 'paths:', + ' - "src/api/**/*.ts"', + '---', + 'API guidelines', + ] + }, + { + path: `${rootFolder}/src/component.tsx`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder}/.claude/rules/frontend/react.md`), 'Should match react rules from subdirectory'); + assert.ok(!paths.includes(`${rootFolder}/.claude/rules/backend/api.md`), 'Should not match API rules for tsx file'); + }); + + test('should support multiple paths patterns', async () => { + const rootFolderName = 'claude-rules-multi-paths-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/rules/typescript.md`, + contents: [ + '---', + 'paths:', + ' - "src/**/*.ts"', + ' - "lib/**/*.ts"', + ' - "tests/**/*.test.ts"', + '---', + 'TypeScript rules', + ] + }, + { + path: `${rootFolder}/lib/utils.ts`, + contents: ['code'], + }, + ]); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'lib/utils.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const paths = variables.asArray() + .filter(v => isPromptFileVariableEntry(v)) + .map(v => isPromptFileVariableEntry(v) ? v.value.path : undefined); + + assert.ok(paths.includes(`${rootFolder}/.claude/rules/typescript.md`), 'Should match via lib/**/*.ts pattern'); + }); + }); + suite('referenced instructions', () => { test('should add referenced instruction files', async () => { const rootFolderName = 'referenced-test'; diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index a81e59d0ec6..f6d859e9f27 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -83,50 +83,25 @@ suite('promptFileLocations', function () { assert.strictEqual(getPromptFileType(uri), PromptsType.skill); }); - test('hooks.json should be recognized as hook', () => { - const uri = URI.file('/workspace/.github/hooks/hooks.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + // Note: getPromptFileType assumes the URI is from a valid prompt source folder. + // Any .json file returns PromptsType.hook - the caller filters by folder. + test('any .json file should be recognized as hook', () => { + assert.strictEqual(getPromptFileType(URI.file('/workspace/.github/hooks/hooks.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.github/hooks/custom-hooks.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.claude/settings.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.claude/settings.local.json')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/any/path/config.json')), PromptsType.hook); }); - test('HOOKS.JSON (uppercase) should be recognized as hook', () => { - const uri = URI.file('/workspace/.github/hooks/HOOKS.JSON'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + test('.json files are case insensitive', () => { + assert.strictEqual(getPromptFileType(URI.file('/workspace/.github/hooks/HOOKS.JSON')), PromptsType.hook); + assert.strictEqual(getPromptFileType(URI.file('/workspace/.claude/SETTINGS.JSON')), PromptsType.hook); }); - test('hooks.json in any folder should be recognized as hook', () => { - const uri = URI.file('/workspace/some/other/path/hooks.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('settings.json in .claude folder should be recognized as hook', () => { - const uri = URI.file('/workspace/.claude/settings.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('settings.local.json in .claude folder should be recognized as hook', () => { - const uri = URI.file('/workspace/.claude/settings.local.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('SETTINGS.JSON (uppercase) in .claude folder should be recognized as hook', () => { - const uri = URI.file('/workspace/.claude/SETTINGS.JSON'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); - - test('settings.json outside .claude folder should NOT be recognized as hook', () => { - const uri = URI.file('/workspace/.github/settings.json'); + test('non-json file in .github/hooks folder should NOT be recognized as hook', () => { + const uri = URI.file('/workspace/.github/hooks/readme.md'); assert.strictEqual(getPromptFileType(uri), undefined); }); - - test('settings.local.json outside .claude folder should NOT be recognized as hook', () => { - const uri = URI.file('/workspace/some/path/settings.local.json'); - assert.strictEqual(getPromptFileType(uri), undefined); - }); - - test('settings.json in ~/.claude folder should be recognized as hook', () => { - const uri = URI.file('/Users/user/.claude/settings.json'); - assert.strictEqual(getPromptFileType(uri), PromptsType.hook); - }); }); suite('getCleanPromptName', () => { @@ -208,20 +183,13 @@ suite('promptFileLocations', function () { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/SKILL2.md')), false); }); - test('hooks.json should return true', () => { - assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/hooks/hooks.json')), true); - }); - - test('settings.json in .claude folder should return true', () => { + // Note: Any .json file returns true because getPromptFileType returns hook for all JSON. + // The caller is responsible for only passing URIs from valid prompt source folders. + test('any .json file should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/hooks/custom-hooks.json')), true); assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.json')), true); - }); - - test('settings.local.json in .claude folder should return true', () => { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.local.json')), true); - }); - - test('settings.json outside .claude folder should return false', () => { - assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), false); + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), true); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts index 54a2760c28e..6f852ed7285 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { HookType } from '../../../common/promptSyntax/hookSchema.js'; import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName } from '../../../common/promptSyntax/hookClaudeCompat.js'; +import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookClaudeCompat', () => { @@ -56,9 +57,10 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 1); - assert.ok(result.has(HookType.PreToolUse)); - const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + assert.ok(result.hooks.has(HookType.PreToolUse)); + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.originalId, 'PreToolUse'); assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'echo "pre-tool"'); @@ -74,9 +76,9 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 2); - assert.ok(result.has(HookType.SessionStart)); - assert.ok(result.has(HookType.Stop)); + assert.strictEqual(result.hooks.size, 2); + assert.ok(result.hooks.has(HookType.SessionStart)); + assert.ok(result.hooks.has(HookType.Stop)); }); test('parses multiple commands for same hook type', () => { @@ -91,13 +93,62 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); assert.strictEqual(entry.hooks[0].command, 'echo "first"'); assert.strictEqual(entry.hooks[1].command, 'echo "second"'); }); }); + suite('disableAllHooks', () => { + test('returns empty hooks and disabledAllHooks=true when disableAllHooks is true', () => { + const json = { + disableAllHooks: true, + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "should be ignored"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, true); + assert.strictEqual(result.hooks.size, 0); + }); + + test('parses hooks normally when disableAllHooks is false', () => { + const json = { + disableAllHooks: false, + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "should be parsed"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + + test('parses hooks normally when disableAllHooks is not present', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "should be parsed"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + }); + suite('nested hooks with matchers', () => { test('parses nested hooks with matcher', () => { const json = { @@ -115,7 +166,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'echo "bash hook"'); }); @@ -137,7 +188,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); }); @@ -159,7 +210,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); assert.strictEqual(entry.hooks[0].command, 'echo "bash"'); assert.strictEqual(entry.hooks[1].command, 'echo "write"'); @@ -180,55 +231,42 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 2); assert.strictEqual(entry.hooks[0].command, 'echo "direct"'); assert.strictEqual(entry.hooks[1].command, 'echo "nested"'); }); }); - suite('command without type field', () => { - test('parses command without explicit type field', () => { - const json = { - hooks: { - PreToolUse: [ - { command: 'echo "no type"' } - ] - } - }; - - const result = parseClaudeHooks(json, workspaceRoot, userHome); - - const entry = result.get(HookType.PreToolUse)!; - assert.strictEqual(entry.hooks.length, 1); - assert.strictEqual(entry.hooks[0].command, 'echo "no type"'); - }); - }); - suite('invalid inputs', () => { test('returns empty map for null json', () => { const result = parseClaudeHooks(null, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for undefined json', () => { const result = parseClaudeHooks(undefined, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for non-object json', () => { const result = parseClaudeHooks('string', workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for missing hooks property', () => { const result = parseClaudeHooks({}, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('returns empty map for non-object hooks property', () => { const result = parseClaudeHooks({ hooks: 'invalid' }, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); + assert.strictEqual(result.disabledAllHooks, false); }); test('skips unknown hook types', () => { @@ -241,8 +279,8 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 1); - assert.ok(result.has(HookType.PreToolUse)); + assert.strictEqual(result.hooks.size, 1); + assert.ok(result.hooks.has(HookType.PreToolUse)); }); test('skips non-array hook entries', () => { @@ -254,7 +292,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - assert.strictEqual(result.size, 0); + assert.strictEqual(result.hooks.size, 0); }); test('skips invalid command entries', () => { @@ -270,7 +308,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'valid'); }); @@ -287,7 +325,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.strictEqual(entry.hooks.length, 1); assert.strictEqual(entry.hooks[0].command, 'valid'); }); @@ -305,7 +343,7 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.deepStrictEqual(entry.hooks[0].cwd, URI.file('/workspace/src')); }); @@ -320,24 +358,138 @@ suite('HookClaudeCompat', () => { const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; + const entry = result.hooks.get(HookType.PreToolUse)!; assert.deepStrictEqual(entry.hooks[0].env, { NODE_ENV: 'production' }); }); - test('preserves timeoutSec', () => { + test('preserves timeout', () => { const json = { hooks: { PreToolUse: [ - { type: 'command', command: 'echo "test"', timeoutSec: 60 } + { type: 'command', command: 'echo "test"', timeout: 60 } ] } }; const result = parseClaudeHooks(json, workspaceRoot, userHome); - const entry = result.get(HookType.PreToolUse)!; - assert.strictEqual(entry.hooks[0].timeoutSec, 60); + const entry = result.hooks.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks[0].timeout, 60); + }); + + test('supports Claude timeout alias', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', timeout: 1 } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.hooks.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks[0].timeout, 1); }); }); }); }); + +suite('HookSourceFormat', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getHookSourceFormat', () => { + test('detects Claude format for .claude/settings.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.claude/settings.json')), HookSourceFormat.Claude); + }); + + test('detects Claude format for .claude/settings.local.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.claude/settings.local.json')), HookSourceFormat.Claude); + }); + + test('detects Claude format for ~/.claude/settings.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/home/user/.claude/settings.json')), HookSourceFormat.Claude); + }); + + test('returns Copilot format for .github/hooks/hooks.json', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.github/hooks/hooks.json')), HookSourceFormat.Copilot); + }); + + test('returns Copilot format for arbitrary .json file', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.github/hooks/my-hooks.json')), HookSourceFormat.Copilot); + }); + + test('returns Copilot format for settings.json not inside .claude', () => { + assert.strictEqual(getHookSourceFormat(URI.file('/workspace/.vscode/settings.json')), HookSourceFormat.Copilot); + }); + }); + + suite('buildNewHookEntry', () => { + test('builds Copilot format entry', () => { + assert.deepStrictEqual(buildNewHookEntry(HookSourceFormat.Copilot), { + type: 'command', + command: '' + }); + }); + + test('builds Claude format entry with matcher wrapper', () => { + assert.deepStrictEqual(buildNewHookEntry(HookSourceFormat.Claude), { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + }); + }); + + test('Claude format entry serializes correctly in JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const hooksContent = { + hooks: { + SubagentStart: [entry] + } + }; + const json = JSON.stringify(hooksContent, null, '\t'); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed.hooks.SubagentStart[0], { + matcher: '', + hooks: [{ + type: 'command', + command: '' + }] + }); + }); + + test('Copilot format entry serializes correctly in JSON', () => { + const entry = buildNewHookEntry(HookSourceFormat.Copilot); + const hooksContent = { + hooks: { + SubagentStart: [entry] + } + }; + const json = JSON.stringify(hooksContent, null, '\t'); + const parsed = JSON.parse(json); + assert.deepStrictEqual(parsed.hooks.SubagentStart[0], { + type: 'command', + command: '' + }); + }); + + test('Claude format round-trips through parseClaudeHooks', () => { + const entry = buildNewHookEntry(HookSourceFormat.Claude); + const hooksContent = { + hooks: { + PreToolUse: [entry] + } + }; + + const result = parseClaudeHooks(hooksContent, URI.file('/workspace'), '/home/user'); + assert.strictEqual(result.hooks.size, 1); + assert.ok(result.hooks.has(HookType.PreToolUse)); + const hooks = result.hooks.get(HookType.PreToolUse)!; + assert.strictEqual(hooks.hooks.length, 1); + // Empty command string is falsy and gets omitted by resolveHookCommand + assert.strictEqual(hooks.hooks[0].command, undefined); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts new file mode 100644 index 00000000000..7d4ba6ffe51 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { parseCopilotHooks, parseHooksFromFile, HookSourceFormat } from '../../../common/promptSyntax/hookCompatibility.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +suite('HookCompatibility', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('parseCopilotHooks', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + suite('basic parsing', () => { + test('parses simple hook with command', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "pre-tool"' } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "pre-tool"'); + }); + }); + + suite('invalid inputs', () => { + test('returns empty result for null json', () => { + const result = parseCopilotHooks(null, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty result for undefined json', () => { + const result = parseCopilotHooks(undefined, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty result for missing hooks property', () => { + const result = parseCopilotHooks({}, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + }); + }); + + suite('parseHooksFromFile', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + test('uses Copilot format for .github/hooks/*.json files', () => { + const fileUri = URI.file('/workspace/.github/hooks/my-hooks.json'); + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + assert.strictEqual(result.format, HookSourceFormat.Copilot); + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + + test('uses Claude format for .claude/settings.json files', () => { + const fileUri = URI.file('/workspace/.claude/settings.json'); + const json = { + disableAllHooks: true, + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + assert.strictEqual(result.format, HookSourceFormat.Claude); + assert.strictEqual(result.disabledAllHooks, true); + assert.strictEqual(result.hooks.size, 0); + }); + + test('disableAllHooks is ignored for Copilot format', () => { + const fileUri = URI.file('/workspace/.github/hooks/hooks.json'); + const json = { + disableAllHooks: true, + hooks: { + SessionStart: [ + { type: 'command', command: 'echo "start"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + // Copilot format does not support disableAllHooks + assert.strictEqual(result.disabledAllHooks, false); + assert.strictEqual(result.hooks.size, 1); + }); + + test('disabledAllHooks works for Claude format', () => { + const fileUri = URI.file('/workspace/.claude/settings.local.json'); + const json = { + disableAllHooks: true, + hooks: { + SessionStart: [ + { type: 'command', command: 'echo "start"' } + ] + } + }; + + const result = parseHooksFromFile(fileUri, json, workspaceRoot, userHome); + + assert.strictEqual(result.disabledAllHooks, true); + assert.strictEqual(result.hooks.size, 0); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts index 6b73e1d362e..63bcf59c004 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { resolveHookCommand, resolveEffectiveCommand, formatHookCommandLabel, IHookCommand } from '../../../common/promptSyntax/hookSchema.js'; import { URI } from '../../../../../../base/common/uri.js'; -import * as platform from '../../../../../../base/common/platform.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; suite('HookSchema', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -35,14 +35,14 @@ suite('HookSchema', () => { command: './scripts/validate.sh', cwd: 'src', env: { NODE_ENV: 'test' }, - timeoutSec: 60 + timeout: 60 }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', command: './scripts/validate.sh', cwd: URI.file('/workspace/src'), env: { NODE_ENV: 'test' }, - timeoutSec: 60 + timeout: 60 }); }); @@ -118,18 +118,18 @@ suite('HookSchema', () => { }); }); - test('powershell with timeoutSec', () => { + test('powershell with timeout', () => { const result = resolveHookCommand({ type: 'command', powershell: 'Get-Process', - timeoutSec: 30 + timeout: 30 }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', windows: 'Get-Process', windowsSource: 'powershell', cwd: workspaceRoot, - timeoutSec: 30 + timeout: 30 }); }); @@ -277,11 +277,11 @@ suite('HookSchema', () => { }); }); - test('ignores non-number timeoutSec', () => { + test('ignores non-number timeout', () => { const result = resolveHookCommand({ type: 'command', command: 'echo hello', - timeoutSec: '30' + timeout: '30' }, workspaceRoot, userHome); assert.deepStrictEqual(result, { type: 'command', @@ -408,11 +408,12 @@ suite('HookSchema', () => { type: 'command', command: 'default-command' }; - const result = resolveEffectiveCommand(hook); - assert.strictEqual(result, 'default-command'); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Windows), 'default-command'); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Macintosh), 'default-command'); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Linux), 'default-command'); }); - test('applies current platform override', () => { + test('applies platform override for each platform', () => { const hook: IHookCommand = { type: 'command', command: 'default-command', @@ -420,15 +421,9 @@ suite('HookSchema', () => { linux: 'linux-command', osx: 'osx-command' }; - const result = resolveEffectiveCommand(hook); - // Result depends on the current platform - if (platform.isWindows) { - assert.strictEqual(result, 'win-command'); - } else if (platform.isMacintosh) { - assert.strictEqual(result, 'osx-command'); - } else if (platform.isLinux) { - assert.strictEqual(result, 'linux-command'); - } + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Windows), 'win-command'); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Macintosh), 'osx-command'); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Linux), 'linux-command'); }); test('falls back to command when no platform-specific override', () => { @@ -436,16 +431,14 @@ suite('HookSchema', () => { type: 'command', command: 'default-command' }; - const result = resolveEffectiveCommand(hook); - assert.strictEqual(result, 'default-command'); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Windows), 'default-command'); }); test('returns undefined when no command at all', () => { const hook: IHookCommand = { type: 'command' }; - const result = resolveEffectiveCommand(hook); - assert.strictEqual(result, undefined); + assert.strictEqual(resolveEffectiveCommand(hook, OperatingSystem.Windows), undefined); }); }); @@ -456,14 +449,16 @@ suite('HookSchema', () => { command: 'echo hello' }; // No platform badge when using default command - assert.strictEqual(formatHookCommandLabel(hook), 'echo hello'); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), 'echo hello'); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Macintosh), 'echo hello'); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Linux), 'echo hello'); }); test('returns empty string when no command', () => { const hook: IHookCommand = { type: 'command' }; - assert.strictEqual(formatHookCommandLabel(hook), ''); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), ''); }); test('applies platform override for display with platform badge', () => { @@ -474,15 +469,10 @@ suite('HookSchema', () => { linux: 'linux-command', osx: 'osx-command' }; - const label = formatHookCommandLabel(hook); // Should include platform badge when using platform-specific override - if (platform.isWindows) { - assert.strictEqual(label, '[Windows] win-command'); - } else if (platform.isMacintosh) { - assert.strictEqual(label, '[macOS] osx-command'); - } else if (platform.isLinux) { - assert.strictEqual(label, '[Linux] linux-command'); - } + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), '[Windows] win-command'); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Macintosh), '[macOS] osx-command'); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Linux), '[Linux] linux-command'); }); test('no platform badge when falling back to default command', () => { @@ -492,7 +482,7 @@ suite('HookSchema', () => { // No platform-specific overrides }; // Should not include badge when using default command - assert.strictEqual(formatHookCommandLabel(hook), 'default-command'); + assert.strictEqual(formatHookCommandLabel(hook, OperatingSystem.Windows), 'default-command'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts similarity index 77% rename from src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts rename to src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index 0c70640aadb..156e28227d7 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/newPromptsParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -8,9 +8,9 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; import { URI } from '../../../../../../../base/common/uri.js'; -import { PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; +import { IStringValue, parseCommaSeparatedList, PromptFileParser } from '../../../../common/promptSyntax/promptFileParser.js'; -suite('NewPromptsParser', () => { +suite('PromptFileParser', () => { ensureNoDisposablesAreLeakedInTestSuite(); test('agent', async () => { @@ -239,75 +239,6 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.tools, ['search', 'terminal']); }); - test('prompt file tools as map', async () => { - const uri = URI.parse('file:///test/prompt2.md'); - const content = [ - /* 01 */'---', - /* 02 */'tools:', - /* 03 */' built-in: true', - /* 04 */' mcp:', - /* 05 */' vscode-playright-mcp:', - /* 06 */' browser-click: true', - /* 07 */' extensions:', - /* 08 */' github.vscode-pull-request-github:', - /* 09 */' openPullRequest: true', - /* 10 */' copilotCodingAgent: false', - /* 11 */'---', - ].join('\n'); - const result = new PromptFileParser().parse(uri, content); - assert.deepEqual(result.uri, uri); - assert.ok(result.header); - assert.ok(!result.body); - assert.deepEqual(result.header.range, { startLineNumber: 2, startColumn: 1, endLineNumber: 11, endColumn: 1 }); - assert.deepEqual(result.header.attributes, [ - { - key: 'tools', range: new Range(2, 1, 10, 32), value: { - type: 'object', - properties: [ - { - 'key': { type: 'string', value: 'built-in', range: new Range(3, 3, 3, 11) }, - 'value': { type: 'boolean', value: true, range: new Range(3, 13, 3, 17) } - }, - { - 'key': { type: 'string', value: 'mcp', range: new Range(4, 3, 4, 6) }, - 'value': { - type: 'object', range: new Range(5, 5, 6, 26), properties: [ - { - 'key': { type: 'string', value: 'vscode-playright-mcp', range: new Range(5, 5, 5, 25) }, 'value': { - type: 'object', range: new Range(6, 7, 6, 26), properties: [ - { 'key': { type: 'string', value: 'browser-click', range: new Range(6, 7, 6, 20) }, 'value': { type: 'boolean', value: true, range: new Range(6, 22, 6, 26) } } - ] - } - } - ] - } - }, - { - 'key': { type: 'string', value: 'extensions', range: new Range(7, 3, 7, 13) }, - 'value': { - type: 'object', range: new Range(8, 5, 10, 32), properties: [ - { - 'key': { type: 'string', value: 'github.vscode-pull-request-github', range: new Range(8, 5, 8, 38) }, 'value': { - type: 'object', range: new Range(9, 7, 10, 32), properties: [ - { 'key': { type: 'string', value: 'openPullRequest', range: new Range(9, 7, 9, 22) }, 'value': { type: 'boolean', value: true, range: new Range(9, 24, 9, 28) } }, - { 'key': { type: 'string', value: 'copilotCodingAgent', range: new Range(10, 7, 10, 25) }, 'value': { type: 'boolean', value: false, range: new Range(10, 27, 10, 32) } } - ] - } - } - ] - } - }, - ], - range: new Range(3, 3, 10, 32) - }, - } - ]); - assert.deepEqual(result.header.description, undefined); - assert.deepEqual(result.header.agent, undefined); - assert.deepEqual(result.header.model, undefined); - assert.ok(result.header.tools); - assert.deepEqual(result.header.tools, ['built-in', 'browser-click', 'openPullRequest', 'copilotCodingAgent']); - }); test('agent with agents', async () => { const uri = URI.parse('file:///test/test.agent.md'); @@ -372,4 +303,108 @@ suite('NewPromptsParser', () => { assert.deepEqual(result.header.description, 'Agent without restrictions'); assert.deepEqual(result.header.agents, undefined); }); + + suite('parseCommaSeparatedList', () => { + + function assertCommaSeparatedList(input: string, expected: IStringValue[]): void { + const actual = parseCommaSeparatedList({ type: 'string', value: input, range: new Range(1, 1, 1, input.length + 1) }); + assert.deepStrictEqual(actual.items, expected); + } + + test('simple unquoted values', () => { + assertCommaSeparatedList('a, b, c', [ + { type: 'string', value: 'a', range: new Range(1, 1, 1, 2) }, + { type: 'string', value: 'b', range: new Range(1, 4, 1, 5) }, + { type: 'string', value: 'c', range: new Range(1, 7, 1, 8) } + ]); + }); + + test('unquoted values without spaces', () => { + assertCommaSeparatedList('foo,bar,baz', [ + { type: 'string', value: 'foo', range: new Range(1, 1, 1, 4) }, + { type: 'string', value: 'bar', range: new Range(1, 5, 1, 8) }, + { type: 'string', value: 'baz', range: new Range(1, 9, 1, 12) } + ]); + }); + + test('double quoted values', () => { + assertCommaSeparatedList('"hello", "world"', [ + { type: 'string', value: 'hello', range: new Range(1, 1, 1, 8) }, + { type: 'string', value: 'world', range: new Range(1, 10, 1, 17) } + ]); + }); + + test('single quoted values', () => { + assertCommaSeparatedList(`'one', 'two'`, [ + { type: 'string', value: 'one', range: new Range(1, 1, 1, 6) }, + { type: 'string', value: 'two', range: new Range(1, 8, 1, 13) } + ]); + }); + + test('mixed quoted and unquoted values', () => { + assertCommaSeparatedList('unquoted, "double", \'single\'', [ + { type: 'string', value: 'unquoted', range: new Range(1, 1, 1, 9) }, + { type: 'string', value: 'double', range: new Range(1, 11, 1, 19) }, + { type: 'string', value: 'single', range: new Range(1, 21, 1, 29) } + ]); + }); + + test('quoted values with commas inside', () => { + assertCommaSeparatedList('"a,b", "c,d"', [ + { type: 'string', value: 'a,b', range: new Range(1, 1, 1, 6) }, + { type: 'string', value: 'c,d', range: new Range(1, 8, 1, 13) } + ]); + }); + + test('empty string', () => { + assertCommaSeparatedList('', []); + }); + + test('single value', () => { + assertCommaSeparatedList('single', [ + { type: 'string', value: 'single', range: new Range(1, 1, 1, 7) } + ]); + }); + + test('values with extra whitespace', () => { + assertCommaSeparatedList(' a , b , c ', [ + { type: 'string', value: 'a', range: new Range(1, 3, 1, 4) }, + { type: 'string', value: 'b', range: new Range(1, 9, 1, 10) }, + { type: 'string', value: 'c', range: new Range(1, 15, 1, 16) } + ]); + }); + + test('quoted value with spaces', () => { + assertCommaSeparatedList('"hello world", "foo bar"', [ + { type: 'string', value: 'hello world', range: new Range(1, 1, 1, 14) }, + { type: 'string', value: 'foo bar', range: new Range(1, 16, 1, 25) } + ]); + }); + + test('with position offset', () => { + // Simulate parsing a list that starts at line 5, character 10 + const result = parseCommaSeparatedList({ type: 'string', value: 'a, b, c', range: new Range(6, 11, 6, 18) }); + assert.deepStrictEqual(result.items, [ + { type: 'string', value: 'a', range: new Range(6, 11, 6, 12) }, + { type: 'string', value: 'b', range: new Range(6, 14, 6, 15) }, + { type: 'string', value: 'c', range: new Range(6, 17, 6, 18) } + ]); + }); + + test('entire input wrapped in double quotes', () => { + // When the entire input is wrapped in quotes, it should be treated as a single quoted value + assertCommaSeparatedList('"a, b, c"', [ + { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + ]); + }); + + test('entire input wrapped in single quotes', () => { + // When the entire input is wrapped in single quotes, it should be treated as a single quoted value + assertCommaSeparatedList(`'a, b, c'`, [ + { type: 'string', value: 'a, b, c', range: new Range(1, 1, 1, 10) } + ]); + }); + + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 89c383218f0..f4e54cac7f1 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -38,9 +38,9 @@ import { TestContextService, TestUserDataProfileService } from '../../../../../. import { ChatRequestVariableSet, isPromptFileVariableEntry, toFileVariableEntry } from '../../../../common/attachments/chatVariableEntries.js'; import { ComputeAutomaticInstructions, newInstructionsCollectionEvent } from '../../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../../common/promptSyntax/config/config.js'; -import { INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_CONFIG_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID, PromptsType } from '../../../../common/promptSyntax/promptTypes.js'; -import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ExtensionAgentSourceType, ICustomAgent, IPromptFileContext, IPromptsService, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../../../../common/promptSyntax/service/promptsServiceImpl.js'; import { mockFiles } from '../testUtils/mockFilesystem.js'; import { InMemoryStorageService, IStorageService } from '../../../../../../../platform/storage/common/storage.js'; @@ -74,6 +74,7 @@ suite('PromptsService', () => { testConfigService.setUserConfiguration(PromptsConfig.INSTRUCTIONS_LOCATION_KEY, { [INSTRUCTIONS_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.PROMPT_LOCATIONS_KEY, { [PROMPT_DEFAULT_SOURCE_FOLDER]: true }); testConfigService.setUserConfiguration(PromptsConfig.MODE_LOCATION_KEY, { [LEGACY_MODE_DEFAULT_SOURCE_FOLDER]: true }); + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { [AGENTS_SOURCE_FOLDER]: true }); instaService.stub(IConfigurationService, testConfigService); instaService.stub(IWorkbenchEnvironmentService, {}); @@ -767,7 +768,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), @@ -823,7 +824,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), @@ -841,6 +842,7 @@ suite('PromptsService', () => { }, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } } ]; @@ -897,7 +899,7 @@ suite('PromptsService', () => { }, handOffs: undefined, model: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), @@ -915,7 +917,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), @@ -976,7 +978,7 @@ suite('PromptsService', () => { { name: 'github-agent', description: 'GitHub Copilot specialized agent.', - target: 'github-copilot', + target: Target.GitHubCopilot, tools: ['github-api', 'code-search'], agentInstructions: { content: 'I am optimized for GitHub Copilot workflows.', @@ -994,7 +996,7 @@ suite('PromptsService', () => { { name: 'vscode-agent', description: 'VS Code specialized agent.', - target: 'vscode', + target: Target.VSCode, model: ['gpt-4'], agentInstructions: { content: 'I am specialized for VS Code editor tasks.', @@ -1021,7 +1023,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), @@ -1036,6 +1038,122 @@ suite('PromptsService', () => { ); }); + test('claude agent maps tools and model to vscode equivalents', async () => { + const rootFolderName = 'claude-agent-mapping'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + // Claude agent with tools and model that should be mapped + path: `${rootFolder}/.claude/agents/claude-agent.md`, + contents: [ + '---', + 'description: \'Claude agent with tools and model.\'', + 'tools: [ Read, Edit, Bash ]', + 'model: opus', + '---', + 'I am a Claude agent.', + ] + }, + { + // Claude agent with more tools, some with empty equivalents + path: `${rootFolder}/.claude/agents/claude-agent2.md`, + contents: [ + '---', + 'description: \'Claude agent with various tools.\'', + 'tools: [ Glob, Grep, Write, Task, Skill ]', + 'model: sonnet', + '---', + 'I am another Claude agent.', + ] + }, + { + // Non-Claude agent should NOT have tools/model mapped + path: `${rootFolder}/.github/agents/copilot-agent.agent.md`, + contents: [ + '---', + 'description: \'Copilot agent with same tool names.\'', + 'target: \'github-copilot\'', + 'tools: [ Read, Edit ]', + 'model: gpt-4', + '---', + 'I am a Copilot agent.', + ] + }, + ]); + + const result = (await service.getCustomAgents(CancellationToken.None)).map(agent => ({ ...agent, uri: URI.from(agent.uri) })); + const expected: ICustomAgent[] = [ + { + name: 'copilot-agent', + description: 'Copilot agent with same tool names.', + target: Target.GitHubCopilot, + // Non-Claude agent: tools and model stay as-is + tools: ['Read', 'Edit'], + model: ['gpt-4'], + agentInstructions: { + content: 'I am a Copilot agent.', + toolReferences: [], + metadata: undefined + }, + handOffs: undefined, + argumentHint: undefined, + visibility: { userInvokable: true, agentInvokable: true }, + agents: undefined, + uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), + source: { storage: PromptsStorage.local } + }, + { + name: 'claude-agent', + description: 'Claude agent with tools and model.', + target: Target.Claude, + // Claude tools mapped to vscode equivalents + tools: ['read/readFile', 'read/getNotebookSummary', 'edit/editNotebook', 'edit/editFiles', 'execute'], + // Claude model mapped to vscode equivalent + model: ['Claude Opus 4.6 (copilot)'], + agentInstructions: { + content: 'I am a Claude agent.', + toolReferences: [], + metadata: undefined + }, + handOffs: undefined, + argumentHint: undefined, + visibility: { userInvokable: true, agentInvokable: true }, + agents: undefined, + uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), + source: { storage: PromptsStorage.local } + }, + { + name: 'claude-agent2', + description: 'Claude agent with various tools.', + target: Target.Claude, + // Tools mapped: Glob->search/fileSearch, Grep->search/textSearch, Write->edit/create*, Task->agent, Skill->[] (empty) + tools: ['search/fileSearch', 'search/textSearch', 'edit/createDirectory', 'edit/createFile', 'edit/createJupyterNotebook', 'agent'], + model: ['Claude Sonnet 4.5 (copilot)'], + agentInstructions: { + content: 'I am another Claude agent.', + toolReferences: [], + metadata: undefined + }, + handOffs: undefined, + argumentHint: undefined, + visibility: { userInvokable: true, agentInvokable: true }, + agents: undefined, + uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), + source: { storage: PromptsStorage.local } + }, + ]; + + assert.deepEqual( + result, + expected, + 'Claude tools and models must be mapped to VS Code equivalents; non-Claude agents must remain unchanged.', + ); + }); + test('agents with .md extension should be recognized, except README.md', async () => { const rootFolderName = 'custom-agents-md-extension'; const rootFolder = `/${rootFolderName}`; @@ -1076,7 +1194,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), @@ -1147,7 +1265,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1165,7 +1283,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1183,7 +1301,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, tools: undefined, - target: undefined, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1504,7 +1622,7 @@ suite('PromptsService', () => { const copilotSkill = personalSkills.find(s => s.uri.path.includes('.copilot')); assert.ok(copilotSkill, 'Should find copilot personal skill'); - const claudeSkill = personalSkills.find(s => s.uri.path.includes('.claude')); + const claudeSkill = personalSkills.find(s => s.uri.path.includes(CLAUDE_CONFIG_FOLDER)); assert.ok(claudeSkill, 'Should find claude personal skill'); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 25d7088d46e..32809f2ebdd 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -13,10 +13,11 @@ import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubage import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; -import { ILanguageModelsService } from '../../../../common/languageModels.js'; +import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; -import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { ICustomAgent, PromptsStorage, Target } from '../../../../common/promptSyntax/service/promptsService.js'; import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; +import { ExtensionIdentifier } from '../../../../../../../platform/extensions/common/extensions.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -54,6 +55,7 @@ suite('RunSubagentTool', () => { tools: ['tool1', 'tool2'], agentInstructions: { content: 'Custom agent body', toolReferences: [] }, source: { storage: PromptsStorage.local }, + target: Target.Undefined, visibility: { userInvokable: true, agentInvokable: true } }; promptsService.setCustomModes([customMode]); @@ -77,6 +79,7 @@ suite('RunSubagentTool', () => { description: 'Test task', agentName: 'CustomAgent', }, + toolCallId: 'test-call-1', chatSessionResource: URI.parse('test://session'), }, CancellationToken.None @@ -89,6 +92,7 @@ suite('RunSubagentTool', () => { description: 'Test task', agentName: 'CustomAgent', prompt: 'Test prompt', + modelName: undefined, }); }); }); @@ -208,4 +212,277 @@ suite('RunSubagentTool', () => { assert.deepStrictEqual(matchingEvents, ['matching-tool']); }); }); + + suite('model fallback behavior', () => { + function createMetadata(name: string, multiplierNumeric?: number): ILanguageModelChatMetadata { + return { + extension: new ExtensionIdentifier('test.extension'), + name, + id: name.toLowerCase().replace(/\s+/g, '-'), + vendor: 'TestVendor', + version: '1.0', + family: 'test', + maxInputTokens: 128000, + maxOutputTokens: 8192, + isDefaultForLocation: {}, + modelPickerCategory: undefined, + multiplierNumeric, + }; + } + + function createTool(opts: { + models: Map; + qualifiedNameMap?: Map; + customAgents?: ICustomAgent[]; + }) { + const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); + const configService = new TestConfigurationService(); + const promptsService = new MockPromptsService(); + if (opts.customAgents) { + promptsService.setCustomModes(opts.customAgents); + } + + const mockLanguageModelsService: Partial = { + lookupLanguageModel(modelId: string) { + return opts.models.get(modelId); + }, + lookupLanguageModelByQualifiedName(qualifiedName: string) { + return opts.qualifiedNameMap?.get(qualifiedName); + }, + }; + + const tool = testDisposables.add(new RunSubagentTool( + {} as IChatAgentService, + {} as IChatService, + mockToolsService, + mockLanguageModelsService as ILanguageModelsService, + new NullLogService(), + mockToolsService, + configService, + promptsService, + {} as IInstantiationService, + )); + + return tool; + } + + function createAgent(name: string, modelQualifiedNames?: string[]): ICustomAgent { + return { + uri: URI.parse(`file:///test/${name}.md`), + name, + description: `Agent ${name}`, + tools: ['tool1'], + model: modelQualifiedNames, + agentInstructions: { content: 'test', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + target: Target.Undefined, + visibility: { userInvokable: true, agentInvokable: true } + }; + } + + test('falls back to main model when subagent model has higher multiplier', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const expensiveMeta = createMetadata('O3 Pro', 50); + const models = new Map([ + ['main-model-id', mainMeta], + ['expensive-model-id', expensiveMeta], + ]); + const qualifiedNameMap = new Map([ + ['O3 Pro (TestVendor)', { metadata: expensiveMeta, identifier: 'expensive-model-id' }], + ]); + + const agent = createAgent('ExpensiveAgent', ['O3 Pro (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'ExpensiveAgent' }, + toolCallId: 'call-1', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + // Should fall back to the main model's name, not the expensive model + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'ExpensiveAgent', + prompt: 'test', + modelName: 'GPT-4o', + }); + }); + + test('uses subagent model when it has equal multiplier', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const sameCostMeta = createMetadata('Claude Sonnet', 1); + const models = new Map([ + ['main-model-id', mainMeta], + ['same-cost-model-id', sameCostMeta], + ]); + const qualifiedNameMap = new Map([ + ['Claude Sonnet (TestVendor)', { metadata: sameCostMeta, identifier: 'same-cost-model-id' }], + ]); + + const agent = createAgent('SameCostAgent', ['Claude Sonnet (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'SameCostAgent' }, + toolCallId: 'call-2', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'SameCostAgent', + prompt: 'test', + modelName: 'Claude Sonnet', + }); + }); + + test('uses subagent model when it has lower multiplier', async () => { + const mainMeta = createMetadata('O3 Pro', 50); + const cheapMeta = createMetadata('GPT-4o Mini', 0.25); + const models = new Map([ + ['main-model-id', mainMeta], + ['cheap-model-id', cheapMeta], + ]); + const qualifiedNameMap = new Map([ + ['GPT-4o Mini (TestVendor)', { metadata: cheapMeta, identifier: 'cheap-model-id' }], + ]); + + const agent = createAgent('CheapAgent', ['GPT-4o Mini (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'CheapAgent' }, + toolCallId: 'call-3', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'CheapAgent', + prompt: 'test', + modelName: 'GPT-4o Mini', + }); + }); + + test('uses subagent model when main model has no multiplier', async () => { + const mainMeta = createMetadata('Unknown Model', undefined); + const subMeta = createMetadata('O3 Pro', 50); + const models = new Map([ + ['main-model-id', mainMeta], + ['sub-model-id', subMeta], + ]); + const qualifiedNameMap = new Map([ + ['O3 Pro (TestVendor)', { metadata: subMeta, identifier: 'sub-model-id' }], + ]); + + const agent = createAgent('SubAgent', ['O3 Pro (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'SubAgent' }, + toolCallId: 'call-4', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + // No fallback when main model's multiplier is unknown + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'SubAgent', + prompt: 'test', + modelName: 'O3 Pro', + }); + }); + + test('uses subagent model when subagent model has no multiplier', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const subMeta = createMetadata('Custom Model', undefined); + const models = new Map([ + ['main-model-id', mainMeta], + ['sub-model-id', subMeta], + ]); + const qualifiedNameMap = new Map([ + ['Custom Model (TestVendor)', { metadata: subMeta, identifier: 'sub-model-id' }], + ]); + + const agent = createAgent('CustomAgent', ['Custom Model (TestVendor)']); + const tool = createTool({ models, qualifiedNameMap, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'CustomAgent' }, + toolCallId: 'call-5', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + // No fallback when subagent model's multiplier is unknown + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'CustomAgent', + prompt: 'test', + modelName: 'Custom Model', + }); + }); + + test('uses main model when no subagent is specified', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const models = new Map([['main-model-id', mainMeta]]); + + const tool = createTool({ models }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task' }, + toolCallId: 'call-6', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: undefined, + prompt: 'test', + modelName: 'GPT-4o', + }); + }); + + test('uses main model when subagent has no model configured', async () => { + const mainMeta = createMetadata('GPT-4o', 1); + const models = new Map([['main-model-id', mainMeta]]); + + const agent = createAgent('NoModelAgent', undefined); + const tool = createTool({ models, customAgents: [agent] }); + + const result = await tool.prepareToolInvocation({ + parameters: { prompt: 'test', description: 'test task', agentName: 'NoModelAgent' }, + toolCallId: 'call-7', + modelId: 'main-model-id', + chatSessionResource: URI.parse('test://session'), + }, CancellationToken.None); + + assert.ok(result); + assert.deepStrictEqual(result.toolSpecificData, { + kind: 'subagent', + description: 'test task', + agentName: 'NoModelAgent', + prompt: 'test', + modelName: 'GPT-4o', + }); + }); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts index 0541af5cfca..ea248c48bd2 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/mockLanguageModelToolsService.ts @@ -137,7 +137,7 @@ export class MockLanguageModelToolsService extends Disposable implements ILangua throw new Error('Method not implemented.'); } - toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[], target: string | undefined): IToolAndToolSetEnablementMap { + toToolAndToolSetEnablementMap(toolOrToolSetNames: readonly string[]): IToolAndToolSetEnablementMap { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts index acee94cc06c..12819789846 100644 --- a/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/electron-browser/tools/builtinTools/fetchPageTool.test.ts @@ -192,7 +192,7 @@ suite('FetchWebPageTool', () => { ); const preparation = await tool.prepareToolInvocation( - { parameters: { urls: ['https://valid.com', 'test://valid/resource', 'invalid://invalid'] }, chatSessionResource: undefined }, + { parameters: { urls: ['https://valid.com', 'test://valid/resource', 'invalid://invalid'] }, toolCallId: 'test-call-1', chatSessionResource: undefined }, CancellationToken.None ); @@ -230,7 +230,7 @@ suite('FetchWebPageTool', () => { ); const preparation1 = await tool.prepareToolInvocation( - { parameters: { urls: ['https://example.com'] }, chatSessionResource: LocalChatSessionUri.forSession('a') }, + { parameters: { urls: ['https://example.com'] }, toolCallId: 'test-call-2', chatSessionResource: LocalChatSessionUri.forSession('a') }, CancellationToken.None ); @@ -238,7 +238,7 @@ suite('FetchWebPageTool', () => { assert.strictEqual(preparation1.confirmationMessages?.title, undefined); const preparation2 = await tool.prepareToolInvocation( - { parameters: { urls: ['https://other.com'] }, chatSessionResource: LocalChatSessionUri.forSession('a') }, + { parameters: { urls: ['https://other.com'] }, toolCallId: 'test-call-3', chatSessionResource: LocalChatSessionUri.forSession('a') }, CancellationToken.None ); diff --git a/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts b/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts index e83d4d5d4cc..dc45f781eb0 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/outline/documentSymbolsOutline.ts @@ -110,7 +110,7 @@ class DocumentSymbolBreadcrumbsSource implements IBreadcrumbsDataSource { private readonly _disposables = new DisposableStore(); - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._disposables.add(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; diff --git a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts index 64ccc42d8fc..cacba319f81 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/suggestEnabledInput/suggestEnabledInput.ts @@ -116,10 +116,10 @@ export interface ISuggestEnabledInputStyleOverrides { export class SuggestEnabledInput extends Widget { - private readonly _onShouldFocusResults = new Emitter(); + private readonly _onShouldFocusResults = this._register(new Emitter()); readonly onShouldFocusResults: Event = this._onShouldFocusResults.event; - private readonly _onInputDidChange = new Emitter(); + private readonly _onInputDidChange = this._register(new Emitter()); readonly onInputDidChange: Event = this._onInputDidChange.event; private readonly _onDidFocus = this._register(new Emitter()); diff --git a/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts b/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts index b55067b5ac4..1dce3a40681 100644 --- a/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts +++ b/src/vs/workbench/contrib/codeEditor/browser/toggleWordWrap.ts @@ -259,7 +259,7 @@ class EditorWordWrapContextKeyTracker extends Disposable implements IWorkbenchCo this._canToggleWordWrap = CAN_TOGGLE_WORD_WRAP.bindTo(this._contextService); this._editorWordWrap = EDITOR_WORD_WRAP.bindTo(this._contextService); this._activeEditor = null; - this._activeEditorListener = new DisposableStore(); + this._activeEditorListener = this._register(new DisposableStore()); this._update(); } diff --git a/src/vs/workbench/contrib/comments/browser/commentNode.ts b/src/vs/workbench/contrib/comments/browser/commentNode.ts index b4ded353a75..ee1177e1eb3 100644 --- a/src/vs/workbench/contrib/comments/browser/commentNode.ts +++ b/src/vs/workbench/contrib/comments/browser/commentNode.ts @@ -91,7 +91,7 @@ export class CommentNode extends Disposable { private _commentFormActions: CommentFormActions | null = null; private _commentEditorActions: CommentFormActions | null = null; - private readonly _onDidClick = new Emitter>(); + private readonly _onDidClick = this._register(new Emitter>()); public get domNode(): HTMLElement { return this._domNode; diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts index 08b0f2bd745..7f3deff2eda 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadBody.ts @@ -25,7 +25,7 @@ export class CommentThreadBody extends D private _commentElements: CommentNode[] = []; private _resizeObserver: MutationObserver | null = null; private _focusedComment: number | undefined = undefined; - private _onDidResize = new Emitter(); + private _onDidResize = this._register(new Emitter()); onDidResize = this._onDidResize.event; private _commentDisposable = new DisposableMap, DisposableStore>(); diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts index 7fb4e06542e..8e31f0b2116 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadWidget.ts @@ -45,7 +45,7 @@ export class CommentThreadWidget extends private _threadIsEmpty: IContextKey; private _commentThreadContextValue: IContextKey; private _focusedContextKey: IContextKey; - private _onDidResize = new Emitter(); + private _onDidResize = this._register(new Emitter()); onDidResize = this._onDidResize.event; private _commentThreadState: languages.CommentThreadState | undefined; diff --git a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts index 85e5fba39ef..3a54c6236d8 100644 --- a/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts +++ b/src/vs/workbench/contrib/comments/browser/commentThreadZoneWidget.ts @@ -588,5 +588,8 @@ export class ReviewZoneWidget extends ZoneWidget implements ICommentThreadWidget this._globalToDispose.dispose(); this._commentThreadDisposables.forEach(global => global.dispose()); this._onDidClose.fire(undefined); + this._onDidClose.dispose(); + this._onDidCreateThread.dispose(); + this._onDidChangeExpandedState.dispose(); } } diff --git a/src/vs/workbench/contrib/comments/browser/commentsController.ts b/src/vs/workbench/contrib/comments/browser/commentsController.ts index 2360430ce96..de5f8bea30f 100644 --- a/src/vs/workbench/contrib/comments/browser/commentsController.ts +++ b/src/vs/workbench/contrib/comments/browser/commentsController.ts @@ -365,6 +365,7 @@ class CommentingRangeDecorator { } public dispose(): void { + this._onDidChangeDecorationsCount.dispose(); this.commentingRangeDecorations = []; } } @@ -505,7 +506,7 @@ export class CommentController extends Disposable implements IEditorContribution this.editor = editor; - this._commentingRangeDecorator = new CommentingRangeDecorator(); + this._commentingRangeDecorator = this._register(new CommentingRangeDecorator()); this._register(this._commentingRangeDecorator.onDidChangeDecorationsCount(count => { if (count === 0) { this.clearEditorListeners(); diff --git a/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts b/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts index 7504a9c9d32..0b9ced32f77 100644 --- a/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts +++ b/src/vs/workbench/contrib/debug/browser/debugAdapterManager.ts @@ -49,8 +49,8 @@ export class AdapterManager extends Disposable implements IAdapterManager { private debugAdapterFactories = new Map(); private debuggersAvailable!: IContextKey; private debugExtensionsAvailable!: IContextKey; - private readonly _onDidRegisterDebugger = new Emitter(); - private readonly _onDidDebuggersExtPointRead = new Emitter(); + private readonly _onDidRegisterDebugger = this._register(new Emitter()); + private readonly _onDidDebuggersExtPointRead = this._register(new Emitter()); private breakpointContributions: Breakpoints[] = []; private debuggerWhenKeys = new Set(); private taskLabels: string[] = []; diff --git a/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts b/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts index d7c743c9188..bf2f59505c9 100644 --- a/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts +++ b/src/vs/workbench/contrib/debug/browser/debugConfigurationManager.ts @@ -77,7 +77,7 @@ export class ConfigurationManager implements IConfigurationManager { @ILogService private readonly logService: ILogService, ) { this.configProviders = []; - this.toDispose = [this._onDidChangeConfigurationProviders]; + this.toDispose = [this._onDidChangeConfigurationProviders, this._onDidSelectConfigurationName]; this.initLaunches(); this.setCompoundSchemaValues(); this.registerListeners(); diff --git a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts index 008d4b876b4..48e3c89d7cf 100644 --- a/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/debugEditorContribution.ts @@ -959,12 +959,9 @@ export class DebugEditorContribution implements IDebugEditorContribution { } dispose(): void { - if (this.hoverWidget) { - this.hoverWidget.dispose(); - } - if (this.configurationWidget) { - this.configurationWidget.dispose(); - } + this.hoverWidget?.dispose(); + this.configurationWidget?.dispose(); + this.exceptionWidget?.dispose(); this.toDispose = dispose(this.toDispose); } } diff --git a/src/vs/workbench/contrib/debug/browser/debugHover.ts b/src/vs/workbench/contrib/debug/browser/debugHover.ts index 28f66c5d519..fe8ae2b6ab0 100644 --- a/src/vs/workbench/contrib/debug/browser/debugHover.ts +++ b/src/vs/workbench/contrib/debug/browser/debugHover.ts @@ -153,6 +153,7 @@ export class DebugHoverWidget implements IContentWidget { }); this.toDispose.push(VisualizedVariableRenderer.rendererOnVisualizationRange(this.debugService.getViewModel(), this.tree)); + this.toDispose.push(this.tree); this.valueContainer = $('.value'); this.valueContainer.tabIndex = 0; diff --git a/src/vs/workbench/contrib/debug/browser/debugMemory.ts b/src/vs/workbench/contrib/debug/browser/debugMemory.ts index 9eabf24fed7..9a916b29f39 100644 --- a/src/vs/workbench/contrib/debug/browser/debugMemory.ts +++ b/src/vs/workbench/contrib/debug/browser/debugMemory.ts @@ -17,7 +17,7 @@ const rangeRe = /range=([0-9]+):([0-9]+)/; export class DebugMemoryFileSystemProvider extends Disposable implements IFileSystemProvider { private memoryFdCounter = 0; private readonly fdMemory = new Map(); - private readonly changeEmitter = new Emitter(); + private readonly changeEmitter = this._register(new Emitter()); /** @inheritdoc */ public readonly onDidChangeCapabilities = Event.None; @@ -231,7 +231,7 @@ export class DebugMemoryFileSystemProvider extends Disposable implements IFileSy /** A wrapper for a MemoryRegion that references a subset of data in another region. */ class MemoryRegionView extends Disposable implements IMemoryRegion { - private readonly invalidateEmitter = new Emitter(); + private readonly invalidateEmitter = this._register(new Emitter()); public readonly onDidInvalidate = this.invalidateEmitter.event; public readonly writable: boolean; diff --git a/src/vs/workbench/contrib/debug/browser/debugService.ts b/src/vs/workbench/contrib/debug/browser/debugService.ts index 5705179af39..a5cc44f136c 100644 --- a/src/vs/workbench/contrib/debug/browser/debugService.ts +++ b/src/vs/workbench/contrib/debug/browser/debugService.ts @@ -117,10 +117,10 @@ export class DebugService implements IDebugService { ) { this.breakpointsToSendOnResourceSaved = new Set(); - this._onDidChangeState = new Emitter(); - this._onDidNewSession = new Emitter(); - this._onWillNewSession = new Emitter(); - this._onDidEndSession = new Emitter(); + this._onDidChangeState = this.disposables.add(new Emitter()); + this._onDidNewSession = this.disposables.add(new Emitter()); + this._onWillNewSession = this.disposables.add(new Emitter()); + this._onDidEndSession = this.disposables.add(new Emitter()); this.adapterManager = this.instantiationService.createInstance(AdapterManager, { onDidNewSession: this.onDidNewSession, @@ -136,7 +136,7 @@ export class DebugService implements IDebugService { this.model = this.instantiationService.createInstance(DebugModel, this.debugStorage); this.telemetry = this.instantiationService.createInstance(DebugTelemetry, this.model); - this.viewModel = new ViewModel(contextKeyService); + this.viewModel = this.disposables.add(new ViewModel(contextKeyService)); this.taskRunner = this.instantiationService.createInstance(DebugTaskRunner); this.disposables.add(this.fileService.onDidFilesChange(e => this.onFileChanges(e))); diff --git a/src/vs/workbench/contrib/debug/browser/debugSession.ts b/src/vs/workbench/contrib/debug/browser/debugSession.ts index 107ea3389ce..db7cacfe79f 100644 --- a/src/vs/workbench/contrib/debug/browser/debugSession.ts +++ b/src/vs/workbench/contrib/debug/browser/debugSession.ts @@ -1509,6 +1509,16 @@ export class DebugSession implements IDebugSession { this.cancelAllRequests(); this.rawListeners.dispose(); this.globalDisposables.dispose(); + this._onDidChangeState.dispose(); + this._onDidEndAdapter.dispose(); + this._onDidLoadedSource.dispose(); + this._onDidCustomEvent.dispose(); + this._onDidProgressStart.dispose(); + this._onDidProgressUpdate.dispose(); + this._onDidProgressEnd.dispose(); + this._onDidInvalidMemory.dispose(); + this._onDidChangeREPLElements.dispose(); + this._onDidChangeName.dispose(); this._waitToResume = undefined; } diff --git a/src/vs/workbench/contrib/debug/browser/rawDebugSession.ts b/src/vs/workbench/contrib/debug/browser/rawDebugSession.ts index 8971b30a2b0..0c0e9e53653 100644 --- a/src/vs/workbench/contrib/debug/browser/rawDebugSession.ts +++ b/src/vs/workbench/contrib/debug/browser/rawDebugSession.ts @@ -14,7 +14,7 @@ import { IDebugAdapter, IConfig, AdapterEndEvent, IDebugger } from '../common/de import { IExtensionHostDebugService, IOpenExtensionWindowResult } from '../../../../platform/debug/common/extensionHostDebug.js'; import { URI } from '../../../../base/common/uri.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { IDisposable, dispose } from '../../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; @@ -55,31 +55,31 @@ export class RawDebugSession implements IDisposable { private startTime = 0; private didReceiveStoppedEvent = false; + private readonly toDispose = new DisposableStore(); + // DAP events - private readonly _onDidInitialize = new Emitter(); - private readonly _onDidStop = new Emitter(); - private readonly _onDidContinued = new Emitter(); - private readonly _onDidTerminateDebugee = new Emitter(); - private readonly _onDidExitDebugee = new Emitter(); - private readonly _onDidThread = new Emitter(); - private readonly _onDidOutput = new Emitter(); - private readonly _onDidBreakpoint = new Emitter(); - private readonly _onDidLoadedSource = new Emitter(); - private readonly _onDidProgressStart = new Emitter(); - private readonly _onDidProgressUpdate = new Emitter(); - private readonly _onDidProgressEnd = new Emitter(); - private readonly _onDidInvalidated = new Emitter(); - private readonly _onDidInvalidateMemory = new Emitter(); - private readonly _onDidCustomEvent = new Emitter(); - private readonly _onDidEvent = new Emitter(); + private readonly _onDidInitialize = this.toDispose.add(new Emitter()); + private readonly _onDidStop = this.toDispose.add(new Emitter()); + private readonly _onDidContinued = this.toDispose.add(new Emitter()); + private readonly _onDidTerminateDebugee = this.toDispose.add(new Emitter()); + private readonly _onDidExitDebugee = this.toDispose.add(new Emitter()); + private readonly _onDidThread = this.toDispose.add(new Emitter()); + private readonly _onDidOutput = this.toDispose.add(new Emitter()); + private readonly _onDidBreakpoint = this.toDispose.add(new Emitter()); + private readonly _onDidLoadedSource = this.toDispose.add(new Emitter()); + private readonly _onDidProgressStart = this.toDispose.add(new Emitter()); + private readonly _onDidProgressUpdate = this.toDispose.add(new Emitter()); + private readonly _onDidProgressEnd = this.toDispose.add(new Emitter()); + private readonly _onDidInvalidated = this.toDispose.add(new Emitter()); + private readonly _onDidInvalidateMemory = this.toDispose.add(new Emitter()); + private readonly _onDidCustomEvent = this.toDispose.add(new Emitter()); + private readonly _onDidEvent = this.toDispose.add(new Emitter()); // DA events - private readonly _onDidExitAdapter = new Emitter(); + private readonly _onDidExitAdapter = this.toDispose.add(new Emitter()); private debugAdapter: IDebugAdapter | null; private stoppedSinceLastStep = false; - private toDispose: IDisposable[] = []; - constructor( debugAdapter: IDebugAdapter, public readonly dbgr: IDebugger, @@ -93,11 +93,11 @@ export class RawDebugSession implements IDisposable { this.debugAdapter = debugAdapter; this._capabilities = Object.create(null); - this.toDispose.push(this.debugAdapter.onError(err => { + this.toDispose.add(this.debugAdapter.onError(err => { this.shutdown(err); })); - this.toDispose.push(this.debugAdapter.onExit(code => { + this.toDispose.add(this.debugAdapter.onExit(code => { if (code !== 0) { this.shutdown(new Error(`exit code: ${code}`)); } else { @@ -834,6 +834,6 @@ export class RawDebugSession implements IDisposable { } dispose(): void { - dispose(this.toDispose); + this.toDispose.dispose(); } } diff --git a/src/vs/workbench/contrib/debug/browser/repl.ts b/src/vs/workbench/contrib/debug/browser/repl.ts index e610aca89b3..7533ef033b6 100644 --- a/src/vs/workbench/contrib/debug/browser/repl.ts +++ b/src/vs/workbench/contrib/debug/browser/repl.ts @@ -717,7 +717,7 @@ export class Repl extends FilterViewPane implements IHistoryNavigationWidget { })); // Make sure to select the session if debugging is already active this.selectSession(); - this.styleElement = domStylesheetsJs.createStyleSheet(this.container); + this.styleElement = domStylesheetsJs.createStyleSheet(this.container, undefined, this._store); this.onDidStyleChange(); } diff --git a/src/vs/workbench/contrib/debug/browser/variablesView.ts b/src/vs/workbench/contrib/debug/browser/variablesView.ts index 9a087c15dd0..f18d41bf23c 100644 --- a/src/vs/workbench/contrib/debug/browser/variablesView.ts +++ b/src/vs/workbench/contrib/debug/browser/variablesView.ts @@ -90,7 +90,7 @@ export class VariablesView extends ViewPane implements IDebugViewWithVariables { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); // Use scheduler to prevent unnecessary flashing - this.updateTreeScheduler = new RunOnceScheduler(async () => { + this.updateTreeScheduler = this._register(new RunOnceScheduler(async () => { const stackFrame = this.debugService.getViewModel().focusedStackFrame; this.needsRefresh = false; @@ -116,7 +116,7 @@ export class VariablesView extends ViewPane implements IDebugViewWithVariables { this.autoExpandedScopes.add(toExpand.getId()); await this.tree.expand(toExpand); } - }, 400); + }, 400)); } protected override renderBody(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts b/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts index a2c421aaf1e..f290f709ae5 100644 --- a/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts +++ b/src/vs/workbench/contrib/debug/browser/watchExpressionsView.ts @@ -74,10 +74,10 @@ export class WatchExpressionsView extends ViewPane implements IDebugViewWithVari ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); - this.watchExpressionsUpdatedScheduler = new RunOnceScheduler(() => { + this.watchExpressionsUpdatedScheduler = this._register(new RunOnceScheduler(() => { this.needsRefresh = false; this.tree.updateChildren(); - }, 50); + }, 50)); this.watchExpressionsExist = CONTEXT_WATCH_EXPRESSIONS_EXIST.bindTo(contextKeyService); this.watchExpressionsExist.set(this.debugService.getModel().getWatchExpressions().length > 0); this.expressionRenderer = instantiationService.createInstance(DebugExpressionRenderer); diff --git a/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts b/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts index 8ebd193420a..845a916f0a5 100644 --- a/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts +++ b/src/vs/workbench/contrib/debug/common/abstractDebugAdapter.ts @@ -206,6 +206,8 @@ export abstract class AbstractDebugAdapter implements IDebugAdapter { } dispose(): void { + this._onError.dispose(); + this._onExit.dispose(); this.queue = []; } } diff --git a/src/vs/workbench/contrib/debug/common/debugViewModel.ts b/src/vs/workbench/contrib/debug/common/debugViewModel.ts index 20c2092da07..12825128fa7 100644 --- a/src/vs/workbench/contrib/debug/common/debugViewModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugViewModel.ts @@ -4,11 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../base/common/event.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { CONTEXT_DISASSEMBLE_REQUEST_SUPPORTED, CONTEXT_EXPRESSION_SELECTED, CONTEXT_FOCUSED_SESSION_IS_ATTACH, CONTEXT_FOCUSED_SESSION_IS_NO_DEBUG, CONTEXT_FOCUSED_STACK_FRAME_HAS_INSTRUCTION_POINTER_REFERENCE, CONTEXT_JUMP_TO_CURSOR_SUPPORTED, CONTEXT_LOADED_SCRIPTS_SUPPORTED, CONTEXT_MULTI_SESSION_DEBUG, CONTEXT_RESTART_FRAME_SUPPORTED, CONTEXT_SET_DATA_BREAKPOINT_BYTES_SUPPORTED, CONTEXT_SET_EXPRESSION_SUPPORTED, CONTEXT_SET_VARIABLE_SUPPORTED, CONTEXT_STEP_BACK_SUPPORTED, CONTEXT_STEP_INTO_TARGETS_SUPPORTED, CONTEXT_SUSPEND_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_DEBUGGEE_SUPPORTED, CONTEXT_TERMINATE_THREADS_SUPPORTED, IDebugSession, IExpression, IExpressionContainer, IStackFrame, IThread, IViewModel } from './debug.js'; import { isSessionAttach } from './debugUtils.js'; -export class ViewModel implements IViewModel { +export class ViewModel extends Disposable implements IViewModel { firstSessionStart = true; @@ -16,13 +17,13 @@ export class ViewModel implements IViewModel { private _focusedSession: IDebugSession | undefined; private _focusedThread: IThread | undefined; private selectedExpression: { expression: IExpression; settingWatch: boolean } | undefined; - private readonly _onDidFocusSession = new Emitter(); - private readonly _onDidFocusThread = new Emitter<{ thread: IThread | undefined; explicit: boolean; session: IDebugSession | undefined }>(); - private readonly _onDidFocusStackFrame = new Emitter<{ stackFrame: IStackFrame | undefined; explicit: boolean; session: IDebugSession | undefined }>(); - private readonly _onDidSelectExpression = new Emitter<{ expression: IExpression; settingWatch: boolean } | undefined>(); - private readonly _onDidEvaluateLazyExpression = new Emitter(); - private readonly _onWillUpdateViews = new Emitter(); - private readonly _onDidChangeVisualization = new Emitter<{ original: IExpression; replacement: IExpression }>(); + private readonly _onDidFocusSession = this._register(new Emitter()); + private readonly _onDidFocusThread = this._register(new Emitter<{ thread: IThread | undefined; explicit: boolean; session: IDebugSession | undefined }>()); + private readonly _onDidFocusStackFrame = this._register(new Emitter<{ stackFrame: IStackFrame | undefined; explicit: boolean; session: IDebugSession | undefined }>()); + private readonly _onDidSelectExpression = this._register(new Emitter<{ expression: IExpression; settingWatch: boolean } | undefined>()); + private readonly _onDidEvaluateLazyExpression = this._register(new Emitter()); + private readonly _onWillUpdateViews = this._register(new Emitter()); + private readonly _onDidChangeVisualization = this._register(new Emitter<{ original: IExpression; replacement: IExpression }>()); private readonly visualized = new WeakMap(); private readonly preferredVisualizers = new Map(); private expressionSelectedContextKey!: IContextKey; @@ -44,6 +45,7 @@ export class ViewModel implements IViewModel { private focusedStackFrameHasInstructionPointerReference!: IContextKey; constructor(private contextKeyService: IContextKeyService) { + super(); contextKeyService.bufferChangeEvents(() => { this.expressionSelectedContextKey = CONTEXT_EXPRESSION_SELECTED.bindTo(contextKeyService); this.loadedScriptsSupportedContextKey = CONTEXT_LOADED_SCRIPTS_SUPPORTED.bindTo(contextKeyService); diff --git a/src/vs/workbench/contrib/debug/common/loadedScriptsPicker.ts b/src/vs/workbench/contrib/debug/common/loadedScriptsPicker.ts index 680ba4579ab..0588df1c653 100644 --- a/src/vs/workbench/contrib/debug/common/loadedScriptsPicker.ts +++ b/src/vs/workbench/contrib/debug/common/loadedScriptsPicker.ts @@ -48,8 +48,8 @@ export async function showLoadedScriptMenu(accessor: ServicesAccessor) { const selectedItem = quickPick.selectedItems[0]; selectedItem.accept(); quickPick.hide(); - localDisposableStore.dispose(); })); + localDisposableStore.add(quickPick.onDidHide(() => localDisposableStore.dispose())); quickPick.show(); } diff --git a/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts b/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts index f6d12fd9dcc..a179d8fcd74 100644 --- a/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts +++ b/src/vs/workbench/contrib/debug/test/browser/debugViewModel.test.ts @@ -20,6 +20,10 @@ suite('Debug - View Model', () => { model = new ViewModel(new MockContextKeyService()); }); + teardown(() => { + model.dispose(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); test('focused stack frame', () => { diff --git a/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts b/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts index 0892d0fa54c..710e9607ae2 100644 --- a/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts +++ b/src/vs/workbench/contrib/editSessions/browser/editSessions.contribution.ts @@ -180,11 +180,11 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo this.registerContributedEditSessionOptions(); this._register(this.fileService.registerProvider(EditSessionsFileSystemProvider.SCHEMA, new EditSessionsFileSystemProvider(this.editSessionsStorageService))); - this.lifecycleService.onWillShutdown((e) => { + this._register(this.lifecycleService.onWillShutdown((e) => { if (e.reason !== ShutdownReason.RELOAD && this.editSessionsStorageService.isSignedIn && this.configurationService.getValue('workbench.experimental.cloudChanges.autoStore') === 'onShutdown' && !isWeb) { e.join(this.autoStoreEditSession(), { id: 'autoStoreWorkingChanges', label: localize('autoStoreWorkingChanges', 'Storing current working changes...') }); } - }); + })); this._register(this.editSessionsStorageService.onDidSignIn(() => this.updateAccountsMenuBadge())); this._register(this.editSessionsStorageService.onDidSignOut(() => this.updateAccountsMenuBadge())); } @@ -985,9 +985,9 @@ export class EditSessionsContribution extends Disposable implements IWorkbenchCo : this.contextService.getWorkspace().folders.map((folder) => folder.name).join(', '); quickPick.placeholder = localize('continueEditSessionPick.title.v2', "Select a development environment to continue working on {0} in", `'${workspaceContext}'`); quickPick.items = this.createPickItems(); - this.extensionService.onDidChangeExtensions(() => { + disposables.add(this.extensionService.onDidChangeExtensions(() => { quickPick.items = this.createPickItems(); - }); + })); const command = await new Promise((resolve, reject) => { disposables.add(quickPick.onDidHide(() => { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 23df2756361..dd9ede29d68 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -419,7 +419,7 @@ export class ExtensionEditor extends EditorPane { this._register(onError(this.onError, this)); const body = append(root, $('.body')); - const navbar = new NavBar(body); + const navbar = this._register(new NavBar(body)); const content = append(body, $('.content')); content.id = generateUuid(); // An id is needed for the webview parent flow to diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts index c897cfe7401..d1dacd0dc61 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsActions.ts @@ -435,7 +435,7 @@ export class InstallAction extends ExtensionAction { this.updateLabel(); } - private readonly updateThrottler = new Throttler(); + private readonly updateThrottler = this._register(new Throttler()); public readonly options: InstallOptions; constructor( @@ -954,7 +954,7 @@ export class UpdateAction extends ExtensionAction { private static readonly EnabledClass = `${this.LABEL_ACTION_CLASS} update`; private static readonly DisabledClass = `${this.EnabledClass} disabled`; - private readonly updateThrottler = new Throttler(); + private readonly updateThrottler = this._register(new Throttler()); constructor( private readonly verbose: boolean, @@ -2542,7 +2542,7 @@ export class ExtensionStatusAction extends ExtensionAction { private readonly _onDidChangeStatus = this._register(new Emitter()); readonly onDidChangeStatus = this._onDidChangeStatus.event; - private readonly updateThrottler = new Throttler(); + private readonly updateThrottler = this._register(new Throttler()); constructor( @IExtensionManagementServerService private readonly extensionManagementServerService: IExtensionManagementServerService, diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts index 942a2f4b91b..ae4fdd99cc5 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsViewlet.ts @@ -567,7 +567,7 @@ export class ExtensionsViewPaneContainer extends ViewPaneContainer { return this._onChange.event; } private extensionsNotification: IExtensionsNotification & { readonly key: string } | undefined; - private readonly _onDidChangeExtensionsNotification = new Emitter(); + private readonly _onDidChangeExtensionsNotification = this._register(new Emitter()); readonly onDidChangeExtensionsNotification = this._onDidChangeExtensionsNotification.event; - private readonly _onReset = new Emitter(); + private readonly _onReset = this._register(new Emitter()); get onReset() { return this._onReset.event; } private installing: IExtension[] = []; diff --git a/src/vs/workbench/contrib/extensions/electron-browser/debugExtensionHostAction.ts b/src/vs/workbench/contrib/extensions/electron-browser/debugExtensionHostAction.ts index 48e567c92f3..ec42257d80e 100644 --- a/src/vs/workbench/contrib/extensions/electron-browser/debugExtensionHostAction.ts +++ b/src/vs/workbench/contrib/extensions/electron-browser/debugExtensionHostAction.ts @@ -168,7 +168,8 @@ export class DebugRendererInNewWindowAction extends Action2 { const storage = instantiationService.createInstance(Storage); storage.storeRendererDebugOnNewWindow(port); - hostService.openWindow(); + // Force local window since Chrome debugging only works locally + hostService.openWindow({ remoteAuthority: null }); } } @@ -204,7 +205,8 @@ export class DebugExtensionHostAndRendererAction extends Action2 { const storage = instantiationService.createInstance(Storage); storage.storeDebugOnNewWindow(extHostPort); storage.storeRendererDebugOnNewWindow(rendererPort); - hostService.openWindow(); + // Force local window since Chrome debugging only works locally + hostService.openWindow({ remoteAuthority: null }); } } diff --git a/src/vs/workbench/contrib/files/browser/explorerService.ts b/src/vs/workbench/contrib/files/browser/explorerService.ts index d99b810753c..2a770d78ff2 100644 --- a/src/vs/workbench/contrib/files/browser/explorerService.ts +++ b/src/vs/workbench/contrib/files/browser/explorerService.ts @@ -62,7 +62,7 @@ export class ExplorerService implements IExplorerService { this.disposables.add(this.model); this.disposables.add(this.fileService.onDidRunOperation(e => this.onDidRunOperation(e))); - this.onFileChangesScheduler = new RunOnceScheduler(async () => { + this.onFileChangesScheduler = this.disposables.add(new RunOnceScheduler(async () => { const events = this.fileChangeEvents; this.fileChangeEvents = []; @@ -98,7 +98,7 @@ export class ExplorerService implements IExplorerService { await this.refresh(false); } - }, ExplorerService.EXPLORER_FILE_CHANGES_REACT_DELAY); + }, ExplorerService.EXPLORER_FILE_CHANGES_REACT_DELAY)); this.disposables.add(this.fileService.onDidFilesChange(e => { this.fileChangeEvents.push(e); diff --git a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts index a4a00dfa7ea..ed7dbe07826 100644 --- a/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts +++ b/src/vs/workbench/contrib/files/browser/views/explorerViewer.ts @@ -1245,6 +1245,7 @@ export class FilesFilter implements ITreeFilter { @IUriIdentityService private readonly uriIdentityService: IUriIdentityService, @IFileService private readonly fileService: IFileService ) { + this.toDispose.push(this._onDidChange); this.toDispose.push(this.contextService.onDidChangeWorkspaceFolders(() => this.updateConfiguration())); this.toDispose.push(this.configurationService.onDidChangeConfiguration((e) => { if (e.affectsConfiguration('files.exclude') || e.affectsConfiguration('explorer.excludeGitIgnore')) { diff --git a/src/vs/workbench/contrib/files/common/explorerModel.ts b/src/vs/workbench/contrib/files/common/explorerModel.ts index 468d501385d..ddba6b33bec 100644 --- a/src/vs/workbench/contrib/files/common/explorerModel.ts +++ b/src/vs/workbench/contrib/files/common/explorerModel.ts @@ -81,6 +81,7 @@ export class ExplorerModel implements IDisposable { } dispose(): void { + this._onDidChangeRoots.dispose(); dispose(this._listener); } } diff --git a/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts b/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts index 9a7466a8722..1bbdfe21a72 100644 --- a/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts +++ b/src/vs/workbench/contrib/interactive/browser/interactiveEditor.ts @@ -503,21 +503,21 @@ export class InteractiveEditor extends EditorPane implements IEditorPaneWithScro } })); - this._codeEditorWidget.onDidChangeModelDecorations(() => { + this._widgetDisposableStore.add(this._codeEditorWidget.onDidChangeModelDecorations(() => { if (this.isVisible()) { this._updateInputHint(); } - }); + })); this._widgetDisposableStore.add(this._codeEditorWidget.onDidChangeModel(() => { this._updateInputHint(); })); - this._configurationService.onDidChangeConfiguration(e => { + this._widgetDisposableStore.add(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration(ReplEditorSettings.showExecutionHint)) { this._updateInputHint(); } - }); + })); const cursorAtBoundaryContext = INTERACTIVE_INPUT_CURSOR_BOUNDARY.bindTo(this._contextKeyService); if (input.resource && input.historyService.has(input.resource)) { diff --git a/src/vs/workbench/contrib/logs/common/logsDataCleaner.ts b/src/vs/workbench/contrib/logs/common/logsDataCleaner.ts index de8289f7598..6c987720a53 100644 --- a/src/vs/workbench/contrib/logs/common/logsDataCleaner.ts +++ b/src/vs/workbench/contrib/logs/common/logsDataCleaner.ts @@ -33,11 +33,11 @@ export class LogsDataCleaner extends Disposable { Promises.settled(toDelete.map(stat => this.fileService.del(stat.resource, { recursive: true }))); } }, 10 * 1000); - this.lifecycleService.onWillShutdown(() => { + this._register(this.lifecycleService.onWillShutdown(() => { if (handle) { clearTimeout(handle); handle = undefined; } - }); + })); } } diff --git a/src/vs/workbench/contrib/markers/browser/markersTable.ts b/src/vs/workbench/contrib/markers/browser/markersTable.ts index 2065d259604..ca4d993ef62 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTable.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTable.ts @@ -37,6 +37,7 @@ const $ = DOM.$; interface IMarkerIconColumnTemplateData { readonly icon: HTMLElement; readonly actionBar: ActionBar; + readonly elementDisposables: DisposableStore; } interface IMarkerCodeColumnTemplateData { @@ -79,10 +80,12 @@ class MarkerSeverityColumnRenderer implements ITableRenderer action.id === QuickFixAction.ID ? this.instantiationService.createInstance(QuickFixActionViewItem, action, options) : undefined }); - return { actionBar, icon }; + return { actionBar, icon, elementDisposables: new DisposableStore() }; } renderElement(element: MarkerTableItem, index: number, templateData: IMarkerIconColumnTemplateData): void { + templateData.elementDisposables.clear(); + const toggleQuickFix = (enabled?: boolean) => { if (!isUndefinedOrNull(enabled)) { const container = DOM.findParentWithClass(templateData.icon, 'monaco-table-td')!; @@ -100,17 +103,20 @@ class MarkerSeverityColumnRenderer implements ITableRenderer toggleQuickFix(enabled)); - quickFixAction.onShowQuickFixes(() => { + templateData.elementDisposables.add(quickFixAction.onDidChange(({ enabled }) => toggleQuickFix(enabled))); + templateData.elementDisposables.add(quickFixAction.onShowQuickFixes(() => { const quickFixActionViewItem = templateData.actionBar.viewItems[0]; if (quickFixActionViewItem) { quickFixActionViewItem.showQuickFixes(); } - }); + })); } } - disposeTemplate(templateData: IMarkerIconColumnTemplateData): void { } + disposeTemplate(templateData: IMarkerIconColumnTemplateData): void { + templateData.elementDisposables.dispose(); + templateData.actionBar.dispose(); + } } class MarkerCodeColumnRenderer implements ITableRenderer { diff --git a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts index 081478c7596..0176fb67045 100644 --- a/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts +++ b/src/vs/workbench/contrib/markers/browser/markersTreeViewer.ts @@ -711,7 +711,7 @@ export class MarkersViewModel extends Disposable { private bulkUpdate: boolean = false; private hoveredMarker: Marker | null = null; - private hoverDelayer: Delayer = new Delayer(300); + private hoverDelayer: Delayer = this._register(new Delayer(300)); private viewModeContextKey: IContextKey; constructor( diff --git a/src/vs/workbench/contrib/mergeEditor/browser/mergeMarkers/mergeMarkersController.ts b/src/vs/workbench/contrib/mergeEditor/browser/mergeMarkers/mergeMarkersController.ts index f915f609d19..1efef9d6396 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/mergeMarkers/mergeMarkersController.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/mergeMarkers/mergeMarkersController.ts @@ -19,7 +19,7 @@ export const conflictMarkers = { export class MergeMarkersController extends Disposable { private readonly viewZoneIds: string[] = []; - private readonly disposableStore = new DisposableStore(); + private readonly disposableStore = this._register(new DisposableStore()); public constructor( public readonly editor: ICodeEditor, diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts index 5a2d17c919f..eb4a795bcd3 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/editors/codeEditorView.ts @@ -70,7 +70,7 @@ export abstract class CodeEditorView extends Disposable { h('div@editor'), ]), ]); - this._onDidViewChange = new Emitter(); + this._onDidViewChange = this._register(new Emitter()); this.view = { element: this.htmlElements.root, minimumWidth: DEFAULT_EDITOR_MIN_DIMENSIONS.width, diff --git a/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts b/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts index ba97c865ba2..17c9d460592 100644 --- a/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts +++ b/src/vs/workbench/contrib/mergeEditor/browser/view/mergeEditor.ts @@ -130,7 +130,7 @@ export class MergeEditor extends AbstractTextEditor { this.inputResultView.editor, ); this.scrollSynchronizer = this._register(new ScrollSynchronizer(this._viewModel, this.input1View, this.input2View, this.baseView, this.inputResultView, this._layoutModeObs)); - this._onDidChangeSizeConstraints = new Emitter(); + this._onDidChangeSizeConstraints = this._register(new Emitter()); this.onDidChangeSizeConstraints = this._onDidChangeSizeConstraints.event; this.baseViewDisposables = this._register(new DisposableStore()); this.showNonConflictingChangesStore = this.instantiationService.createInstance(PersistentStore, 'mergeEditor/showNonConflictingChanges'); diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/findModel.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/findModel.ts index ae53643d6d7..25020d79764 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/findModel.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/findModel.ts @@ -82,7 +82,7 @@ export class FindModel extends Disposable { ) { super(); - this._throttledDelayer = new Delayer(20); + this._throttledDelayer = this._register(new Delayer(20)); this._computePromise = null; this._register(_state.onFindReplaceStateChange(e => { diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts index 4a836cc19b0..7c3772033d8 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/notebookVariables/notebookVariablesView.ts @@ -74,7 +74,7 @@ export class NotebookVariablesView extends ViewPane { this.handleActiveEditorChange(false); this.dataSource = new NotebookVariableDataSource(this.notebookKernelService); - this.updateScheduler = new RunOnceScheduler(() => this.tree?.updateChildren(), 100); + this.updateScheduler = this._register(new RunOnceScheduler(() => this.tree?.updateChildren(), 100)); } protected override renderBody(container: HTMLElement): void { diff --git a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts index 5f3ae5f09cd..ca43d2dcb48 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/inlineDiff/notebookCellDiffDecorator.ts @@ -26,7 +26,7 @@ import { InlineDecoration, InlineDecorationType } from '../../../../../../editor //TODO: allow client to set read-only - chateditsession should set read-only while making changes export class NotebookCellDiffDecorator extends DisposableStore { private _viewZones: string[] = []; - private readonly throttledDecorator = new ThrottledDelayer(50); + private readonly throttledDecorator = this.add(new ThrottledDelayer(50)); private diffForPreviouslyAppliedDecorators?: IDocumentDiff; private readonly perEditorDisposables = this.add(new DisposableStore()); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts index e035bb1179c..4c8c264e829 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookAccessibilityProvider.ts @@ -22,7 +22,7 @@ import { alert } from '../../../../base/browser/ui/aria/aria.js'; type executionUpdate = { cellHandle: number; state: NotebookCellExecutionState | undefined }; export class NotebookAccessibilityProvider extends Disposable implements IListAccessibilityProvider { - private readonly _onDidAriaLabelChange = new Emitter(); + private readonly _onDidAriaLabelChange = this._register(new Emitter()); private readonly onDidAriaLabelChange = this._onDidAriaLabelChange.event; constructor( diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineDataSource.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineDataSource.ts index cc0d3987e61..6e172f4a6ce 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineDataSource.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookOutlineDataSource.ts @@ -218,5 +218,6 @@ export class NotebookCellOutlineDataSource implements INotebookCellOutlineDataSo this._entries.length = 0; this._activeEntry = undefined; this._disposables.dispose(); + this._onDidChange.dispose(); } } diff --git a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts index 25755ec15f7..1bf893c2b75 100644 --- a/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts +++ b/src/vs/workbench/contrib/notebook/browser/viewModel/notebookViewModelImpl.ts @@ -177,7 +177,7 @@ export class NotebookViewModel extends Disposable implements EditorFoldingStateD private readonly _instanceId: string; public readonly id: string; private _foldingRanges: FoldingRegions | null = null; - private _onDidFoldingStateChanged = new Emitter(); + private _onDidFoldingStateChanged = this._register(new Emitter()); readonly onDidFoldingStateChanged: Event = this._onDidFoldingStateChanged.event; private _hiddenRanges: ICellRange[] = []; private _focused: boolean = true; diff --git a/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts b/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts index 89591f8ed93..824155d84c2 100644 --- a/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts +++ b/src/vs/workbench/contrib/output/browser/outputLinkProvider.ts @@ -34,7 +34,7 @@ export class OutputLinkProvider extends Disposable { ) { super(); - this.disposeWorkerScheduler = new RunOnceScheduler(() => this.disposeWorker(), OutputLinkProvider.DISPOSE_WORKER_TIME); + this.disposeWorkerScheduler = this._register(new RunOnceScheduler(() => this.disposeWorker(), OutputLinkProvider.DISPOSE_WORKER_TIME)); this.registerListeners(); this.updateLinkProviderWorker(); diff --git a/src/vs/workbench/contrib/output/common/outputChannelModel.ts b/src/vs/workbench/contrib/output/common/outputChannelModel.ts index 34940ddcb5a..1e768b2bda8 100644 --- a/src/vs/workbench/contrib/output/common/outputChannelModel.ts +++ b/src/vs/workbench/contrib/output/common/outputChannelModel.ts @@ -115,10 +115,10 @@ interface IContentProvider { class FileContentProvider extends Disposable implements IContentProvider { - private readonly _onDidAppend = new Emitter(); + private readonly _onDidAppend = this._register(new Emitter()); get onDidAppend() { return this._onDidAppend.event; } - private readonly _onDidReset = new Emitter(); + private readonly _onDidReset = this._register(new Emitter()); get onDidReset() { return this._onDidReset.event; } private watching: boolean = false; diff --git a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts index 759f22b3926..6fce0410f84 100644 --- a/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts +++ b/src/vs/workbench/contrib/preferences/browser/keybindingsEditor.ts @@ -133,14 +133,14 @@ export class KeybindingsEditor extends EditorPane imp @IAccessibilityService private readonly accessibilityService: IAccessibilityService ) { super(KeybindingsEditor.ID, group, telemetryService, themeService, storageService); - this.delayedFiltering = new Delayer(300); + this.delayedFiltering = this._register(new Delayer(300)); this._register(keybindingsService.onDidUpdateKeybindings(() => this.render(!!this.keybindingFocusContextKey.get()))); this.keybindingsEditorContextKey = CONTEXT_KEYBINDINGS_EDITOR.bindTo(this.contextKeyService); this.searchFocusContextKey = CONTEXT_KEYBINDINGS_SEARCH_FOCUS.bindTo(this.contextKeyService); this.keybindingFocusContextKey = CONTEXT_KEYBINDING_FOCUS.bindTo(this.contextKeyService); this.searchHasValueContextKey = CONTEXT_KEYBINDINGS_SEARCH_HAS_VALUE.bindTo(this.contextKeyService); - this.searchHistoryDelayer = new Delayer(500); + this.searchHistoryDelayer = this._register(new Delayer(500)); this.recordKeysAction = this._register(new Action(KEYBINDINGS_EDITOR_COMMAND_RECORD_SEARCH_KEYS, localize('recordKeysLabel', "Record Keys"), ThemeIcon.asClassName(keybindingsRecordKeysIcon))); this.recordKeysAction.checked = false; diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts index 7fa5fd339a9..5d78d73a69f 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesRenderers.ts @@ -61,7 +61,7 @@ export class UserSettingsRenderer extends Disposable implements IPreferencesRend private settingHighlighter: SettingHighlighter; private editSettingActionRenderer: EditSettingRenderer; - private modelChangeDelayer: Delayer = new Delayer(200); + private modelChangeDelayer = this._register(new Delayer(200)); private associatedPreferencesModel!: IPreferencesEditorModel; private unsupportedSettingsRenderer: UnsupportedSettingsRenderer; @@ -194,7 +194,7 @@ class EditSettingRenderer extends Disposable { this.editPreferenceWidgetForCursorPosition = this._register(this.instantiationService.createInstance(EditPreferenceWidget, editor)); this.editPreferenceWidgetForMouseMove = this._register(this.instantiationService.createInstance(EditPreferenceWidget, editor)); - this.toggleEditPreferencesForMouseMoveDelayer = new Delayer(75); + this.toggleEditPreferencesForMouseMoveDelayer = this._register(new Delayer(75)); this._register(this.editPreferenceWidgetForCursorPosition.onClick(e => this.onEditSettingClicked(this.editPreferenceWidgetForCursorPosition, e))); this._register(this.editPreferenceWidgetForMouseMove.onClick(e => this.onEditSettingClicked(this.editPreferenceWidgetForMouseMove, e))); @@ -486,7 +486,7 @@ class SettingHighlighter extends Disposable { class UnsupportedSettingsRenderer extends Disposable implements languages.CodeActionProvider { - private renderingDelayer: Delayer = new Delayer(200); + private renderingDelayer = this._register(new Delayer(200)); private readonly codeActions = new ResourceMap<[Range, languages.CodeAction[]][]>(uri => this.uriIdentityService.extUri.getComparisonKey(uri)); @@ -791,7 +791,7 @@ class UnsupportedSettingsRenderer extends Disposable implements languages.CodeAc class McpSettingsRenderer extends Disposable implements languages.CodeActionProvider { - private renderingDelayer: Delayer = new Delayer(200); + private renderingDelayer = this._register(new Delayer(200)); private readonly codeActions = new ResourceMap<[Range, languages.CodeAction[]][]>(uri => this.uriIdentityService.extUri.getComparisonKey(uri)); constructor( @@ -913,7 +913,7 @@ class WorkspaceConfigurationRenderer extends Disposable { private static readonly supportedKeys = ['folders', 'tasks', 'launch', 'extensions', 'settings', 'remoteAuthority', 'transient']; private readonly decorations: editorCommon.IEditorDecorationsCollection; - private renderingDelayer: Delayer = new Delayer(200); + private renderingDelayer = this._register(new Delayer(200)); constructor(private editor: ICodeEditor, private workspaceSettingsEditorModel: SettingsEditorModel, @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, diff --git a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts index 3721b873139..99fd9647231 100644 --- a/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts +++ b/src/vs/workbench/contrib/preferences/browser/preferencesSearch.ts @@ -242,10 +242,13 @@ export class SettingMatches { // Search the description if we found non-contiguous key matches at best. const hasContiguousKeyMatchTypes = this.matchType >= SettingMatchType.ContiguousWordsInSettingsLabel; if (this.searchDescription && !hasContiguousKeyMatchTypes) { + // Search the description lines and any additional keywords. + const searchableLines = setting.keywords?.length + ? [...setting.description, setting.keywords.join(' ')] + : setting.description; for (const word of queryWords) { - // Search the description lines. - for (let lineIndex = 0; lineIndex < setting.description.length; lineIndex++) { - const descriptionMatches = matchesBaseContiguousSubString(word, setting.description[lineIndex]); + for (let lineIndex = 0; lineIndex < searchableLines.length; lineIndex++) { + const descriptionMatches = matchesBaseContiguousSubString(word, searchableLines[lineIndex]); if (descriptionMatches?.length) { descriptionMatchingWords.set(word, descriptionMatches.map(match => this.toDescriptionRange(setting, match, lineIndex))); } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index 0c8da5089e1..4c7e17c9cc3 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -93,13 +93,8 @@ export function createGroupIterator(group: SettingsTreeGroupElement): Iterable(SettingsEditor2.SETTING_UPDATE_FAST_DEBOUNCE); - this.settingSlowUpdateDelayer = new Delayer(SettingsEditor2.SETTING_UPDATE_SLOW_DEBOUNCE); + this.settingFastUpdateDelayer = this._register(new Delayer(SettingsEditor2.SETTING_UPDATE_FAST_DEBOUNCE)); + this.settingSlowUpdateDelayer = this._register(new Delayer(SettingsEditor2.SETTING_UPDATE_SLOW_DEBOUNCE)); - this.searchInputDelayer = new Delayer(SettingsEditor2.SEARCH_DEBOUNCE); - this.updatedConfigSchemaDelayer = new Delayer(SettingsEditor2.CONFIG_SCHEMA_UPDATE_DELAYER); + this.searchInputDelayer = this._register(new Delayer(SettingsEditor2.SEARCH_DEBOUNCE)); + this.updatedConfigSchemaDelayer = this._register(new Delayer(SettingsEditor2.CONFIG_SCHEMA_UPDATE_DELAYER)); this.inSettingsEditorContextKey = CONTEXT_SETTINGS_EDITOR.bindTo(contextKeyService); this.searchFocusContextKey = CONTEXT_SETTINGS_SEARCH_FOCUS.bindTo(contextKeyService); @@ -1058,64 +1054,14 @@ export class SettingsEditor2 extends EditorPane { this.tocFocusedElement = element; this.tocTree.setSelection(element ? [element] : []); - const scrollBehavior = this.configurationService.getValue<'paginated' | 'continuous'>(SCROLL_BEHAVIOR_KEY); - if (this.searchResultModel || scrollBehavior === 'paginated') { - // In search mode or paginated mode, filter to show only the selected category - if (this.viewState.categoryFilter !== element) { - this.viewState.categoryFilter = element ?? undefined; - // Force render in this case, because - // onDidClickSetting relies on the updated view. - this.renderTree(undefined, true); - this.settingsTree.scrollTop = 0; - } - } else { - // In continuous mode, clear any category filter that may have been set in paginated mode - if (this.viewState.categoryFilter) { - this.viewState.categoryFilter = undefined; - this.renderTree(undefined, true); - } - if (element && (!e.browserEvent || !(e.browserEvent).fromScroll)) { - let targetElement = element; - // Searches equivalent old Object currently living in the Tree nodes. - if (!this.settingsTree.hasElement(targetElement)) { - if (element instanceof SettingsTreeGroupElement) { - const targetId = element.id; - const findInViewNodes = (nodes: any[]): SettingsTreeGroupElement | undefined => { - for (const node of nodes) { - if (node.element instanceof SettingsTreeGroupElement && node.element.id === targetId) { - return node.element; - } - if (node.children && node.children.length > 0) { - const found = findInViewNodes(node.children); - if (found) { - return found; - } - } - } - return undefined; - }; - - try { - const rootNode = this.settingsTree.getNode(null); - if (rootNode && rootNode.children) { - const foundOldElement = findInViewNodes(rootNode.children); - if (foundOldElement) { - // Now we don't reveal the New Object, reveal the Old Object" - targetElement = foundOldElement; - } - } - } catch (err) { - // Tree might be in an invalid state, ignore - } - } - } - - if (this.settingsTree.hasElement(targetElement)) { - this.settingsTree.reveal(targetElement, 0); - this.settingsTree.setFocus([targetElement]); - } - } + // Filter to show only the selected category + if (this.viewState.categoryFilter !== element) { + this.viewState.categoryFilter = element ?? undefined; + // Force render in this case, because + // onDidClickSetting relies on the updated view. + this.renderTree(undefined, true); + this.settingsTree.scrollTop = 0; } })); @@ -1262,74 +1208,6 @@ export class SettingsEditor2 extends EditorPane { private updateTreeScrollSync(): void { this.settingRenderers.cancelSuggesters(); - if (this.searchResultModel) { - return; - } - - // In paginated mode, we don't sync scroll position since categories are filtered - const scrollBehavior = this.configurationService.getValue<'paginated' | 'continuous'>(SCROLL_BEHAVIOR_KEY); - if (scrollBehavior === 'paginated') { - return; - } - - if (!this.tocTreeModel) { - return; - } - - const elementToSync = this.settingsTree.firstVisibleElement; - const element = elementToSync instanceof SettingsTreeSettingElement ? elementToSync.parent : - elementToSync instanceof SettingsTreeGroupElement ? elementToSync : - null; - - // It's possible for this to be called when the TOC and settings tree are out of sync - e.g. when the settings tree has deferred a refresh because - // it is focused. So, bail if element doesn't exist in the TOC. - let nodeExists = true; - try { this.tocTree.getNode(element); } catch (e) { nodeExists = false; } - if (!nodeExists) { - return; - } - - if (element && this.tocTree.getSelection()[0] !== element) { - const ancestors = this.getAncestors(element); - ancestors.forEach(e => this.tocTree.expand(e)); - - this.tocTree.reveal(element); - const elementTop = this.tocTree.getRelativeTop(element); - if (typeof elementTop !== 'number') { - return; - } - - this.tocTree.collapseAll(); - - ancestors.forEach(e => this.tocTree.expand(e)); - if (elementTop < 0 || elementTop > 1) { - this.tocTree.reveal(element); - } else { - this.tocTree.reveal(element, elementTop); - } - - this.tocTree.expand(element); - - this.tocTree.setSelection([element]); - - const fakeKeyboardEvent = new KeyboardEvent('keydown'); - (fakeKeyboardEvent).fromScroll = true; - this.tocTree.setFocus([element], fakeKeyboardEvent); - } - } - - private getAncestors(element: SettingsTreeElement): SettingsTreeElement[] { - const ancestors: SettingsTreeElement[] = []; - - while (element.parent) { - if (element.parent.id !== 'root') { - ancestors.push(element.parent); - } - - element = element.parent; - } - - return ancestors.reverse(); } private updateChangedSetting(key: string, value: unknown, manualReset: boolean, languageFilter: string | undefined, scope: ConfigurationScope | undefined): Promise { @@ -1709,17 +1587,14 @@ export class SettingsEditor2 extends EditorPane { } else { this.refreshTOCTree(); - // In paginated mode, set initial category to the first one (Commonly Used) - const scrollBehavior = this.configurationService.getValue<'paginated' | 'continuous'>(SCROLL_BEHAVIOR_KEY); - if (scrollBehavior === 'paginated') { - const rootChildren = this.settingsTreeModel.value.root.children; - if (Array.isArray(rootChildren) && rootChildren.length > 0) { - const firstCategory = rootChildren[0]; - if (firstCategory instanceof SettingsTreeGroupElement) { - this.viewState.categoryFilter = firstCategory; - this.tocTree.setFocus([firstCategory]); - this.tocTree.setSelection([firstCategory]); - } + // Set initial category to the first one (Commonly Used) + const rootChildren = this.settingsTreeModel.value.root.children; + if (Array.isArray(rootChildren) && rootChildren.length > 0) { + const firstCategory = rootChildren[0]; + if (firstCategory instanceof SettingsTreeGroupElement) { + this.viewState.categoryFilter = firstCategory; + this.tocTree.setFocus([firstCategory]); + this.tocTree.setSelection([firstCategory]); } } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index 30b1f1f9524..bc3a037c246 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -174,7 +174,6 @@ export const tocData: ITOCEntry = { { id: 'chat', label: localize('chat', "Chat"), - settings: ['chat.*'], children: [ { id: 'chat/agent', @@ -187,7 +186,8 @@ export const tocData: ITOCEntry = { 'chat.undoRequests.*', 'chat.customAgentInSubagent.*', 'chat.editing.autoAcceptDelay', - 'chat.editing.confirmEditRequest*' + 'chat.editing.confirmEditRequest*', + 'chat.planAgent.defaultModel' ] }, { @@ -209,7 +209,8 @@ export const tocData: ITOCEntry = { 'chat.notifyWindow*', 'chat.statusWidget.*', 'chat.tips.*', - 'chat.unifiedAgentsBar.*' + 'chat.unifiedAgentsBar.*', + 'chat.confettiOnThumbsUp' ] }, { @@ -244,6 +245,7 @@ export const tocData: ITOCEntry = { label: localize('chatContext', "Context"), settings: [ 'chat.detectParticipant.*', + 'chat.experimental.detectParticipant.*', 'chat.implicitContext.*', 'chat.promptFilesLocations', 'chat.instructionsFilesLocations', @@ -259,7 +261,8 @@ export const tocData: ITOCEntry = { 'chat.useChatHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', - 'chat.sendElementsToChat.*' + 'chat.sendElementsToChat.*', + 'chat.useClaudeMdFile' ] }, { diff --git a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts index 186468f57ad..9e2704a79b6 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsTreeModels.ts @@ -471,14 +471,31 @@ export class SettingsTreeSettingElement extends SettingsTreeElement { return true; } - const features = tocData.children!.find(child => child.id === 'features'); + // Restrict to core settings + if (this.setting.extensionInfo) { + return false; + } + // Chat settings are now in their own top-level category + if (featureFilters.has('chat')) { + const chatFeatures = tocData.children!.find(child => child.id === 'chat'); + if (chatFeatures?.children) { + const patterns = chatFeatures.children + .flatMap(feature => feature.settings ?? []) + .map(setting => createSettingMatchRegExp(setting)); + if (patterns.some(pattern => pattern.test(this.setting.key))) { + return true; + } + } + } + + const features = tocData.children!.find(child => child.id === 'features'); return Array.from(featureFilters).some(filter => { - if (features && features.children) { + if (features?.children) { const feature = features.children.find(feature => 'features/' + filter === feature.id); - if (feature) { - const patterns = feature.settings?.map(setting => createSettingMatchRegExp(setting)); - return patterns && !this.setting.extensionInfo && patterns.some(pattern => pattern.test(this.setting.key.toLowerCase())); + if (feature?.settings) { + const patterns = feature.settings.map(setting => createSettingMatchRegExp(setting)); + return patterns.some(pattern => pattern.test(this.setting.key)); } else { return false; } diff --git a/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts b/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts index 1b1e55f4529..e5cec6f206a 100644 --- a/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts +++ b/src/vs/workbench/contrib/preferences/common/preferencesContribution.ts @@ -125,17 +125,6 @@ registry.registerConfiguration({ 'description': nls.localize('settingsSearchTocBehavior', "Controls the behavior of the Settings editor Table of Contents while searching. If this setting is being changed in the Settings editor, the setting will take effect after the search query is modified."), 'default': 'filter', 'scope': ConfigurationScope.WINDOW - }, - 'workbench.settings.scrollBehavior': { - 'type': 'string', - 'enum': ['paginated', 'continuous'], - 'enumDescriptions': [ - nls.localize('settingsScrollBehavior.paginated', "Show only settings from the selected category. Clicking a category in the Table of Contents filters the view to that category."), - nls.localize('settingsScrollBehavior.continuous', "Show all settings in a continuous scrolling list. Clicking a category scrolls to that section."), - ], - 'description': nls.localize('settingsScrollBehavior', "Controls whether the Settings editor shows one category at a time (paginated) or allows continuous scrolling through all settings."), - 'default': 'paginated', - 'scope': ConfigurationScope.WINDOW } } }); diff --git a/src/vs/workbench/contrib/remote/browser/remote.ts b/src/vs/workbench/contrib/remote/browser/remote.ts index 98d145ebf0f..530b0c4ae0f 100644 --- a/src/vs/workbench/contrib/remote/browser/remote.ts +++ b/src/vs/workbench/contrib/remote/browser/remote.ts @@ -525,7 +525,7 @@ class HelpPanelDescriptor implements IViewDescriptor { class RemoteViewPaneContainer extends FilterViewPaneContainer implements IViewModel { private helpPanelDescriptor = new HelpPanelDescriptor(this); helpInformation: HelpInformation[] = []; - private _onDidChangeHelpInformation = new Emitter(); + private _onDidChangeHelpInformation = this._register(new Emitter()); public onDidChangeHelpInformation: Event = this._onDidChangeHelpInformation.event; private hasRegisteredHelpView: boolean = false; private remoteSwitcher: SwitchRemoteViewItem | undefined; diff --git a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts index a71bcec9350..937aed25b45 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts @@ -9,7 +9,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { localize } from '../../../../nls.js'; import { isWeb } from '../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { getRemoteName } from '../../../../platform/remote/common/remoteHosts.js'; +import { getRemoteName, getRemoteServerRootPath } from '../../../../platform/remote/common/remoteHosts.js'; import { IBannerService } from '../../../services/banner/browser/bannerService.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -124,16 +124,19 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont web: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Is web ui.' }; connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connected' }; remoteName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the resolver.' }; + tunnelName?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the tunnel for tunnel connections.' }; }; type RemoteConnectionSuccessEvent = { web: boolean; connectionTimeMs: number | undefined; remoteName: string | undefined; + tunnelName?: string; }; this._telemetryService.publicLog2('remoteConnectionSuccess', { web: isWeb, connectionTimeMs: await this._remoteAgentService.getConnection()?.getInitialConnectionTimeMs(), - remoteName: getRemoteName(this._environmentService.remoteAuthority) + remoteName: getRemoteName(this._environmentService.remoteAuthority), + tunnelName: getRemoteServerRootPath(this._environmentService.remoteAuthority) }); await this._measureExtHostLatency(); diff --git a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts index 66108502b91..04ee77ccc8d 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteIndicator.ts @@ -851,7 +851,7 @@ export class RemoteStatusIndicator extends Disposable implements IWorkbenchContr if (!this.remoteMetadataInitialized) { quickPick.busy = true; - this._register(this.onDidChangeEntries(() => { + disposables.add(this.onDidChangeEntries(() => { // If quick pick is open, update the quick pick items after initialization. quickPick.busy = false; quickPick.items = computeItems(); diff --git a/src/vs/workbench/contrib/remote/browser/urlFinder.ts b/src/vs/workbench/contrib/remote/browser/urlFinder.ts index 7f972c52425..29bdccac0c0 100644 --- a/src/vs/workbench/contrib/remote/browser/urlFinder.ts +++ b/src/vs/workbench/contrib/remote/browser/urlFinder.ts @@ -38,8 +38,8 @@ export class UrlFinder extends Disposable { private static readonly excludeTerminals = ['Dev Containers']; - private _onDidMatchLocalUrl: Emitter<{ host: string; port: number }> = new Emitter(); - public readonly onDidMatchLocalUrl = this._onDidMatchLocalUrl.event; + private readonly _onDidMatchLocalUrl = this._register(new Emitter<{ host: string; port: number }>()); + readonly onDidMatchLocalUrl = this._onDidMatchLocalUrl.event; private readonly listeners: Map = new Map(); private readonly terminalDataWorkers = this._register(new DisposableMap>()); diff --git a/src/vs/workbench/contrib/scm/browser/menus.ts b/src/vs/workbench/contrib/scm/browser/menus.ts index 249ef2138ee..d783fd55814 100644 --- a/src/vs/workbench/contrib/scm/browser/menus.ts +++ b/src/vs/workbench/contrib/scm/browser/menus.ts @@ -29,12 +29,12 @@ export class SCMTitleMenu implements IDisposable { private _secondaryActions: IAction[] = []; get secondaryActions(): IAction[] { return this._secondaryActions; } - private readonly _onDidChangeTitle = new Emitter(); - readonly onDidChangeTitle = this._onDidChangeTitle.event; - readonly menu: IMenu; private readonly disposables = new DisposableStore(); + private readonly _onDidChangeTitle = this.disposables.add(new Emitter()); + readonly onDidChangeTitle = this._onDidChangeTitle.event; + constructor( @IMenuService menuService: IMenuService, @IContextKeyService contextKeyService: IContextKeyService diff --git a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts index d5c6b8459e5..1523a82cf18 100644 --- a/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmRepositoriesViewPane.ts @@ -883,6 +883,7 @@ export class SCMRepositoriesViewPane extends ViewPane { override dispose(): void { this.visibilityDisposables.dispose(); + this.repositoryDisposables.dispose(); super.dispose(); } } diff --git a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts index 012d3c545bd..669dd15c1c9 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewPane.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewPane.ts @@ -1698,6 +1698,7 @@ class SCMInputWidgetEditorOptions { dispose(): void { this._disposables.dispose(); + this._onDidChange.dispose(); } } @@ -3007,6 +3008,8 @@ export class SCMViewPane extends ViewPane { } override dispose(): void { + this._onDidChangeViewMode.dispose(); + this._onDidChangeViewSortKey.dispose(); this.visibilityDisposables.dispose(); this.disposables.dispose(); this.items.dispose(); diff --git a/src/vs/workbench/contrib/scm/browser/scmViewService.ts b/src/vs/workbench/contrib/scm/browser/scmViewService.ts index 114cfdc8c2e..d7b3583901c 100644 --- a/src/vs/workbench/contrib/scm/browser/scmViewService.ts +++ b/src/vs/workbench/contrib/scm/browser/scmViewService.ts @@ -593,6 +593,7 @@ export class SCMViewService implements ISCMViewService { dispose(): void { this.disposables.dispose(); + this._onDidFocusRepository.dispose(); this._onDidChangeRepositories.dispose(); this._onDidSetVisibleRepositories.dispose(); } diff --git a/src/vs/workbench/contrib/scm/common/scmService.ts b/src/vs/workbench/contrib/scm/common/scmService.ts index 8b6e1a13559..5bdf2e5f9aa 100644 --- a/src/vs/workbench/contrib/scm/common/scmService.ts +++ b/src/vs/workbench/contrib/scm/common/scmService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore, toDisposable } from '../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { Event, Emitter } from '../../../../base/common/event.js'; import { ISCMService, ISCMProvider, ISCMInput, ISCMRepository, IInputValidator, ISCMInputChangeEvent, SCMInputChangeReason, InputValidationType, IInputValidation } from './scm.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -27,7 +27,7 @@ class SCMInput extends Disposable implements ISCMInput { return this._value; } - private readonly _onDidChange = new Emitter(); + private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange: Event = this._onDidChange.event; private _placeholder = ''; @@ -41,7 +41,7 @@ class SCMInput extends Disposable implements ISCMInput { this._onDidChangePlaceholder.fire(placeholder); } - private readonly _onDidChangePlaceholder = new Emitter(); + private readonly _onDidChangePlaceholder = this._register(new Emitter()); readonly onDidChangePlaceholder: Event = this._onDidChangePlaceholder.event; private _enabled = true; @@ -55,7 +55,7 @@ class SCMInput extends Disposable implements ISCMInput { this._onDidChangeEnablement.fire(enabled); } - private readonly _onDidChangeEnablement = new Emitter(); + private readonly _onDidChangeEnablement = this._register(new Emitter()); readonly onDidChangeEnablement: Event = this._onDidChangeEnablement.event; private _visible = true; @@ -69,28 +69,28 @@ class SCMInput extends Disposable implements ISCMInput { this._onDidChangeVisibility.fire(visible); } - private readonly _onDidChangeVisibility = new Emitter(); + private readonly _onDidChangeVisibility = this._register(new Emitter()); readonly onDidChangeVisibility: Event = this._onDidChangeVisibility.event; setFocus(): void { this._onDidChangeFocus.fire(); } - private readonly _onDidChangeFocus = new Emitter(); + private readonly _onDidChangeFocus = this._register(new Emitter()); readonly onDidChangeFocus: Event = this._onDidChangeFocus.event; showValidationMessage(message: string | IMarkdownString, type: InputValidationType): void { this._onDidChangeValidationMessage.fire({ message: message, type: type }); } - private readonly _onDidChangeValidationMessage = new Emitter(); + private readonly _onDidChangeValidationMessage = this._register(new Emitter()); readonly onDidChangeValidationMessage: Event = this._onDidChangeValidationMessage.event; clearValidation(): void { this._onDidClearValidation.fire(); } - private readonly _onDidClearValidation = new Emitter(); + private readonly _onDidClearValidation = this._register(new Emitter()); readonly onDidClearValidation: Event = this._onDidClearValidation.event; private _validateInput: IInputValidator = () => Promise.resolve(undefined); @@ -104,7 +104,7 @@ class SCMInput extends Disposable implements ISCMInput { this._onDidChangeValidateInput.fire(); } - private readonly _onDidChangeValidateInput = new Emitter(); + private readonly _onDidChangeValidateInput = this._register(new Emitter()); readonly onDidChangeValidateInput: Event = this._onDidChangeValidateInput.event; private readonly historyNavigator: HistoryNavigator2; @@ -191,7 +191,7 @@ class SCMRepository implements ISCMRepository { private readonly _onDidChangeSelection = new Emitter(); readonly onDidChangeSelection: Event = this._onDidChangeSelection.event; - readonly input: ISCMInput; + readonly input: ISCMInput & IDisposable; constructor( public readonly id: string, @@ -213,6 +213,8 @@ class SCMRepository implements ISCMRepository { dispose(): void { this.disposables.dispose(); + this._onDidChangeSelection.dispose(); + this.input.dispose(); this.provider.dispose(); } } diff --git a/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts b/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts index 35b3076a111..ec64a54de06 100644 --- a/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts +++ b/src/vs/workbench/contrib/scrollLocking/browser/scrollLocking.ts @@ -23,7 +23,7 @@ export class SyncScroll extends Disposable implements IWorkbenchContribution { private readonly paneInitialScrollTop = new Map(); private readonly syncScrollDispoasbles = this._register(new DisposableStore()); - private readonly paneDisposables = new DisposableStore(); + private readonly paneDisposables = this._register(new DisposableStore()); private readonly statusBarEntry = this._register(new MutableDisposable()); diff --git a/src/vs/workbench/contrib/search/browser/notebookSearch/notebookSearchModel.ts b/src/vs/workbench/contrib/search/browser/notebookSearch/notebookSearchModel.ts index 6d5fe1e346a..ed24052b8cf 100644 --- a/src/vs/workbench/contrib/search/browser/notebookSearch/notebookSearchModel.ts +++ b/src/vs/workbench/contrib/search/browser/notebookSearch/notebookSearchModel.ts @@ -195,7 +195,7 @@ export class NotebookCompatibleFileMatch extends FileMatchImpl implements INoteb ) { super(_query, _previewOptions, _maxResults, _parent, rawMatch, _closestRoot, modelService, replaceService, labelService); this._cellMatches = new Map(); - this._notebookUpdateScheduler = new RunOnceScheduler(this.updateMatchesForEditorWidget.bind(this), 250); + this._notebookUpdateScheduler = this._register(new RunOnceScheduler(this.updateMatchesForEditorWidget.bind(this), 250)); } private _cellMatches: Map; public get cellContext(): Map> { diff --git a/src/vs/workbench/contrib/search/browser/searchTreeModel/fileMatch.ts b/src/vs/workbench/contrib/search/browser/searchTreeModel/fileMatch.ts index 7986616810d..7ac7a45def6 100644 --- a/src/vs/workbench/contrib/search/browser/searchTreeModel/fileMatch.ts +++ b/src/vs/workbench/contrib/search/browser/searchTreeModel/fileMatch.ts @@ -101,7 +101,7 @@ export class FileMatchImpl extends Disposable implements ISearchTreeFileMatch { this._resource = this.rawMatch.resource; this._textMatches = new Map(); this._removedTextMatches = new Set(); - this._updateScheduler = new RunOnceScheduler(this.updateMatchesForModel.bind(this), 250); + this._updateScheduler = this._register(new RunOnceScheduler(this.updateMatchesForModel.bind(this), 250)); this._name = new Lazy(() => labelService.getUriBasenameLabel(this.resource)); } diff --git a/src/vs/workbench/contrib/search/browser/searchWidget.ts b/src/vs/workbench/contrib/search/browser/searchWidget.ts index cfc48f507de..e9c0fcd4ae1 100644 --- a/src/vs/workbench/contrib/search/browser/searchWidget.ts +++ b/src/vs/workbench/contrib/search/browser/searchWidget.ts @@ -173,7 +173,7 @@ export class SearchWidget extends Widget { private _onDidHeightChange = this._register(new Emitter()); readonly onDidHeightChange: Event = this._onDidHeightChange.event; - private readonly _onDidToggleContext = new Emitter(); + private readonly _onDidToggleContext = this._register(new Emitter()); readonly onDidToggleContext: Event = this._onDidToggleContext.event; private showContextToggle!: Toggle; diff --git a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts index 8e408accee9..1d4336a45d7 100644 --- a/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts +++ b/src/vs/workbench/contrib/searchEditor/browser/searchEditor.ts @@ -86,7 +86,7 @@ export class SearchEditor extends AbstractTextCodeEditor private toggleQueryDetailsButton!: HTMLElement; private messageBox!: HTMLElement; - private runSearchDelayer = new Delayer(0); + private runSearchDelayer = this._register(new Delayer(0)); private pauseSearching: boolean = false; private showingIncludesExcludes: boolean = false; private searchOperation: LongRunningOperation; @@ -125,7 +125,7 @@ export class SearchEditor extends AbstractTextCodeEditor this.searchOperation = this._register(new LongRunningOperation(progressService)); this._register(this.messageDisposables = new DisposableStore()); - this.searchHistoryDelayer = new Delayer(2000); + this.searchHistoryDelayer = this._register(new Delayer(2000)); this.searchModel = this._register(this.instantiationService.createInstance(SearchModelImpl)); } diff --git a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts index c18c3d6ac88..808dcdd8486 100644 --- a/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts +++ b/src/vs/workbench/contrib/tasks/browser/abstractTaskService.ts @@ -240,15 +240,15 @@ export abstract class AbstractTaskService extends Disposable implements ITaskSer protected _outputChannel: IOutputChannel; protected readonly _onDidStateChange: Emitter; private _waitForAllSupportedExecutions: Promise; - private _onDidRegisterSupportedExecutions: Emitter = new Emitter(); - private _onDidRegisterAllSupportedExecutions: Emitter = new Emitter(); - private _onDidChangeTaskSystemInfo: Emitter = new Emitter(); + private _onDidRegisterSupportedExecutions = this._register(new Emitter()); + private _onDidRegisterAllSupportedExecutions = this._register(new Emitter()); + private _onDidChangeTaskSystemInfo = this._register(new Emitter()); private _willRestart: boolean = false; - public onDidChangeTaskSystemInfo: Event = this._onDidChangeTaskSystemInfo.event; - private _onDidReconnectToTasks: Emitter = new Emitter(); - public onDidReconnectToTasks: Event = this._onDidReconnectToTasks.event; - private _onDidChangeTaskConfig: Emitter = new Emitter(); - public onDidChangeTaskConfig: Event = this._onDidChangeTaskConfig.event; + public onDidChangeTaskSystemInfo = this._onDidChangeTaskSystemInfo.event; + private _onDidReconnectToTasks = this._register(new Emitter()); + public onDidReconnectToTasks = this._onDidReconnectToTasks.event; + private _onDidChangeTaskConfig = this._register(new Emitter()); + public onDidChangeTaskConfig = this._onDidChangeTaskConfig.event; public get isReconnected(): boolean { return this._tasksReconnected; } private _onDidChangeTaskProviders = this._register(new Emitter()); public onDidChangeTaskProviders = this._onDidChangeTaskProviders.event; diff --git a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts index 2b029d727aa..1e408be694c 100644 --- a/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts +++ b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts @@ -180,12 +180,12 @@ export class RunAutomaticTasks extends Disposable implements IWorkbenchContribut private _showPrompt(notificationService: INotificationService, storageService: IStorageService, openerService: IOpenerService, configurationService: IConfigurationService, taskNames: string[], locations: Map): Promise { return new Promise(resolve => { notificationService.prompt(Severity.Info, nls.localize('tasks.run.allowAutomatic', - "This workspace has tasks ({0}) defined ({1}) that run automatically when you open this workspace. Do you allow automatic tasks to run when you open this workspace?", + "This workspace has tasks ({0}) defined ({1}) that can launch processes automatically when you open this workspace. Do you want to allow automatic tasks to run in all trusted workspaces?", taskNames.join(', '), Array.from(locations.keys()).join(', ') ), [{ - label: nls.localize('allow', "Allow and Run"), + label: nls.localize('allow', "Allow"), run: () => { resolve(true); configurationService.updateValue(ALLOW_AUTOMATIC_TASKS, 'on', ConfigurationTarget.USER); diff --git a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts index 4893f05b809..82bc7998af4 100644 --- a/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts +++ b/src/vs/workbench/contrib/tasks/browser/terminalTaskSystem.ts @@ -232,7 +232,7 @@ export class TerminalTaskSystem extends Disposable implements ITaskSystem { this._terminals = Object.create(null); this._idleTaskTerminals = new LinkedMap(); this._sameTaskTerminals = Object.create(null); - this._onDidStateChange = new Emitter(); + this._onDidStateChange = this._register(new Emitter()); this._taskSystemInfoResolver = taskSystemInfoResolver; this._register(this._terminalStatusManager = instantiationService.createInstance(TaskTerminalStatus)); this._register(this._taskProblemMonitor = instantiationService.createInstance(TaskProblemMonitor)); diff --git a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts index 5d5fbbc473f..892a948cb65 100644 --- a/src/vs/workbench/contrib/tasks/common/problemCollectors.ts +++ b/src/vs/workbench/contrib/tasks/common/problemCollectors.ts @@ -58,13 +58,13 @@ export abstract class AbstractProblemCollector extends Disposable implements IDi protected _onDidStateChange: Emitter; - protected readonly _onDidFindFirstMatch = new Emitter(); + protected readonly _onDidFindFirstMatch = this._register(new Emitter()); readonly onDidFindFirstMatch = this._onDidFindFirstMatch.event; - protected readonly _onDidFindErrors = new Emitter(); + protected readonly _onDidFindErrors = this._register(new Emitter()); readonly onDidFindErrors = this._onDidFindErrors.event; - protected readonly _onDidRequestInvalidateLastMarker = new Emitter(); + protected readonly _onDidRequestInvalidateLastMarker = this._register(new Emitter()); readonly onDidRequestInvalidateLastMarker = this._onDidRequestInvalidateLastMarker.event; constructor(public readonly problemMatchers: ProblemMatcher[], protected markerService: IMarkerService, protected modelService: IModelService, fileService?: IFileService) { @@ -108,7 +108,7 @@ export abstract class AbstractProblemCollector extends Disposable implements IDi }, this, this.modelListeners)); this.modelService.getModels().forEach(model => this.openModels[model.uri.toString()] = true); - this._onDidStateChange = new Emitter(); + this._onDidStateChange = this._register(new Emitter()); } public get onDidStateChange(): Event { diff --git a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts index 31b2ac595af..e2abc0fe925 100644 --- a/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts +++ b/src/vs/workbench/contrib/terminal/browser/remoteTerminalBackend.ts @@ -78,28 +78,28 @@ class RemoteTerminalBackend extends BaseTerminalBackend implements ITerminalBack ) { super(_remoteTerminalChannel, logService, _historyService, configurationResolverService, statusBarService, workspaceContextService); - this._remoteTerminalChannel.onProcessData(e => this._ptys.get(e.id)?.handleData(e.event)); - this._remoteTerminalChannel.onProcessReplay(e => { + this._register(this._remoteTerminalChannel.onProcessData(e => this._ptys.get(e.id)?.handleData(e.event))); + this._register(this._remoteTerminalChannel.onProcessReplay(e => { this._ptys.get(e.id)?.handleReplay(e.event); if (e.event.commands.commands.length > 0) { this._onRestoreCommands.fire({ id: e.id, commands: e.event.commands.commands }); } - }); - this._remoteTerminalChannel.onProcessOrphanQuestion(e => this._ptys.get(e.id)?.handleOrphanQuestion()); - this._remoteTerminalChannel.onDidRequestDetach(e => this._onDidRequestDetach.fire(e)); - this._remoteTerminalChannel.onProcessReady(e => this._ptys.get(e.id)?.handleReady(e.event)); - this._remoteTerminalChannel.onDidChangeProperty(e => this._ptys.get(e.id)?.handleDidChangeProperty(e.property)); - this._remoteTerminalChannel.onProcessExit(e => { + })); + this._register(this._remoteTerminalChannel.onProcessOrphanQuestion(e => this._ptys.get(e.id)?.handleOrphanQuestion())); + this._register(this._remoteTerminalChannel.onDidRequestDetach(e => this._onDidRequestDetach.fire(e))); + this._register(this._remoteTerminalChannel.onProcessReady(e => this._ptys.get(e.id)?.handleReady(e.event))); + this._register(this._remoteTerminalChannel.onDidChangeProperty(e => this._ptys.get(e.id)?.handleDidChangeProperty(e.property))); + this._register(this._remoteTerminalChannel.onProcessExit(e => { const pty = this._ptys.get(e.id); if (pty) { pty.handleExit(e.event); pty.dispose(); this._ptys.delete(e.id); } - }); + })); const allowedCommands = ['_remoteCLI.openExternal', '_remoteCLI.windowOpen', '_remoteCLI.getSystemStatus', '_remoteCLI.manageExtensions']; - this._remoteTerminalChannel.onExecuteCommand(async e => { + this._register(this._remoteTerminalChannel.onExecuteCommand(async e => { // Ensure this request for for this window const pty = this._ptys.get(e.persistentProcessId); if (!pty) { @@ -118,7 +118,7 @@ class RemoteTerminalBackend extends BaseTerminalBackend implements ITerminalBack } catch (err) { this._remoteTerminalChannel.sendCommandResult(reqId, true, err); } - }); + })); this._onPtyHostConnected.fire(); } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts b/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts index 326d159c078..71dd2237254 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalConfigurationService.ts @@ -33,7 +33,7 @@ export class TerminalConfigurationService extends Disposable implements ITermina return TerminalLocation.Panel; } - private readonly _onConfigChanged = new Emitter(); + private readonly _onConfigChanged = this._register(new Emitter()); get onConfigChanged(): Event { return this._onConfigChanged.event; } constructor( diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 02b12985084..251a94fd3e6 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -370,6 +370,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } this._processListeners = [ newProcess.onProcessReady((e: IProcessReadyEvent) => { + this._logService.debug('onProcessReady', e); this._processTraits = e; this.shellProcessId = e.pid; this._initialCwd = e.cwd; @@ -379,6 +380,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce if (this._preLaunchInputQueue.length > 0 && this._process) { // Send any queued data that's waiting + this._logService.debug('sending prelaunch input queue', this._preLaunchInputQueue); newProcess.input(this._preLaunchInputQueue.join('')); this._preLaunchInputQueue.length = 0; } @@ -632,6 +634,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce } } else { // If the pty is not ready, queue the data received to send later + this._logService.debug('queueing data in prelaunch input queue', data); this._preLaunchInputQueue.push(data); } } diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts b/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts index b3b306e07f8..1f06f04d3f0 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProfileQuickpick.ts @@ -19,6 +19,7 @@ import { getIconRegistry } from '../../../../platform/theme/common/iconRegistry. import { basename } from '../../../../base/common/path.js'; import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; import { hasKey, isString } from '../../../../base/common/types.js'; +import { Event } from '../../../../base/common/event.js'; type DefaultProfileName = string; @@ -247,7 +248,9 @@ export class TerminalProfileQuickpick { run: () => r(false) }] ); - handle.onDidClose(() => r(false)); + Event.once(handle.onDidClose)(() => { + r(false); + }); }); } diff --git a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 838d17df872..d66f1c39a38 100644 --- a/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts @@ -244,9 +244,11 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach scrollSensitivity: config.mouseWheelScrollSensitivity, scrollOnEraseInDisplay: true, wordSeparator: config.wordSeparators, - overviewRuler: options.disableOverviewRuler ? { width: 0 } : { + scrollbar: options.disableOverviewRuler ? undefined : { width: 14, - showTopBorder: true, + overviewRuler: { + showTopBorder: true, + }, }, ignoreBracketedPasteMode: config.ignoreBracketedPasteMode, rescaleOverlappingGlyphs: config.rescaleOverlappingGlyphs, @@ -630,16 +632,16 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach } this._searchAddon = new AddonCtor({ highlightLimit: XtermTerminalConstants.SearchHighlightLimit }); this.raw.loadAddon(this._searchAddon); - this._searchAddon.onDidChangeResults((results: { resultIndex: number; resultCount: number }) => { + this._store.add(this._searchAddon.onDidChangeResults((results: { resultIndex: number; resultCount: number }) => { this._lastFindResult = results; this._onDidChangeFindResults.fire(results); - }); - this._searchAddon.onBeforeSearch(() => { + })); + this._store.add(this._searchAddon.onBeforeSearch(() => { this._onBeforeSearch.fire(); - }); - this._searchAddon.onAfterSearch(() => { + })); + this._store.add(this._searchAddon.onAfterSearch(() => { this._onAfterSearch.fire(); - }); + })); return this._searchAddon; }); } @@ -844,10 +846,10 @@ export class XtermTerminal extends Disposable implements IXtermTerminal, IDetach try { this.raw.loadAddon(this._webglAddon); this._logService.trace('Webgl was loaded'); - this._webglAddon.onContextLoss(() => { + this._store.add(this._webglAddon.onContextLoss(() => { this._logService.info(`Webgl lost context, disposing of webgl renderer`); this._disposeOfWebglRenderer(); - }); + })); this._refreshImageAddon(); // WebGL renderer cell dimensions differ from the DOM renderer, make sure the terminal // gets resized after the webgl addon is loaded diff --git a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts index 81bc2516e21..0eb0cf84c48 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/terminalInstance.test.ts @@ -288,6 +288,12 @@ suite('Workbench - TerminalInstance', () => { { code: 1260, message: `The terminal process failed to launch: Windows cannot open this program because it has been prevented by a software restriction policy. For more information, open Event Viewer or contact your system Administrator.` } ); }); + test('should format conpty launch failure', () => { + deepStrictEqual( + parseExitResult({ message: 'A native exception occurred during launch (Cannot launch conpty). Winpty has been removed, see https://code.visualstudio.com/updates/v1_109#_removal-of-winpty-support for more details. You can also try enabling the `terminal.integrated.windowsUseConptyDll` setting.' }, {}, ProcessState.KilledDuringLaunch, undefined), + { code: undefined, message: `The terminal process failed to launch: A native exception occurred during launch (Cannot launch conpty). Winpty has been removed, see https://code.visualstudio.com/updates/v1_109#_removal-of-winpty-support for more details. You can also try enabling the \`terminal.integrated.windowsUseConptyDll\` setting..` } + ); + }); test('should format generic failures', () => { deepStrictEqual( parseExitResult({ code: 123, message: 'A native exception occurred during launch (Cannot create process, error code: 123)' }, {}, ProcessState.KilledDuringLaunch, undefined), diff --git a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts index 2796901804d..3f45c6281a8 100644 --- a/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts +++ b/src/vs/workbench/contrib/terminalContrib/accessibility/browser/terminalAccessibleBufferProvider.ts @@ -21,7 +21,7 @@ export class TerminalAccessibleBufferProvider extends Disposable implements IAcc private _focusedInstance: ITerminalInstance | undefined; - private readonly _onDidRequestClearProvider = new Emitter(); + private readonly _onDidRequestClearProvider = this._register(new Emitter()); readonly onDidRequestClearLastProvider = this._onDidRequestClearProvider.event; constructor( diff --git a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts index 4979520e356..dc20533be3f 100644 --- a/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/autoReplies/common/terminalAutoRepliesConfiguration.ts @@ -26,6 +26,7 @@ export const terminalAutoRepliesConfiguration: IStringDictionary = { // Avoid making `git diff` interactive when called from copilot GIT_PAGER: 'cat', + // Prevent git from opening an editor for merge commits + GIT_MERGE_AUTOEDIT: 'no', + // Prevent git from opening an editor (e.g. for commit --amend, rebase -i). + // `:` is a POSIX shell built-in no-op (returns 0), works cross-platform + // since git always invokes the editor via `sh -c`. + GIT_EDITOR: ':', }; const preventShellHistory = this._configurationService.getValue(TerminalChatAgentToolsSettingId.PreventShellHistory) === true; 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 8c70eb496af..081534d5031 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 @@ -877,13 +877,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } private async _getLanguageModel(): Promise { - let models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); - - // Fallback to gpt-4o-mini if copilot-fast is not available for backwards compatibility - if (!models.length) { - models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', family: 'gpt-4o-mini' }); - } - + const models = await this._languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-fast' }); return models.length ? models[0] : undefined; } } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 2fed85e9a45..5903834e459 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -534,11 +534,17 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary('terminalSandboxService'); @@ -52,6 +53,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb @IEnvironmentService private readonly _environmentService: IEnvironmentService, @ILogService private readonly _logService: ILogService, @IRemoteAgentService private readonly _remoteAgentService: IRemoteAgentService, + @ITrustedDomainService private readonly _trustedDomainService: ITrustedDomainService, ) { super(); this._appRoot = dirname(FileAccess.asFileUri('').path); @@ -72,6 +74,10 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb this.setNeedsForceUpdateConfigFile(); } })); + + this._register(this._trustedDomainService.onDidChangeTrustedDomains(() => { + this.setNeedsForceUpdateConfigFile(); + })); } public async isEnabled(): Promise { @@ -142,17 +148,26 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb await this._initTempDir(); } if (this._tempDir) { - const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; + const networkSetting = this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork) ?? {}; const linuxFileSystemSetting = this._os === OperatingSystem.Linux - ? this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem) ?? {} + ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.TerminalSandboxLinuxFileSystem) ?? {} : {}; const macFileSystemSetting = this._os === OperatingSystem.Macintosh - ? this._configurationService.getValue(TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem) ?? {} + ? this._configurationService.getValue<{ denyRead?: string[]; allowWrite?: string[]; denyWrite?: string[] }>(TerminalChatAgentToolsSettingId.TerminalSandboxMacFileSystem) ?? {} : {}; const configFileUri = URI.joinPath(this._tempDir, `vscode-sandbox-settings-${this._sandboxSettingsId}.json`); + + const allowedDomainsSet = new Set(networkSetting.allowedDomains ?? []); + if (networkSetting.allowTrustedDomains) { + for (const domain of this._trustedDomainService.trustedDomains) { + allowedDomainsSet.add(domain); + } + } + const allowedDomains = Array.from(allowedDomainsSet); + const sandboxSettings = { network: { - allowedDomains: networkSetting.allowedDomains ?? [], + allowedDomains, deniedDomains: networkSetting.deniedDomains ?? [] }, filesystem: { diff --git a/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts b/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts index bee79eabe20..bdea55b6466 100644 --- a/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/links/browser/terminal.links.contribution.ts @@ -83,9 +83,9 @@ class TerminalLinkContribution extends DisposableStore implements ITerminalContr async showLinkQuickpick(extended?: boolean): Promise { if (!this._terminalLinkQuickpick) { this._terminalLinkQuickpick = this.add(this._instantiationService.createInstance(TerminalLinkQuickpick)); - this._terminalLinkQuickpick.onDidRequestMoreLinks(() => { + this.add(this._terminalLinkQuickpick.onDidRequestMoreLinks(() => { this.showLinkQuickpick(true); - }); + })); } const links = await this._getLinks(); return await this._terminalLinkQuickpick.show(this._ctx.instance, links); diff --git a/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts b/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts index 06dd0578137..c34b5dece25 100644 --- a/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/quickFix/browser/quickFixAddon.ts @@ -76,9 +76,9 @@ export class TerminalQuickFixAddon extends Disposable implements ITerminalAddon, private _didRun: boolean = false; - private readonly _onDidRequestRerunCommand = new Emitter<{ command: string; shouldExecute?: boolean }>(); + private readonly _onDidRequestRerunCommand = this._register(new Emitter<{ command: string; shouldExecute?: boolean }>()); readonly onDidRequestRerunCommand = this._onDidRequestRerunCommand.event; - private readonly _onDidUpdateQuickFixes = new Emitter<{ command: ITerminalCommand; actions: ITerminalAction[] | undefined }>(); + private readonly _onDidUpdateQuickFixes = this._register(new Emitter<{ command: ITerminalCommand; actions: ITerminalAction[] | undefined }>()); readonly onDidUpdateQuickFixes = this._onDidUpdateQuickFixes.event; constructor( diff --git a/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts b/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts index 25f126cdd8a..66b343c5574 100644 --- a/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts +++ b/src/vs/workbench/contrib/terminalContrib/typeAhead/browser/terminalTypeAheadAddon.ts @@ -648,7 +648,7 @@ export class PredictionStats extends Disposable { private readonly _stats: [latency: number, correct: boolean][] = []; private _index = 0; private readonly _addedAtTime = new WeakMap(); - private readonly _changeEmitter = new Emitter(); + private readonly _changeEmitter = this._register(new Emitter()); readonly onChange = this._changeEmitter.event; /** diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/listProjection.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/listProjection.ts index 468121c4c1d..8e3fca051d8 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/listProjection.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/listProjection.ts @@ -65,7 +65,7 @@ class ListTestItemElement extends TestItemTreeElement { * Projection that lists tests in their traditional tree view. */ export class ListProjection extends Disposable implements ITestTreeProjection { - private readonly updateEmitter = new Emitter(); + private readonly updateEmitter = this._register(new Emitter()); private readonly items = new Map(); /** diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts index 054bc7bf954..c97c256c9d6 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/treeProjection.ts @@ -96,7 +96,7 @@ class TreeTestItemElement extends TestItemTreeElement { * Projection that lists tests in their traditional tree view. */ export class TreeProjection extends Disposable implements ITestTreeProjection { - private readonly updateEmitter = new Emitter(); + private readonly updateEmitter = this._register(new Emitter()); private readonly changedParents = new Set(); private readonly resortedParents = new Set(); diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts index 9a542a7d6b2..e19fc273fd5 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts @@ -63,7 +63,7 @@ const enum CoverageSortOrder { } export class TestCoverageView extends ViewPane { - private readonly tree = new MutableDisposable(); + private readonly tree = this._register(new MutableDisposable()); public readonly sortOrder = observableValue('sortOrder', CoverageSortOrder.Location); constructor( diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts index 0cda4a7ac35..50bd28bca26 100644 --- a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -126,7 +126,7 @@ export class TestingDecorationService extends Disposable implements ITestingDeco declare public _serviceBrand: undefined; private generation = 0; - private readonly changeEmitter = new Emitter(); + private readonly changeEmitter = this._register(new Emitter()); private readonly decorationCache = new ResourceMap<{ /** The document version at which ranges have been updated, requiring rerendering */ rangeUpdateVersionId?: number; diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 6b371cab5c7..ef75da688b1 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -681,10 +681,10 @@ class TestingExplorerViewModel extends Disposable { private filter: TestsFilter; public readonly projection = this._register(new MutableDisposable()); - private readonly revealTimeout = new MutableDisposable(); + private readonly revealTimeout = this._register(new MutableDisposable()); private readonly _viewMode: IContextKey; private readonly _viewSorting: IContextKey; - private readonly welcomeVisibilityEmitter = new Emitter(); + private readonly welcomeVisibilityEmitter = this._register(new Emitter()); private readonly actionRunner = this._register(new TestExplorerActionRunner(() => this.tree.getSelection().filter(isDefined))); private readonly lastViewState: StoredValue; private readonly noTestForDocumentWidget: NoTestsForDocumentWidget; diff --git a/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts b/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts index 9c187e33e76..b1b5b41edd8 100644 --- a/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts +++ b/src/vs/workbench/contrib/testing/common/testExplorerFilterState.ts @@ -80,7 +80,7 @@ const trimExtraWhitespace = (str: string) => str.replace(/\s\s+/g, ' ').trim(); export class TestExplorerFilterState extends Disposable implements ITestExplorerFilterState { declare _serviceBrand: undefined; - private readonly focusEmitter = new Emitter(); + private readonly focusEmitter = this._register(new Emitter()); /** * Mapping of terms to whether they're included in the text. */ diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index 8790f01262f..87c3210aeff 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -106,7 +106,7 @@ export class TestResultService extends Disposable implements ITestResultService } })); - protected readonly persistScheduler = new RunOnceScheduler(() => this.persistImmediately(), 500); + protected readonly persistScheduler = this._register(new RunOnceScheduler(() => this.persistImmediately(), 500)); constructor( @IContextKeyService contextKeyService: IContextKeyService, diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 5ff7b8c97ac..f731239870f 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -41,9 +41,9 @@ export class TestService extends Disposable implements ITestService { private testControllers = observableValue>('testControllers', new Map()); private testExtHosts = new Set(); - private readonly cancelExtensionTestRunEmitter = new Emitter<{ runId: string | undefined; taskId: string | undefined }>(); - private readonly willProcessDiffEmitter = new Emitter(); - private readonly didProcessDiffEmitter = new Emitter(); + private readonly cancelExtensionTestRunEmitter = this._register(new Emitter<{ runId: string | undefined; taskId: string | undefined }>()); + private readonly willProcessDiffEmitter = this._register(new Emitter()); + private readonly didProcessDiffEmitter = this._register(new Emitter()); private readonly testRefreshCancellations = new Set(); private readonly isRefreshingTests: IContextKey; private readonly activeEditorHasTests: IContextKey; diff --git a/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts b/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts index ccb27d63e7a..866aee01e9f 100644 --- a/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts +++ b/src/vs/workbench/contrib/testing/common/testingContinuousRunService.ts @@ -86,7 +86,7 @@ type RunningRef = { path: readonly string[]; profiles: ISettableObservable(); + private readonly changeEmitter = this._register(new Emitter()); private readonly running = new WellDefinedPrefixTree(); private readonly lastRun: StoredValue>; diff --git a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts index efca4183366..c7c9cc7f99b 100644 --- a/src/vs/workbench/contrib/timeline/browser/timelinePane.ts +++ b/src/vs/workbench/contrib/timeline/browser/timelinePane.ts @@ -926,7 +926,7 @@ export class TimelinePane extends ViewPane { // this.treeElement.classList.add('show-file-icons'); container.appendChild(this.$tree); - this.treeRenderer = this.instantiationService.createInstance(TimelineTreeRenderer, this.commands, this.viewDescriptorService.getViewLocationById(this.id)); + this.treeRenderer = this._register(this.instantiationService.createInstance(TimelineTreeRenderer, this.commands, this.viewDescriptorService.getViewLocationById(this.id))); this._register(this.treeRenderer.onDidScrollToEnd(item => { if (this.pageOnScroll) { this.loadMore(item); @@ -1153,8 +1153,8 @@ export class TimelineListVirtualDelegate implements IListVirtualDelegate { - private readonly _onDidScrollToEnd = new Emitter(); +class TimelineTreeRenderer extends Disposable implements ITreeRenderer { + private readonly _onDidScrollToEnd = this._register(new Emitter()); readonly onDidScrollToEnd: Event = this._onDidScrollToEnd.event; readonly templateId: string = TimelineElementTemplate.id; @@ -1169,6 +1169,7 @@ class TimelineTreeRenderer implements ITreeRenderer 0) { const releaseDateNode = dom.append(details, dom.$('.product-release-date')); releaseDateNode.textContent = nls.localize('updateStatus.releasedLabel', "Released {0}", formatDate(releaseDate)); } @@ -416,11 +422,11 @@ export class UpdateStatusBarEntryContribution extends Disposable implements IWor * Tries to parse a date string and returns the timestamp or undefined if parsing fails. */ export function tryParseDate(date: string | undefined): number | undefined { - try { - return date !== undefined ? Date.parse(date) : undefined; - } catch { + if (date === undefined) { return undefined; } + const parsed = Date.parse(date); + return isNaN(parsed) ? undefined : parsed; } /** diff --git a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts index accef621795..c97431d5dd0 100644 --- a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts +++ b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import * as sinon from 'sinon'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Downloading, StateType } from '../../../../../platform/update/common/update.js'; -import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatTimeRemaining } from '../../browser/updateStatusBarEntry.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatDate, formatTimeRemaining, tryParseDate } from '../../browser/updateStatusBarEntry.js'; suite('UpdateStatusBarEntry', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -129,6 +129,32 @@ suite('UpdateStatusBarEntry', () => { }); }); + suite('tryParseDate', () => { + test('returns undefined for undefined input', () => { + assert.strictEqual(tryParseDate(undefined), undefined); + }); + + test('returns undefined for invalid date strings', () => { + assert.strictEqual(tryParseDate(''), undefined); + assert.strictEqual(tryParseDate('not-a-date'), undefined); + }); + + test('parses valid ISO date strings', () => { + const result = tryParseDate('2026-02-06T05:03:03.991Z'); + assert.ok(result !== undefined); + assert.strictEqual(typeof result, 'number'); + assert.ok(result > 0); + }); + }); + + suite('formatDate', () => { + test('formats a timestamp as a readable date', () => { + const result = formatDate(1705276800000); + assert.ok(result.length > 0); + assert.ok(result.includes('2024')); + }); + }); + suite('computeDownloadSpeed', () => { test('returns undefined for invalid or incomplete input', () => { const now = Date.now(); diff --git a/src/vs/workbench/contrib/url/browser/trustedDomainService.ts b/src/vs/workbench/contrib/url/browser/trustedDomainService.ts index 6f93585f61f..d54b3589832 100644 --- a/src/vs/workbench/contrib/url/browser/trustedDomainService.ts +++ b/src/vs/workbench/contrib/url/browser/trustedDomainService.ts @@ -7,20 +7,14 @@ import { WindowIdleValue } from '../../../../base/browser/dom.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { URI } from '../../../../base/common/uri.js'; -import { IInstantiationService, createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; import { TRUSTED_DOMAINS_STORAGE_KEY, readStaticTrustedDomains } from './trustedDomains.js'; import { isURLDomainTrusted } from '../../../../platform/url/common/trustedDomains.js'; -import { Event, Emitter } from '../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { ITrustedDomainService } from '../common/trustedDomainService.js'; -export const ITrustedDomainService = createDecorator('ITrustedDomainService'); - -export interface ITrustedDomainService { - _serviceBrand: undefined; - readonly onDidChangeTrustedDomains: Event; - isValid(resource: URI): boolean; - readonly trustedDomains: string[]; -} +export { ITrustedDomainService }; export class TrustedDomainService extends Disposable implements ITrustedDomainService { _serviceBrand: undefined; diff --git a/src/vs/workbench/contrib/url/common/trustedDomainService.ts b/src/vs/workbench/contrib/url/common/trustedDomainService.ts new file mode 100644 index 00000000000..af6fd3f1f18 --- /dev/null +++ b/src/vs/workbench/contrib/url/common/trustedDomainService.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../../base/common/event.js'; +import { URI } from '../../../../base/common/uri.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; + +export const ITrustedDomainService = createDecorator('ITrustedDomainService'); + +export interface ITrustedDomainService { + _serviceBrand: undefined; + readonly onDidChangeTrustedDomains: Event; + isValid(resource: URI): boolean; + readonly trustedDomains: string[]; +} diff --git a/src/vs/workbench/contrib/url/test/browser/mockTrustedDomainService.ts b/src/vs/workbench/contrib/url/test/browser/mockTrustedDomainService.ts index 5d722ce72b4..5cae557d0e0 100644 --- a/src/vs/workbench/contrib/url/test/browser/mockTrustedDomainService.ts +++ b/src/vs/workbench/contrib/url/test/browser/mockTrustedDomainService.ts @@ -5,7 +5,7 @@ import { Event } from '../../../../../base/common/event.js'; import { URI } from '../../../../../base/common/uri.js'; -import { ITrustedDomainService } from '../../browser/trustedDomainService.js'; +import { ITrustedDomainService } from '../../common/trustedDomainService.js'; import { isURLDomainTrusted } from '../../../../../platform/url/common/trustedDomains.js'; export class MockTrustedDomainService implements ITrustedDomainService { diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index 30789ae673c..cebab4bb5db 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -47,6 +47,7 @@ import { ChatSessionPosition, getResourceForNewChatSession } from '../../chat/br import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsFilter.js'; +import { AgentSessionsListDelegate } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IResolvedWalkthrough, IWalkthroughsService } from '../../welcomeGettingStarted/browser/gettingStartedService.js'; import { GettingStartedEditorOptions, GettingStartedInput } from '../../welcomeGettingStarted/browser/gettingStartedInput.js'; @@ -127,7 +128,6 @@ export class AgentSessionsWelcomePage extends EditorPane { private chatModelRef: IReference | undefined; private sessionsControl: AgentSessionsControl | undefined; private sessionsControlContainer: HTMLElement | undefined; - private sessionsLoadingContainer: HTMLElement | undefined; private readonly sessionsControlDisposables = this._register(new DisposableStore()); private readonly contentDisposables = this._register(new DisposableStore()); private contextService: IContextKeyService; @@ -534,7 +534,6 @@ export class AgentSessionsWelcomePage extends EditorPane { // Clear previous sessions control this.sessionsControlDisposables.clear(); this.sessionsControl = undefined; - this.sessionsLoadingContainer = undefined; const sessions = this.agentSessionsService.model.sessions.filter(s => !s.isArchived()); @@ -545,45 +544,10 @@ export class AgentSessionsWelcomePage extends EditorPane { } } - private buildLoadingSkeleton(container: HTMLElement): HTMLElement { - const loadingContainer = append(container, $('.agentSessionsWelcome-sessionsLoading', { - 'role': 'status', - 'aria-busy': 'true', - 'aria-label': localize('loadingSessions', "Loading sessions...") - })); - - // Create skeleton items to match MAX_SESSIONS (6 items, arranged in 2 columns) - for (let i = 0; i < MAX_SESSIONS; i++) { - const skeleton = append(loadingContainer, $('.agentSessionsWelcome-sessionSkeleton', { 'aria-hidden': 'true' })); - append(skeleton, $('.agentSessionsWelcome-sessionSkeleton-icon')); - const content = append(skeleton, $('.agentSessionsWelcome-sessionSkeleton-content')); - append(content, $('.agentSessionsWelcome-sessionSkeleton-title')); - append(content, $('.agentSessionsWelcome-sessionSkeleton-description')); - } - - return loadingContainer; - } - - private hideLoadingSkeleton(): void { - // Hide loading skeleton and show the sessions control - if (this.sessionsLoadingContainer) { - this.sessionsLoadingContainer.style.display = 'none'; - } - if (this.sessionsControlContainer) { - this.sessionsControlContainer.style.display = ''; - this.layoutSessionsControl(); - } - } - private buildSessionsGrid(container: HTMLElement, _sessions: IAgentSession[]): void { - // Show loading skeleton initially - this.sessionsLoadingContainer = this.buildLoadingSkeleton(container); - + // Show cached sessions immediately if available, otherwise show loading skeleton this.sessionsControlContainer = append(container, $('.agentSessionsWelcome-sessionsGrid')); - // Hide the control initially until loading completes - this.sessionsControlContainer.style.display = 'none'; - const options: IAgentSessionsControlOptions = { overrideStyles: getListStyles({ listBackground: editorBackground, @@ -611,11 +575,11 @@ export class AgentSessionsWelcomePage extends EditorPane { // Listen for loading state changes to toggle skeleton visibility this.sessionsControlDisposables.add(this.agentSessionsService.model.onDidResolve(() => { - this.hideLoadingSkeleton(); + this.layoutSessionsControl(); })); if (this.agentSessionsService.model.resolved) { - this.hideLoadingSkeleton(); + this.layoutSessionsControl(); } // Schedule layout at next animation frame to ensure proper rendering @@ -852,19 +816,19 @@ export class AgentSessionsWelcomePage extends EditorPane { // TODO: @osortega this is a weird way of doing this, maybe we handle the 2-colum layout in the control itself? const sessionsWidth = Math.min(800, this.lastDimension.width - 80); // Calculate height based on actual visible sessions (capped at MAX_SESSIONS) - // Use 52px per item from AgentSessionsListDelegate.ITEM_HEIGHT + // Use ITEM_HEIGHT per item from AgentSessionsListDelegate // Give the list FULL height so virtualization renders all items // CSS transforms handle the 2-column visual layout const visibleSessions = Math.min( this.agentSessionsService.model.sessions.filter(s => !s.isArchived()).length, MAX_SESSIONS ); - const sessionsHeight = visibleSessions * 52; + const sessionsHeight = visibleSessions * AgentSessionsListDelegate.ITEM_HEIGHT; this.sessionsControl.layout(sessionsHeight, sessionsWidth); // Set margin offset for 2-column layout: actual height - visual height - // Visual height = ceil(n/2) * 52, so offset = floor(n/2) * 52 - const marginOffset = Math.floor(visibleSessions / 2) * 52; + // Visual height = ceil(n/2) * ITEM_HEIGHT, so offset = floor(n/2) * ITEM_HEIGHT + const marginOffset = Math.floor(visibleSessions / 2) * AgentSessionsListDelegate.ITEM_HEIGHT; this.sessionsControl.element!.style.marginBottom = `-${marginOffset}px`; } diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css index 3021512101f..edc3e1d193d 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/media/agentSessionsWelcome.css @@ -168,31 +168,31 @@ * Each pair forms a visual row. * Left column items need to move up by floor((index-1)/2) rows * Right column items need to move right and up by (index/2) rows - * Row height is 52px. + * Row height is 44px. */ /* Left column items (odd positions): move up to form 2-column layout */ /* Item 3: move up 1 row */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(3) { - transform: translateY(-52px); + transform: translateY(-44px); } /* Item 5: move up 2 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(5) { - transform: translateY(-104px); + transform: translateY(-88px); } /* Right column items (even positions): move right and up */ /* Item 2: move right, up 1 row */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(2) { - transform: translateX(100%) translateY(-52px); + transform: translateX(100%) translateY(-44px); } /* Item 4: move right, up 2 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(4) { - transform: translateX(100%) translateY(-104px); + transform: translateX(100%) translateY(-88px); } /* Item 6: move right, up 3 rows */ .agentSessionsWelcome-sessionsGrid .monaco-list-row:nth-child(6) { - transform: translateX(100%) translateY(-156px); + transform: translateX(100%) translateY(-132px); } /* Style individual session items in the welcome page */ @@ -482,7 +482,7 @@ gap: 12px; padding: 8px 12px 8px 8px; border-radius: 4px; - height: 52px; + height: 44px; box-sizing: border-box; } diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts index 03e466cec04..d4670f21ac9 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts @@ -141,7 +141,7 @@ export class GettingStartedPage extends EditorPane { private detailsScrollbar: DomScrollableElement | undefined; - private buildSlideThrottle: Throttler = new Throttler(); + private buildSlideThrottle = this._register(new Throttler()); private container: HTMLElement; @@ -217,7 +217,6 @@ export class GettingStartedPage extends EditorPane { this.gettingStartedCategories = this.gettingStartedService.getWalkthroughs(); this._register(this.dispatchListeners); - this.buildSlideThrottle = new Throttler(); const rerender = () => { this.gettingStartedCategories = this.gettingStartedService.getWalkthroughs(); diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedList.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedList.ts index e3213ed92d9..0ffdd5159ed 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedList.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStartedList.ts @@ -23,7 +23,7 @@ type GettingStartedIndexListOptions = { }; export class GettingStartedIndexList extends Disposable { - private readonly _onDidChangeEntries = new Emitter(); + private readonly _onDidChangeEntries = this._register(new Emitter()); private readonly onDidChangeEntries: Event = this._onDidChangeEntries.event; private domElement: HTMLElement; diff --git a/src/vs/workbench/contrib/welcomeWalkthrough/browser/walkThroughPart.ts b/src/vs/workbench/contrib/welcomeWalkthrough/browser/walkThroughPart.ts index 27908c633b0..c6fb17f8525 100644 --- a/src/vs/workbench/contrib/welcomeWalkthrough/browser/walkThroughPart.ts +++ b/src/vs/workbench/contrib/welcomeWalkthrough/browser/walkThroughPart.ts @@ -155,7 +155,7 @@ export class WalkThroughPart extends EditorPane { } private registerClickHandler() { - this.content.addEventListener('click', event => { + this.disposables.add(this.addEventListener(this.content, 'click', event => { for (let node = event.target as HTMLElement; node; node = node.parentNode as HTMLElement) { if (isHTMLAnchorElement(node) && node.href) { // eslint-disable-next-line no-restricted-syntax @@ -184,7 +184,7 @@ export class WalkThroughPart extends EditorPane { break; } } - }); + })); } private open(uri: URI) { diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index f0a645aea45..bc2ada6a3d9 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -7,7 +7,7 @@ import { Emitter } from '../../../../base/common/event.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../authentication/common/authentication.js'; -import { asJson, IRequestService } from '../../../../platform/request/common/request.js'; +import { asJson, IRequestService, isClientError, isSuccess } from '../../../../platform/request/common/request.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; @@ -18,7 +18,7 @@ import { IHostService } from '../../host/browser/host.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IEntitlementsData, IPolicyData } from '../../../../base/common/defaultAccount.js'; -import { isString, Mutable } from '../../../../base/common/types.js'; +import { isString, isUndefined, Mutable } from '../../../../base/common/types.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { isWeb } from '../../../../base/common/platform.js'; @@ -60,7 +60,7 @@ const enum DefaultAccountStatus { const CONTEXT_DEFAULT_ACCOUNT_STATE = new RawContextKey('defaultAccountStatus', DefaultAccountStatus.Uninitialized); const CACHED_POLICY_DATA_KEY = 'defaultAccount.cachedPolicyData'; -const ACCOUNT_DATA_POLL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes +const ACCOUNT_DATA_POLL_INTERVAL_MS = 60 * 60 * 1000; // 1 hour interface ITokenEntitlementsResponse { token: string; @@ -69,7 +69,7 @@ interface ITokenEntitlementsResponse { interface IMcpRegistryProvider { readonly url: string; readonly registry_access: 'allow_all' | 'registry_only'; - readonly owner: { + readonly owner?: { readonly login: string; readonly id: number; readonly type: string; @@ -189,9 +189,12 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount interface IAccountPolicyData { readonly accountId: string; readonly policyData: IPolicyData; + readonly tokenEntitlementsFetchedAt?: number; + readonly mcpRegistryDataFetchedAt?: number; } interface IDefaultAccountData { + accountId: string; defaultAccount: IDefaultAccount; policyData: IAccountPolicyData | null; } @@ -226,7 +229,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid private initialized = false; private readonly initPromise: Promise; private readonly updateThrottler = this._register(new ThrottledDelayer(100)); - private readonly accountDataPollScheduler = this._register(new RunOnceScheduler(() => this.updateDefaultAccount(), ACCOUNT_DATA_POLL_INTERVAL_MS)); + private readonly accountDataPollScheduler = this._register(new RunOnceScheduler(() => this.refetchDefaultAccount(), ACCOUNT_DATA_POLL_INTERVAL_MS)); constructor( private readonly defaultAccountConfig: IDefaultAccountConfig, @@ -330,11 +333,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid })); this._register(this.hostService.onDidChangeFocus(focused => { - if (focused && this._defaultAccount) { - // Update default account when window gets focused - this.accountDataPollScheduler.cancel(); - this.logService.debug('[DefaultAccount] Window focused, updating default account'); - this.updateDefaultAccount(); + if (focused) { + this.refetchDefaultAccount(true); } })); } @@ -346,17 +346,31 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Refreshing default account'); + await this.updateDefaultAccount(); return this.defaultAccount; } - private async updateDefaultAccount(): Promise { - await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount()); + private async refetchDefaultAccount(useExistingEntitlements?: boolean): Promise { + if (this.accountDataPollScheduler.isScheduled()) { + this.accountDataPollScheduler.cancel(); + } + if (!this.hostService.hasFocus || !this._defaultAccount) { + this.scheduleAccountDataPoll(); + this.logService.debug('[DefaultAccount] Skipping refetching default account. Host is not focused or default account is not set'); + return; + } + this.logService.debug('[DefaultAccount] Refetching default account'); + await this.updateDefaultAccount(useExistingEntitlements); } - private async doUpdateDefaultAccount(): Promise { + private async updateDefaultAccount(useExistingEntitlements?: boolean): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(useExistingEntitlements)); + } + + private async doUpdateDefaultAccount(useExistingEntitlements: boolean = false): Promise { try { - const defaultAccount = await this.fetchDefaultAccount(); + const defaultAccount = await this.fetchDefaultAccount(useExistingEntitlements); this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -364,7 +378,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(): Promise { + private async fetchDefaultAccount(useExistingEntitlements: boolean): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -374,7 +388,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, useExistingEntitlements); } private setDefaultAccount(account: IDefaultAccountData | null): void { @@ -437,7 +451,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider): Promise { + private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, useExistingEntitlements: boolean): Promise { try { this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); const sessions = await this.findMatchingProviderSession(authenticationProvider.id, this.defaultAccountConfig.authenticationProvider.scopes); @@ -447,33 +461,42 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions); + return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, useExistingEntitlements); } catch (error) { this.logService.error('[DefaultAccount] Failed to get default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; } } - private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[]): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], useExistingEntitlements: boolean): Promise { try { const accountId = sessions[0].account.id; - const [entitlementsData, tokenEntitlementsData] = await Promise.all([ - this.getEntitlements(sessions), - this.getTokenEntitlements(sessions), + const existingEntitlementsData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; + const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; + + const [entitlementsData, tokenEntitlementsResult] = await Promise.all([ + useExistingEntitlements && existingEntitlementsData ? existingEntitlementsData : this.getEntitlements(sessions), + this.getTokenEntitlements(sessions, accountPolicyData), ]); - let policyData: Mutable | undefined = this._policyData?.accountId === accountId ? { ...this._policyData.policyData } : undefined; - if (tokenEntitlementsData) { + let tokenEntitlementsFetchedAt: number | undefined; + let mcpRegistryDataFetchedAt: number | undefined; + let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; + if (tokenEntitlementsResult) { + tokenEntitlementsFetchedAt = tokenEntitlementsResult.fetchedAt; + const tokenEntitlementsData = tokenEntitlementsResult.data; policyData = policyData ?? {}; policyData.chat_agent_enabled = tokenEntitlementsData.chat_agent_enabled; policyData.chat_preview_features_enabled = tokenEntitlementsData.chat_preview_features_enabled; policyData.mcp = tokenEntitlementsData.mcp; if (policyData.mcp) { - const mcpRegistryProvider = await this.getMcpRegistryProvider(sessions); - if (mcpRegistryProvider) { - policyData.mcpRegistryUrl = mcpRegistryProvider.url; - policyData.mcpAccess = mcpRegistryProvider.registry_access; - } + const mcpRegistryResult = await this.getMcpRegistryProvider(sessions, accountPolicyData); + mcpRegistryDataFetchedAt = mcpRegistryResult?.fetchedAt; + policyData.mcpRegistryUrl = mcpRegistryResult?.data?.url; + policyData.mcpAccess = mcpRegistryResult?.data?.registry_access; + } else { + policyData.mcpRegistryUrl = undefined; + policyData.mcpAccess = undefined; } } @@ -484,7 +507,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid entitlementsData, }; this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); - return { defaultAccount, policyData: policyData ? { accountId, policyData } : null }; + return { defaultAccount, accountId, policyData: policyData ? { accountId, policyData, tokenEntitlementsFetchedAt, mcpRegistryDataFetchedAt } : null }; } catch (error) { this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; @@ -539,7 +562,16 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(sessions: AuthenticationSession[]): Promise | undefined> { + private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: Partial; fetchedAt: number } | undefined> { + if (accountPolicyData?.tokenEntitlementsFetchedAt && !this.isDataStale(accountPolicyData.tokenEntitlementsFetchedAt)) { + this.logService.debug('[DefaultAccount] Using last fetched token entitlements data'); + return { data: accountPolicyData.policyData, fetchedAt: accountPolicyData.tokenEntitlementsFetchedAt }; + } + const data = await this.requestTokenEntitlements(sessions); + return data ? { data, fetchedAt: Date.now() } : undefined; + } + + private async requestTokenEntitlements(sessions: AuthenticationSession[]): Promise | undefined> { const tokenEntitlementsUrl = this.getTokenEntitlementUrl(); if (!tokenEntitlementsUrl) { this.logService.debug('[DefaultAccount] No token entitlements URL found'); @@ -610,11 +642,21 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { + private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise<{ data: IMcpRegistryProvider | null; fetchedAt: number } | undefined> { + if (accountPolicyData?.mcpRegistryDataFetchedAt && !this.isDataStale(accountPolicyData.mcpRegistryDataFetchedAt)) { + this.logService.debug('[DefaultAccount] Using last fetched MCP registry data'); + const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; + } + const data = await this.requestMcpRegistryProvider(sessions); + return !isUndefined(data) ? { data, fetchedAt: Date.now() } : undefined; + } + + private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { const mcpRegistryDataUrl = this.getMcpRegistryDataUrl(); if (!mcpRegistryDataUrl) { this.logService.debug('[DefaultAccount] No MCP registry data URL found'); - return undefined; + return null; } this.logService.debug('[DefaultAccount] Fetching MCP registry data from:', mcpRegistryDataUrl); @@ -623,8 +665,12 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - if (response.res.statusCode && response.res.statusCode !== 200) { - this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); + if (!isSuccess(response)) { + if (isClientError(response)) { + this.logService.debug(`[DefaultAccount] Received ${response.res.statusCode} for MCP registry data, treating as no registry available.`); + return null; + } + this.logService.debug(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); return undefined; } @@ -632,13 +678,14 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const data = await asJson(response); if (data) { this.logService.debug('Fetched MCP registry providers', data.mcp_registries); - return data.mcp_registries[0]; + return data.mcp_registries[0] ?? null; } - this.logService.debug('Failed to fetch MCP registry providers', 'No data returned'); + this.logService.debug('No MCP registry providers content found in response'); + return null; } catch (error) { this.logService.error('Failed to fetch MCP registry providers', getErrorMessage(error)); + return undefined; } - return undefined; } private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise; @@ -663,7 +710,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid }, token); const status = response.res.statusCode; - if (status && status !== 200) { + if (status === 401 || status === 404) { + this.logService.debug(`[DefaultAccount] Received ${status} for URL ${url} with session ${session.id}, likely due to expired/revoked token or insufficient permissions.`, 'Trying next session if available.'); lastResponse = response; continue; // try next session } @@ -671,7 +719,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return response; } catch (error) { if (!token.isCancellationRequested) { - this.logService.error(`[chat entitlement] request: error ${error}`); + this.logService.error(`[DefaultAccount] request: error ${error}`, url); } } } @@ -681,14 +729,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - if (lastResponse.res.statusCode && lastResponse.res.statusCode !== 200) { - this.logService.trace(`[DefaultAccount]: unexpected status code ${lastResponse.res.statusCode} for request`, url); - return undefined; - } - return lastResponse; } + private isDataStale(fetchedAt: number): boolean { + return (Date.now() - fetchedAt) >= ACCOUNT_DATA_POLL_INTERVAL_MS; + } + private getEntitlementUrl(): string | undefined { if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { diff --git a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts index 0b7630309b9..ad7aa09f9ea 100644 --- a/src/vs/workbench/services/actions/common/menusExtensionPoint.ts +++ b/src/vs/workbench/services/actions/common/menusExtensionPoint.ts @@ -514,6 +514,12 @@ const apiMenus: IAPIMenu[] = [ description: localize('menus.chatContextUsageActions', "Actions in the chat context usage details popup."), proposed: 'chatParticipantAdditions' }, + { + key: 'chat/newSession', + id: MenuId.ChatNewMenu, + description: localize('menus.chatNewSession', "The Chat new session menu."), + proposed: 'chatSessionsProvider' + }, ]; namespace schema { diff --git a/src/vs/workbench/services/commands/common/commandService.ts b/src/vs/workbench/services/commands/common/commandService.ts index 05aea74d96d..9e38f1ff9d2 100644 --- a/src/vs/workbench/services/commands/common/commandService.ts +++ b/src/vs/workbench/services/commands/common/commandService.ts @@ -22,7 +22,7 @@ export class CommandService extends Disposable implements ICommandService { private readonly _onWillExecuteCommand: Emitter = this._register(new Emitter()); public readonly onWillExecuteCommand: Event = this._onWillExecuteCommand.event; - private readonly _onDidExecuteCommand: Emitter = new Emitter(); + private readonly _onDidExecuteCommand = this._register(new Emitter()); public readonly onDidExecuteCommand: Event = this._onDidExecuteCommand.event; constructor( diff --git a/src/vs/workbench/services/dialogs/browser/abstractFileDialogService.ts b/src/vs/workbench/services/dialogs/browser/abstractFileDialogService.ts index bb082edfd8d..6300496bd4e 100644 --- a/src/vs/workbench/services/dialogs/browser/abstractFileDialogService.ts +++ b/src/vs/workbench/services/dialogs/browser/abstractFileDialogService.ts @@ -32,6 +32,7 @@ import { ICodeEditorService } from '../../../../editor/browser/services/codeEdit import { IEditorService } from '../../editor/common/editorService.js'; import { EditorOpenSource } from '../../../../platform/editor/common/editor.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; export abstract class AbstractFileDialogService implements IFileDialogService { @@ -54,7 +55,8 @@ export abstract class AbstractFileDialogService implements IFileDialogService { @ICommandService protected readonly commandService: ICommandService, @IEditorService protected readonly editorService: IEditorService, @ICodeEditorService protected readonly codeEditorService: ICodeEditorService, - @ILogService private readonly logService: ILogService + @ILogService private readonly logService: ILogService, + @IRemoteAgentService private readonly remoteAgentService: IRemoteAgentService ) { } async defaultFilePath(schemeFilter = this.getSchemeFilterForWindow(), authorityFilter = this.getAuthorityFilterForWindow()): Promise { @@ -62,6 +64,12 @@ export abstract class AbstractFileDialogService implements IFileDialogService { // Check for last active file first... let candidate = this.historyService.getLastActiveFile(schemeFilter, authorityFilter); + // Skip user data files (e.g. Machine/settings.json) as default path candidates + if (candidate && await this.isRemoteUserData(candidate)) { + this.logService.debug(`[FileDialogService] Skipping last active file as it is a remote user data resource: ${candidate}`); + candidate = undefined; + } + // ...then for last active file root if (!candidate) { candidate = this.historyService.getLastActiveWorkspaceRoot(schemeFilter, authorityFilter); @@ -89,6 +97,13 @@ export abstract class AbstractFileDialogService implements IFileDialogService { // ...then for last active file if (!candidate) { candidate = this.historyService.getLastActiveFile(schemeFilter, authorityFilter); + + // Skip user data files (e.g. Machine/settings.json) as default path candidates + if (candidate && await this.isRemoteUserData(candidate)) { + this.logService.debug(`[FileDialogService] Skipping last active file as it is a remote user data resource: ${candidate}`); + candidate = undefined; + } + if (candidate) { this.logService.debug(`[FileDialogService] Default folder path using parent of last active file: ${candidate}`); } @@ -318,6 +333,28 @@ export abstract class AbstractFileDialogService implements IFileDialogService { return this.getSimpleFileDialog().showSaveDialog(options); } + /** + * Checks whether the given resource is a remote user data file + * that should not be used as a default file dialog path candidate. + * This covers remote user data files such as settings.json, keybindings.json, etc. + */ + private async isRemoteUserData(resource: URI): Promise { + if (!this.environmentService.remoteAuthority) { + return false; + } + + const remoteEnv = await this.remoteAgentService.getEnvironment(); + if (remoteEnv) { + + const remoteDataHome = resources.dirname(resources.dirname(remoteEnv.settingsPath)); + if (!resources.isEqual(remoteDataHome, remoteDataHome.with({ path: '/' })) && resources.isEqualOrParent(resource, remoteDataHome)) { + return true; + } + } + + return false; + } + private getSchemeFilterForWindow(defaultUriScheme?: string): string { return defaultUriScheme ?? this.pathService.defaultUriScheme; } diff --git a/src/vs/workbench/services/dialogs/electron-browser/fileDialogService.ts b/src/vs/workbench/services/dialogs/electron-browser/fileDialogService.ts index f2730dc421c..309436ec7ed 100644 --- a/src/vs/workbench/services/dialogs/electron-browser/fileDialogService.ts +++ b/src/vs/workbench/services/dialogs/electron-browser/fileDialogService.ts @@ -27,6 +27,7 @@ import { ICodeEditorService } from '../../../../editor/browser/services/codeEdit import { IEditorService } from '../../editor/common/editorService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { getActiveWindow } from '../../../../base/browser/dom.js'; +import { IRemoteAgentService } from '../../remote/common/remoteAgentService.js'; export class FileDialogService extends AbstractFileDialogService implements IFileDialogService { @@ -48,10 +49,11 @@ export class FileDialogService extends AbstractFileDialogService implements IFil @ICommandService commandService: ICommandService, @IEditorService editorService: IEditorService, @ICodeEditorService codeEditorService: ICodeEditorService, - @ILogService logService: ILogService + @ILogService logService: ILogService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService ) { super(hostService, contextService, historyService, environmentService, instantiationService, - configurationService, fileService, openerService, dialogService, languageService, workspacesService, labelService, pathService, commandService, editorService, codeEditorService, logService); + configurationService, fileService, openerService, dialogService, languageService, workspacesService, labelService, pathService, commandService, editorService, codeEditorService, logService, remoteAgentService); } private toNativeOpenDialogOptions(options: IPickAndOpenOptions): INativeOpenDialogOptions { diff --git a/src/vs/workbench/services/dialogs/test/electron-browser/fileDialogService.test.ts b/src/vs/workbench/services/dialogs/test/electron-browser/fileDialogService.test.ts index f97f6a1d418..8f55c547ee9 100644 --- a/src/vs/workbench/services/dialogs/test/electron-browser/fileDialogService.test.ts +++ b/src/vs/workbench/services/dialogs/test/electron-browser/fileDialogService.test.ts @@ -35,6 +35,7 @@ import { IPathService } from '../../../path/common/pathService.js'; import { BrowserWorkspaceEditingService } from '../../../workspaces/browser/workspaceEditingService.js'; import { IWorkspaceEditingService } from '../../../workspaces/common/workspaceEditing.js'; import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { IRemoteAgentService } from '../../../remote/common/remoteAgentService.js'; class TestFileDialogService extends FileDialogService { constructor( @@ -56,10 +57,11 @@ class TestFileDialogService extends FileDialogService { @ICommandService commandService: ICommandService, @IEditorService editorService: IEditorService, @ICodeEditorService codeEditorService: ICodeEditorService, - @ILogService logService: ILogService + @ILogService logService: ILogService, + @IRemoteAgentService remoteAgentService: IRemoteAgentService ) { super(hostService, contextService, historyService, environmentService, instantiationService, configurationService, fileService, - openerService, nativeHostService, dialogService, languageService, workspacesService, labelService, pathService, commandService, editorService, codeEditorService, logService); + openerService, nativeHostService, dialogService, languageService, workspacesService, labelService, pathService, commandService, editorService, codeEditorService, logService, remoteAgentService); } protected override getSimpleFileDialog() { diff --git a/src/vs/workbench/services/editor/common/editorGroupFinder.ts b/src/vs/workbench/services/editor/common/editorGroupFinder.ts index 1b93908f2f7..825061a3663 100644 --- a/src/vs/workbench/services/editor/common/editorGroupFinder.ts +++ b/src/vs/workbench/services/editor/common/editorGroupFinder.ts @@ -8,7 +8,7 @@ import { EditorActivation } from '../../../../platform/editor/common/editor.js'; import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { EditorInputWithOptions, isEditorInputWithOptions, IUntypedEditorInput, isEditorInput, EditorInputCapabilities } from '../../../common/editor.js'; import { EditorInput } from '../../../common/editor/editorInput.js'; -import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService } from './editorGroupsService.js'; +import { IEditorGroup, GroupsOrder, preferredSideBySideGroupDirection, IEditorGroupsService, IModalEditorPart } from './editorGroupsService.js'; import { AUX_WINDOW_GROUP, AUX_WINDOW_GROUP_TYPE, MODAL_GROUP, MODAL_GROUP_TYPE, PreferredGroup, SIDE_GROUP } from './editorService.js'; /** @@ -29,12 +29,39 @@ export function findGroup(accessor: ServicesAccessor, editor: EditorInputWithOpt const group = doFindGroup(editor, preferredGroup, editorGroupService, configurationService); if (group instanceof Promise) { - return group.then(group => handleGroupActivation(group, editor, preferredGroup, editorGroupService)); + return group.then(group => handleGroupResult(group, editor, preferredGroup, editorGroupService)); + } + + return handleGroupResult(group, editor, preferredGroup, editorGroupService); +} + +function handleGroupResult(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService): [IEditorGroup, EditorActivation | undefined] { + const modalEditorPart = editorGroupService.activeModalEditorPart; + if (modalEditorPart && preferredGroup !== MODAL_GROUP) { + // Only allow to open in modal group if MODAL_GROUP is explicitly requested + group = handleModalEditorPart(group, editor, modalEditorPart, editorGroupService); } return handleGroupActivation(group, editor, preferredGroup, editorGroupService); } +function handleModalEditorPart(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, modalEditorPart: IModalEditorPart, editorGroupService: IEditorGroupsService): IEditorGroup { + const options = editor.options; + + // If the resolved group is part of the modal, redirect + // to the main window active group instead + if (modalEditorPart.groups.some(modalGroup => modalGroup.id === group.id)) { + group = editorGroupService.mainPart.activeGroup; + } + + // Try to close the modal editor part unless preserveFocus is set + if (!options?.preserveFocus) { + modalEditorPart.close(); + } + + return group; +} + function handleGroupActivation(group: IEditorGroup, editor: EditorInputWithOptions | IUntypedEditorInput, preferredGroup: PreferredGroup | undefined, editorGroupService: IEditorGroupsService): [IEditorGroup, EditorActivation | undefined] { // Resolve editor activation strategy diff --git a/src/vs/workbench/services/editor/common/editorGroupsService.ts b/src/vs/workbench/services/editor/common/editorGroupsService.ts index a67469b8609..d7a24923b9e 100644 --- a/src/vs/workbench/services/editor/common/editorGroupsService.ts +++ b/src/vs/workbench/services/editor/common/editorGroupsService.ts @@ -510,6 +510,21 @@ export interface IModalEditorPart extends IEditorPart { */ readonly onWillClose: Event; + /** + * Whether the modal editor part is currently maximized. + */ + readonly maximized: boolean; + + /** + * Fired when the maximized state changes. + */ + readonly onDidChangeMaximized: Event; + + /** + * Toggle between default and maximized size. + */ + toggleMaximized(): void; + /** * Close this modal editor part after moving all * editors of all groups back to the main editor part @@ -595,6 +610,11 @@ export interface IEditorGroupsService extends IEditorGroupsContainer { */ createModalEditorPart(): Promise; + /** + * The currently active modal editor part, if any. + */ + readonly activeModalEditorPart: IModalEditorPart | undefined; + /** * Returns the instantiation service that is scoped to the * provided editor part. Use this method when building UI diff --git a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts index c20d6b95d32..f33d165e59f 100644 --- a/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts +++ b/src/vs/workbench/services/editor/test/browser/modalEditorGroup.test.ts @@ -15,6 +15,7 @@ import { SideBySideEditorInput } from '../../../../common/editor/sideBySideEdito import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { MODAL_GROUP, MODAL_GROUP_TYPE } from '../../common/editorService.js'; +import { findGroup } from '../../common/editorGroupFinder.js'; suite('Modal Editor Group', () => { @@ -372,5 +373,136 @@ suite('Modal Editor Group', () => { assert.strictEqual(removedGroupId, modalGroupId); }); + test('activeModalEditorPart is set when modal is created and cleared on close', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // No modal initially + assert.strictEqual(parts.activeModalEditorPart, undefined); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + assert.strictEqual(parts.activeModalEditorPart, modalPart); + + // Close modal + modalPart.close(); + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + + test('findGroup returns main part group when modal is active and preferredGroup is not MODAL_GROUP', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const mainGroup = parts.mainPart.activeGroup; + + // Create modal and open an editor in it + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + // findGroup without MODAL_GROUP should return main part group, not modal group + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + const [group] = instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource }, undefined)); + + assert.strictEqual(group.id, mainGroup.id); + }); + + test('findGroup closes modal when preferredGroup is not MODAL_GROUP and preserveFocus is not set', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.ok(parts.activeModalEditorPart); + + // findGroup without MODAL_GROUP and without preserveFocus should close the modal + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource }, undefined)); + + assert.strictEqual(parts.activeModalEditorPart, undefined); + }); + + test('findGroup keeps modal open when preserveFocus is true', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + // Create modal + const modalPart = await parts.createModalEditorPart(); + const input = createTestFileEditorInput(URI.file('foo/bar'), TEST_EDITOR_INPUT_ID); + await modalPart.activeGroup.openEditor(input, { pinned: true }); + + assert.ok(parts.activeModalEditorPart); + + // findGroup with preserveFocus should keep the modal open + const newInput = createTestFileEditorInput(URI.file('foo/baz'), TEST_EDITOR_INPUT_ID); + instantiationService.invokeFunction(accessor => findGroup(accessor, { resource: newInput.resource, options: { preserveFocus: true } }, undefined)); + + assert.strictEqual(parts.activeModalEditorPart, modalPart); + + modalPart.close(); + }); + + test('modal editor part starts not maximized', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.strictEqual(modalPart.maximized, false); + + modalPart.close(); + }); + + test('modal editor part toggleMaximized toggles state', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + assert.strictEqual(modalPart.maximized, false); + + modalPart.toggleMaximized(); + assert.strictEqual(modalPart.maximized, true); + + modalPart.toggleMaximized(); + assert.strictEqual(modalPart.maximized, false); + + modalPart.close(); + }); + + test('modal editor part fires onDidChangeMaximized', async () => { + const instantiationService = workbenchInstantiationService({ contextKeyService: instantiationService => instantiationService.createInstance(MockScopableContextKeyService) }, disposables); + instantiationService.invokeFunction(accessor => Registry.as(EditorExtensions.EditorFactory).start(accessor)); + const parts = await createEditorParts(instantiationService, disposables); + instantiationService.stub(IEditorGroupsService, parts); + + const modalPart = await parts.createModalEditorPart(); + + const events: boolean[] = []; + disposables.add(modalPart.onDidChangeMaximized(maximized => events.push(maximized))); + + modalPart.toggleMaximized(); + modalPart.toggleMaximized(); + + assert.deepStrictEqual(events, [true, false]); + + modalPart.close(); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts index d4c780d04ea..7ab99c17744 100644 --- a/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts +++ b/src/vs/workbench/services/extensionManagement/browser/extensionEnablementService.ts @@ -44,7 +44,7 @@ export class ExtensionEnablementService extends Disposable implements IWorkbench declare readonly _serviceBrand: undefined; - private readonly _onEnablementChanged = new Emitter(); + private readonly _onEnablementChanged = this._register(new Emitter()); public readonly onEnablementChanged: Event = this._onEnablementChanged.event; protected readonly extensionsManager: ExtensionsManager; diff --git a/src/vs/workbench/services/keybinding/browser/keyboardLayoutService.ts b/src/vs/workbench/services/keybinding/browser/keyboardLayoutService.ts index 2fa44d2bbc5..4c347a65ce3 100644 --- a/src/vs/workbench/services/keybinding/browser/keyboardLayoutService.ts +++ b/src/vs/workbench/services/keybinding/browser/keyboardLayoutService.ts @@ -36,7 +36,7 @@ export class BrowserKeyboardMapperFactoryBase extends Disposable { // keyboard mapper protected _initialized: boolean; protected _keyboardMapper: IKeyboardMapper | null; - private readonly _onDidChangeKeyboardMapper = new Emitter(); + private readonly _onDidChangeKeyboardMapper = this._register(new Emitter()); public readonly onDidChangeKeyboardMapper: Event = this._onDidChangeKeyboardMapper.event; // keymap infos @@ -520,7 +520,7 @@ class UserKeyboardLayout extends Disposable { export class BrowserKeyboardLayoutService extends Disposable implements IKeyboardLayoutService { public _serviceBrand: undefined; - private readonly _onDidChangeKeyboardLayout = new Emitter(); + private readonly _onDidChangeKeyboardLayout = this._register(new Emitter()); public readonly onDidChangeKeyboardLayout: Event = this._onDidChangeKeyboardLayout.event; private _userKeyboardLayout: UserKeyboardLayout; diff --git a/src/vs/workbench/services/preferences/common/preferences.ts b/src/vs/workbench/services/preferences/common/preferences.ts index 4fc67f48798..106103bdd9f 100644 --- a/src/vs/workbench/services/preferences/common/preferences.ts +++ b/src/vs/workbench/services/preferences/common/preferences.ts @@ -64,6 +64,7 @@ export interface ISetting { value: any; valueRange: IRange; description: string[]; + keywords?: string[]; descriptionIsMarkdown?: boolean; descriptionRanges: IRange[]; overrides?: ISetting[]; diff --git a/src/vs/workbench/services/preferences/common/preferencesModels.ts b/src/vs/workbench/services/preferences/common/preferencesModels.ts index 3dc1900f41a..a6e78cc1fa0 100644 --- a/src/vs/workbench/services/preferences/common/preferencesModels.ts +++ b/src/vs/workbench/services/preferences/common/preferencesModels.ts @@ -723,6 +723,7 @@ export class DefaultSettings extends Disposable { value, description: descriptionLines, descriptionIsMarkdown: !!prop.markdownDescription, + keywords: prop.keywords, range: nullRange, keyRange: nullRange, valueRange: nullRange, diff --git a/src/vs/workbench/services/remote/common/tunnelModel.ts b/src/vs/workbench/services/remote/common/tunnelModel.ts index 469d8f1b4a9..73858926257 100644 --- a/src/vs/workbench/services/remote/common/tunnelModel.ts +++ b/src/vs/workbench/services/remote/common/tunnelModel.ts @@ -196,7 +196,7 @@ export class PortsAttributes extends Disposable { private static HOST_AND_PORT = /^([a-z0-9\-]+):(\d{1,5})$/; private portsAttributes: PortAttributes[] = []; private defaultPortAttributes: Attributes | undefined; - private _onDidChangeAttributes = new Emitter(); + private _onDidChangeAttributes = this._register(new Emitter()); public readonly onDidChangeAttributes = this._onDidChangeAttributes.event; constructor(private readonly configurationService: IConfigurationService) { @@ -402,26 +402,26 @@ export class TunnelModel extends Disposable { private readonly inProgress: Map = new Map(); readonly detected: Map; private remoteTunnels: Map; - private _onForwardPort: Emitter = new Emitter(); - public onForwardPort: Event = this._onForwardPort.event; - private _onClosePort: Emitter<{ host: string; port: number }> = new Emitter(); - public onClosePort: Event<{ host: string; port: number }> = this._onClosePort.event; - private _onPortName: Emitter<{ host: string; port: number }> = new Emitter(); - public onPortName: Event<{ host: string; port: number }> = this._onPortName.event; + private _onForwardPort = this._register(new Emitter()); + public onForwardPort = this._onForwardPort.event; + private _onClosePort = this._register(new Emitter<{ host: string; port: number }>()); + public onClosePort = this._onClosePort.event; + private _onPortName = this._register(new Emitter<{ host: string; port: number }>()); + public onPortName = this._onPortName.event; private _candidates: Map | undefined; - private _onCandidatesChanged: Emitter> = new Emitter(); + private _onCandidatesChanged = this._register(new Emitter>()); // onCandidateChanged returns the removed candidates - public onCandidatesChanged: Event> = this._onCandidatesChanged.event; + public onCandidatesChanged = this._onCandidatesChanged.event; private _candidateFilter: ((candidates: CandidatePort[]) => Promise) | undefined; private tunnelRestoreValue: Promise; - private _onEnvironmentTunnelsSet: Emitter = new Emitter(); - public onEnvironmentTunnelsSet: Event = this._onEnvironmentTunnelsSet.event; + private _onEnvironmentTunnelsSet = this._register(new Emitter()); + public onEnvironmentTunnelsSet = this._onEnvironmentTunnelsSet.event; private _environmentTunnelsSet: boolean = false; public readonly configPortsAttributes: PortsAttributes; private restoreListener: DisposableStore | undefined = undefined; private knownPortsRestoreValue: string | undefined; private restoreComplete = false; - private onRestoreComplete: Emitter = new Emitter(); + private onRestoreComplete = this._register(new Emitter()); private unrestoredExtensionTunnels: Map = new Map(); private sessionCachedProperties: Map> = new Map(); diff --git a/src/vs/workbench/services/search/browser/searchService.ts b/src/vs/workbench/services/search/browser/searchService.ts index c9011956dbb..6d047cba7f1 100644 --- a/src/vs/workbench/services/search/browser/searchService.ts +++ b/src/vs/workbench/services/search/browser/searchService.ts @@ -51,7 +51,7 @@ export class LocalFileSearchWorkerClient extends Disposable implements ISearchRe protected _worker: IWebWorkerClient | null; - private readonly _onDidReceiveTextSearchMatch = new Emitter<{ match: IFileMatch; queryId: number }>(); + private readonly _onDidReceiveTextSearchMatch = this._register(new Emitter<{ match: IFileMatch; queryId: number }>()); readonly onDidReceiveTextSearchMatch: Event<{ match: IFileMatch; queryId: number }> = this._onDidReceiveTextSearchMatch.event; private cache: { key: string; cache: ISearchComplete } | undefined; diff --git a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts index 6b5bc990d72..f031be8574d 100644 --- a/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts +++ b/src/vs/workbench/services/textMate/browser/backgroundTokenization/worker/textMateWorkerTokenizer.ts @@ -55,6 +55,7 @@ export class TextMateWorkerTokenizer extends MirrorTextModel { public override dispose(): void { this._isDisposed = true; + this._tokenizeDebouncer.dispose(); super.dispose(); } diff --git a/src/vs/workbench/services/themes/browser/colorThemeCss.ts b/src/vs/workbench/services/themes/browser/colorThemeCss.ts new file mode 100644 index 00000000000..0d597784a99 --- /dev/null +++ b/src/vs/workbench/services/themes/browser/colorThemeCss.ts @@ -0,0 +1,72 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IColorTheme, ICssStyleCollector, IThemingParticipant } from '../../../../platform/theme/common/themeService.js'; +import { asCssVariableName, getColorRegistry } from '../../../../platform/theme/common/colorRegistry.js'; +import { asCssVariableName as asSizeCssVariableName, getSizeRegistry, sizeValueToCss } from '../../../../platform/theme/common/sizeRegistry.js'; +import { IEnvironmentService } from '../../../../platform/environment/common/environment.js'; + +/** + * Generates CSS content (variables + theming participant rules) for a color theme. + * Pure function - no DOM side effects. + * + * @param theme The color theme to generate CSS for + * @param scopeSelector CSS selector to scope the rules (e.g. '.monaco-workbench') + * @param themingParticipants Functions that contribute additional CSS rules (optional) + * @param environmentService Passed to theming participants (required if themingParticipants is non-empty) + */ +export function generateColorThemeCSS( + theme: IColorTheme, + scopeSelector: string, + themingParticipants?: readonly IThemingParticipant[], + environmentService?: IEnvironmentService +): CSSValue { + const cssRules = new Set(); + const ruleCollector: ICssStyleCollector = { + addRule: (rule: string) => { + if (!cssRules.has(rule)) { + cssRules.add(rule); + } + } + }; + + // Base rule + ruleCollector.addRule(`${scopeSelector} { forced-color-adjust: none; }`); + + // Theming participant rules + if (themingParticipants && environmentService) { + for (const participant of themingParticipants) { + participant(theme, ruleCollector, environmentService); + } + } + + // Color CSS variables + const variables: string[] = []; + for (const item of getColorRegistry().getColors()) { + const color = theme.getColor(item.id, true); + if (color) { + variables.push(`${asCssVariableName(item.id)}: ${color.toString()};`); + } + } + + // Size CSS variables + for (const item of getSizeRegistry().getSizes()) { + const sizeValue = getSizeRegistry().resolveDefaultSize(item.id, theme); + if (sizeValue) { + variables.push(`${asSizeCssVariableName(item.id)}: ${sizeValueToCss(sizeValue)};`); + } + } + + ruleCollector.addRule(`${scopeSelector} { ${variables.join('\n')} }`); + + return new CSSValue([...cssRules].join('\n')); +} + +/** + * A typed wrapper for CSS content + */ +export class CSSValue { + constructor(readonly code: string) { } +} diff --git a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts index e286ec94d86..8884c7958f8 100644 --- a/src/vs/workbench/services/themes/browser/workbenchThemeService.ts +++ b/src/vs/workbench/services/themes/browser/workbenchThemeService.ts @@ -39,10 +39,10 @@ import { IHostColorSchemeService } from '../common/hostColorSchemeService.js'; import { RunOnceScheduler, Sequencer } from '../../../../base/common/async.js'; import { IUserDataInitializationService } from '../../userData/browser/userDataInit.js'; import { getIconsStyleSheet } from '../../../../platform/theme/browser/iconsStyleSheet.js'; -import { asCssVariableName, getColorRegistry } from '../../../../platform/theme/common/colorRegistry.js'; -import { asCssVariableName as asSizeCssVariableName, getSizeRegistry, sizeValueToCss } from '../../../../platform/theme/common/sizeRegistry.js'; +import { getColorRegistry } from '../../../../platform/theme/common/colorRegistry.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; import { mainWindow } from '../../../../base/browser/window.js'; +import { generateColorThemeCSS } from './colorThemeCss.js'; // implementation @@ -460,36 +460,13 @@ export class WorkbenchThemeService extends Disposable implements IWorkbenchTheme } private updateDynamicCSSRules(themeData: IColorTheme) { - const cssRules = new Set(); - const ruleCollector = { - addRule: (rule: string) => { - if (!cssRules.has(rule)) { - cssRules.add(rule); - } - } - }; - ruleCollector.addRule(`.monaco-workbench { forced-color-adjust: none; }`); - themingRegistry.getThemingParticipants().forEach(p => p(themeData, ruleCollector, this.environmentService)); - - const colorVariables: string[] = []; - for (const item of getColorRegistry().getColors()) { - const color = themeData.getColor(item.id, true); - if (color) { - colorVariables.push(`${asCssVariableName(item.id)}: ${color.toString()};`); - } - } - - const sizeVariables: string[] = []; - for (const item of getSizeRegistry().getSizes()) { - const sizeValue = getSizeRegistry().resolveDefaultSize(item.id, themeData); - if (sizeValue) { - sizeVariables.push(`${asSizeCssVariableName(item.id)}: ${sizeValueToCss(sizeValue)};`); - } - } - - ruleCollector.addRule(`.monaco-workbench { ${(colorVariables.concat(sizeVariables)).join('\n')} }`); - - _applyRules([...cssRules].join('\n'), colorThemeRulesClassName); + const css = generateColorThemeCSS( + themeData, + '.monaco-workbench', + themingRegistry.getThemingParticipants(), + this.environmentService + ); + _applyRules(css.code, colorThemeRulesClassName); } private applyTheme(newTheme: ColorThemeData, settingsTarget: ThemeSettingTarget, silent = false): Promise { diff --git a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts index 028e88c2a29..aa8af78316c 100644 --- a/src/vs/workbench/services/themes/common/themeExtensionPoints.ts +++ b/src/vs/workbench/services/themes/common/themeExtensionPoints.ts @@ -185,6 +185,7 @@ export class ThemeRegistry implements IDisposable { dispose() { this.themesExtPoint.setHandler(() => { }); + this.onDidChangeEmitter.dispose(); } private initialize() { diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index a2b918d627a..ba91f4f6ac8 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -924,6 +924,7 @@ export class TestEditorGroupsService implements IEditorGroupsService { enforcePartOptions(options: IEditorPartOptions): IDisposable { return Disposable.None; } readonly mainPart = this; + readonly activeModalEditorPart: IModalEditorPart | undefined = undefined; registerEditorPart(part: any): IDisposable { return Disposable.None; } createAuxiliaryEditorPart(): Promise { throw new Error('Method not implemented.'); } createModalEditorPart(): Promise { throw new Error('Method not implemented.'); } @@ -1639,6 +1640,7 @@ export class TestEditorPart extends MainEditorPart implements IEditorGroupsServi readonly mainPart = this; readonly parts: readonly IEditorPart[] = [this]; + readonly activeModalEditorPart: IModalEditorPart | undefined = undefined; readonly onDidCreateAuxiliaryEditorPart: Event = Event.None; diff --git a/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts b/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts index d9e6583c093..e7cd493ae50 100644 --- a/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts @@ -24,7 +24,9 @@ declare module 'vscode' { export function registerChatWorkspaceContextProvider(id: string, provider: ChatWorkspaceContextProvider): Disposable; /** - * Register a chat explicit context provider. Explicit context items are shown as options when the user explicitly attaches context. + * Register a chat explicit context provider. Explicit context items are shown as options when the user explicitly attaches context use the "Attache Context" action in the chat input box. + * + * Explicit context providers should also be statically contributed in package.json using the `chatContext` contribution point. * * To ensure your extension is activated when chat context is requested, make sure to include the `onChatContextProvider:` activation event in your `package.json`. * @@ -109,7 +111,12 @@ declare module 'vscode' { * * @param token A cancellation token. */ - provideChatContext(token: CancellationToken): ProviderResult; + provideWorkspaceChatContext(token: CancellationToken): ProviderResult; + + /** + * @deprecated + */ + provideChatContext?(token: CancellationToken): ProviderResult; } export interface ChatExplicitContextProvider { @@ -121,7 +128,12 @@ declare module 'vscode' { * * @param token A cancellation token. */ - provideChatContext(token: CancellationToken): ProviderResult; + provideExplicitChatContext(token: CancellationToken): ProviderResult; + + /** + * @deprecated + */ + provideChatContext?(token: CancellationToken): ProviderResult; /** * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. @@ -129,7 +141,12 @@ declare module 'vscode' { * @param context The context item to resolve. * @param token A cancellation token. */ - resolveChatContext(context: T, token: CancellationToken): ProviderResult; + resolveExplicitChatContext(context: T, token: CancellationToken): ProviderResult; + + /** + * @deprecated + */ + resolveChatContext?(context: T, token: CancellationToken): ProviderResult; } export interface ChatResourceContextProvider { @@ -144,7 +161,12 @@ declare module 'vscode' { * @param options Options include the resource for which to provide context. * @param token A cancellation token. */ - provideChatContext(options: { resource: Uri }, token: CancellationToken): ProviderResult; + provideResourceChatContext(options: { resource: Uri }, token: CancellationToken): ProviderResult; + + /** + * @deprecated + */ + provideChatContext?(options: { resource: Uri }, token: CancellationToken): ProviderResult; /** * If a chat context item is provided without a `value`, this method is called to resolve the `value` for the item. @@ -152,7 +174,12 @@ declare module 'vscode' { * @param context The context item to resolve. * @param token A cancellation token. */ - resolveChatContext(context: T, token: CancellationToken): ProviderResult; + resolveResourceChatContext(context: T, token: CancellationToken): ProviderResult; + + /** + * @deprecated + */ + resolveChatContext?(context: T, token: CancellationToken): ProviderResult; } /** @@ -168,19 +195,19 @@ declare module 'vscode' { /** * Provide a list of chat context items to be included as workspace context for all chat requests. - * @deprecated Use {@link ChatWorkspaceContextProvider.provideChatContext} instead. + * @deprecated Use {@link ChatWorkspaceContextProvider.provideWorkspaceChatContext} instead. */ provideWorkspaceChatContext?(token: CancellationToken): ProviderResult; /** * Provide a list of chat context items that a user can choose from. - * @deprecated Use {@link ChatExplicitContextProvider.provideChatContext} instead. + * @deprecated Use {@link ChatExplicitContextProvider.provideExplicitChatContext} instead. */ provideChatContextExplicit?(token: CancellationToken): ProviderResult; /** * Given a particular resource, provide a chat context item for it. - * @deprecated Use {@link ChatResourceContextProvider.provideChatContext} instead. + * @deprecated Use {@link ChatResourceContextProvider.provideResourceChatContext} instead. */ provideChatContextForResource?(options: { resource: Uri }, token: CancellationToken): ProviderResult; diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index a222ea6ef69..eec28002b77 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -3,65 +3,124 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 2 +// version: 6 declare module 'vscode' { /** * The type of hook to execute. */ - export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'SubagentStart' | 'SubagentStop' | 'Stop'; + export type ChatHookType = 'SessionStart' | 'UserPromptSubmit' | 'PreToolUse' | 'PostToolUse' | 'PreCompact' | 'SubagentStart' | 'SubagentStop' | 'Stop'; /** - * Options for executing a hook command. + * A resolved hook command ready for execution. + * The command has already been resolved for the current platform. */ - export interface ChatHookExecutionOptions { + export interface ChatHookCommand { /** - * Input data to pass to the hook via stdin (will be JSON-serialized). + * The shell command to execute, already resolved for the current platform. */ - readonly input?: unknown; + readonly command: string; /** - * The tool invocation token from the chat request context, - * used to associate the hook execution with the current chat session. + * Working directory for the command. */ - readonly toolInvocationToken: ChatParticipantToolToken; + readonly cwd?: Uri; + /** + * Additional environment variables for the command. + */ + readonly env?: Record; + /** + * Maximum execution time in seconds. + */ + readonly timeout?: number; } + /** + * Collected hooks for a chat request, organized by hook type. + */ + export interface ChatRequestHooks { + readonly [hookType: string]: readonly ChatHookCommand[]; + } + + /** + * The kind of result from executing a hook command. + * - 'success': Hook executed successfully (exit code 0) + * - 'error': Blocking error shown to model (exit code 2) + * - 'warning': Non-blocking warning shown to user only (other exit codes) + */ + export type ChatHookResultKind = 'success' | 'error' | 'warning'; + /** * Result of executing a hook command. * Contains common flow control fields and the hook's output. */ export interface ChatHookResult { + /** + * The kind of result from executing the hook. + */ + readonly resultKind: ChatHookResultKind; /** * If set, the agent should stop processing entirely after this hook. * The message is shown to the user but not to the agent. */ readonly stopReason?: string; /** - * Message shown to the user. + * Warning message shown to the user. */ - readonly messageForUser?: string; + readonly warningMessage?: string; /** * The hook's output (hook-specific fields only). * For errors, this is the error message string. */ readonly output: unknown; - /** - * Whether the hook command executed successfully (exit code 0). - */ - readonly success: boolean; } - export namespace chat { + export interface ChatRequest { /** - * Execute all hooks of the specified type for the current chat session. - * Hooks are configured in hooks.json files in the workspace. - * - * @param hookType The type of hook to execute. - * @param options Hook execution options including the input data. - * @param token Optional cancellation token. - * @returns A promise that resolves to an array of hook execution results. + * Resolved hook commands for this request, organized by hook type. + * The commands have already been resolved for the current platform. + * Only present when hooks are enabled. */ - export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable; + readonly hooks?: ChatRequestHooks; + } + + /** + * A progress part representing the execution result of a hook. + * Hooks are user-configured scripts that run at specific points during chat processing. + * If {@link stopReason} is set, the hook blocked/denied the operation. + */ + export class ChatResponseHookPart { + /** The type of hook that was executed */ + hookType: ChatHookType; + /** If set, the hook blocked processing. This message is shown to the user. */ + stopReason?: string; + /** Warning/system message from the hook, shown to the user */ + systemMessage?: string; + /** Optional metadata associated with the hook execution */ + metadata?: { readonly [key: string]: unknown }; + + /** + * Creates a new hook progress part. + * @param hookType The type of hook that was executed + * @param stopReason Message shown when processing was stopped + * @param systemMessage Warning/system message from the hook + * @param metadata Optional metadata + */ + constructor(hookType: ChatHookType, stopReason?: string, systemMessage?: string, metadata?: { readonly [key: string]: unknown }); + } + + export interface ExtendedChatResponseParts { + ChatResponseHookPart: ChatResponseHookPart; + } + + export interface ChatResponseStream { + + /** + * Push a hook execution result to this stream. + * @param hookType The type of hook that was executed + * @param stopReason If set, the hook blocked processing. This message is shown to the user. + * @param systemMessage Warning/system message from the hook + */ + hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): void; } } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts index 0d9ef00fa3e..603f8a6fcb8 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantAdditions.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 1 +// version: 3 declare module 'vscode' { @@ -302,6 +302,30 @@ declare module 'vscode' { values: Array; } + export class ChatSubagentToolInvocationData { + /** + * A description of the subagent's purpose or task. + */ + description?: string; + + /** + * The name of the subagent being invoked. + */ + agentName?: string; + + /** + * The prompt given to the subagent. + */ + prompt?: string; + + /** + * The result text from the subagent after completion. + */ + result?: string; + + constructor(description?: string, agentName?: string, prompt?: string, result?: string); + } + export class ChatToolInvocationPart { toolName: string; toolCallId: string; @@ -311,11 +335,16 @@ declare module 'vscode' { pastTenseMessage?: string | MarkdownString; isConfirmed?: boolean; isComplete?: boolean; - toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData | ChatTodoToolInvocationData | ChatSimpleToolResultData | ChatToolResourcesInvocationData; + toolSpecificData?: ChatTerminalToolInvocationData | ChatMcpToolInvocationData | ChatTodoToolInvocationData | ChatSimpleToolResultData | ChatToolResourcesInvocationData | ChatSubagentToolInvocationData; subAgentInvocationId?: string; presentation?: 'hidden' | 'hiddenAfterComplete' | undefined; - constructor(toolName: string, toolCallId: string, isError?: boolean); + /** + * If this flag is set, this will be treated as an update to any previous tool call with the same id. + */ + enablePartialUpdate?: boolean; + + constructor(toolName: string, toolCallId: string, errorMessage?: string); } /** @@ -384,7 +413,31 @@ declare module 'vscode' { constructor(uris: Uri[], callback: () => Thenable); } - export type ExtendedChatResponsePart = ChatResponsePart | ChatResponseTextEditPart | ChatResponseNotebookEditPart | ChatResponseWorkspaceEditPart | ChatResponseConfirmationPart | ChatResponseCodeCitationPart | ChatResponseReferencePart2 | ChatResponseMovePart | ChatResponseExtensionsPart | ChatResponsePullRequestPart | ChatToolInvocationPart | ChatResponseMultiDiffPart | ChatResponseThinkingProgressPart | ChatResponseExternalEditPart | ChatResponseQuestionCarouselPart | ChatResponseHookPart; + /** + * Internal type that lists all the proposed chat response parts. This is used to generate `ExtendedChatResponsePart` + * which is the actual type used in this API. This is done so that other proposals can easily add their own response parts + * without having to modify this file. + */ + export interface ExtendedChatResponseParts { + ChatResponsePart: ChatResponsePart; + ChatResponseTextEditPart: ChatResponseTextEditPart; + ChatResponseNotebookEditPart: ChatResponseNotebookEditPart; + ChatResponseWorkspaceEditPart: ChatResponseWorkspaceEditPart; + ChatResponseConfirmationPart: ChatResponseConfirmationPart; + ChatResponseCodeCitationPart: ChatResponseCodeCitationPart; + ChatResponseReferencePart2: ChatResponseReferencePart2; + ChatResponseMovePart: ChatResponseMovePart; + ChatResponseExtensionsPart: ChatResponseExtensionsPart; + ChatResponsePullRequestPart: ChatResponsePullRequestPart; + ChatToolInvocationPart: ChatToolInvocationPart; + ChatResponseMultiDiffPart: ChatResponseMultiDiffPart; + ChatResponseThinkingProgressPart: ChatResponseThinkingProgressPart; + ChatResponseExternalEditPart: ChatResponseExternalEditPart; + ChatResponseQuestionCarouselPart: ChatResponseQuestionCarouselPart; + } + + export type ExtendedChatResponsePart = ExtendedChatResponseParts[keyof ExtendedChatResponseParts]; + export class ChatResponseWarningPart { value: MarkdownString; constructor(value: string | MarkdownString); @@ -413,31 +466,6 @@ declare module 'vscode' { constructor(value: string | string[], id?: string, metadata?: { readonly [key: string]: any }, task?: (progress: Progress) => Thenable); } - /** - * A progress part representing the execution result of a hook. - * Hooks are user-configured scripts that run at specific points during chat processing. - * If {@link stopReason} is set, the hook blocked/denied the operation. - */ - export class ChatResponseHookPart { - /** The type of hook that was executed */ - hookType: ChatHookType; - /** If set, the hook blocked processing. This message is shown to the user. */ - stopReason?: string; - /** Warning/system message from the hook, shown to the user */ - systemMessage?: string; - /** Optional metadata associated with the hook execution */ - metadata?: { readonly [key: string]: unknown }; - - /** - * Creates a new hook progress part. - * @param hookType The type of hook that was executed - * @param stopReason Message shown when processing was stopped - * @param systemMessage Warning/system message from the hook - * @param metadata Optional metadata - */ - constructor(hookType: ChatHookType, stopReason?: string, systemMessage?: string, metadata?: { readonly [key: string]: unknown }); - } - export class ChatResponseReferencePart2 { /** * The reference target. @@ -539,14 +567,6 @@ declare module 'vscode' { thinkingProgress(thinkingDelta: ThinkingDelta): void; - /** - * Push a hook execution result to this stream. - * @param hookType The type of hook that was executed - * @param stopReason If set, the hook blocked processing. This message is shown to the user. - * @param systemMessage Warning/system message from the hook - */ - hookProgress(hookType: ChatHookType, stopReason?: string, systemMessage?: string): void; - textEdit(target: Uri, edits: TextEdit | TextEdit[]): void; textEdit(target: Uri, isDone: true): void; diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index 86dcf0337e4..ad37a1404ce 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 12 +// version: 13 declare module 'vscode' { @@ -258,6 +258,8 @@ declare module 'vscode' { provideFileIgnored(uri: Uri, token: CancellationToken): ProviderResult; } + export type PreToolUsePermissionDecision = 'allow' | 'deny' | 'ask'; + export interface LanguageModelToolInvocationOptions { chatRequestId?: string; /** @deprecated Use {@link chatSessionResource} instead */ @@ -269,6 +271,16 @@ declare module 'vscode' { * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ subAgentInvocationId?: string; + /** + * Pre-tool-use hook result, if the hook was already executed by the caller. + * When provided, the tools service will skip executing its own preToolUse hook + * and use this result for permission decisions and input modifications instead. + */ + preToolUseResult?: { + permissionDecision?: PreToolUsePermissionDecision; + permissionDecisionReason?: string; + updatedInput?: object; + }; } export interface LanguageModelToolInvocationPrepareOptions { diff --git a/src/vscode-dts/vscode.proposed.chatProvider.d.ts b/src/vscode-dts/vscode.proposed.chatProvider.d.ts index 09d3264195f..48a3f1048fe 100644 --- a/src/vscode-dts/vscode.proposed.chatProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatProvider.d.ts @@ -38,6 +38,11 @@ declare module 'vscode' { */ readonly multiplier?: string; + /** + * A numeric form of the `multiplier` label + */ + readonly multiplierNumeric?: number; + /** * Whether or not this will be selected by default in the model picker * NOT BEING FINALIZED diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index a2dc921d69f..df078abc002 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -23,7 +23,12 @@ declare module 'vscode' { /** * The chat session is currently in progress. */ - InProgress = 2 + InProgress = 2, + + /** + * The chat session needs user input (e.g. an unresolved confirmation). + */ + NeedsInput = 3 } export namespace chat {