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/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index a310fbbe548..f48738be53a 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -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/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/eslint.config.js b/eslint.config.js index 96e1232427b..fa55c74032c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -2064,7 +2064,9 @@ export default tseslint.config( // Additional extension strictness rules { files: [ - 'extensions/markdown-language-features/**/*.ts', + 'extensions/markdown-language-features/src/**/*.ts', + 'extensions/markdown-language-features/notebook/**/*.ts', + 'extensions/markdown-language-features/preview-src/**/*.ts', 'extensions/mermaid-chat-features/**/*.ts', 'extensions/media-preview/**/*.ts', 'extensions/simple-browser/**/*.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/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/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 40140f3b6ac..2aca9467744 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -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", @@ -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 da3849b0028..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": "#F7F7F7", + "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", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 0141a1a695c..035e15149e7 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) { @@ -189,10 +185,6 @@ border: 1px solid var(--vscode-menu-border) !important; } -.monaco-workbench .quick-input-widget .monaco-list-rows { - background-color: transparent !important; -} - .monaco-workbench .quick-input-widget .quick-input-header, .monaco-workbench .quick-input-widget .quick-input-list, .monaco-workbench .quick-input-widget .quick-input-titlebar, @@ -207,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; @@ -271,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 { @@ -286,7 +284,7 @@ /* Context Menus */ .monaco-workbench .monaco-menu .monaco-action-bar.vertical { 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); } @@ -294,7 +292,7 @@ .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 .action-widget { @@ -310,8 +308,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; @@ -331,10 +328,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; @@ -437,6 +441,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 { @@ -538,7 +546,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); } @@ -569,10 +577,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 { @@ -580,31 +592,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 { @@ -616,9 +606,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); @@ -641,7 +631,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 */ @@ -678,7 +668,7 @@ } .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..7c6806f2b4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", @@ -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 5452598ee2e..a6e5273cfca 100644 --- a/package.json +++ b/package.json @@ -95,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..e83d2319329 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.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", @@ -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..f738d5554fa 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.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/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/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/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/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/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 8769eef63c2..b6a71de582b 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -45,7 +45,7 @@ const _allApiProposals = { }, chatHooks: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', - version: 3 + version: 6 }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', @@ -56,7 +56,7 @@ const _allApiProposals = { }, 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/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/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/terminal/node/ptyService.ts b/src/vs/platform/terminal/node/ptyService.ts index a600f7b27d1..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); 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..968009e20e6 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'; @@ -301,7 +301,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/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/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 797db7cff68..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'; @@ -326,7 +326,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes private readonly _handle: number; private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); - readonly onDidChangeChatSessionItems: Event = this._onDidChangeChatSessionItems.event; + public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; constructor( proxy: ExtHostChatSessionsShape, @@ -337,20 +337,32 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes this._handle = handle; } - private _items: IChatSessionItem[] = []; + private readonly _items = new ResourceMap(); get items(): IChatSessionItem[] { - return this._items; + return Array.from(this._items.values()); } refresh(token: CancellationToken): Promise { return this._proxy.$refreshChatSessionItems(this._handle, token); } - setItems(items: IChatSessionItem[]): void { - this._items = items; + 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(); } @@ -410,17 +422,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } $registerChatSessionItemController(handle: number, chatSessionType: string): void { - // Register the controller handle - items will be pushed via $setChatSessionItems const disposables = new DisposableStore(); - const controller = new MainThreadChatSessionItemController(this._proxy, handle); - disposables.add(controller); + const controller = disposables.add(new MainThreadChatSessionItemController(this._proxy, handle)); disposables.add(this._chatSessionsService.registerChatSessionItemController(chatSessionType, controller)); this._itemControllerRegistrations.set(handle, { - dispose: () => disposables.dispose(), chatSessionType, controller, + dispose: () => disposables.dispose(), }); disposables.add(this._chatSessionsService.registerChatModelChangeListeners( @@ -434,46 +444,58 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat 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 controller registered for handle ${handle}`); + this._logService.warn(`No chat session controller registered for handle ${handle}`); return; } - const resolvedItems = await Promise.all(items.map(async item => { - 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, - } satisfies IChatSessionItem; - })); - + 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 { const sessionResource = URI.revive(sessionResourceComponents); 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 ef3f6a731dd..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 diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index fff9a8d8845..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; } @@ -3431,6 +3418,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { $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; @@ -3535,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'), @@ -3615,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/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 14b71d20dbb..febaa3a413d 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -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'; @@ -324,26 +324,26 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - // Helper to fetch and push items to main thread - const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { - collection.replace(items); - const convertedItems: IChatSessionItem[] = []; - for (const sessionContent of items) { - this._sessionItems.set(sessionContent.resource, sessionContent); - convertedItems.push(this.convertChatSessionItem(sessionContent)); - } - void this._proxy.$setChatSessionItems(handle, convertedItems); - }; - 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'); + throw new Error('Not implemented for providers'); }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, dispose: () => { @@ -389,10 +389,10 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const onItemsChanged = () => { - const items: IChatSessionItem[] = []; + const items: Array> = []; for (const [_, item] of collection) { this._sessionItems.set(item.resource, item); - items.push(this.convertChatSessionItem(item)); + items.push(typeConvert.ChatSessionItem.from(item)); } void this._proxy.$setChatSessionItems(controllerHandle, items); }; @@ -416,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 - onItemsChanged(); + const item = new ChatSessionItemImpl(resource, label, () => { + void this._proxy.$updateChatSessionItem(controllerHandle, typeConvert.ChatSessionItem.from(item)); }); + return item; }, dispose: () => { isDisposed = true; @@ -466,49 +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 $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { 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/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 885302dcb9b..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'; @@ -44,8 +45,9 @@ import { IChatRequestModeInstructions } from '../../contrib/chat/common/model/ch 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'; @@ -3437,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')) { @@ -3464,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')) { @@ -4082,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 { - resultKind: result.resultKind, - stopReason: result.stopReason, - warningMessage: result.warningMessage, - output: result.output, + 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 9e47fb165dc..d7d1c805b87 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3552,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 1b00ae2a271..00000000000 --- a/src/vs/workbench/api/node/extHostHooksNode.ts +++ /dev/null @@ -1,196 +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 * as nls from '../../../nls.js'; -import { disposableTimeout } from '../../../base/common/async.js'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; -import { OS } from '../../../base/common/platform.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)) { - this._logService.error('[NodeExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - 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], OS); - if (!effectiveCommand) { - return Promise.resolve({ - kind: HookCommandResultKind.NonBlockingError, - result: nls.localize('noCommandForPlatform', "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], OS); - 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 if (code === 2) { - // Blocking error - show stderr to model and stop processing - resolve({ kind: HookCommandResultKind.Error, result: stderrStr }); - } else { - // Non-blocking error - show stderr to user only - resolve({ kind: HookCommandResultKind.NonBlockingError, result: stderrStr }); - } - }); - - child.on('error', err => { - cleanup(); - reject(err); - }); - }); - } -} 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 f398cffd3f5..00000000000 --- a/src/vs/workbench/api/test/node/extHostHooks.test.ts +++ /dev/null @@ -1,137 +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 non-blocking error for exit code 1', async () => { - const hookCommand = createHookCommandDto('exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - test('$runHookCommand returns blocking error for exit code 2', async () => { - const hookCommand = createHookCommandDto('exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - }); - - test('$runHookCommand captures stderr on non-blocking error', async () => { - const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - assert.strictEqual((result.result as string).trim(), 'error message'); - }); - - test('$runHookCommand captures stderr on blocking error', async () => { - const hookCommand = createHookCommandDto('echo "blocking error" >&2 && exit 2'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - assert.strictEqual(result.kind, HookCommandResultKind.Error); - assert.strictEqual((result.result as string).trim(), 'blocking error'); - }); - - 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 non-blocking error for invalid command', async () => { - const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); - const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); - - // Invalid commands typically return non-zero exit codes (127 for command not found) - // which are treated as non-blocking errors unless it's exit code 2 - assert.strictEqual(result.kind, HookCommandResultKind.NonBlockingError); - }); - - 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 3bd7fcf6edf..00000000000 --- a/src/vs/workbench/api/worker/extHostHooksWorker.ts +++ /dev/null @@ -1,50 +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)) { - this._logService.error('[WorkerExtHostHooks] Invalid or missing tool invocation token'); - return []; - } - - 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/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 f4b26051c5b..6e65299f400 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -22,7 +22,7 @@ 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'; @@ -145,10 +145,32 @@ export class ModalEditorPart { })); // 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`; @@ -156,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(); @@ -176,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( @@ -212,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/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/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index fefa2b156dd..980ba420f12 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,24 @@ export function registerChatActions() { } }); + 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() { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index e4eb46e3aab..bb72913ae98 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -325,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, remoteAgentService, token); + const parsedHooks = await parseHookFiles(promptsService, fileService, labelService, pathService, workspaceContextService, remoteAgentService, token, disabledFileUris); return { type, paths, files, enabled, parsedHooks }; } @@ -341,7 +346,8 @@ async function parseHookFiles( pathService: IPathService, workspaceContextService: IWorkspaceContextService, remoteAgentService: IRemoteAgentService, - token: CancellationToken + token: CancellationToken, + additionalDisabledFileUris?: URI[] ): Promise { // Get workspace root and user home for path resolution const workspaceFolder = workspaceContextService.getWorkspace().folders[0]; @@ -354,7 +360,7 @@ async function parseHookFiles( const targetOS = remoteEnv?.os ?? OS; // Use the shared helper - return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token); + return parseAllHookFiles(promptsService, fileService, labelService, workspaceRootUri, userHome, targetOS, token, { additionalDisabledFileUris }); } /** @@ -442,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'); } @@ -735,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/agentSessions/agentSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.contribution.ts index 3824399027b..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 { LocalAgentsSessionsController } 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'; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessions.ts index 8270198d110..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."); } } 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/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/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts similarity index 93% rename from src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts rename to src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts index bbbb67e2cdb..18d0bcb1c29 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsController.ts @@ -19,7 +19,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; export class LocalAgentsSessionsController extends Disposable implements IChatSessionItemController, IWorkbenchContribution { - static readonly ID = 'workbench.contrib.localAgentsSessionsProvider'; + static readonly ID = 'workbench.contrib.localAgentsSessionsController'; readonly chatSessionType = localChatSessionType; @@ -51,22 +51,18 @@ export class LocalAgentsSessionsController extends Disposable implements IChatSe } private registerListeners(): void { - this._register(this.chatSessionsService.registerChatModelChangeListeners( - this.chatService, - Schemas.vscodeLocalChatSession, - () => this._onDidChangeChatSessionItems.fire() - )); + const refreshItems = async () => { + this._onDidChangeChatSessionItems.fire(); + await this.refresh(CancellationToken.None); + this._onDidChangeChatSessionItems.fire(); + }; - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { - if (chatSessionType === this.chatSessionType) { - this._onDidChange.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(); } })); } 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 1c71ce192a5..f51de3f44f1 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,7 +52,6 @@ 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, CLAUDE_AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.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'; @@ -143,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[] = []; @@ -355,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', @@ -614,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."), @@ -1182,37 +1191,6 @@ class ChatResolverContribution extends Disposable { } } -class ChatHooksProgressContribution extends Disposable implements IWorkbenchContribution { - - static readonly ID = 'workbench.contrib.chatHooksProgress'; - - constructor( - @IChatService private readonly chatService: IChatService, - @IHooksExecutionService hooksExecutionService: IHooksExecutionService, - ) { - super(); - - this._register(hooksExecutionService.onDidHookProgress(event => { - const model = this.chatService.getSession(event.sessionResource); - if (!model) { - return; - } - - const request = model.getRequests().at(-1); - if (!request) { - return; - } - - this.chatService.appendProgress(request, { - kind: 'hook', - hookType: event.hookType, - stopReason: event.stopReason, - systemMessage: event.systemMessage, - }); - })); - } -} - class ChatAgentSettingContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.chatAgentSetting'; @@ -1223,6 +1201,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr ) { super(); this.registerMaxRequestsSetting(); + this.registerBackgroundAgentDisplayName(); } @@ -1253,6 +1232,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); + } + }); + } } @@ -1421,6 +1408,56 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, 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: '', @@ -1479,7 +1516,6 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(ChatHooksProgressContribution.ID, ChatHooksProgressContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); @@ -1560,7 +1596,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/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/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 9076add61bd..8a6372af31d 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -44,7 +44,7 @@ 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 { 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'; @@ -200,6 +200,11 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint 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)) { @@ -647,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)); } diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 309d5b366f2..5c55c8159ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.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'; @@ -14,9 +14,12 @@ import { IConfigurationService } from '../../../../platform/configuration/common 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 { IPromptsService } from '../common/promptSyntax/service/promptsService.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'); @@ -57,7 +60,7 @@ export interface IChatTipService { /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ - disableTips(): void; + disableTips(): Promise; } export interface ITipDefinition { @@ -83,6 +86,21 @@ export interface ITipDefinition { * 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; + }; } /** @@ -106,10 +124,12 @@ const TIP_CATALOG: ITipDefinition[] = [ { 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', @@ -124,7 +144,78 @@ const TIP_CATALOG: ITipDefinition[] = [ 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 messages and steering 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 approval](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'], + }, ]; /** @@ -136,26 +227,38 @@ 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()); /** - * Whether agent instruction files exist in the workspace. - * Defaults to `true` (hide the tip) until the async check completes. + * 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 _hasInstructionFiles = true; + 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[], - commandService: ICommandService, - private readonly _storageService: IStorageService, - promptsService: IPromptsService, + @ICommandService commandService: ICommandService, + @IStorageService private readonly _storageService: IStorageService, + @IPromptsService private readonly _promptsService: IPromptsService, + @ILanguageModelToolsService languageModelToolsService: ILanguageModelToolsService, + @ILogService private readonly _logService: ILogService, ) { super(); @@ -167,6 +270,9 @@ export class TipEligibilityTracker extends Disposable { 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(); @@ -187,6 +293,15 @@ export class TipEligibilityTracker extends Disposable { } } + 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) { @@ -203,9 +318,40 @@ export class TipEligibilityTracker extends Disposable { }); } - // --- Async file check -------------------------------------------------- + // --- Set up tool listener (auto-disposes when all seen) ----------------- - this._checkForInstructionFiles(promptsService); + 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); + } + } + })); } /** @@ -243,6 +389,7 @@ export class TipEligibilityTracker extends Disposable { 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; } } @@ -250,22 +397,59 @@ export class TipEligibilityTracker extends Disposable { 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.id === 'tip.customInstructions' && this._hasInstructionFiles) { + 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 _checkForInstructionFiles(promptsService: IPromptsService): Promise { + 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 files = await promptsService.listAgentInstructions(CancellationToken.None); - this._hasInstructionFiles = files.length > 0; + 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 { - this._hasInstructionFiles = true; + if (this._fileCheckGeneration.get(tip.id) !== generation) { + return; + } + if (config.excludeUntilChecked) { + this._excludedByFiles.add(tip.id); + } } } @@ -312,14 +496,11 @@ export class ChatTipService extends Disposable implements IChatTipService { @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, @IStorageService private readonly _storageService: IStorageService, - @ICommandService commandService: ICommandService, - @IStorageService storageService: IStorageService, - @IPromptsService promptsService: IPromptsService, + @IInstantiationService instantiationService: IInstantiationService, + @ILogService private readonly _logService: ILogService, ) { super(); - this._tracker = this._register(new TipEligibilityTracker( - TIP_CATALOG, commandService, storageService, promptsService, - )); + this._tracker = this._register(instantiationService.createInstance(TipEligibilityTracker, TIP_CATALOG)); } dismissTip(): void { @@ -341,17 +522,18 @@ export class ChatTipService extends Disposable implements IChatTipService { } try { const parsed = JSON.parse(raw); + this._logService.debug('#ChatTips dismissed:', parsed); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } - disableTips(): void { + async disableTips(): Promise { this._hasShownTip = false; this._shownTip = undefined; this._tipRequestId = undefined; - this._configurationService.updateValue('chat.tips.enabled', false); + await this._configurationService.updateValue('chat.tips.enabled', false); this._onDidDisableTips.fire(); } @@ -406,9 +588,14 @@ export class ChatTipService extends Disposable implements IChatTipService { private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { 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 !this._tracker.isExcluded(tip); + 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/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/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index c3cab3b104c..babfb20a486 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -22,6 +22,8 @@ import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQui import { IFileService } from '../../../../../platform/files/common/files.js'; import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.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'; @@ -116,15 +118,23 @@ async function addHookToFile( 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 = usesCopilotCliNaming(hooksContent.hooks); - const hookTypeKeyName = getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention); + 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 = resolveCopilotCliHookType(key); + const resolvedType = isClaude + ? resolveClaudeHookType(key) + : resolveCopilotCliHookType(key); if (resolvedType === hookTypeId || key === hookTypeId) { existingKeyForType = key; break; @@ -135,10 +145,7 @@ async function addHookToFile( const keyToUse = existingKeyForType ?? hookTypeKeyName; // Add the new hook entry (append if hook type already exists) - const newHookEntry = { - type: 'command', - command: '' - }; + const newHookEntry = buildNewHookEntry(sourceFormat); let newHookIndex: number; if (!hooksContent.hooks[keyToUse]) { hooksContent.hooks[keyToUse] = [newHookEntry]; @@ -668,14 +675,19 @@ export async function showConfigureHooksQuickPick( 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: { - [selectedHookType!.hookType.id]: [ - { - type: 'command', - command: '' - } + [hookTypeKey]: [ + newFileHookEntry ] } }; @@ -684,7 +696,7 @@ export async function showConfigureHooksQuickPick( await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, selectedHookType!.hookType.id, 0, 'command'); + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); // Open editor with selection store.dispose(); diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts index 87acdace945..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,7 +11,7 @@ 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'; @@ -126,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[]; } /** @@ -139,7 +146,8 @@ export async function parseAllHookFiles( workspaceRootUri: URI | undefined, userHome: string, os: OperatingSystem, - token: CancellationToken + token: CancellationToken, + options?: IParseAllHookFilesOptions ): Promise { const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, token); const parsedHooks: IParsedHook[] = []; @@ -147,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); @@ -179,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/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index aba6e221b02..80612e6daf3 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -20,6 +20,7 @@ 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'; @@ -35,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 { HookAbortError, 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'; @@ -45,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); @@ -126,7 +124,6 @@ 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(); @@ -212,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); @@ -367,74 +371,37 @@ 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}`); + + 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 hookInput: IPreToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - toolCallId: dto.callId, + return { + content: [{ kind: 'text', value: `Tool execution denied: ${hookReason}` }], + toolResultError: hookReason, }; - let hookResult: IPreToolUseHookResult | undefined; - try { - hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); - } catch (e) { - if (e instanceof HookAbortError) { - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} aborted by preToolUse hook: ${e.stopReason}`); - throw new CancellationError(); - } - throw e; - } - - 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); - } - } - - const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason); - return { - denialResult: { - content: [{ kind: 'text', value: denialMessage }], - toolResultError: hookReason, - }, - hookResult, - }; - } - - return { hookResult }; } /** @@ -469,53 +436,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * 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. - */ - private async _executePostToolUseHook( - dto: IToolInvocation, - toolResult: IToolResult, - token: CancellationToken - ): Promise { - if (!dto.context?.sessionResource) { - return; - } - - const hookInput: IPostToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - getToolResponseText: () => toolContentToA11yString(toolResult.content), - toolCallId: dto.callId, - }; - let hookResult; - try { - hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); - } catch (e) { - if (e instanceof HookAbortError) { - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook aborted for tool ${dto.toolId}: ${e.stopReason}`); - throw new CancellationError(); - } - throw e; - } - - 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' }); - } - } - } - async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); @@ -563,14 +483,14 @@ 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 @@ -730,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', { @@ -777,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); @@ -798,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, @@ -818,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 }; } 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 86a60cfacfd..5e03ac8c247 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatSubagentContentPart.ts @@ -16,7 +16,7 @@ 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 { IChatMarkdownContent, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.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'; @@ -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 @@ -587,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. */ @@ -705,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); + } } } @@ -759,6 +826,11 @@ 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 || ChatSubagentContentPart.isParentSubagentTool(other))) { // For parent subagent tool, use toolCallId as the effective ID 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..4f926a701d4 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -765,6 +765,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 +810,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 +1172,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 +1219,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 0e71dfec5c1..df83eb5d194 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -32,11 +32,11 @@ export class ChatTipContentPart extends Disposable { constructor( tip: IChatTip, private readonly _renderer: IMarkdownRenderer, - private readonly _chatTipService: IChatTipService, - private readonly _contextMenuService: IContextMenuService, - private readonly _menuService: IMenuService, - private readonly _contextKeyService: IContextKeyService, 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(); @@ -114,7 +114,7 @@ registerAction2(class DisableTipsAction extends Action2 { } override async run(accessor: ServicesAccessor): Promise { - accessor.get(IChatTipService).disableTips(); + await accessor.get(IChatTipService).disableTips(); } }); 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/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 0d812eb533f..57267238df8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -34,7 +34,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { IMenuEntryActionViewItemOptions, createActionViewItem } from '../../../../../platform/actions/browser/menuEntryActionViewItem.js'; import { MenuWorkbenchToolBar } from '../../../../../platform/actions/browser/toolbar.js'; -import { MenuId, MenuItemAction, IMenuService } from '../../../../../platform/actions/common/actions.js'; +import { MenuId, MenuItemAction } from '../../../../../platform/actions/common/actions.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; @@ -108,6 +108,7 @@ 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.$; @@ -254,8 +255,6 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), ); templateData.value.appendChild(tipPart.domNode); @@ -1232,7 +1227,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.agent.thinking.collapsedTools'); @@ -1434,6 +1430,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 { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 85111d9787d..8fa0fbb402f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -71,6 +71,7 @@ import { IPromptsService } from '../../common/promptSyntax/service/promptsServic 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'; @@ -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(); @@ -2210,6 +2212,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 +2226,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, @@ -2268,6 +2274,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; 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 00b4229ebb0..b0fd053396d 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'; @@ -420,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; @@ -590,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(); @@ -1698,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. */ @@ -2575,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(); } }); } @@ -2669,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; @@ -2720,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); } @@ -2780,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/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 ca2ba9b331c..071230853fa 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/modePickerActionItem.ts @@ -45,7 +45,13 @@ export interface IModePickerDelegate { } // 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( @@ -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 }; }; @@ -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, @@ -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..422e3a3e905 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,16 @@ export class ChatContextUsageWidget extends Disposable { private currentData: IChatContextUsageData | undefined; + private static readonly _OPENED_STORAGE_KEY = 'chat.contextUsage.hasBeenOpened'; + + 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 +140,69 @@ 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 = { + appearance: { showPointer: true, compact: true }, + persistence: { hideOnHover: false }, + trapFocus: true + }; + + private _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; + } + + 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 +210,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/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index a9b4206c5e5..cb507daf273 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -119,7 +119,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/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index 70c534c3f07..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 { @@ -1300,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 69054bd688a..b55b824cd7d 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -51,7 +51,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'; @@ -156,7 +155,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, - @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, ) { super(); @@ -911,10 +909,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`); @@ -952,6 +946,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 9f280a98fb8..839510819e7 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -85,6 +85,7 @@ 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 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 b939a402040..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksCommandTypes.ts +++ /dev/null @@ -1,127 +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, - /** Blocking error - shown to model */ - Error = 2, - /** Non-blocking error - shown to user only */ - NonBlockingError = 3 -} - -/** - * 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 updatedInput?: object; - 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 266ce042a49..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ /dev/null @@ -1,644 +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; -} - -/** - * Event fired when a hook produces progress that should be shown to the user. - */ -export interface IHookProgressEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly stopReason?: string; - readonly systemMessage?: string; -} - -/** - * 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; - - /** - * Fires when a hook produces progress (warning or stop) that should be shown to the user. - */ - readonly onDidHookProgress: 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 readonly _onDidHookProgress = this._register(new Emitter()); - readonly onDidHookProgress: Event = this._onDidHookProgress.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}`); - // Proxy errors (e.g., process spawn failure) are treated as warnings - return { - resultKind: 'warning', - output: undefined, - warningMessage: errMessage, - }; - } - } - - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { - switch (commandResult.kind) { - case HookCommandResultKind.Error: { - // Exit code 2 - stop processing with message shown to user (not model) - // Equivalent to continue=false with stopReason=stderr - const message = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'error', - stopReason: message, - output: undefined, - }; - } - case HookCommandResultKind.NonBlockingError: { - // Non-blocking error - shown to user only as warning - const errorMessage = typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result); - return { - resultKind: 'warning', - output: undefined, - warningMessage: errorMessage, - }; - } - case HookCommandResultKind.Success: { - // For string results, no common fields to extract - if (typeof commandResult.result !== 'object') { - return { - resultKind: 'success', - output: commandResult.result, - }; - } - - // 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); - - // Handle continue field: when false, stopReason is effective - // stopReason takes precedence if both are set - let stopReason = commonFields.stopReason; - if (commonFields.continue === false && !stopReason) { - stopReason = ''; // Empty string signals stop without a specific reason - } - - return { - resultKind: 'success', - stopReason, - warningMessage: commonFields.systemMessage, - output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, - }; - } - default: { - // Unexpected result kind - treat as warning - return { - resultKind: 'warning', - warningMessage: `Unexpected hook command result kind: ${(commandResult as IHookCommandResult).kind}`, - output: undefined, - }; - } - } - } - - /** - * Extract hook-specific output fields, excluding common fields. - */ - private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['continue', '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' - : result.kind === HookCommandResultKind.NonBlockingError ? 'NonBlockingError' - : '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; - } - - /** - * Emit a hook progress event to show warnings or stop reasons to the user. - */ - private _emitHookProgress(hookType: HookTypeValue, sessionResource: URI, stopReason?: string, systemMessage?: string): void { - this._onDidHookProgress.fire({ - hookType, - sessionResource, - stopReason, - systemMessage, - }); - } - - /** - * Collect all warning messages from hook results and emit them as a single aggregated progress event. - * Uses numbered list formatting when there are multiple warnings. - */ - private _emitAggregatedWarnings(hookType: HookTypeValue, sessionResource: URI, results: readonly IHookResult[]): void { - const warnings = results - .filter(r => r.warningMessage !== undefined) - .map(r => r.warningMessage!); - - if (warnings.length > 0) { - const message = warnings.length === 1 - ? warnings[0] - : warnings.map((w, i) => `${i + 1}. ${w}`).join('\n'); - this._emitHookProgress(hookType, sessionResource, undefined, message); - } - } - - 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; - let lastUpdatedInput: object | undefined; - - for (const result of results) { - if (result.resultKind === '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 last updatedInput (later hooks override earlier ones) - if (hookSpecificOutput.updatedInput) { - lastUpdatedInput = hookSpecificOutput.updatedInput; - } - - // 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}`); - } - } - } - - const baseResult = winningResult ?? results[0]; - - // Emit hook progress for warning messages after all hooks have completed - this._emitAggregatedWarnings(HookType.PreToolUse, sessionResource, results); - - // If any hook set stopReason, throw HookAbortError after processing - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason !== undefined) { - this._emitHookProgress(HookType.PreToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - throw new HookAbortError(HookType.PreToolUse, stoppedResult.stopReason ?? 'Unknown error'); - } - - return { - ...baseResult, - permissionDecision: mostRestrictiveDecision, - permissionDecisionReason: winningReason, - updatedInput: lastUpdatedInput, - 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.resultKind === '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}`); - } - } - } - - const baseResult = blockResult ?? results[0]; - - // Emit hook progress for warning messages after all hooks have completed - this._emitAggregatedWarnings(HookType.PostToolUse, sessionResource, results); - - // If any hook set stopReason, throw HookAbortError after processing - const stoppedResult = results.find(r => r.stopReason !== undefined); - if (stoppedResult?.stopReason !== undefined) { - this._emitHookProgress(HookType.PostToolUse, sessionResource, formatHookErrorMessage(stoppedResult.stopReason)); - throw new HookAbortError(HookType.PostToolUse, stoppedResult.stopReason ?? 'Unknown error'); - } - - return { - ...baseResult, - decision: hasBlock ? 'block' : undefined, - reason: blockReason, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } -} - -/** - * Error thrown when a hook requests the agent to abort processing. - * The message should be shown to the user. - */ -export class HookAbortError extends Error { - constructor( - public readonly hookType: string, - public readonly stopReason: string - ) { - super(`Hook ${hookType} aborted: ${stopReason}`); - this.name = 'HookAbortError'; - } -} - -/** - * Formats a localized error message for a failed hook. - * @param errorMessage The error message from the hook - * @returns A localized error message string - */ -export function formatHookErrorMessage(errorMessage: string): string { - if (errorMessage) { - return localize('hookFatalErrorWithMessage', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details. Error message: {0}', errorMessage); - } - return localize('hookFatalError', 'A hook prevented chat from continuing. Please check the Hooks output channel for more details.'); -} 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 f35308d8935..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ /dev/null @@ -1,144 +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 { vBoolean, vEnum, vObj, vObjAny, vOptionalProp, vString } from '../../../../../base/common/validation.js'; - -//#region Common Hook Types - -/** - * The kind of result from executing a hook command. - */ -export type HookResultKind = 'success' | 'error' | 'warning'; - -/** - * Semantic hook result with common fields extracted and defaults applied. - * This is what callers receive from executeHook. - */ -export interface IHookResult { - /** - * The kind of result from executing the hook. - */ - readonly resultKind: HookResultKind; - /** - * 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; - /** - * Warning message shown to the user. - * (Mapped from `systemMessage` in command output, or stderr for non-blocking errors.) - */ - readonly warningMessage?: string; - /** - * The hook's output (hook-specific fields only). - * For errors, this is the error message string. - */ - readonly output: unknown; -} - -export const commonHookOutputValidator = vObj({ - continue: vOptionalProp(vBoolean()), - 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: vOptionalProp(vEnum('allow', 'deny', 'ask')), - permissionDecisionReason: vOptionalProp(vString()), - updatedInput: vOptionalProp(vObjAny()), - 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; - /** - * Modified tool input parameters from the hook. - * When set, replaces the original tool input before execution. - * Combine with 'allow' to auto-approve, or 'ask' to show modified input to the user. - */ - readonly updatedInput?: object; - 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/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/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index 5f2079ea401..c159acfa4c3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -53,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: @@ -70,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; @@ -140,7 +162,7 @@ export function parseClaudeHooks( } } - return result; + return { hooks: result, disabledAllHooks: false }; } /** @@ -158,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 e3c483d3811..6bdf4afdc89 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -111,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. */ @@ -119,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 }; } /** @@ -148,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 297a0535e13..ccf368ed54a 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -92,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. */ @@ -164,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).') } } }; @@ -240,7 +240,7 @@ export const hookFileSchema: IJSONSchema = { { type: 'command', command: '${2:./scripts/validate.sh}', - timeoutSec: 15 + timeout: 15 } ] } @@ -278,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; } @@ -313,7 +313,8 @@ 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 }), }; } @@ -456,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/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 47b3e64e180..f59d436d4fd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -273,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 c8196999288..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'; @@ -1030,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) { @@ -1304,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; @@ -1312,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') { @@ -1330,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/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index d1f19a0f152..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. @@ -222,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') }); @@ -244,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, @@ -258,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 diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 05a5d1d1752..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 { 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 77685cafa97..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 @@ -22,7 +22,7 @@ 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', () => { @@ -1949,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)); @@ -2087,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; 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 7bc70307a08..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 { LocalAgentsSessionsController } 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'; @@ -300,39 +300,39 @@ suite('LocalAgentsSessionsController', () => { ensureNoDisposablesAreLeakedInTestSuite(); - function createProvider(): LocalAgentsSessionsController { + 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([]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + 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({ @@ -351,8 +351,8 @@ suite('LocalAgentsSessionsController', () => { lastResponseState: ResponseModelState.Complete }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + 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()); @@ -361,7 +361,7 @@ suite('LocalAgentsSessionsController', () => { test('should provide history session items', async () => { return runWithFakedTimers({}, async () => { - const provider = createProvider(); + const controller = createController(); const sessionResource = LocalChatSessionUri.forSession('history-session'); @@ -375,8 +375,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'History Session'); }); @@ -384,7 +384,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -410,8 +410,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].label, 'Live Session'); }); @@ -420,7 +420,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -439,8 +439,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.InProgress); }); @@ -448,7 +448,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -470,8 +470,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -479,7 +479,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -500,8 +500,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Completed); }); @@ -509,7 +509,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -530,8 +530,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming(), }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].status, ChatSessionStatus.Failed); }); @@ -541,7 +541,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -580,8 +580,8 @@ suite('LocalAgentsSessionsController', () => { } }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + 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 }; @@ -593,7 +593,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -621,8 +621,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].changes, undefined); }); @@ -632,7 +632,7 @@ suite('LocalAgentsSessionsController', () => { 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; @@ -652,8 +652,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ created: modelTimestamp }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, modelTimestamp); }); @@ -661,7 +661,7 @@ suite('LocalAgentsSessionsController', () => { 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; @@ -676,8 +676,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ created: lastMessageDate }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.created, lastMessageDate); }); @@ -685,7 +685,7 @@ suite('LocalAgentsSessionsController', () => { 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; @@ -706,8 +706,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming({ lastRequestEnded: completedAt }) }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].timing.lastRequestEnded, completedAt); }); @@ -717,7 +717,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -735,8 +735,8 @@ suite('LocalAgentsSessionsController', () => { timing: createTestTiming() }]); - await provider.refresh(CancellationToken.None); - const sessions = provider.items; + await controller.refresh(CancellationToken.None); + const sessions = controller.items; assert.strictEqual(sessions.length, 1); assert.strictEqual(sessions[0].iconPath, Codicon.chatSparkle); }); @@ -746,7 +746,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -759,7 +759,7 @@ suite('LocalAgentsSessionsController', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -772,7 +772,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -785,7 +785,7 @@ suite('LocalAgentsSessionsController', () => { mockChatService.addSession(sessionResource, mockModel); let changeEventCount = 0; - disposables.add(provider.onDidChangeChatSessionItems(() => { + disposables.add(controller.onDidChangeChatSessionItems(() => { changeEventCount++; })); @@ -798,7 +798,7 @@ suite('LocalAgentsSessionsController', () => { 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({ @@ -816,7 +816,7 @@ suite('LocalAgentsSessionsController', () => { // 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 5da31f55818..3ee8fdae9f6 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -12,12 +12,17 @@ import { TestConfigurationService } from '../../../../../platform/configuration/ 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 { IStorageService, InMemoryStorageService } from '../../../../../platform/storage/common/storage.js'; import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../browser/chatTipService.js'; -import { IPromptsService, IResolvedAgentFile } from '../../common/promptSyntax/service/promptsService.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 { @@ -34,6 +39,7 @@ suite('ChatTipService', () => { let commandExecutedEmitter: Emitter; let storageService: InMemoryStorageService; let mockInstructionFiles: IResolvedAgentFile[]; + let mockPromptInstructionFiles: IPromptPath[]; function createProductService(hasCopilot: boolean): IProductService { return { @@ -55,16 +61,21 @@ suite('ChatTipService', () => { 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', () => { @@ -190,7 +201,7 @@ suite('ChatTipService', () => { assert.ok(fired, 'onDidDismissTip should fire'); }); - test('disableTips fires onDidDisableTips event', () => { + test('disableTips fires onDidDisableTips event', async () => { const service = createService(); const now = Date.now(); @@ -198,12 +209,12 @@ suite('ChatTipService', () => { let fired = false; testDisposables.add(service.onDidDisableTips(() => { fired = true; })); - service.disableTips(); + await service.disableTips(); assert.ok(fired, 'onDidDisableTips should fire'); }); - test('disableTips resets state so re-enabling works', () => { + test('disableTips resets state so re-enabling works', async () => { const service = createService(); const now = Date.now(); @@ -212,7 +223,7 @@ suite('ChatTipService', () => { assert.ok(tip1); // Disable tips - service.disableTips(); + await service.disableTips(); // Re-enable tips configurationService.setUserConfiguration('chat.tips.enabled', true); @@ -222,6 +233,22 @@ suite('ChatTipService', () => { 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', @@ -233,7 +260,9 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before command is executed'); @@ -243,36 +272,86 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after command is executed'); }); - test('excludes tip.customInstructions when instruction files exist in workspace', async () => { + 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, - { listAgentInstructions: async () => [{ uri: { path: '/.github/copilot-instructions.md' } } as IResolvedAgentFile] } as Partial as IPromptsService, + 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 instruction files exist'); + 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, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); // Wait for the async file check to complete @@ -295,7 +374,9 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -319,7 +400,9 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before mode is recorded'); @@ -340,7 +423,9 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: commandExecutedEmitter.event, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); commandExecutedEmitter.fire({ commandId: 'workbench.action.chat.restoreCheckpoint', args: [] }); @@ -351,7 +436,9 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); assert.strictEqual(tracker2.isExcluded(tip), true, 'New tracker should read persisted exclusion from workspace storage'); @@ -371,7 +458,9 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + createMockPromptsService() as IPromptsService, + createMockToolsService(), + new NullLogService(), )); tracker1.recordCurrentMode(contextKeyService); @@ -382,9 +471,146 @@ suite('ChatTipService', () => { [tip], { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, storageService, - { listAgentInstructions: async () => [] } as Partial as IPromptsService, + 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/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 3913649070d..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,7 +8,6 @@ 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'; @@ -34,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 --- @@ -65,31 +60,6 @@ class TestTelemetryService implements Partial { } } -class MockHooksExecutionService implements IHooksExecutionService { - readonly _serviceBrand: undefined; - readonly onDidExecuteHook = Event.None; - readonly onDidHookProgress = 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, @@ -148,7 +118,6 @@ 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; @@ -173,7 +142,6 @@ function createTestToolsService(store: ReturnType { 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', { @@ -1786,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', { @@ -1829,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', { @@ -2599,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', { @@ -2638,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 @@ -2678,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 @@ -2721,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 @@ -3571,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 @@ -3811,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, - resultKind: 'success', - permissionDecision: 'deny', - permissionDecisionReason: 'Destructive operations require approval', - }; - const tool = registerToolForTest(hookService, store, 'hookDenyTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'should not run' }] }) }); @@ -3839,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 ); @@ -3863,12 +3865,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook allows, tool executes normally', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - const tool = registerToolForTest(hookService, store, 'hookAllowTool', { invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] }) }); @@ -3876,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 ); @@ -3888,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' }] }) }); @@ -3906,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, - resultKind: 'success', - 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, - resultKind: 'success', - permissionDecision: 'deny', - permissionDecisionReason: 'Operation not allowed', - }; - let invokeCalled = false; const tool = registerToolForTest(hookService, store, 'hookNeverInvokeTool', { invoke: async () => { @@ -3949,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 ); @@ -3959,13 +3933,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns ask, tool is not auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'ask', - permissionDecisionReason: 'Requires user confirmation', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAskTool', { invoke: async () => { @@ -3984,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 ); @@ -4007,12 +3980,6 @@ suite('LanguageModelToolsService', () => { }); test('when hook returns allow, tool is auto-approved', async () => { - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - }; - let invokeCompleted = false; const tool = registerToolForTest(hookService, store, 'hookAutoApproveTool', { invoke: async () => { @@ -4031,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 ); @@ -4046,12 +4018,6 @@ suite('LanguageModelToolsService', () => { test('when hook returns updatedInput, tool is invoked with replaced parameters', async () => { let receivedParameters: Record | undefined; - mockHooksService.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { safeCommand: 'echo hello' }, - }; const tool = registerToolForTest(hookService, store, 'hookUpdatedInputTool', { invoke: async (dto) => { @@ -4069,8 +4035,14 @@ suite('LanguageModelToolsService', () => { 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' }, + }; + await hookService.invokeTool( - tool.makeDto({ originalCommand: 'rm -rf /' }, { sessionId: 'hook-test-updated-input' }), + dto, async () => 0, CancellationToken.None ); @@ -4088,19 +4060,11 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); let receivedParameters: Record | undefined; - mockHooks.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { invalidField: 'wrong' }, - }; const tool = registerToolForTest(setup.service, store, 'hookValidationFailTool', { invoke: async (dto) => { @@ -4124,8 +4088,14 @@ suite('LanguageModelToolsService', () => { 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( - tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-fail' }), + dto, async () => 0, CancellationToken.None ); @@ -4144,19 +4114,11 @@ suite('LanguageModelToolsService', () => { } }; - const mockHooks = new MockHooksExecutionService(); const setup = createTestToolsService(store, { - hooksExecutionService: mockHooks, commandService: mockCommandService as ICommandService, }); let receivedParameters: Record | undefined; - mockHooks.preToolUseHookResult = { - output: undefined, - resultKind: 'success', - permissionDecision: 'allow', - updatedInput: { command: 'safe-command' }, - }; const tool = registerToolForTest(setup.service, store, 'hookValidationPassTool', { invoke: async (dto) => { @@ -4180,8 +4142,14 @@ suite('LanguageModelToolsService', () => { 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( - tool.makeDto({ command: 'original' }, { sessionId: 'hook-test-validation-pass' }), + dto, async () => 0, CancellationToken.None ); @@ -4190,154 +4158,4 @@ suite('LanguageModelToolsService', () => { assert.deepStrictEqual(receivedParameters, { command: 'safe-command' }); }); }); - - suite('postToolUse hooks', () => { - let mockHooksService: MockHooksExecutionService; - let hookService: LanguageModelToolsService; - let hookChatService: MockChatService; - - setup(() => { - mockHooksService = new MockHooksExecutionService(); - const setup = createTestToolsService(store, { - hooksExecutionService: mockHooksService - }); - hookService = setup.service; - hookChatService = setup.chatService; - }); - - test('when hook blocks, block context is appended to tool result', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - decision: 'block', - reason: 'Lint errors detected', - }; - - 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, - resultKind: 'success', - 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' }), - 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'); - }); - - test('when hook blocks with both decision and additionalContext, both are appended', async () => { - mockHooksService.postToolUseHookResult = { - output: undefined, - resultKind: 'success', - decision: 'block', - reason: 'Security issue found', - additionalContext: ['Please review the file permissions'], - }; - - const tool = registerToolForTest(hookService, store, 'postHookBlockContextTool', { - invoke: async () => ({ content: [{ kind: 'text', value: 'original' }] }) - }); - - stubGetSession(hookChatService, 'post-hook-block-ctx', { requestId: 'req1' }); - - const result = await hookService.invokeTool( - tool.makeDto({ test: 1 }, { sessionId: 'post-hook-block-ctx' }), - 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')); - }); - }); }); 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..d9f9a38a76e --- /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('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('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('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/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 43f2f82410d..00000000000 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ /dev/null @@ -1,1080 +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].resultKind, 'success'); - 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 warning 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); - // Proxy errors are now treated as warnings (non-blocking) - assert.strictEqual(results[0].resultKind, 'warning'); - assert.strictEqual(results[0].warningMessage, 'proxy failed'); - assert.strictEqual(results[0].output, undefined); - 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].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'User requested stop'); - assert.strictEqual(results[0].warningMessage, 'Warning: hook triggered'); - // Hook-specific fields are in output with wrapper - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles continue false by setting stopReason', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - systemMessage: 'User requested to stop' - } - })); - 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].resultKind, 'success'); - // continue:false without explicit stopReason sets stopReason to empty string - assert.strictEqual(results[0].stopReason, ''); - assert.strictEqual(results[0].warningMessage, 'User requested to stop'); - }); - - test('stopReason takes precedence over continue false', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - continue: false, - stopReason: 'Explicit stop reason' - } - })); - 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].resultKind, 'success'); - assert.strictEqual(results[0].stopReason, 'Explicit stop reason'); - }); - - 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].warningMessage, undefined); - assert.deepStrictEqual(results[0].output, { hookSpecificOutput: { permissionDecision: 'allow' } }); - }); - - test('handles error results from command (exit code 2) as stop with stopReason', 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); - // Exit code 2 produces error with stopReason - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].stopReason, 'command failed with error'); - assert.strictEqual(results[0].output, undefined); - }); - - test('handles non-blocking error results from command', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: 'non-blocking warning message' - })); - 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].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, 'non-blocking warning message'); - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('handles non-blocking error with object result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.NonBlockingError, - result: { code: 'WARN_001', message: 'Something went wrong' } - })); - 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].resultKind, 'warning'); - assert.strictEqual(results[0].output, undefined); - assert.strictEqual(results[0].warningMessage, '{"code":"WARN_001","message":"Something went wrong"}'); - 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].resultKind, 'success'); - // 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'); - }); - - test('returns updatedInput when hook provides it', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'allow', - updatedInput: { path: '/safe/path.ts' } - } - } - })); - 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: { path: '/original/path.ts' }, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'allow'); - assert.deepStrictEqual(result.updatedInput, { path: '/safe/path.ts' }); - }); - - test('later hook updatedInput overrides earlier one', async () => { - let callCount = 0; - const proxy = createMockProxy(() => { - callCount++; - if (callCount === 1) { - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'first' } } } - }; - } - return { - kind: HookCommandResultKind.Success, - result: { hookSpecificOutput: { permissionDecision: 'allow', updatedInput: { value: 'second' } } } - }; - }); - 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.deepStrictEqual(result.updatedInput, { value: 'second' }); - }); - - test('returns result with updatedInput even without permission decision', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - updatedInput: { modified: true } - } - } - })); - 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.deepStrictEqual(result.updatedInput, { modified: true }); - assert.strictEqual(result.permissionDecision, undefined); - }); - - test('updatedInput combined with ask shows modified input to user', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - hookSpecificOutput: { - permissionDecision: 'ask', - permissionDecisionReason: 'Modified input needs review', - updatedInput: { command: 'echo 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: { command: 'rm -rf /' }, toolCallId: 'call-1' } - ); - - assert.ok(result); - assert.strictEqual(result.permissionDecision, 'ask'); - assert.strictEqual(result.permissionDecisionReason, 'Modified input needs review'); - assert.deepStrictEqual(result.updatedInput, { command: 'echo safe' }); - }); - }); - - 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/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/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 7bf407a0523..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 @@ -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', 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 fc210a06396..9a33bfea060 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -148,6 +148,7 @@ export class SettingsEditor2 extends EditorPane { `@${FEATURE_SETTING_TAG}remote`, `@${FEATURE_SETTING_TAG}timeline`, `@${FEATURE_SETTING_TAG}notebook`, + `@${FEATURE_SETTING_TAG}chat`, `@${POLICY_SETTING_TAG}` ]; 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/tasks/browser/runAutomaticTasks.ts b/src/vs/workbench/contrib/tasks/browser/runAutomaticTasks.ts index 2b029d727aa..ef940211d69 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 run 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/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/xterm/xtermTerminal.ts b/src/vs/workbench/contrib/terminal/browser/xterm/xtermTerminal.ts index 96098673241..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, 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/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 66b782d7c91..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'; @@ -189,11 +189,12 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount interface IAccountPolicyData { readonly accountId: string; readonly policyData: IPolicyData; - readonly isTokenEntitlementsDataFetched: boolean; - readonly isMcpRegistryDataFetched: boolean; + readonly tokenEntitlementsFetchedAt?: number; + readonly mcpRegistryDataFetchedAt?: number; } interface IDefaultAccountData { + accountId: string; defaultAccount: IDefaultAccount; policyData: IAccountPolicyData | null; } @@ -261,7 +262,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid const { accountId, policyData } = JSON.parse(cached); if (accountId && policyData) { this.logService.debug('[DefaultAccount] Initializing with cached policy data'); - return { accountId, policyData, isTokenEntitlementsDataFetched: false, isMcpRegistryDataFetched: false }; + return { accountId, policyData }; } } catch (error) { this.logService.error('[DefaultAccount] Failed to parse cached policy data', getErrorMessage(error)); @@ -284,7 +285,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Starting initialization'); - await this.doUpdateDefaultAccount(false); + await this.doUpdateDefaultAccount(); this.logService.debug('[DefaultAccount] Initialization complete'); this._register(this.onDidChangeDefaultAccount(account => { @@ -332,11 +333,8 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid })); this._register(this.hostService.onDidChangeFocus(focused => { - // Refresh default account when window gets focused and policy data is not fully fetched, to ensure we have the latest policy data. - if (focused && this._policyData && (!this._policyData.isMcpRegistryDataFetched || !this._policyData.isTokenEntitlementsDataFetched)) { - this.accountDataPollScheduler.cancel(); - this.logService.debug('[DefaultAccount] Window focused, updating default account'); - this.refresh(); + if (focused) { + this.refetchDefaultAccount(true); } })); } @@ -348,27 +346,31 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } this.logService.debug('[DefaultAccount] Refreshing default account'); + await this.updateDefaultAccount(); return this.defaultAccount; } - private async refetchDefaultAccount(): Promise { - if (!this.hostService.hasFocus) { + 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 because window is not focused'); + 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(true); + await this.updateDefaultAccount(useExistingEntitlements); } - private async updateDefaultAccount(donotUseLastFetchedData: boolean = false): Promise { - await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(donotUseLastFetchedData)); + private async updateDefaultAccount(useExistingEntitlements?: boolean): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount(useExistingEntitlements)); } - private async doUpdateDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async doUpdateDefaultAccount(useExistingEntitlements: boolean = false): Promise { try { - const defaultAccount = await this.fetchDefaultAccount(donotUseLastFetchedData); + const defaultAccount = await this.fetchDefaultAccount(useExistingEntitlements); this.setDefaultAccount(defaultAccount); this.scheduleAccountDataPoll(); } catch (error) { @@ -376,7 +378,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid } } - private async fetchDefaultAccount(donotUseLastFetchedData: boolean): Promise { + private async fetchDefaultAccount(useExistingEntitlements: boolean): Promise { const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); @@ -386,7 +388,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, donotUseLastFetchedData); + return await this.getDefaultAccountForAuthenticationProvider(defaultAccountProvider, useExistingEntitlements); } private setDefaultAccount(account: IDefaultAccountData | null): void { @@ -449,7 +451,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return result; } - private async getDefaultAccountForAuthenticationProvider(authenticationProvider: IDefaultAccountAuthenticationProvider, donotUseLastFetchedData: boolean): 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); @@ -459,39 +461,39 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return null; } - return this.getDefaultAccountFromAuthenticatedSessions(authenticationProvider, sessions, donotUseLastFetchedData); + 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[], donotUseLastFetchedData: boolean): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, sessions: AuthenticationSession[], useExistingEntitlements: boolean): Promise { try { const accountId = sessions[0].account.id; - const accountPolicyData = !donotUseLastFetchedData && this._policyData?.accountId === accountId ? this._policyData : undefined; + const existingEntitlementsData = this._defaultAccount?.accountId === accountId ? this._defaultAccount?.defaultAccount.entitlementsData : undefined; + const accountPolicyData = this._policyData?.accountId === accountId ? this._policyData : undefined; - const [entitlementsData, tokenEntitlementsData] = await Promise.all([ - this.getEntitlements(sessions), + const [entitlementsData, tokenEntitlementsResult] = await Promise.all([ + useExistingEntitlements && existingEntitlementsData ? existingEntitlementsData : this.getEntitlements(sessions), this.getTokenEntitlements(sessions, accountPolicyData), ]); - let isTokenEntitlementsDataFetched = false; - let isMcpRegistryDataFetched = false; + let tokenEntitlementsFetchedAt: number | undefined; + let mcpRegistryDataFetchedAt: number | undefined; let policyData: Mutable | undefined = accountPolicyData?.policyData ? { ...accountPolicyData.policyData } : undefined; - if (tokenEntitlementsData) { - isTokenEntitlementsDataFetched = true; + 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, accountPolicyData); - if (!isUndefined(mcpRegistryProvider)) { - isMcpRegistryDataFetched = true; - 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; @@ -505,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, isTokenEntitlementsDataFetched, isMcpRegistryDataFetched } : 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; @@ -560,12 +562,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise | undefined> { - if (accountPolicyData?.isTokenEntitlementsDataFetched) { + 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 accountPolicyData.policyData; + return { data: accountPolicyData.policyData, fetchedAt: accountPolicyData.tokenEntitlementsFetchedAt }; } - return await this.requestTokenEntitlements(sessions); + const data = await this.requestTokenEntitlements(sessions); + return data ? { data, fetchedAt: Date.now() } : undefined; } private async requestTokenEntitlements(sessions: AuthenticationSession[]): Promise | undefined> { @@ -639,12 +642,14 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return undefined; } - private async getMcpRegistryProvider(sessions: AuthenticationSession[], accountPolicyData: IAccountPolicyData | undefined): Promise { - if (accountPolicyData?.isMcpRegistryDataFetched) { + 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'); - return accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + const data = accountPolicyData.policyData.mcpRegistryUrl && accountPolicyData.policyData.mcpAccess ? { url: accountPolicyData.policyData.mcpRegistryUrl, registry_access: accountPolicyData.policyData.mcpAccess } : null; + return { data, fetchedAt: accountPolicyData.mcpRegistryDataFetchedAt }; } - return await this.requestMcpRegistryProvider(sessions); + const data = await this.requestMcpRegistryProvider(sessions); + return !isUndefined(data) ? { data, fetchedAt: Date.now() } : undefined; } private async requestMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { @@ -660,11 +665,13 @@ 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`); - return response.res.statusCode === 404 /* mcp not configured */ - ? null - : undefined; + 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; } try { @@ -703,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 } @@ -711,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); } } } @@ -724,6 +732,10 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid 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/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/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/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.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts index 39064b88952..eec28002b77 100644 --- a/src/vscode-dts/vscode.proposed.chatHooks.d.ts +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// version: 3 +// version: 6 declare module 'vscode' { @@ -13,18 +13,33 @@ declare module 'vscode' { 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[]; } /** @@ -60,20 +75,15 @@ declare module 'vscode' { readonly output: unknown; } - 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. 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.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 {