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/build/win32/code.iss b/build/win32/code.iss index d889ca8c4d3..0d47e15103f 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1453,6 +1453,12 @@ begin Result := not (IsBackgroundUpdate() and FileExists(Path)); end; +// Check if VS Code created a cancel file to signal that the update should be aborted +function CancelFileExists(): Boolean; +begin + Result := FileExists(ExpandConstant('{param:cancel}')) +end; + function ShouldRunAfterUpdate(): Boolean; begin if IsBackgroundUpdate() then @@ -1639,11 +1645,17 @@ begin Log('Checking whether application is still running...'); while (CheckForMutexes('{#AppMutex}')) do begin + if CancelFileExists() then + begin + Log('Cancel file detected, aborting background update.'); + DeleteFile(ExpandConstant('{app}\updating_version')); + Abort; + end; Sleep(1000) end; Log('Application appears not to be running.'); - if not SessionEndFileExists() then begin + if not SessionEndFileExists() and not CancelFileExists() then begin StopTunnelServiceIfNeeded(); Log('Invoking inno_updater for background update'); Exec(ExpandConstant('{app}\{#VersionedResourcesFolder}\tools\inno_updater.exe'), ExpandConstant('"{app}\{#ExeBasename}.exe" ' + BoolToStr(LockFileExists()) + ' "{cm:UpdatingVisualStudioCode}"'), '', SW_SHOW, ewWaitUntilTerminated, UpdateResultCode); @@ -1657,7 +1669,7 @@ begin end; #endif end else begin - Log('Skipping inno_updater.exe call because OS session is ending'); + Log('Skipping inno_updater.exe call because OS session is ending or cancel was requested'); end; end else begin if IsVersionedUpdate() then begin diff --git a/eslint.config.js b/eslint.config.js index 96e1232427b..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/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..76e870eca8b 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -102,6 +102,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/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 d7794f4e979..e79cfc22656 100644 --- a/package.json +++ b/package.json @@ -92,16 +92,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/common/config/editorOptions.ts b/src/vs/editor/common/config/editorOptions.ts index a6e7322d41c..acc23078dae 100644 --- a/src/vs/editor/common/config/editorOptions.ts +++ b/src/vs/editor/common/config/editorOptions.ts @@ -496,7 +496,7 @@ export interface IEditorOptions { * Enable quick suggestions (shadow suggestions) * Defaults to true. */ - quickSuggestions?: boolean | IQuickSuggestionsOptions; + quickSuggestions?: boolean | QuickSuggestionsValue | IQuickSuggestionsOptions; /** * Quick suggestions show delay (in ms) * Defaults to 10 (ms) @@ -3712,7 +3712,7 @@ class PlaceholderOption extends BaseEditorOption { +class EditorQuickSuggestions extends BaseEditorOption { public override readonly defaultValue: InternalQuickSuggestionsOptions; @@ -3743,32 +3743,45 @@ class EditorQuickSuggestions extends BaseEditorOption apply same value to all token types + const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off', 'offWhenInlineCompletions']; + const validated = stringSet(input as QuickSuggestionsValue, this.defaultValue.other, allowedValues); + return { comments: validated, strings: validated, other: validated }; + } if (!input || typeof input !== 'object') { - // invalid object + // invalid input return this.defaultValue; } const { other, comments, strings } = (input); - const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off']; + const allowedValues: QuickSuggestionsValue[] = ['on', 'inline', 'off', 'offWhenInlineCompletions']; let validatedOther: QuickSuggestionsValue; let validatedComments: QuickSuggestionsValue; let validatedStrings: QuickSuggestionsValue; diff --git a/src/vs/editor/contrib/hover/browser/hoverUtils.ts b/src/vs/editor/contrib/hover/browser/hoverUtils.ts index 997d4512c1a..1dc56a043b6 100644 --- a/src/vs/editor/contrib/hover/browser/hoverUtils.ts +++ b/src/vs/editor/contrib/hover/browser/hoverUtils.ts @@ -6,12 +6,16 @@ import * as dom from '../../../../base/browser/dom.js'; import { IEditorMouseEvent } from '../../../browser/editorBrowser.js'; +const enum PADDING { + VALUE = 3 +} + export function isMousePositionWithinElement(element: HTMLElement, posx: number, posy: number): boolean { const elementRect = dom.getDomNodePagePosition(element); - if (posx < elementRect.left - || posx > elementRect.left + elementRect.width - || posy < elementRect.top - || posy > elementRect.top + elementRect.height) { + if (posx < elementRect.left + PADDING.VALUE + || posx > elementRect.left + elementRect.width - PADDING.VALUE + || posy < elementRect.top + PADDING.VALUE + || posy > elementRect.top + elementRect.height - PADDING.VALUE) { return false; } return true; diff --git a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts index e289a3dbca0..ba261eaa4a4 100644 --- a/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts +++ b/src/vs/editor/contrib/hover/browser/markdownHoverParticipant.ts @@ -36,8 +36,8 @@ import { HoverStartSource } from './hoverOperation.js'; import { ScrollEvent } from '../../../../base/common/scrollable.js'; const $ = dom.$; -const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.add, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); -const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.remove, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); +const increaseHoverVerbosityIcon = registerIcon('hover-increase-verbosity', Codicon.addSmall, nls.localize('increaseHoverVerbosity', 'Icon for increaseing hover verbosity.')); +const decreaseHoverVerbosityIcon = registerIcon('hover-decrease-verbosity', Codicon.removeSmall, nls.localize('decreaseHoverVerbosity', 'Icon for decreasing hover verbosity.')); export class MarkdownHover implements IHoverPart { diff --git a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts index e40987aeefe..593eb58d304 100644 --- a/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts +++ b/src/vs/editor/contrib/hover/test/browser/hoverUtils.test.ts @@ -114,10 +114,10 @@ suite('Hover Utils', () => { test('returns true when mouse is on element edges', () => { const element = createMockElement(100, 100, 200, 100); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); // top-left corner - assert.strictEqual(isMousePositionWithinElement(element, 300, 100), true); // top-right corner - assert.strictEqual(isMousePositionWithinElement(element, 100, 200), true); // bottom-left corner - assert.strictEqual(isMousePositionWithinElement(element, 300, 200), true); // bottom-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); // top-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 100), false); // top-right corner + assert.strictEqual(isMousePositionWithinElement(element, 100, 200), false); // bottom-left corner + assert.strictEqual(isMousePositionWithinElement(element, 300, 200), false); // bottom-right corner }); test('returns false when mouse is left of element', () => { @@ -146,16 +146,16 @@ suite('Hover Utils', () => { test('handles element at origin (0,0)', () => { const element = createMockElement(0, 0, 100, 100); - assert.strictEqual(isMousePositionWithinElement(element, 0, 0), true); + assert.strictEqual(isMousePositionWithinElement(element, 0, 0), false); assert.strictEqual(isMousePositionWithinElement(element, 50, 50), true); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); }); test('handles small elements (1x1)', () => { const element = createMockElement(100, 100, 1, 1); - assert.strictEqual(isMousePositionWithinElement(element, 100, 100), true); - assert.strictEqual(isMousePositionWithinElement(element, 101, 101), true); + assert.strictEqual(isMousePositionWithinElement(element, 100, 100), false); + assert.strictEqual(isMousePositionWithinElement(element, 101, 101), false); assert.strictEqual(isMousePositionWithinElement(element, 102, 102), false); }); }); diff --git a/src/vs/editor/contrib/suggest/browser/suggestModel.ts b/src/vs/editor/contrib/suggest/browser/suggestModel.ts index fcf97af35a6..30c1276d5c4 100644 --- a/src/vs/editor/contrib/suggest/browser/suggestModel.ts +++ b/src/vs/editor/contrib/suggest/browser/suggestModel.ts @@ -415,7 +415,10 @@ export class SuggestModel implements IDisposable { const lineTokens = model.tokenization.getLineTokens(pos.lineNumber); const tokenType = lineTokens.getStandardTokenType(lineTokens.findTokenIndexAtOffset(Math.max(pos.column - 1 - 1, 0))); if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'on') { - return; + if (QuickSuggestionsOptions.valueFor(config, tokenType) !== 'offWhenInlineCompletions' + || (this._languageFeaturesService.inlineCompletionsProvider.has(model) && this._editor.getOption(EditorOption.inlineSuggest).enabled)) { + return; + } } } diff --git a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts index dccb55ef441..ff465706c0c 100644 --- a/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts +++ b/src/vs/editor/contrib/suggest/test/browser/suggestModel.test.ts @@ -15,7 +15,7 @@ import { Selection } from '../../../../common/core/selection.js'; import { Handler } from '../../../../common/editorCommon.js'; import { ITextModel } from '../../../../common/model.js'; import { TextModel } from '../../../../common/model/textModel.js'; -import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, IState, TokenizationRegistry } from '../../../../common/languages.js'; +import { CompletionItemKind, CompletionItemProvider, CompletionList, CompletionTriggerKind, EncodedTokenizationResult, InlineCompletionsProvider, IState, TokenizationRegistry } from '../../../../common/languages.js'; import { MetadataConsts } from '../../../../common/encodedTokenAttributes.js'; import { ILanguageConfigurationService } from '../../../../common/languages/languageConfigurationRegistry.js'; import { NullState } from '../../../../common/languages/nullTokenize.js'; @@ -41,6 +41,7 @@ import { IInstantiationService } from '../../../../../platform/instantiation/com import { getSnippetSuggestSupport, setSnippetSuggestSupport } from '../../browser/suggest.js'; import { IEnvironmentService } from '../../../../../platform/environment/common/environment.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; function createMockEditor(model: TextModel, languageFeaturesService: ILanguageFeaturesService): ITestCodeEditor { @@ -1228,4 +1229,142 @@ suite('SuggestModel - TriggerAndCancelOracle', function () { }); }); + + test('offWhenInlineCompletions - suppresses quick suggest when inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); + + return new Promise((resolve, reject) => { + const unexpectedSuggestSub = suggestOracle.onDidSuggest(() => { + unexpectedSuggestSub.dispose(); + reject(new Error('Quick suggestions should not have been triggered')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + // Wait for the quick suggest delay to pass without triggering + setTimeout(() => { + unexpectedSuggestSub.dispose(); + resolve(); + }, 200); + }); + }); + }); + + test('offWhenInlineCompletions - allows quick suggest when no inline provider exists', function () { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // No inline completions provider registered for 'test' scheme + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' } }); + + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); + }); + }); + + test('offWhenInlineCompletions - allows quick suggest when inlineSuggest is disabled', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + // Register a dummy inline completions provider + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + editor.updateOptions({ + quickSuggestions: { comments: 'off', strings: 'off', other: 'offWhenInlineCompletions' }, + inlineSuggest: { enabled: false } + }); + + return assertEvent(suggestOracle.onDidSuggest, () => { + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + }, suggestEvent => { + assert.strictEqual(suggestEvent.triggerOptions.auto, true); + assert.strictEqual(suggestEvent.completionModel.items.length, 1); + }); + }); + }); + }); + + test('string shorthand - "off" disables quick suggestions for all token types', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + return withOracle((suggestOracle, editor) => { + // Use string shorthand instead of object form + editor.updateOptions({ quickSuggestions: 'off' }); + + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by string shorthand "off"')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); + }); + }); + }); + }); + + test('string shorthand - "offWhenInlineCompletions" suppresses when inline provider exists', function () { + return runWithFakedTimers({ useFakeTimers: true }, () => { + disposables.add(registry.register({ scheme: 'test' }, alwaysSomethingSupport)); + + const inlineProvider: InlineCompletionsProvider = { + provideInlineCompletions: () => ({ items: [] }), + disposeInlineCompletions: () => { } + }; + disposables.add(languageFeaturesService.inlineCompletionsProvider.register({ scheme: 'test' }, inlineProvider)); + + return withOracle((suggestOracle, editor) => { + // Use string shorthand — applies to all token types + editor.updateOptions({ quickSuggestions: 'offWhenInlineCompletions' }); + + return new Promise((resolve, reject) => { + const sub = suggestOracle.onDidSuggest(() => { + sub.dispose(); + reject(new Error('Quick suggestions should have been suppressed by offWhenInlineCompletions shorthand')); + }); + + editor.setPosition({ lineNumber: 1, column: 4 }); + editor.trigger('keyboard', Handler.Type, { text: 'd' }); + + setTimeout(() => { + sub.dispose(); + resolve(); + }, 200); + }); + }); + }); + }); }); diff --git a/src/vs/monaco.d.ts b/src/vs/monaco.d.ts index 22641b4e01a..be805ae83df 100644 --- a/src/vs/monaco.d.ts +++ b/src/vs/monaco.d.ts @@ -3645,7 +3645,7 @@ declare namespace monaco.editor { * Enable quick suggestions (shadow suggestions) * Defaults to true. */ - quickSuggestions?: boolean | IQuickSuggestionsOptions; + quickSuggestions?: boolean | QuickSuggestionsValue | IQuickSuggestionsOptions; /** * Quick suggestions show delay (in ms) * Defaults to 10 (ms) @@ -4587,7 +4587,7 @@ declare namespace monaco.editor { cycle?: boolean; } - export type QuickSuggestionsValue = 'on' | 'inline' | 'off'; + export type QuickSuggestionsValue = 'on' | 'inline' | 'off' | 'offWhenInlineCompletions'; /** * Configuration options for quick suggestions @@ -5374,7 +5374,7 @@ declare namespace monaco.editor { showUnused: IEditorOption; showDeprecated: IEditorOption; inlayHints: IEditorOption>>; - snippetSuggestions: IEditorOption; + snippetSuggestions: IEditorOption; smartSelect: IEditorOption>>; smoothScrolling: IEditorOption; stopRenderingLineAfter: IEditorOption; diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 7c470fb6cca..a81fe449d62 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -273,6 +273,7 @@ export class MenuId { static readonly ChatEditingCodeBlockContext = new MenuId('ChatEditingCodeBlockContext'); static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu'); static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext'); + static readonly ChatTipContext = new MenuId('ChatTipContext'); static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar'); static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu'); static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); diff --git a/src/vs/platform/configuration/common/configurationRegistry.ts b/src/vs/platform/configuration/common/configurationRegistry.ts index f12e732db79..f035cdec47c 100644 --- a/src/vs/platform/configuration/common/configurationRegistry.ts +++ b/src/vs/platform/configuration/common/configurationRegistry.ts @@ -200,6 +200,11 @@ export interface IConfigurationPropertySchema extends IJSONSchema { */ enumItemLabels?: string[]; + /** + * Optional keywords used for search purposes. + */ + keywords?: string[]; + /** * When specified, controls the presentation format of string settings. * Otherwise, the presentation format defaults to `singleline`. diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 8769eef63c2..abc94186d7f 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: 5 }, 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 6e4e11e17ec..ee6ff01fdf0 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -38,6 +38,8 @@ import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; import { isMotionReduced, QUICK_INPUT_OPEN_DURATION, QUICK_INPUT_CLOSE_DURATION, EASE_OUT, EASE_IN } from '../../../base/browser/ui/motion/motion.js'; +import { AnchorAlignment, AnchorPosition, layout2d } from '../../../base/common/layout.js'; +import { getAnchorRect } from '../../../base/browser/ui/contextview/contextview.js'; const $ = dom.$; @@ -542,6 +544,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]) => { @@ -711,6 +714,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); @@ -901,16 +905,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); } } @@ -1005,6 +1045,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; @@ -1040,6 +1082,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) { @@ -1051,6 +1097,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({ @@ -1081,6 +1132,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; @@ -1097,6 +1152,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/remote/common/remoteHosts.ts b/src/vs/platform/remote/common/remoteHosts.ts index 9583e30f1f0..f6fc4cb18fa 100644 --- a/src/vs/platform/remote/common/remoteHosts.ts +++ b/src/vs/platform/remote/common/remoteHosts.ts @@ -25,6 +25,30 @@ export function getRemoteName(authority: string | undefined): string | undefined return authority.substr(0, pos); } +/** + * Returns the suffix part of the authority after the '+' character. + * For remote connections, this is typically the server/tunnel identifier. + * Examples: + * - For tunnels: `tunnel+myTunnel` returns `myTunnel` + * - For SSH: `ssh+myserver` returns `myserver` + * - For localhost: `localhost:8000` returns `undefined` + * @param authority The remote authority string. + * @returns The suffix after the '+' character, or undefined if there is no '+' character. + */ +export function getRemoteServerRootPath(authority: string): string | undefined; +export function getRemoteServerRootPath(authority: undefined): undefined; +export function getRemoteServerRootPath(authority: string | undefined): string | undefined; +export function getRemoteServerRootPath(authority: string | undefined): string | undefined { + if (!authority) { + return undefined; + } + const pos = authority.indexOf('+'); + if (pos < 0) { + return undefined; + } + return authority.substring(pos + 1); +} + export function parseAuthorityWithPort(authority: string): { host: string; port: number } { const { host, port } = parseAuthority(authority); if (typeof port === 'undefined') { diff --git a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts index bae34610f67..d85e651b231 100644 --- a/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts +++ b/src/vs/platform/remoteTunnel/node/remoteTunnelService.ts @@ -28,11 +28,25 @@ type RemoteTunnelEnablementClassification = { comment: 'Reporting when Remote Tunnel access is turned on or off'; enabled?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is enabled or not' }; service?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Flag indicating if Remote Tunnel Access is installed as a service' }; + tunnelName?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the tunnel being enabled or disabled' }; }; type RemoteTunnelEnablementEvent = { enabled: boolean; service: boolean; + tunnelName?: string; +}; + +type RemoteTunnelConnectedClassification = { + owner: 'aeschli'; + comment: 'Reporting when a Remote Tunnel connection is established'; + tunnelName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the connected tunnel' }; + isAttached: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the connection is attached to an existing tunnel process' }; +}; + +type RemoteTunnelConnectedEvent = { + tunnelName: string; + isAttached: boolean; }; const restartTunnelOnConfigurationChanges: readonly string[] = [ @@ -241,9 +255,11 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ } private async updateTunnelProcess(): Promise { + const tunnelName = this._getTunnelName(); this.telemetryService.publicLog2('remoteTunnel.enablement', { enabled: this._mode.active, service: this._mode.active && this._mode.asService, + tunnelName, }); if (this._tunnelProcess) { @@ -396,6 +412,10 @@ export class RemoteTunnelService extends Disposable implements IRemoteTunnelServ const m = message.match(/Open this link in your browser (https:\/\/([^\/\s]+)\/([^\/\s]+)\/([^\/\s]+))/); if (m) { const info: ConnectionInfo = { link: m[1], domain: m[2], tunnelName: m[4], isAttached }; + this.telemetryService.publicLog2('remoteTunnel.connected', { + tunnelName: info.tunnelName, + isAttached: info.isAttached, + }); this.setTunnelStatus(TunnelStates.connected(info, serviceInstallFailed)); } else if (message.match(/error refreshing token/)) { serveCommand.cancel(); diff --git a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts index 66d8a0aa918..8de7fdcc25c 100644 --- a/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts +++ b/src/vs/platform/terminal/common/xterm/shellIntegrationAddon.ts @@ -21,20 +21,17 @@ import { removeAnsiEscapeCodesFromPrompt } from '../../../../base/common/strings import { ShellEnvDetectionCapability } from '../capabilities/shellEnvDetectionCapability.js'; import { PromptTypeDetectionCapability } from '../capabilities/promptTypeDetectionCapability.js'; - -/** - * Shell integration is a feature that enhances the terminal's understanding of what's happening - * in the shell by injecting special sequences into the shell's prompt using the "Set Text - * Parameters" sequence (`OSC Ps ; Pt ST`). - * - * Definitions: - * - OSC: `\x1b]` - * - Ps: A single (usually optional) numeric parameter, composed of one or more digits. - * - Pt: A text parameter composed of printable characters. - * - ST: `\x7` - * - * This is inspired by a feature of the same name in the FinalTerm, iTerm2 and kitty terminals. - */ +// Shell integration is a feature that enhances the terminal's understanding of what's happening +// in the shell by injecting special sequences into the shell's prompt using the "Set Text +// Parameters" sequence (`OSC Ps ; Pt ST`). +// +// Definitions: +// - OSC: `\x1b]` +// - Ps: A single (usually optional) numeric parameter, composed of one or more digits. +// - Pt: A text parameter composed of printable characters. +// - ST: `\x7` +// +// This is inspired by a feature of the same name in the FinalTerm, iTerm2 and kitty terminals. /** * The identifier for the first numeric parameter (`Ps`) for OSC commands used by shell integration. @@ -174,6 +171,8 @@ const enum VSCodeOscPt { /** * Similar to prompt start but for line continuations. * + * Format: `OSC 633 ; F ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ ContinuationStart = 'F', @@ -181,6 +180,8 @@ const enum VSCodeOscPt { /** * Similar to command start but for line continuations. * + * Format: `OSC 633 ; G ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ ContinuationEnd = 'G', @@ -188,6 +189,8 @@ const enum VSCodeOscPt { /** * The start of the right prompt. * + * Format: `OSC 633 ; H ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ RightPromptStart = 'H', @@ -195,6 +198,8 @@ const enum VSCodeOscPt { /** * The end of the right prompt. * + * Format: `OSC 633 ; I ST` + * * WARNING: This sequence is unfinalized, DO NOT use this in your shell integration script. */ RightPromptEnd = 'I', @@ -224,7 +229,7 @@ const enum VSCodeOscPt { /** * Sets a mark/point-of-interest in the buffer. * - * Format: `OSC 633 ; SetMark [; Id=] [; Hidden]` + * Format: `OSC 633 ; SetMark [; Id=] [; Hidden] ST` * * `Id` - The identifier of the mark that can be used to reference it * `Hidden` - When set, the mark will be available to reference internally but will not visible @@ -236,7 +241,7 @@ const enum VSCodeOscPt { /** * Sends the shell's complete environment in JSON format. * - * Format: `OSC 633 ; EnvJson ; ; ` + * Format: `OSC 633 ; EnvJson ; ; ST` * * - `Environment` - A stringified JSON object containing the shell's complete environment. The * variables and values use the same encoding rules as the {@link CommandLine} sequence. @@ -250,7 +255,7 @@ const enum VSCodeOscPt { /** * Delete a single environment variable from cached environment. * - * Format: `OSC 633 ; EnvSingleDelete ; ; [; ]` + * Format: `OSC 633 ; EnvSingleDelete ; ; [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -262,7 +267,7 @@ const enum VSCodeOscPt { /** * The start of the collecting user's environment variables individually. * - * Format: `OSC 633 ; EnvSingleStart ; [; ]` + * Format: `OSC 633 ; EnvSingleStart ; [; ] ST` * * - `Clear` - An _mandatory_ flag indicating any cached environment variables will be cleared. * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order @@ -275,7 +280,7 @@ const enum VSCodeOscPt { /** * Sets an entry of single environment variable to transactional pending map of environment variables. * - * Format: `OSC 633 ; EnvSingleEntry ; ; [; ]` + * Format: `OSC 633 ; EnvSingleEntry ; ; [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -288,7 +293,7 @@ const enum VSCodeOscPt { * The end of the collecting user's environment variables individually. * Clears any pending environment variables and fires an event that contains user's environment. * - * Format: `OSC 633 ; EnvSingleEnd [; ]` + * Format: `OSC 633 ; EnvSingleEnd [; ] ST` * * - `Nonce` - An optional nonce can be provided which may be required by the terminal in order * to enable some features. This helps ensure no malicious command injection has occurred. @@ -305,7 +310,7 @@ const enum ITermOscPt { /** * Sets a mark/point-of-interest in the buffer. * - * Format: `OSC 1337 ; SetMark` + * Format: `OSC 1337 ; SetMark ST` */ SetMark = 'SetMark', 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/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 0c396b41681..ed54d90f383 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -253,7 +253,15 @@ export abstract class AbstractUpdateService implements IUpdateService { if (isLatest === false && this._state.type === StateType.Ready) { this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); - await this.cancelPendingUpdate(); + + try { + await this.cancelPendingUpdate(); + } catch (error) { + this.logService.error('update#checkForOverwriteUpdates(): failed to cancel pending update, aborting overwrite'); + this.logService.error(error); + return false; + } + this._overwrite = true; this.setState(State.Overwriting(this._state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 37c7f4e8ec1..7778a01ffa3 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { spawn } from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; @@ -18,6 +18,7 @@ import { transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { checksum } from '../../../base/node/crypto.js'; import * as pfs from '../../../base/node/pfs.js'; +import { killTree } from '../../../base/node/processes.js'; import { IConfigurationService } from '../../configuration/common/configuration.js'; import { IEnvironmentMainService } from '../../environment/electron-main/environmentMainService.js'; import { IFileService } from '../../files/common/files.js'; @@ -40,6 +41,10 @@ async function pollUntil(fn: () => boolean, millis = 1000): Promise { interface IAvailableUpdate { packagePath: string; updateFilePath?: string; + /** File path used to signal the Inno Setup installer to cancel */ + cancelFilePath?: string; + /** The Inno Setup process that is applying the update in the background */ + updateProcess?: ChildProcess; } let _updateType: UpdateType | undefined = undefined; @@ -75,7 +80,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun @IProductService productService: IProductService, @IMeteredConnectionService meteredConnectionService: IMeteredConnectionService, ) { - super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, false); + super(lifecycleMainService, configurationService, environmentMainService, requestService, logService, productService, meteredConnectionService, true); lifecycleMainService.setRelaunchHandler(this); } @@ -168,14 +173,18 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return createUpdateURL(this.productService.updateUrl!, platform, quality, commit, options); } - protected doCheckForUpdates(explicit: boolean): void { + protected doCheckForUpdates(explicit: boolean, pendingCommit?: string): void { if (!this.quality) { return; } const background = !explicit && !this.shouldDisableProgressiveReleases(); - const url = this.buildUpdateFeedUrl(this.quality, this.productService.commit!, { background }); - this.setState(State.CheckingForUpdates(explicit)); + const url = this.buildUpdateFeedUrl(this.quality, pendingCommit ?? this.productService.commit!, { background }); + + // Only set CheckingForUpdates if we're not already in Overwriting state + if (this.state.type !== StateType.Overwriting) { + this.setState(State.CheckingForUpdates(explicit)); + } this.requestService.request({ url }, CancellationToken.None) .then(asJson) @@ -183,7 +192,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const updateType = getUpdateType(); if (!update || !update.url || !update.version || !update.productVersion) { - this.setState(State.Idle(updateType)); + // If we were checking for an overwrite update and found nothing newer, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(updateType)); + } return Promise.resolve(null); } @@ -245,13 +261,12 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun }); }).then(packagePath => { this.availableUpdate = { packagePath }; + this.saveUpdateMetadata(update); this.setState(State.Downloaded(update, explicit, this._overwrite)); const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, explicit, this._overwrite)); } @@ -264,7 +279,15 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun // only show message when explicitly checking for updates const message: string | undefined = explicit ? (err.message || err) : undefined; - this.setState(State.Idle(getUpdateType(), message)); + + // If we were checking for an overwrite update and it failed, + // restore the Ready state with the pending update + if (this.state.type === StateType.Overwriting) { + this._overwrite = false; + this.setState(State.Ready(this.state.update, this.state.explicit, false)); + } else { + this.setState(State.Idle(getUpdateType(), message)); + } }); } @@ -312,15 +335,36 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const cachePath = await this.cachePath; const sessionEndFlagPath = path.join(cachePath, 'session-ending.flag'); + const cancelFilePath = path.join(cachePath, `cancel.flag`); + try { + await unlink(cancelFilePath); + } catch { + // ignore + } this.availableUpdate.updateFilePath = path.join(cachePath, `CodeSetup-${this.productService.quality}-${update.version}.flag`); + this.availableUpdate.cancelFilePath = cancelFilePath; await pfs.Promises.writeFile(this.availableUpdate.updateFilePath, 'flag'); - const child = spawn(this.availableUpdate.packagePath, ['/verysilent', '/log', `/update="${this.availableUpdate.updateFilePath}"`, `/sessionend="${sessionEndFlagPath}"`, '/nocloseapplications', '/mergetasks=runcode,!desktopicon,!quicklaunchicon'], { - detached: true, - stdio: ['ignore', 'ignore', 'ignore'], - windowsVerbatimArguments: true - }); + const child = spawn(this.availableUpdate.packagePath, + [ + '/verysilent', + '/log', + `/update="${this.availableUpdate.updateFilePath}"`, + `/sessionend="${sessionEndFlagPath}"`, + `/cancel="${cancelFilePath}"`, + '/nocloseapplications', + '/mergetasks=runcode,!desktopicon,!quicklaunchicon' + ], + { + detached: true, + stdio: ['ignore', 'ignore', 'ignore'], + windowsVerbatimArguments: true + } + ); + + // Track the process so we can cancel it if needed + this.availableUpdate.updateProcess = child; child.once('exit', () => { this.availableUpdate = undefined; @@ -335,6 +379,58 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun .then(() => this.setState(State.Ready(update, explicit, this._overwrite))); } + protected override async cancelPendingUpdate(): Promise { + if (!this.availableUpdate) { + return; + } + + this.logService.trace('update#cancelPendingUpdate: cancelling pending update'); + const { updateProcess, updateFilePath, cancelFilePath } = this.availableUpdate; + + if (updateProcess && updateProcess.exitCode === null) { + // Remove all listeners to prevent the exit handler from changing state + updateProcess.removeAllListeners(); + const exitPromise = new Promise(resolve => updateProcess.once('exit', () => resolve(true))); + + // Write the cancel file to signal Inno Setup to exit gracefully + if (cancelFilePath) { + try { + await pfs.Promises.writeFile(cancelFilePath, 'cancel'); + } catch (err) { + this.logService.warn('update#cancelPendingUpdate: failed to write cancel file', err); + } + } + + // Wait for the process to exit gracefully, then force-kill if needed + const pid = updateProcess.pid; + const exited = await Promise.race([exitPromise, timeout(30 * 1000).then(() => false)]); + if (pid && !exited) { + this.logService.trace('update#cancelPendingUpdate: process did not exit gracefully, killing process tree'); + await killTree(pid, true); + } + } + + // Clean up the flag file + if (updateFilePath) { + try { + await unlink(updateFilePath); + } catch (err) { + // ignore + } + } + + // Clean up the cancel file + if (cancelFilePath) { + try { + await unlink(cancelFilePath); + } catch (err) { + // ignore + } + } + + this.availableUpdate = undefined; + } + protected override doQuitAndInstall(): void { if (this.state.type !== StateType.Ready || !this.availableUpdate) { return; @@ -352,6 +448,30 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } } + private async saveUpdateMetadata(update: IUpdate): Promise { + try { + const cachePath = await this.cachePath; + const metadataPath = path.join(cachePath, 'update-metadata.json'); + await pfs.Promises.writeFile(metadataPath, JSON.stringify(update)); + } catch (e) { + this.logService.error('update#saveUpdateMetadata: failed to save', e); + } + } + + private async loadUpdateMetadata(): Promise { + try { + const cachePath = await this.cachePath; + const metadataPath = path.join(cachePath, 'update-metadata.json'); + if (await pfs.Promises.exists(metadataPath)) { + const content = await readFile(metadataPath, 'utf8'); + return JSON.parse(content); + } + } catch (e) { + this.logService.error('update#loadUpdateMetadata: failed to load', e); + } + return undefined; + } + protected override getUpdateType(): UpdateType { return getUpdateType(); } @@ -362,16 +482,14 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun } const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); - const update: IUpdate = { version: 'unknown', productVersion: 'unknown' }; + const update: IUpdate = await this.loadUpdateMetadata() ?? { version: 'unknown', productVersion: 'unknown' }; this.setState(State.Downloading(update, true, false)); this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); - if (fastUpdatesEnabled) { - if (this.productService.target === 'user') { - this.doApplyUpdate(); - } + if (fastUpdatesEnabled && this.productService.target === 'user') { + this.doApplyUpdate(); } else { this.setState(State.Ready(update, true, false)); } diff --git a/src/vs/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 27dfd835f3e..ff9ea448f4a 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -5,7 +5,6 @@ import { raceCancellationError } from '../../../base/common/async.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; -import { isCancellationError } from '../../../base/common/errors.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'; @@ -321,12 +320,47 @@ export class ObservableChatSession extends Disposable implements IChatSession { } } +class MainThreadChatSessionItemController extends Disposable implements IChatSessionItemController { + + private readonly _proxy: ExtHostChatSessionsShape; + private readonly _handle: number; + + private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); + public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; + + constructor( + proxy: ExtHostChatSessionsShape, + handle: number + ) { + super(); + this._proxy = proxy; + this._handle = handle; + } + + private _items: IChatSessionItem[] = []; + get items(): IChatSessionItem[] { + return this._items; + } + + refresh(token: CancellationToken): Promise { + return this._proxy.$refreshChatSessionItems(this._handle, token); + } + + setItems(items: IChatSessionItem[]): void { + this._items = items; + this._onDidChangeChatSessionItems.fire(); + } + + fireOnDidChangeChatSessionItems(): void { + this._onDidChangeChatSessionItems.fire(); + } +} + @extHostNamedCustomer(MainContext.MainThreadChatSessions) export class MainThreadChatSessions extends Disposable implements MainThreadChatSessionsShape { private readonly _itemControllerRegistrations = this._register(new DisposableMap; + readonly controller: MainThreadChatSessionItemController; }>()); private readonly _contentProvidersRegistrations = this._register(new DisposableMap()); private readonly _sessionTypeToHandle = new Map(); @@ -375,51 +409,67 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return this._sessionTypeToHandle.get(chatSessionType); } - $registerChatSessionItemProvider(handle: number, chatSessionType: string): void { - // Register the provider handle - this tracks that a provider exists + $registerChatSessionItemController(handle: number, chatSessionType: string): void { const disposables = new DisposableStore(); - const changeEmitter = disposables.add(new Emitter()); - - const self = this; - - const controller = new class implements IChatSessionItemController { - - items: IChatSessionItem[] = []; - - get onDidChangeChatSessionItems() { - return changeEmitter.event; - } - - async refresh(token: CancellationToken): Promise { - try { - this.items = await self._provideChatSessionItems(handle, token); - } catch (err) { - if (isCancellationError(err)) { - return; - } - throw err; - } - } - }; + 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, - onDidChangeItems: changeEmitter, + dispose: () => disposables.dispose(), }); disposables.add(this._chatSessionsService.registerChatModelChangeListeners( this._chatService, chatSessionType, - () => changeEmitter.fire() + () => controller.fireOnDidChangeChatSessionItems() )); } $onDidChangeChatSessionItems(handle: number): void { - this._itemControllerRegistrations.get(handle)?.onDidChangeItems.fire(); + this._itemControllerRegistrations.get(handle)?.controller.fireOnDidChangeChatSessionItems(); + } + + async $setChatSessionItems(handle: number, items: Dto[]): Promise { + const registration = this._itemControllerRegistrations.get(handle); + if (!registration) { + this._logService.warn(`No chat session controller registered for handle ${handle}`); + return; + } + + const resolvedItems = await Promise.all(items.map(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; + })); + + registration.controller.setItems(resolvedItems); } $onDidChangeChatSessionOptions(handle: number, sessionResourceComponents: UriComponents, updates: ReadonlyArray<{ optionId: string; value: string }>): void { @@ -503,46 +553,6 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } - private async _provideChatSessionItems(handle: number, token: CancellationToken): Promise { - try { - // Get all results as an array from the RPC call - const sessions = await this._proxy.$provideChatSessionItems(handle, token); - return Promise.all(sessions.map(async session => { - const uri = URI.revive(session.resource); - const model = this._chatService.getSession(uri); - if (model) { - session = await this.handleSessionModelOverrides(model, session); - } - - // We can still get stats if there is no model or if fetching from model failed - if (!session.changes || !model) { - const stats = (await this._chatService.getMetadataForSession(uri))?.stats; - // TODO: we shouldn't be converting this, the types should match - const diffs: IAgentSession['changes'] = { - files: stats?.fileCount || 0, - insertions: stats?.added || 0, - deletions: stats?.removed || 0 - }; - if (hasValidDiff(diffs)) { - session.changes = diffs; - } - } - - return { - ...session, - changes: revive(session.changes), - resource: uri, - iconPath: session.iconPath, - tooltip: session.tooltip ? this._reviveTooltip(session.tooltip) : undefined, - archived: session.archived, - } satisfies IChatSessionItem; - })); - } catch (error) { - this._logService.error('Error providing chat sessions:', error); - } - return []; - } - private async handleSessionModelOverrides(model: IChatModel, session: Dto): Promise> { // Override desciription if there's an in-progress count const inProgress = model.getRequests().filter(r => r.response && !r.response.isComplete); @@ -611,7 +621,7 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } } - $unregisterChatSessionItemProvider(handle: number): void { + $unregisterChatSessionItemController(handle: number): void { this._itemControllerRegistrations.deleteAndDispose(handle); } 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 0d23cba7e36..c1a9e98276b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,9 +99,6 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; -import { IHookCommandResult } from '../../contrib/chat/common/hooks/hooksCommandTypes.js'; -import { IHookResult } from '../../contrib/chat/common/hooks/hooksTypes.js'; -import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3234,12 +3231,6 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } -export type IHookCommandDto = Dto; - -export interface ExtHostHooksShape { - $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; $resolveMcpLaunch(collectionId: string, label: string): Promise; @@ -3295,10 +3286,6 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } -export interface MainThreadHooksShape extends IDisposable { - $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; -} - export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3428,8 +3415,9 @@ export interface IChatSessionProviderOptions { } export interface MainThreadChatSessionsShape extends IDisposable { - $registerChatSessionItemProvider(handle: number, chatSessionType: string): void; - $unregisterChatSessionItemProvider(handle: number): void; + $registerChatSessionItemController(handle: number, chatSessionType: string): void; + $unregisterChatSessionItemController(handle: number): void; + $setChatSessionItems(handle: number, items: Dto[]): Promise; $onDidChangeChatSessionItems(handle: number): void; $onDidCommitChatSessionItem(handle: number, original: UriComponents, modified: UriComponents): void; $registerChatSessionContentProvider(handle: number, chatSessionScheme: string): void; @@ -3443,7 +3431,7 @@ export interface MainThreadChatSessionsShape extends IDisposable { } export interface ExtHostChatSessionsShape { - $provideChatSessionItems(providerHandle: number, token: CancellationToken): Promise[]>; + $refreshChatSessionItems(providerHandle: number, token: CancellationToken): Promise; $onDidChangeChatSessionItemState(providerHandle: number, sessionResource: UriComponents, archived: boolean): void; $provideChatSessionContent(providerHandle: number, sessionResource: UriComponents, token: CancellationToken): Promise; @@ -3534,7 +3522,6 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), - MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3614,7 +3601,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 e900c111d9e..46a7230a092 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -9,7 +9,7 @@ import { coalesce } from '../../../base/common/arrays.js'; import { DeferredPromise } from '../../../base/common/async.js'; import { CancellationToken, CancellationTokenSource } from '../../../base/common/cancellation.js'; import { CancellationError } from '../../../base/common/errors.js'; -import { Emitter, Event } from '../../../base/common/event.js'; +import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, toDisposable } from '../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../base/common/map.js'; import { MarshalledId } from '../../../base/common/marshallingIds.js'; @@ -257,17 +257,9 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio private readonly _proxy: Proxied; - private _itemProviderHandlePool = 0; - private readonly _chatSessionItemProviders = new Map(); - private _itemControllerHandlePool = 0; private readonly _chatSessionItemControllers = new Map()); + + const collection = new ChatSessionItemCollectionImpl(() => { + // Noop for providers + }); + + // Helper to push items to main thread + const updateItems = async (items: readonly vscode.ChatSessionItem[]) => { + collection.replace(items); + const convertedItems: IChatSessionItem[] = []; + for (const sessionContent of items) { + this._sessionItems.set(sessionContent.resource, sessionContent); + convertedItems.push(this.convertChatSessionItem(sessionContent)); + } + void this._proxy.$setChatSessionItems(handle, convertedItems); + }; + + const controller: vscode.ChatSessionItemController = { + id: chatSessionType, + items: collection, + createChatSessionItem: (_resource: vscode.Uri, _label: string) => { + throw new Error('Not implemented for providers'); + }, + onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, + dispose: () => { + disposables.dispose(); + }, + refreshHandler: async (token: vscode.CancellationToken) => { + const items = await provider.provideChatSessionItems(token) ?? []; + updateItems(items); + }, + }; + + this._chatSessionItemControllers.set(handle, { chatSessionType: chatSessionType, controller, extension, disposable: disposables, onDidChangeChatSessionItemStateEmitter }); + this._proxy.$registerChatSessionItemController(handle, chatSessionType); + if (provider.onDidChangeChatSessionItems) { disposables.add(provider.onDidChangeChatSessionItems(() => { - this._logService.trace(`ExtHostChatSessions. Firing $onDidChangeChatSessionItems for ${chatSessionType}`); + this._logService.trace(`ExtHostChatSessions. Provider items changed for ${chatSessionType}`); this._proxy.$onDidChangeChatSessionItems(handle); })); } @@ -347,35 +374,30 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return { dispose: () => { - this._chatSessionItemProviders.delete(handle); + this._chatSessionItemControllers.delete(handle); disposables.dispose(); - this._proxy.$unregisterChatSessionItemProvider(handle); + this._proxy.$unregisterChatSessionItemController(handle); } }; } - createChatSessionItemController(extension: IExtensionDescription, id: string, refreshHandler: (token: vscode.CancellationToken) => Thenable): vscode.ChatSessionItemController { const controllerHandle = this._itemControllerHandlePool++; const disposables = new DisposableStore(); let isDisposed = false; - let refreshIdPool = 0; - let activeRefreshId: number | undefined = undefined; - - const onDidChangeItemsEmitter = disposables.add(new Emitter()); const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); - const notifyItemsChanged = () => { - // Suppress updates when a refresh is already happening - if (typeof activeRefreshId === 'undefined') { - onDidChangeItemsEmitter.fire(); + const onItemsChanged = () => { + const items: IChatSessionItem[] = []; + for (const [_, item] of collection) { + this._sessionItems.set(item.resource, item); + items.push(this.convertChatSessionItem(item)); } + void this._proxy.$setChatSessionItems(controllerHandle, items); }; - const collection = new ChatSessionItemCollectionImpl(() => { - notifyItemsChanged(); - }); + const collection = new ChatSessionItemCollectionImpl(onItemsChanged); const controller = Object.freeze({ id, @@ -384,17 +406,8 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio throw new Error('ChatSessionItemController has been disposed'); } - const opId = ++refreshIdPool; - activeRefreshId = opId; - - try { - this._logService.trace(`ExtHostChatSessions. Controller(${id}).refresh()`); - await refreshHandler(refreshToken); - } finally { - if (activeRefreshId === opId) { - activeRefreshId = undefined; - } - } + this._logService.trace(`ExtHostChatSessions. Controller(${id}).refresh()`); + await refreshHandler(refreshToken); }, items: collection, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, @@ -405,7 +418,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio return new ChatSessionItemImpl(resource, label, () => { // TODO: Optimize to only update the specific item - notifyItemsChanged(); + onItemsChanged(); }); }, dispose: () => { @@ -414,21 +427,14 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, }); - this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, sessionType: id, onDidChangeChatSessionItemStateEmitter }); + this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, chatSessionType: id, onDidChangeChatSessionItemStateEmitter }); - // Controllers are implemented using providers on the ext host side for now - disposables.add(this.registerChatSessionItemProvider(extension, id, { - onDidChangeChatSessionItems: onDidChangeItemsEmitter.event, - onDidCommitChatSessionItem: Event.None, - provideChatSessionItems: async (token: CancellationToken): Promise => { - await controller.refreshHandler(token); - return Array.from(controller.items, x => x[1]); - }, - })); + // Register the controller with the main thread + this._proxy.$registerChatSessionItemController(controllerHandle, id); disposables.add(toDisposable(() => { this._chatSessionItemControllers.delete(controllerHandle); - this._proxy.$unregisterChatSessionItemProvider(controllerHandle); + this._proxy.$unregisterChatSessionItemController(controllerHandle); })); return controller; @@ -503,27 +509,6 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; } - async $provideChatSessionItems(handle: number, token: vscode.CancellationToken): Promise { - const itemProvider = this._chatSessionItemProviders.get(handle); - if (!itemProvider) { - this._logService.error(`No provider registered for handle ${handle}`); - return []; - } - - this._logService.trace(`ExtHostChatSessions:$provideChatSessionItems(${itemProvider.sessionType})`); - const items = await itemProvider.provider.provideChatSessionItems(token) ?? []; - if (token.isCancellationRequested) { - return []; - } - - const response: IChatSessionItem[] = []; - for (const sessionContent of items) { - this._sessionItems.set(sessionContent.resource, sessionContent); - response.push(this.convertChatSessionItem(sessionContent)); - } - return response; - } - async $provideChatSessionContent(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise { const provider = this._chatSessionContentProviders.get(handle); if (!provider) { @@ -785,6 +770,16 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio } } + async $refreshChatSessionItems(handle: number, token: CancellationToken): Promise { + const controllerData = this._chatSessionItemControllers.get(handle); + if (!controllerData) { + this._logService.warn(`No controller found for handle ${handle}`); + return; + } + + await controllerData.controller.refreshHandler(token); + } + $onDidChangeChatSessionItemState(controllerHandle: number, sessionResourceComponents: UriComponents, archived: boolean): void { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts deleted file mode 100644 index d03d803c47c..00000000000 --- a/src/vs/workbench/api/common/extHostHooks.ts +++ /dev/null @@ -1,21 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import type * as vscode from 'vscode'; -import { CancellationToken } from '../../../base/common/cancellation.js'; -import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; -import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; -import { ExtHostHooksShape } from './extHost.protocol.js'; - -export const IExtHostHooks = createDecorator('IExtHostHooks'); - -export interface IChatHookExecutionOptions { - readonly input?: unknown; - readonly toolInvocationToken: unknown; -} - -export interface IExtHostHooks extends ExtHostHooksShape { - executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; -} diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 68b95881b32..6095aca4be1 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -127,6 +127,7 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape chatInteractionId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.chatInteractionId : undefined, subAgentInvocationId: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.subAgentInvocationId : undefined, chatStreamToolCallId: isProposedApiEnabled(extension, 'chatParticipantAdditions') ? options.chatStreamToolCallId : undefined, + preToolUseResult: isProposedApiEnabled(extension, 'chatParticipantPrivate') ? options.preToolUseResult : undefined, }, token); const dto: Dto = result instanceof SerializableObjectWithBuffers ? result.value : result; diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 885302dcb9b..3bddf605a30 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'; @@ -45,7 +46,7 @@ import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCom import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.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 +3438,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 +3466,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 +4086,39 @@ 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, + timeoutSec: hook.timeoutSec, }; } } 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/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index fdb8e734540..2f846d3c7fb 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -60,7 +60,7 @@ suite('ObservableChatSession', function () { $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), - $provideChatSessionItems: sinon.stub(), + $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), }; }); @@ -357,7 +357,7 @@ suite('MainThreadChatSessions', function () { $interruptChatSessionActiveResponse: sinon.stub(), $invokeChatSessionRequestHandler: sinon.stub(), $disposeChatSessionContent: sinon.stub(), - $provideChatSessionItems: sinon.stub(), + $refreshChatSessionItems: sinon.stub(), $onDidChangeChatSessionItemState: sinon.stub(), }; diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts deleted file mode 100644 index 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/activitybar/activitybarPart.ts b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts index 080b6871061..6978ffacbf0 100644 --- a/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts +++ b/src/vs/workbench/browser/parts/activitybar/activitybarPart.ts @@ -41,7 +41,7 @@ import { SwitchCompositeViewAction } from '../compositeBarActions.js'; export class ActivitybarPart extends Part { - static readonly ACTION_HEIGHT = 48; + static readonly ACTION_HEIGHT = 32; static readonly pinnedViewContainersKey = 'workbench.activity.pinnedViewlets2'; static readonly placeholderViewContainersKey = 'workbench.activity.placeholderViewlets'; @@ -49,8 +49,8 @@ export class ActivitybarPart extends Part { //#region IView - readonly minimumWidth: number = 48; - readonly maximumWidth: number = 48; + readonly minimumWidth: number = 36; + readonly maximumWidth: number = 36; readonly minimumHeight: number = 0; readonly maximumHeight: number = Number.POSITIVE_INFINITY; diff --git a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css index 8cc8ca0e484..6c19b80055b 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activityaction.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activityaction.css @@ -12,7 +12,7 @@ .monaco-workbench .activitybar > .content .composite-bar > .monaco-action-bar .action-item::after { position: absolute; content: ''; - width: 48px; + width: 36px; height: 2px; display: none; background-color: transparent; @@ -46,8 +46,8 @@ z-index: 1; display: flex; overflow: hidden; - width: 48px; - height: 48px; + width: 36px; + height: 32px; margin-right: 0; box-sizing: border-box; @@ -55,12 +55,12 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label:not(.codicon) { font-size: 15px; - line-height: 40px; - padding: 0 0 0 48px; + line-height: 32px; + padding: 0 0 0 36px; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .action-label.codicon { - font-size: 24px; + font-size: 16px; align-items: center; justify-content: center; } @@ -157,27 +157,28 @@ .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .badge .badge-content { position: absolute; - top: 24px; - right: 8px; + top: 17px; + right: 6px; font-size: 9px; font-weight: 600; - min-width: 8px; - height: 16px; - line-height: 16px; - padding: 0 4px; - border-radius: 20px; + min-width: 9px; + height: 13px; + line-height: 13px; + padding: 0 2px; + border-radius: 13px; text-align: center; + border: none !important; } .monaco-workbench .activitybar > .content :not(.monaco-menu) > .monaco-action-bar .profile-badge .profile-text-overlay { position: absolute; font-weight: 600; - font-size: 9px; - line-height: 10px; - top: 24px; - right: 6px; - padding: 2px 3px; - border-radius: 7px; + font-size: 8px; + line-height: 8px; + top: 14px; + right: 2px; + padding: 2px 2px; + border-radius: 6px; background-color: var(--vscode-profileBadge-background); color: var(--vscode-profileBadge-foreground); border: 2px solid var(--vscode-activityBar-background); diff --git a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css index abe1427ca20..452cc971b24 100644 --- a/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css +++ b/src/vs/workbench/browser/parts/activitybar/media/activitybarpart.css @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ .monaco-workbench .part.activitybar { - width: 48px; + width: 36px; height: 100%; } diff --git a/src/vs/workbench/browser/parts/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/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/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/agentSessionsModel.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts index 9757d370d55..37a75d93f19 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsModel.ts @@ -428,7 +428,7 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode // Sessions changes this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); - this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.resolve(chatSessionType))); + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.updateItems([chatSessionType], CancellationToken.None))); // State this._register(this.storageService.onWillSaveState(() => { @@ -468,12 +468,21 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode const providersToResolve = Array.from(this.providersToResolve); this.providersToResolve.clear(); + const providerFilter = providersToResolve.includes(undefined) ? undefined : coalesce(providersToResolve); + + await this.chatSessionsService.refreshChatSessionItems(providerFilter, token); + await this.updateItems(providerFilter, token); + } + + /** + * Update the sessions by fetching from the service. This does not trigger an explicit refresh + */ + private async updateItems(providerFilter: readonly string[] | undefined, token: CancellationToken): Promise { const mapSessionContributionToType = new Map(); for (const contribution of this.chatSessionsService.getAllChatSessionContributions()) { mapSessionContributionToType.set(contribution.type, contribution); } - const providerFilter = providersToResolve.includes(undefined) ? undefined : coalesce(providersToResolve); const providerResults = await this.chatSessionsService.getChatSessionItems(providerFilter, token); const resolvedProviders = new Set(); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index 9251a44e9fe..27fe44366cb 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -67,6 +67,7 @@ export class AgentSessionsPicker { private readonly sorter = new AgentSessionsSorter(); constructor( + private readonly anchor: HTMLElement | undefined, @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @IQuickInputService private readonly quickInputService: IQuickInputService, @IInstantiationService private readonly instantiationService: IInstantiationService, @@ -78,6 +79,7 @@ export class AgentSessionsPicker { const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); + picker.anchor = this.anchor; picker.items = this.createPickerItems(filter); picker.canAcceptInBackground = true; picker.placeholder = localize('chatAgentPickerPlaceholder', "Search agent sessions by name"); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/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 2a20ea90483..f5d3a047e54 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'; @@ -143,6 +142,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[] = []; @@ -614,6 +614,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."), @@ -1390,6 +1398,46 @@ class ChatSlashStaticSlashCommandsContribution extends Disposable { }, async () => { await instantiationService.invokeFunction(showConfigureHooksQuickPick); })); + 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: '', @@ -1528,7 +1576,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/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index b28871666e1..9076add61bd 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -5,7 +5,7 @@ import { sep } from '../../../../../base/common/path.js'; import { raceCancellationError } from '../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { AsyncEmitter, Emitter, Event } from '../../../../../base/common/event.js'; import { combinedDisposable, Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; @@ -45,7 +45,7 @@ import { IViewsService } from '../../../../services/views/common/viewsService.js import { ChatViewId } from '../chat.js'; import { ChatViewPane } from '../widgetHosts/viewPane/chatViewPane.js'; import { AgentSessionProviders, getAgentSessionProviderName } from '../agentSessions/agentSessions.js'; -import { BugIndicatingError } from '../../../../../base/common/errors.js'; +import { BugIndicatingError, isCancellationError } from '../../../../../base/common/errors.js'; import { IEditorGroupsService } from '../../../../services/editor/common/editorGroupsService.js'; import { LocalChatSessionUri } from '../../common/model/chatUri.js'; import { assertNever } from '../../../../../base/common/assert.js'; @@ -251,7 +251,7 @@ class ContributedChatSessionData extends Disposable { export class ChatSessionsService extends Disposable implements IChatSessionsService { readonly _serviceBrand: undefined; - private readonly _itemControllers = new Map(); + private readonly _itemControllers = new Map }>(); private readonly _contributions: Map = new Map(); private readonly _contributionDisposables = this._register(new DisposableMap()); @@ -735,7 +735,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ await this.doActivateChatSessionItemController(chatViewType); } - private async doActivateChatSessionItemController(chatViewType: string): Promise<{ resolvedType: string; controller: IChatSessionItemController } | undefined> { + private async doActivateChatSessionItemController(chatViewType: string): Promise { await this._extensionService.whenInstalledExtensionsRegistered(); const resolvedType = this._resolveToPrimaryType(chatViewType); if (resolvedType) { @@ -744,17 +744,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const contribution = this._contributions.get(chatViewType)?.contribution; if (contribution && !this._isContributionAvailable(contribution)) { - return undefined; + return false; } if (this._itemControllers.has(chatViewType)) { - return { resolvedType: chatViewType, controller: this._itemControllers.get(chatViewType)! }; + return true; } await this._extensionService.activateByEvent(`onChatSession:${chatViewType}`); const controller = this._itemControllers.get(chatViewType)!; - return controller && { resolvedType: chatViewType, controller }; + return !!controller; } async canResolveChatSession(chatSessionResource: URI) { @@ -773,67 +773,74 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._contentProviders.has(chatSessionResource.scheme); } - public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { - const results: Array<{ readonly chatSessionType: string; readonly items: readonly IChatSessionItem[] }> = []; - const resolvedProviderTypes = new Set(); - - // First, iterate over extension point contributions - for (const contrib of this.getAllChatSessionContributions()) { + private async tryActivateControllers(providersToResolve: readonly string[] | undefined): Promise { + await Promise.all(this.getAllChatSessionContributions().map(async (contrib) => { if (providersToResolve && !providersToResolve.includes(contrib.type)) { - continue; // skip: not considered for resolving + return; // skip: not considered for resolving } - const controllerEntry = await this.doActivateChatSessionItemController(contrib.type); - if (!controllerEntry) { + if (!await this.doActivateChatSessionItemController(contrib.type)) { // We requested this provider but it is not available if (providersToResolve?.includes(contrib.type)) { this._logService.trace(`[ChatSessionsService] No enabled provider found for chat session type ${contrib.type}`); } - continue; + } + })); + } + + public async getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { + // First, make sure contributed controller are active + await this.tryActivateControllers(providersToResolve); + + // Then actually resolve items for all active controllers + const results: Array<{ readonly chatSessionType: string; readonly items: readonly IChatSessionItem[] }> = []; + await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { + const resolvedType = this._resolveToPrimaryType(chatSessionType) ?? chatSessionType; + if (providersToResolve && !providersToResolve.includes(resolvedType)) { + return; // skip: not considered for resolving } try { - await raceCancellationError(controllerEntry.controller.refresh(token), token); + await controllerEntry.initialRefresh; // Ensure initial refresh is complete before accessing items + const providerSessions = controllerEntry.controller.items; - this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${controllerEntry.resolvedType}`); - results.push({ chatSessionType: controllerEntry.resolvedType, items: providerSessions }); - resolvedProviderTypes.add(controllerEntry.resolvedType); - } catch (error) { - // Log error but continue with other providers - this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${controllerEntry.resolvedType}`, error); - continue; + this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for provider ${resolvedType}`); + results.push({ chatSessionType: resolvedType, items: providerSessions }); + } catch (err) { + if (!isCancellationError(err)) { + // Log error but continue with other providers + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${resolvedType}`, err); + } } - } - - // Also include registered items providers that don't have corresponding contributions - // (e.g., the local session provider which is built-in and not an extension contribution) - for (const [chatSessionType, controller] of this._itemControllers) { - if (resolvedProviderTypes.has(chatSessionType)) { - continue; // already resolved via contribution - } - if (providersToResolve && !providersToResolve.includes(chatSessionType)) { - continue; // skip: not considered for resolving - } - - try { - await raceCancellationError(controller.refresh(token), token); - const providerSessions = controller.items; - this._logService.trace(`[ChatSessionsService] Resolved ${providerSessions.length} sessions for built-in provider ${chatSessionType}`); - results.push({ chatSessionType, items: providerSessions }); - } catch (error) { - this._logService.error(`[ChatSessionsService] Failed to resolve sessions for built-in provider ${chatSessionType}`, error); - continue; - } - } + })); return results; } + public async refreshChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise { + await this.tryActivateControllers(providersToResolve); + + await Promise.all(Array.from(this._itemControllers).map(async ([chatSessionType, controllerEntry]) => { + try { + await controllerEntry.controller.refresh(token); + } catch (err) { + if (!isCancellationError(err)) { + // Log error but continue with other providers + this._logService.error(`[ChatSessionsService] Failed to resolve sessions for provider ${chatSessionType}`, err); + } + } + })); + } + registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable { - this._itemControllers.set(chatSessionType, controller); + const disposables = new DisposableStore(); + + + // Register and trigger an initial refresh to populate the provider's items + const initialRefreshCts = disposables.add(new CancellationTokenSource()); + this._itemControllers.set(chatSessionType, { controller, initialRefresh: controller.refresh(initialRefreshCts.token) }); this._onDidChangeItemsProviders.fire({ chatSessionType }); - const disposables = new DisposableStore(); disposables.add(controller.onDidChangeChatSessionItems(() => { this._onDidChangeSessionItems.fire({ chatSessionType }); })); @@ -844,6 +851,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return { dispose: () => { + initialRefreshCts.cancel(); disposables.dispose(); const controller = this._itemControllers.get(chatSessionType); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 0f80b97a1d5..5c55c8159ad 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -3,19 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; -import { IProductService } from '../../../../platform/product/common/productService.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { MarkdownString } from '../../../../base/common/htmlContent.js'; -import { localize } from '../../../../nls.js'; import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; import { ChatModeKind } from '../common/constants.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { 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'); @@ -27,6 +31,16 @@ export interface IChatTip { export interface IChatTipService { readonly _serviceBrand: undefined; + /** + * Fired when the current tip is dismissed. + */ + readonly onDidDismissTip: Event; + + /** + * Fired when tips are disabled. + */ + readonly onDidDisableTips: Event; + /** * Gets a tip to show for a request, or undefined if a tip has already been shown this session. * Only one tip is shown per VS Code session (resets on reload). @@ -36,6 +50,17 @@ export interface IChatTipService { * @param contextKeyService The context key service to evaluate tip eligibility. */ getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Dismisses the current tip and allows a new one to be picked for the same request. + * The dismissed tip will not be shown again in this workspace. + */ + dismissTip(): void; + + /** + * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. + */ + disableTips(): Promise; } export interface ITipDefinition { @@ -61,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; + }; } /** @@ -84,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', @@ -102,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'], + }, ]; /** @@ -114,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(); @@ -145,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(); @@ -165,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) { @@ -181,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); + } + } + })); } /** @@ -221,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; } } @@ -228,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); + } } } @@ -255,6 +461,12 @@ export class TipEligibilityTracker extends Disposable { export class ChatTipService extends Disposable implements IChatTipService { readonly _serviceBrand: undefined; + private readonly _onDidDismissTip = this._register(new Emitter()); + readonly onDidDismissTip = this._onDidDismissTip.event; + + private readonly _onDidDisableTips = this._register(new Emitter()); + readonly onDidDisableTips = this._onDidDisableTips.event; + /** * Timestamp when this service was instantiated. * Used to only show tips for requests created after this time. @@ -277,19 +489,52 @@ export class ChatTipService extends Disposable implements IChatTipService { */ private _shownTip: ITipDefinition | undefined; + private static readonly _DISMISSED_TIP_KEY = 'chat.tip.dismissed'; private readonly _tracker: TipEligibilityTracker; constructor( @IProductService private readonly _productService: IProductService, @IConfigurationService private readonly _configurationService: IConfigurationService, - @ICommandService commandService: ICommandService, - @IStorageService storageService: IStorageService, - @IPromptsService promptsService: IPromptsService, + @IStorageService private readonly _storageService: IStorageService, + @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 { + if (this._shownTip) { + const dismissed = this._getDismissedTipIds(); + dismissed.push(this._shownTip.id); + this._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.WORKSPACE, StorageTarget.MACHINE); + } + this._hasShownTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + } + + private _getDismissedTipIds(): string[] { + const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.WORKSPACE); + if (!raw) { + return []; + } + try { + const parsed = JSON.parse(raw); + this._logService.debug('#ChatTips dismissed:', parsed); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } + } + + async disableTips(): Promise { + this._hasShownTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + await this._configurationService.updateValue('chat.tips.enabled', false); + this._onDidDisableTips.fire(); } getNextTip(requestId: string, requestTimestamp: number, contextKeyService: IContextKeyService): IChatTip | undefined { @@ -319,12 +564,12 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Find eligible tips (excluding dismissed ones) + const dismissedIds = new Set(this._getDismissedTipIds()); + const eligibleTips = TIP_CATALOG.filter(tip => !dismissedIds.has(tip.id) && this._isEligible(tip, contextKeyService)); // Record the current mode for future eligibility decisions this._tracker.recordCurrentMode(contextKeyService); - // Find eligible tips - const eligibleTips = TIP_CATALOG.filter(tip => this._isEligible(tip, contextKeyService)); - if (eligibleTips.length === 0) { return undefined; } @@ -343,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 c2b43e81907..babfb20a486 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -6,6 +6,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { ChatViewId } from '../chat.js'; import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -17,10 +18,12 @@ import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contex import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; import { IFileService } from '../../../../../platform/files/common/files.js'; import { 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'; @@ -115,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; @@ -134,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]; @@ -233,6 +241,46 @@ async function addHookToFile( } } +/** + * Awaits a single pick interaction on the given picker. + * Returns the selected item, 'back' if the back button was pressed, or undefined if cancelled. + */ +function awaitPick( + picker: IQuickPick, + backButton: IQuickInputButton, +): Promise { + return new Promise(resolve => { + let resolved = false; + const done = (value: T | 'back' | undefined) => { + if (!resolved) { + resolved = true; + disposables.dispose(); + resolve(value); + } + }; + const disposables = new DisposableStore(); + disposables.add(picker.onDidAccept(() => { + done(picker.activeItems[0] as T | undefined); + })); + disposables.add(picker.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + disposables.add(picker.onDidHide(() => { + done(undefined); + })); + }); +} + +const enum Step { + SelectHookType = 1, + SelectHook = 2, + SelectFile = 3, + SelectFolder = 4, + EnterFilename = 5, +} + /** * Shows the Configure Hooks quick pick UI, allowing the user to view, * open, or create hooks. Can be called from the action or slash command. @@ -278,258 +326,389 @@ export async function showConfigureHooksQuickPick( hookCountByType.set(entry.hookType, (hookCountByType.get(entry.hookType) ?? 0) + 1); } - // Step 1: Show all lifecycle events with hook counts - const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { - const count = hookCountByType.get(hookType.id) ?? 0; - const countLabel = count > 0 ? ` (${count})` : ''; - return { - label: `${hookType.label}${countLabel}`, - description: hookType.description, - hookType - }; - }); - - const selectedHookType = await quickInputService.pick(hookTypeItems, { - placeHolder: localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'), - title: localize('commands.hooks.title', 'Hooks') - }); - - if (!selectedHookType) { - return; - } - - // Filter hooks by the selected type - const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType.hookType.id); - - // Step 2: Show "Add new hook" + existing hooks of this type - const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "Add new hook" option at the top - hookItems.push({ - label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, - isAddNewHook: true, - alwaysShow: true - }); - - // Add existing hooks - if (hooksOfType.length > 0) { - hookItems.push({ - type: 'separator', - label: localize('existingHooks', "Existing Hooks") - }); - - for (const entry of hooksOfType) { - const description = labelService.getUriLabel(entry.fileUri, { relative: true }); - hookItems.push({ - label: entry.commandLabel, - description, - hookEntry: entry - }); + // Create a single picker instance reused across all steps + const store = new DisposableStore(); + const picker = store.add(quickInputService.createQuickPick({ useSeparators: true })); + const backButton = quickInputService.backButton; + let suppressHideDispose = false; + store.add(picker.onDidHide(() => { + if (!suppressHideDispose) { + store.dispose(); } - } + })); + picker.show(); - // Auto-execute if only "Add new hook" is available (no existing hooks) + let step = Step.SelectHookType; + let selectedHookType: IHookTypeQuickPickItem | undefined; let selectedHook: IHookQuickPickItem | undefined; - if (hooksOfType.length === 0) { - selectedHook = hookItems[0] as IHookQuickPickItem; - } else { - selectedHook = await quickInputService.pick(hookItems, { - placeHolder: localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'), - title: selectedHookType.hookType.label - }); - } + let selectedFile: IHookFileQuickPickItem | undefined; + let selectedFolder: { uri: URI } | undefined; - if (!selectedHook) { - return; - } + // Track steps that were actually shown to the user, so Back + // skips over auto-executed steps and returns to the last visible one. + const stepHistory: Step[] = []; + const goBack = (): Step | undefined => stepHistory.pop(); - // Handle clicking on existing hook (focus into command) - if (selectedHook.hookEntry) { - const entry = selectedHook.hookEntry; - let selection: ITextEditorSelection | undefined; - - // Determine the command field name to highlight based on target platform - const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); - - // Try to find the command field to highlight - if (commandFieldName) { - try { - const content = await fileService.readFile(entry.fileUri); - selection = findHookCommandSelection( - content.value.toString(), - entry.originalHookTypeId, - entry.index, - commandFieldName - ); - } catch { - // Ignore errors and just open without selection - } - } - - await editorService.openEditor({ - resource: entry.fileUri, - options: { - selection, - pinned: false - } - }); - return; - } - - // Step 3: Handle "Add new hook" - show create new file + existing hook files - if (selectedHook.isAddNewHook) { - // Get existing hook files (local storage only, not User Data) - const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); - - const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; - - // Add "Create new hook config file" option at the top - fileItems.push({ - label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, - isCreateNewFile: true, - alwaysShow: true - }); - - // Add existing hook files - if (hookFiles.length > 0) { - fileItems.push({ - type: 'separator', - label: localize('existingHookFiles', "Existing Hook Files") - }); - - for (const hookFile of hookFiles) { - const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); - fileItems.push({ - label: relativePath, - fileUri: hookFile.uri + while (true) { + switch (step) { + case Step.SelectHookType: { + // Step 1: Show all lifecycle events with hook counts + const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => { + const count = hookCountByType.get(hookType.id) ?? 0; + const countLabel = count > 0 ? ` (${count})` : ''; + return { + label: `${hookType.label}${countLabel}`, + description: hookType.description, + hookType + }; }); - } - } - // Auto-execute if no existing hook files - let selectedFile: IHookFileQuickPickItem | undefined; - if (hookFiles.length === 0) { - selectedFile = fileItems[0] as IHookFileQuickPickItem; - } else { - selectedFile = await quickInputService.pick(fileItems, { - placeHolder: localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'), - title: localize('commands.hooks.addHook.title', 'Add Hook') - }); - } + picker.items = hookTypeItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'); + picker.title = localize('commands.hooks.title', 'Hooks'); + picker.buttons = []; - if (!selectedFile) { - return; - } + const result = await awaitPick(picker, backButton); - // Handle creating new hook config file - if (selectedFile.isCreateNewFile) { - // Get source folders for hooks, filter to local storage only (no User Data) - const allFolders = await promptsService.getSourceFolders(PromptsType.hook); - const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); - - if (localFolders.length === 0) { - notificationService.error(localize('commands.hook.noLocalFolders', "No local hook folder found. Please configure a hooks folder in your workspace.")); - return; - } - - // Auto-select if only one folder, otherwise show picker - let selectedFolder = localFolders[0]; - if (localFolders.length > 1) { - const folderItems = localFolders.map(folder => ({ - label: labelService.getUriLabel(folder.uri, { relative: true }), - folder - })); - const pickedFolder = await quickInputService.pick(folderItems, { - placeHolder: localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'), - title: localize('commands.hook.selectFolder.title', 'Hook File Location') - }); - if (!pickedFolder) { + if (!result || result === 'back') { + picker.hide(); return; } - selectedFolder = pickedFolder.folder; + + selectedHookType = result; + stepHistory.push(Step.SelectHookType); + step = Step.SelectHook; + break; } - // Ask for filename - const fileName = await quickInputService.input({ - prompt: localize('commands.hook.filename.prompt', "Enter hook file name"), - placeHolder: localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"), - validateInput: async (value) => { - if (!value || !value.trim()) { - return localize('commands.hook.filename.required', "File name is required"); + case Step.SelectHook: { + // Filter hooks by the selected type + const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType.id); + + // Step 2: Show "Add new hook" + existing hooks of this type + const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Add new hook" option at the top + hookItems.push({ + label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`, + isAddNewHook: true, + alwaysShow: true + }); + + // Add existing hooks + if (hooksOfType.length > 0) { + hookItems.push({ + type: 'separator', + label: localize('existingHooks', "Existing Hooks") + }); + + for (const entry of hooksOfType) { + const description = labelService.getUriLabel(entry.fileUri, { relative: true }); + hookItems.push({ + label: entry.commandLabel, + description, + hookEntry: entry + }); } - const name = value.trim(); - // Basic validation - no path separators or invalid characters - if (/[/\\:*?"<>|]/.test(name)) { - return localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); - } - return undefined; } - }); - if (!fileName) { - return; - } + // Auto-execute if only "Add new hook" is available (no existing hooks) + if (hooksOfType.length === 0) { + selectedHook = hookItems[0] as IHookQuickPickItem; + } else { + picker.items = hookItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'); + picker.title = selectedHookType!.hookType.label; + picker.buttons = [backButton]; - // Create the hooks folder if it doesn't exist - await fileService.createFolder(selectedFolder.uri); + const result = await awaitPick(picker, backButton); - // Use user-provided filename with .json extension - const hookFileName = fileName.trim().endsWith('.json') ? fileName.trim() : `${fileName.trim()}.json`; - const hookFileUri = URI.joinPath(selectedFolder.uri, hookFileName); + if (result === 'back') { + step = goBack() ?? Step.SelectHookType; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedHook = result; + stepHistory.push(Step.SelectHook); + } - // Check if file already exists - if (await fileService.exists(hookFileUri)) { - // File exists - add hook to it instead of creating new - await addHookToFile( - hookFileUri, - selectedHookType.hookType.id as HookType, - fileService, - editorService, - notificationService, - bulkEditService - ); - return; - } + // Handle clicking on existing hook (focus into command) + if (selectedHook.hookEntry) { + const entry = selectedHook.hookEntry; + let selection: ITextEditorSelection | undefined; - // Create new hook file with the selected hook type - const hooksContent = { - hooks: { - [selectedHookType.hookType.id]: [ - { - type: 'command', - command: '' + // Determine the command field name to highlight based on target platform + const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS); + + // Try to find the command field to highlight + if (commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + commandFieldName + ); + } catch { + // Ignore errors and just open without selection } - ] + } + + picker.hide(); + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + return; } - }; - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + // "Add new hook" was selected + step = Step.SelectFile; + break; + } - // Find the selection for the new hook's command field - const selection = findHookCommandSelection(jsonContent, selectedHookType.hookType.id, 0, 'command'); + case Step.SelectFile: { + // Step 3: Handle "Add new hook" - show create new file + existing hook files + // Get existing hook files (local storage only, not User Data) + const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None); - // Open editor with selection - await editorService.openEditor({ - resource: hookFileUri, - options: { - selection, - pinned: false + const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "Create new hook config file" option at the top + fileItems.push({ + label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`, + isCreateNewFile: true, + alwaysShow: true + }); + + // Add existing hook files + if (hookFiles.length > 0) { + fileItems.push({ + type: 'separator', + label: localize('existingHookFiles', "Existing Hook Files") + }); + + for (const hookFile of hookFiles) { + const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true }); + fileItems.push({ + label: relativePath, + fileUri: hookFile.uri + }); + } } - }); - return; - } - // Handle adding hook to existing file - if (selectedFile.fileUri) { - await addHookToFile( - selectedFile.fileUri, - selectedHookType.hookType.id as HookType, - fileService, - editorService, - notificationService, - bulkEditService - ); + // Auto-execute if no existing hook files + if (hookFiles.length === 0) { + selectedFile = fileItems[0] as IHookFileQuickPickItem; + } else { + picker.items = fileItems; + picker.value = ''; + picker.placeholder = localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'); + picker.title = localize('commands.hooks.addHook.title', 'Add Hook'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectHook; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedFile = result; + stepHistory.push(Step.SelectFile); + } + + // Handle adding hook to existing file + if (selectedFile.fileUri) { + picker.hide(); + await addHookToFile( + selectedFile.fileUri, + selectedHookType!.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // "Create new hook config file" was selected + step = Step.SelectFolder; + break; + } + + case Step.SelectFolder: { + // Get source folders for hooks + const allFolders = await promptsService.getSourceFolders(PromptsType.hook); + const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local); + + if (localFolders.length === 0) { + picker.hide(); + notificationService.error(localize('commands.hook.noLocalFolders', "Please open a workspace folder to configure hooks.")); + return; + } + + // Auto-select if only one folder, otherwise show picker + selectedFolder = localFolders[0]; + if (localFolders.length > 1) { + const folderItems = localFolders.map(folder => ({ + label: labelService.getUriLabel(folder.uri, { relative: true }), + folder + })); + + picker.items = folderItems; + picker.value = ''; + picker.placeholder = localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'); + picker.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + picker.buttons = [backButton]; + + const result = await awaitPick(picker, backButton); + + if (result === 'back') { + step = goBack() ?? Step.SelectFile; + break; + } + if (!result) { + picker.hide(); + return; + } + selectedFolder = result.folder; + stepHistory.push(Step.SelectFolder); + } + + step = Step.EnterFilename; + break; + } + + case Step.EnterFilename: { + // Hide the picker and show an input box for the filename + suppressHideDispose = true; + picker.hide(); + suppressHideDispose = false; + + const fileNameResult = await new Promise(resolve => { + let resolved = false; + const done = (value: string | 'back' | undefined) => { + if (!resolved) { + resolved = true; + inputDisposables.dispose(); + resolve(value); + } + }; + const inputDisposables = new DisposableStore(); + const inputBox = inputDisposables.add(quickInputService.createInputBox()); + inputBox.prompt = localize('commands.hook.filename.prompt', "Enter hook file name"); + inputBox.placeholder = localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"); + inputBox.title = localize('commands.hook.selectFolder.title', 'Hook File Location'); + inputBox.buttons = [backButton]; + inputBox.ignoreFocusOut = true; + + inputDisposables.add(inputBox.onDidAccept(async () => { + const value = inputBox.value; + if (!value || !value.trim()) { + inputBox.validationMessage = localize('commands.hook.filename.required', "File name is required"); + return; + } + const name = value.trim(); + if (/[/\\:*?"<>|]/.test(name)) { + inputBox.validationMessage = localize('commands.hook.filename.invalidChars', "File name contains invalid characters"); + return; + } + done(name); + })); + inputDisposables.add(inputBox.onDidChangeValue(() => { + inputBox.validationMessage = undefined; + })); + inputDisposables.add(inputBox.onDidTriggerButton(button => { + if (button === backButton) { + done('back'); + } + })); + inputDisposables.add(inputBox.onDidHide(() => { + done(undefined); + })); + inputBox.show(); + }); + + if (fileNameResult === 'back') { + // Re-show the picker for the previous step + picker.show(); + step = goBack() ?? Step.SelectFolder; + break; + } + if (!fileNameResult) { + store.dispose(); + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder!.uri); + + // Use user-provided filename with .json extension + const hookFileName = fileNameResult.endsWith('.json') ? fileNameResult : `${fileNameResult}.json`; + const hookFileUri = URI.joinPath(selectedFolder!.uri, hookFileName); + + // Check if file already exists + if (await fileService.exists(hookFileUri)) { + // File exists - add hook to it instead of creating new + store.dispose(); + await addHookToFile( + hookFileUri, + selectedHookType!.hookType.id as HookType, + fileService, + editorService, + notificationService, + bulkEditService + ); + return; + } + + // Detect if new file is a Claude hooks file based on its path + const newFileFormat = getHookSourceFormat(hookFileUri); + const isClaudeNewFile = newFileFormat === HookSourceFormat.Claude; + const hookTypeKey = isClaudeNewFile + ? (getClaudeHookTypeName(selectedHookType!.hookType.id as HookType) ?? selectedHookType!.hookType.id) + : selectedHookType!.hookType.id; + const newFileHookEntry = buildNewHookEntry(newFileFormat); + + // Create new hook file with the selected hook type + const hooksContent = { + hooks: { + [hookTypeKey]: [ + newFileHookEntry + ] + } + }; + + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, hookTypeKey, 0, 'command'); + + // Open editor with selection + store.dispose(); + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + return; + } } } } diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 95fa26ab65d..4bc871cae89 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 { 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, 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(); @@ -367,65 +364,35 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo return undefined; } - /** - * Execute the preToolUse hook and handle denial. - * Returns an object containing: - * - denialResult: A tool result if the hook denied execution (caller should return early) - * - hookResult: The full hook result for use in auto-approval logic (allow/ask decisions) - * @param pendingInvocation If there's an existing streaming invocation from beginToolCall, pass it here to cancel it instead of creating a new one. - */ - private async _executePreToolUseHook( + private _handlePreToolUseDenial( dto: IToolInvocation, + hookResult: IExternalPreToolUseHookResult, toolData: IToolData | undefined, - request: IChatRequestModel | undefined, pendingInvocation: ChatToolInvocation | undefined, - token: CancellationToken - ): Promise<{ denialResult?: IToolResult; hookResult?: IPreToolUseHookResult }> { - // Skip hook if no session context or tool doesn't exist - if (!dto.context?.sessionResource || !toolData) { - return {}; - } + request: IChatRequestModel | undefined, + ): IToolResult { + const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); + const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); + this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); - const hookInput: IPreToolUseCallerInput = { - toolName: dto.toolId, - toolInput: dto.parameters, - toolCallId: dto.callId, - }; - const hookResult = await this._hooksExecutionService.executePreToolUseHook(dto.context.sessionResource, hookInput, token); - - if (hookResult?.permissionDecision === 'deny') { - const hookReason = hookResult.permissionDecisionReason ?? localize('hookDeniedNoReason', "Hook denied tool execution"); - const reason = localize('deniedByPreToolUseHook', "Denied by {0} hook: {1}", HookType.PreToolUse, hookReason); - this._logService.debug(`[LanguageModelToolsService#invokeTool] Tool ${dto.toolId} denied by preToolUse hook: ${hookReason}`); - - // Handle the tool invocation in cancelled state - if (toolData) { - if (pendingInvocation) { - // If there's an existing streaming invocation, cancel it - pendingInvocation.cancelFromStreaming(ToolConfirmKind.Denied, reason); - } else if (request) { - // Otherwise create a new cancelled invocation and add it to the chat model - const toolInvocation = ChatToolInvocation.createCancelled( - { toolCallId: dto.callId, toolId: dto.toolId, toolData, subagentInvocationId: dto.subAgentInvocationId, chatRequestId: dto.chatRequestId }, - dto.parameters, - ToolConfirmKind.Denied, - reason - ); - this._chatService.appendProgress(request, toolInvocation); - } + if (toolData) { + if (pendingInvocation) { + pendingInvocation.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 + ); + this._chatService.appendProgress(request, cancelledInvocation); } - - const denialMessage = localize('toolExecutionDenied', "Tool execution denied: {0}", hookReason); - return { - denialResult: { - content: [{ kind: 'text', value: denialMessage }], - toolResultError: hookReason, - }, - hookResult, - }; } - return { hookResult }; + return { + content: [{ kind: 'text', value: `Tool execution denied: ${hookReason}` }], + toolResultError: hookReason, + }; } /** @@ -460,44 +427,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, - }; - const hookResult = await this._hooksExecutionService.executePostToolUseHook(dto.context.sessionResource, hookInput, token); - - if (hookResult?.decision === 'block') { - const hookReason = hookResult.reason ?? localize('postToolUseHookBlockedNoReason', "Hook blocked tool result"); - this._logService.debug(`[LanguageModelToolsService#invokeTool] PostToolUse hook blocked for tool ${dto.toolId}: ${hookReason}`); - const blockMessage = localize('postToolUseHookBlockedContext', "The PostToolUse hook blocked this tool result. Reason: {0}", hookReason); - toolResult.content.push({ kind: 'text', value: '\n\n' + blockMessage + '\n' }); - } - - if (hookResult?.additionalContext) { - // Append additional context from all hooks to the tool result content - for (const context of hookResult.additionalContext) { - toolResult.content.push({ kind: 'text', value: '\n\n' + context + '\n' }); - } - } - } - async invokeTool(dto: IToolInvocation, countTokens: CountTokensCallback, token: CancellationToken): Promise { this._logService.trace(`[LanguageModelToolsService#invokeTool] Invoking tool ${dto.toolId} with parameters ${JSON.stringify(dto.parameters)}`); @@ -545,14 +474,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 @@ -712,9 +641,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', { @@ -759,7 +685,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); @@ -780,7 +706,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, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 3c3fab695f7..df83eb5d194 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -5,26 +5,117 @@ import './media/chatTipContent.css'; import * as dom from '../../../../../../base/browser/dom.js'; +import { StandardMouseEvent } from '../../../../../../base/browser/mouseEvent.js'; import { renderIcon } from '../../../../../../base/browser/ui/iconLabel/iconLabels.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { Disposable } from '../../../../../../base/common/lifecycle.js'; +import { Emitter } from '../../../../../../base/common/event.js'; +import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; +import { localize2 } from '../../../../../../nls.js'; +import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; +import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { IChatTip } from '../../chatTipService.js'; +import { IChatTip, IChatTipService } from '../../chatTipService.js'; const $ = dom.$; export class ChatTipContentPart extends Disposable { public readonly domNode: HTMLElement; + private readonly _onDidHide = this._register(new Emitter()); + public readonly onDidHide = this._onDidHide.event; + + private readonly _renderedContent = this._register(new MutableDisposable()); + constructor( tip: IChatTip, - renderer: IMarkdownRenderer, + private readonly _renderer: IMarkdownRenderer, + private readonly _getNextTip: () => IChatTip | undefined, + @IChatTipService private readonly _chatTipService: IChatTipService, + @IContextMenuService private readonly _contextMenuService: IContextMenuService, + @IMenuService private readonly _menuService: IMenuService, + @IContextKeyService private readonly _contextKeyService: IContextKeyService, ) { super(); this.domNode = $('.chat-tip-widget'); + this._renderTip(tip); + + this._register(this._chatTipService.onDidDismissTip(() => { + const nextTip = this._getNextTip(); + if (nextTip) { + this._renderTip(nextTip); + } else { + this._onDidHide.fire(); + } + })); + + this._register(this._chatTipService.onDidDisableTips(() => { + this._onDidHide.fire(); + })); + + this._register(dom.addDisposableListener(this.domNode, dom.EventType.CONTEXT_MENU, (e: MouseEvent) => { + dom.EventHelper.stop(e, true); + const event = new StandardMouseEvent(dom.getWindow(this.domNode), e); + this._contextMenuService.showContextMenu({ + getAnchor: () => event, + getActions: () => { + const menu = this._menuService.getMenuActions(MenuId.ChatTipContext, this._contextKeyService); + return getFlatContextMenuActions(menu); + }, + }); + })); + } + + private _renderTip(tip: IChatTip): void { + dom.clearNode(this.domNode); this.domNode.appendChild(renderIcon(Codicon.lightbulb)); - const markdownContent = this._register(renderer.render(tip.content)); + const markdownContent = this._renderer.render(tip.content); + this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); } } + +//#region Tip context menu actions + +registerAction2(class DismissTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.dismissTip', + title: localize2('chatTip.dismiss', "Dismiss this tip"), + f1: false, + menu: [{ + id: MenuId.ChatTipContext, + group: 'chatTip', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).dismissTip(); + } +}); + +registerAction2(class DisableTipsAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.disableTips', + title: localize2('chatTip.disableTips', "Disable tips"), + f1: false, + menu: [{ + id: MenuId.ChatTipContext, + group: 'chatTip', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + await accessor.get(IChatTipService).disableTips(); + } +}); + +//#endregion diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 148f548ca4f..38d1242b302 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -1056,9 +1056,16 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer this.chatTipService.getNextTip(element.id, element.timestamp, this.contextKeyService), + ); templateData.value.appendChild(tipPart.domNode); templateData.elementDisposables.add(tipPart); + templateData.elementDisposables.add(tipPart.onDidHide(() => { + tipPart.domNode.remove(); + })); } let inlineSlashCommandRendered = false; 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/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 00b4229ebb0..24ab3f42c01 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1698,6 +1698,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. */ 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/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 952b37142f1..004b2fd6eca 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1239,14 +1239,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 { 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/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..d2d47e5842f 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -120,6 +120,8 @@ export namespace ChatContextKeys { export const hasAgentSessionChanges = new RawContextKey('agentSessionHasChanges', false, { type: 'boolean', description: localize('agentSessionHasChanges', "True when the current agent session item has changes.") }); 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..657c6d6ac1e 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1300,6 +1300,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 caa8538f4d3..9f280a98fb8 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -228,11 +228,16 @@ export interface IChatSessionsService { getInputPlaceholderForSessionType(chatSessionType: string): string | undefined; /** - * Get the list of chat session items grouped by session type. + * Get the list of current chat session items grouped by session type. * @param providerTypeFilter If specified, only returns items from the given providers. If undefined, returns items from all providers. */ getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; + /** + * Forces the controllers to refresh their session items, optionally filtered by provider type. + */ + refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise; + reportInProgress(chatSessionType: string, count: number): void; getInProgress(): { displayName: string; count: number }[]; 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 a8b9d012158..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksExecutionService.ts +++ /dev/null @@ -1,545 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { CancellationToken } from '../../../../../base/common/cancellation.js'; -import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; -import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { URI, isUriComponents } from '../../../../../base/common/uri.js'; -import { localize } from '../../../../../nls.js'; -import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { ILogService } from '../../../../../platform/log/common/log.js'; -import { Registry } from '../../../../../platform/registry/common/platform.js'; -import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../../services/output/common/output.js'; -import { HookType, HookTypeValue, IChatRequestHooks, IHookCommand } from '../promptSyntax/hookSchema.js'; -import { - HookCommandResultKind, - IHookCommandInput, - IHookCommandResult, - IPostToolUseCommandInput, - IPreToolUseCommandInput -} from './hooksCommandTypes.js'; -import { - commonHookOutputValidator, - IHookResult, - IPostToolUseCallerInput, - IPostToolUseHookResult, - IPreToolUseCallerInput, - IPreToolUseHookResult, - postToolUseOutputValidator, - PreToolUsePermissionDecision, - preToolUseOutputValidator -} from './hooksTypes.js'; - -export const hooksOutputChannelId = 'hooksExecution'; -const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); - -export interface IHooksExecutionOptions { - readonly input?: unknown; - readonly token?: CancellationToken; -} - -export interface IHookExecutedEvent { - readonly hookType: HookTypeValue; - readonly sessionResource: URI; - readonly input: unknown; - readonly results: readonly IHookResult[]; - readonly durationMs: number; -} - -/** - * Callback interface for hook execution proxies. - * MainThreadHooks implements this to forward calls to the extension host. - */ -export interface IHooksExecutionProxy { - runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; -} - -export const IHooksExecutionService = createDecorator('hooksExecutionService'); - -export interface IHooksExecutionService { - _serviceBrand: undefined; - - /** - * Fires when a hook has finished executing. - */ - readonly onDidExecuteHook: Event; - - /** - * Called by mainThreadHooks when extension host is ready - */ - setProxy(proxy: IHooksExecutionProxy): void; - - /** - * Register hooks for a session. Returns a disposable that unregisters them. - */ - registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; - - /** - * Get hooks registered for a session. - */ - getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; - - /** - * Execute hooks of the given type for the given session - */ - executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; - - /** - * Execute preToolUse hooks with typed input and validated output. - * The execution service builds the full hook input from the caller input plus session context. - * Returns a combined result with common fields and permission decision. - */ - executePreToolUseHook(sessionResource: URI, input: IPreToolUseCallerInput, token?: CancellationToken): Promise; - - /** - * Execute postToolUse hooks with typed input and validated output. - * Called after a tool completes successfully. The execution service builds the full hook input - * from the caller input plus session context. - * Returns a combined result with decision and additional context. - */ - executePostToolUseHook(sessionResource: URI, input: IPostToolUseCallerInput, token?: CancellationToken): Promise; -} - -/** - * Keys that should be redacted when logging hook input. - */ -const redactedInputKeys = ['toolArgs']; - -export class HooksExecutionService extends Disposable implements IHooksExecutionService { - declare readonly _serviceBrand: undefined; - - private readonly _onDidExecuteHook = this._register(new Emitter()); - readonly onDidExecuteHook: Event = this._onDidExecuteHook.event; - - private _proxy: IHooksExecutionProxy | undefined; - private readonly _sessionHooks = new Map(); - /** Stored transcript path per session (keyed by session URI string). */ - private readonly _sessionTranscriptPaths = new Map(); - private _channelRegistered = false; - private _requestCounter = 0; - - constructor( - @ILogService private readonly _logService: ILogService, - @IOutputService private readonly _outputService: IOutputService, - ) { - super(); - } - - setProxy(proxy: IHooksExecutionProxy): void { - this._proxy = proxy; - } - - private _ensureOutputChannel(): void { - if (this._channelRegistered) { - return; - } - Registry.as(Extensions.OutputChannels).registerChannel({ - id: hooksOutputChannelId, - label: hooksOutputChannelLabel, - log: false - }); - this._channelRegistered = true; - } - - private _log(requestId: number, hookType: HookTypeValue, message: string): void { - this._ensureOutputChannel(); - const channel = this._outputService.getChannel(hooksOutputChannelId); - if (channel) { - channel.append(`${new Date().toISOString()} [#${requestId}] [${hookType}] ${message}\n`); - } - } - - private _redactForLogging(input: object): object { - const result: Record = { ...input }; - for (const key of redactedInputKeys) { - if (Object.hasOwn(result, key)) { - result[key] = '...'; - } - } - return result; - } - - /** - * JSON.stringify replacer that converts URI / UriComponents values to their string form. - */ - private readonly _uriReplacer = (_key: string, value: unknown): unknown => { - if (URI.isUri(value)) { - return value.fsPath; - } - if (isUriComponents(value)) { - return URI.revive(value).fsPath; - } - return value; - }; - - private async _runSingleHook( - requestId: number, - hookType: HookTypeValue, - hookCommand: IHookCommand, - sessionResource: URI, - callerInput: unknown, - transcriptPath: URI | undefined, - token: CancellationToken - ): Promise { - // Build the common hook input properties. - // URI values are kept as URI objects through the RPC boundary, and converted - // to filesystem paths on the extension host side during JSON serialization. - const commonInput: IHookCommandInput = { - timestamp: new Date().toISOString(), - cwd: hookCommand.cwd ?? URI.file(''), - sessionId: sessionResource.toString(), - hookEventName: hookType, - ...(transcriptPath ? { transcript_path: transcriptPath } : undefined), - }; - - // Merge common properties with caller-specific input - const fullInput = !!callerInput && typeof callerInput === 'object' - ? { ...commonInput, ...callerInput } - : commonInput; - - const hookCommandJson = JSON.stringify(hookCommand, this._uriReplacer); - this._log(requestId, hookType, `Running: ${hookCommandJson}`); - const inputForLog = this._redactForLogging(fullInput); - this._log(requestId, hookType, `Input: ${JSON.stringify(inputForLog, this._uriReplacer)}`); - - const sw = StopWatch.create(); - try { - const commandResult = await this._proxy!.runHookCommand(hookCommand, fullInput, token); - const result = this._toInternalResult(commandResult); - this._logCommandResult(requestId, hookType, commandResult, Math.round(sw.elapsed())); - return result; - } catch (err) { - const errMessage = err instanceof Error ? err.message : String(err); - this._log(requestId, hookType, `Error in ${Math.round(sw.elapsed())}ms: ${errMessage}`); - return this._createErrorResult(errMessage); - } - } - - private _createErrorResult(errorMessage: string): IHookResult { - return { - resultKind: 'error', - output: errorMessage, - }; - } - - private _toInternalResult(commandResult: IHookCommandResult): IHookResult { - switch (commandResult.kind) { - case HookCommandResultKind.Error: { - // Blocking error - shown to model - return this._createErrorResult( - typeof commandResult.result === 'string' ? commandResult.result : JSON.stringify(commandResult.result) - ); - } - 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); - - return { - resultKind: 'success', - stopReason: commonFields.stopReason, - warningMessage: commonFields.systemMessage, - output: Object.keys(hookOutput).length > 0 ? hookOutput : undefined, - }; - } - default: { - // Unexpected result kind - treat as blocking error - return this._createErrorResult(`Unexpected hook command result kind: ${commandResult.kind}`); - } - } - } - - /** - * Extract hook-specific output fields, excluding common fields. - */ - private _extractHookSpecificOutput(result: Record): Record { - const commonFields = new Set(['stopReason', 'systemMessage']); - const output: Record = {}; - for (const [key, value] of Object.entries(result)) { - if (value !== undefined && !commonFields.has(key)) { - output[key] = value; - } - } - - return output; - } - - private _logCommandResult(requestId: number, hookType: HookTypeValue, result: IHookCommandResult, elapsed: number): void { - const resultKindStr = result.kind === HookCommandResultKind.Success ? 'Success' - : 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; - } - - 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}`); - } - } - } - - if (!mostRestrictiveDecision && !lastUpdatedInput && allAdditionalContext.length === 0) { - return undefined; - } - - const baseResult = winningResult ?? results[0]; - 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}`); - } - } - } - - // Return combined result if there's a block decision or any additional context - if (!hasBlock && allAdditionalContext.length === 0) { - return undefined; - } - - const baseResult = blockResult ?? results[0]; - return { - ...baseResult, - decision: hasBlock ? 'block' : undefined, - reason: blockReason, - additionalContext: allAdditionalContext.length > 0 ? allAdditionalContext : undefined, - }; - } -} diff --git a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts b/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts deleted file mode 100644 index 77368770734..00000000000 --- a/src/vs/workbench/contrib/chat/common/hooks/hooksTypes.ts +++ /dev/null @@ -1,143 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Internal hook types - types used within VS Code's hooks execution service. - * - * "Internal" means these types are used by VS Code code only - they never cross the - * process boundary to external hook commands. They use camelCase for field names. - * - * Examples: - * - IPreToolUseCallerInput: provided by VS Code callers (e.g., LanguageModelToolsService) - * - IPreToolUseHookResult: returned TO VS Code callers after processing command output - * - * External types (in hooksCommandTypes.ts) define the contract with spawned commands. - */ - -import { vEnum, vObj, 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({ - 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/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index e3c483d3811..1525bbb59e8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -148,3 +148,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/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 034145f82f7..c8196999288 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -427,8 +427,8 @@ export class PromptsService extends Disposable implements IPromptsService { } } - if (type !== PromptsType.skill) { - // no user source folders for skills + if (type !== PromptsType.skill && type !== PromptsType.hook) { + // no user source folders for skills and hooks const userHome = this.userDataService.currentProfile.promptsHome; result.push({ uri: userHome, storage: PromptsStorage.user, type }); } 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 e1340d8927a..77685cafa97 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 @@ -532,6 +532,9 @@ suite('AgentSessions', () => { }; mockChatSessionsService.registerChatSessionItemController('test-type', controller); + // Registering calls a refresh initially + assert.strictEqual(controllerCallCount, 1); + viewModel = createViewModel(); // Make multiple rapid resolve calls @@ -543,8 +546,8 @@ suite('AgentSessions', () => { await Promise.all(resolvePromises); - // Should only call controller once due to throttling - assert.strictEqual(controllerCallCount, 1); + // Should only call controller once more due to throttling + assert.strictEqual(controllerCallCount, 2); assert.strictEqual(viewModel.sessions.length, 1); }); }); @@ -590,8 +593,8 @@ suite('AgentSessions', () => { // First resolve all await viewModel.resolve(undefined); assert.strictEqual(viewModel.sessions.length, 2); - assert.strictEqual(controller1CallCount, 1); - assert.strictEqual(controller2CallCount, 1); + assert.strictEqual(controller1CallCount, 2); // One from registration and one from resolve + assert.strictEqual(controller2CallCount, 2); // One from registration and one from resolve // Now resolve only type-2 await viewModel.resolve('type-2'); @@ -599,9 +602,9 @@ suite('AgentSessions', () => { // Should still have only one session assert.strictEqual(viewModel.sessions.length, 1); // Controller 1 should not be called again - assert.strictEqual(controller1CallCount, 1); + assert.strictEqual(controller1CallCount, 2); // Controller 2 should be called again - assert.strictEqual(controller2CallCount, 2); + assert.strictEqual(controller2CallCount, 3); }); }); 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 849bed14cdf..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', () => { @@ -158,6 +169,86 @@ suite('ChatTipService', () => { assert.ok(tip, 'New request should get a tip after multiple old requests'); }); + test('dismissTip excludes the dismissed tip and allows a new one', () => { + const service = createService(); + const now = Date.now(); + + // Get a tip + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1); + + // Dismiss it + service.dismissTip(); + + // Next call should return a different tip (since the dismissed one is excluded) + const tip2 = service.getNextTip('request-1', now + 1000, contextKeyService); + if (tip2) { + assert.notStrictEqual(tip1.id, tip2.id, 'Dismissed tip should not be shown again'); + } + // tip2 may be undefined if it was the only eligible tip — that's also valid + }); + + test('dismissTip fires onDidDismissTip event', () => { + const service = createService(); + const now = Date.now(); + + service.getNextTip('request-1', now + 1000, contextKeyService); + + let fired = false; + testDisposables.add(service.onDidDismissTip(() => { fired = true; })); + service.dismissTip(); + + assert.ok(fired, 'onDidDismissTip should fire'); + }); + + test('disableTips fires onDidDisableTips event', async () => { + const service = createService(); + const now = Date.now(); + + service.getNextTip('request-1', now + 1000, contextKeyService); + + let fired = false; + testDisposables.add(service.onDidDisableTips(() => { fired = true; })); + await service.disableTips(); + + assert.ok(fired, 'onDidDisableTips should fire'); + }); + + test('disableTips resets state so re-enabling works', async () => { + const service = createService(); + const now = Date.now(); + + // Show a tip + const tip1 = service.getNextTip('request-1', now + 1000, contextKeyService); + assert.ok(tip1); + + // Disable tips + await service.disableTips(); + + // Re-enable tips + configurationService.setUserConfiguration('chat.tips.enabled', true); + + // Should be able to get a tip again on a new request + const tip2 = service.getNextTip('request-2', now + 2000, contextKeyService); + assert.ok(tip2, 'Should return a tip after disabling and re-enabling'); + }); + + function createMockPromptsService( + agentInstructions: IResolvedAgentFile[] = [], + promptInstructions: IPromptPath[] = [], + options?: { onDidChangeCustomAgents?: Event; listPromptFiles?: (_type: PromptsType) => Promise }, + ): Partial { + return { + listAgentInstructions: async () => agentInstructions, + listPromptFiles: options?.listPromptFiles ?? (async (_type: PromptsType) => promptInstructions), + onDidChangeCustomAgents: options?.onDidChangeCustomAgents ?? Event.None, + }; + } + + function createMockToolsService(): MockLanguageModelToolsService { + return testDisposables.add(new MockLanguageModelToolsService()); + } + test('excludes tip.undoChanges when restore checkpoint command has been executed', () => { const tip: ITipDefinition = { id: 'tip.undoChanges', @@ -169,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'); @@ -179,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 @@ -231,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'); @@ -255,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'); @@ -276,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: [] }); @@ -287,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'); @@ -307,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); @@ -318,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 4b6f909e845..3b53ed241ba 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,30 +60,6 @@ class TestTelemetryService implements Partial { } } -class MockHooksExecutionService implements IHooksExecutionService { - readonly _serviceBrand: undefined; - readonly onDidExecuteHook = Event.None; - public preToolUseHookResult: IPreToolUseHookResult | undefined = undefined; - public postToolUseHookResult: IPostToolUseHookResult | undefined = undefined; - public lastPreToolUseInput: IPreToolUseCallerInput | undefined = undefined; - public lastPostToolUseInput: IPostToolUseCallerInput | undefined = undefined; - - setProxy(_proxy: IHooksExecutionProxy): void { } - registerHooks(_sessionResource: URI, _hooks: IChatRequestHooks): IDisposable { return { dispose: () => { } }; } - getHooksForSession(_sessionResource: URI): IChatRequestHooks | undefined { return undefined; } - executeHook(_hookType: HookTypeValue, _sessionResource: URI, _options?: IHooksExecutionOptions): Promise { - return Promise.resolve([]); - } - async executePreToolUseHook(_sessionResource: URI, input: IPreToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPreToolUseInput = input; - return this.preToolUseHookResult; - } - async executePostToolUseHook(_sessionResource: URI, input: IPostToolUseCallerInput, _token?: CancellationToken): Promise { - this.lastPostToolUseInput = input; - return this.postToolUseHookResult; - } -} - function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial) { const toolData: IToolData = { id, @@ -147,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; @@ -172,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', { @@ -1785,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', { @@ -1828,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', { @@ -2598,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', { @@ -2637,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 @@ -2677,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 @@ -2720,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 @@ -3810,27 +3772,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' }] }) }); @@ -3838,8 +3789,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 ); @@ -3862,12 +3819,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' }] }) }); @@ -3875,8 +3826,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 ); @@ -3887,8 +3843,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' }] }) }); @@ -3905,38 +3859,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 () => { @@ -3948,8 +3871,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 ); @@ -3958,13 +3887,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 () => { @@ -3983,9 +3905,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 ); @@ -4006,12 +3934,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 () => { @@ -4030,9 +3952,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 ); @@ -4045,12 +3972,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) => { @@ -4068,8 +3989,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 ); @@ -4087,19 +4014,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) => { @@ -4123,8 +4042,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 ); @@ -4143,19 +4068,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) => { @@ -4179,8 +4096,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 ); @@ -4189,154 +4112,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/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 bd017e42b0b..00000000000 --- a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts +++ /dev/null @@ -1,1037 +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 error result', async () => { - const proxy = createMockProxy(() => { - throw new Error('proxy failed'); - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].output, 'proxy failed'); - // Error results still have default common fields - assert.strictEqual(results[0].stopReason, undefined); - }); - - test('passes cancellation token to proxy', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const cts = store.add(new CancellationTokenSource()); - await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); - - assert.strictEqual(receivedToken, cts.token); - }); - - test('uses CancellationToken.None when no token provided', async () => { - let receivedToken: CancellationToken | undefined; - const proxy = createMockProxy((_cmd, _input, token) => { - receivedToken = token; - return { kind: HookCommandResultKind.Success, result: 'ok' }; - }); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(receivedToken, CancellationToken.None); - }); - - test('extracts common fields from successful result', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Success, - result: { - stopReason: 'User requested stop', - systemMessage: 'Warning: hook triggered', - hookSpecificOutput: { - permissionDecision: 'allow' - } - } - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].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('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', async () => { - const proxy = createMockProxy(() => ({ - kind: HookCommandResultKind.Error, - result: 'command failed with error' - })); - service.setProxy(proxy); - - const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; - store.add(service.registerHooks(sessionUri, hooks)); - - const results = await service.executeHook(HookType.PreToolUse, sessionUri); - - assert.strictEqual(results.length, 1); - assert.strictEqual(results[0].resultKind, 'error'); - assert.strictEqual(results[0].output, 'command failed with error'); - // Defaults are still applied - assert.strictEqual(results[0].stopReason, 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/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 82d8f827d77..097866e2e4d 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -41,7 +41,7 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onRequestNotifyExtension = new AsyncEmitter(); readonly onRequestNotifyExtension = this._onRequestNotifyExtension.event; - private sessionItemControllers = new Map(); + private sessionItemControllers = new Map }>(); private contentProviders = new Map(); private contributions: IChatSessionsExtensionPoint[] = []; private optionGroups = new Map(); @@ -67,7 +67,7 @@ export class MockChatSessionsService implements IChatSessionsService { } registerChatSessionItemController(chatSessionType: string, controller: IChatSessionItemController): IDisposable { - this.sessionItemControllers.set(chatSessionType, controller); + this.sessionItemControllers.set(chatSessionType, { controller, initialRefresh: controller.refresh(CancellationToken.None) }); return { dispose: () => { this.sessionItemControllers.delete(chatSessionType); @@ -108,19 +108,28 @@ export class MockChatSessionsService implements IChatSessionsService { return this.contributions.find(c => c.type === chatSessionType)?.inputPlaceholder; } - getChatSessionItems(providersToResolve: readonly string[] | undefined, token: CancellationToken): Promise> { + getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise> { return Promise.all( Array.from(this.sessionItemControllers.entries()) - .filter(([chatSessionType]) => !providersToResolve || providersToResolve.includes(chatSessionType)) - .map(async ([chatSessionType, controller]) => { - await controller.refresh(token); + .filter(([chatSessionType]) => !providerTypeFilter || providerTypeFilter.includes(chatSessionType)) + .map(async ([chatSessionType, controllerEntry]) => { + await controllerEntry.initialRefresh; // ensure initial refresh is done return ({ chatSessionType: chatSessionType, - items: controller.items + items: controllerEntry.controller.items }); })); } + async refreshChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise { + await Promise.all( + Array.from(this.sessionItemControllers.entries()) + .filter(([chatSessionType]) => !providerTypeFilter || providerTypeFilter.includes(chatSessionType)) + .map(async ([_chatSessionType, controllerEntry]) => { + await controllerEntry.controller.refresh(token); + })); + } + reportInProgress(chatSessionType: string, count: number): void { this.inProgress.set(chatSessionType, count); this._onDidChangeInProgress.fire(); 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..8321a90cf69 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', () => { @@ -341,3 +342,102 @@ suite('HookClaudeCompat', () => { }); }); }); + +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.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const hooks = result.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/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/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/remote/browser/remoteConnectionHealth.ts b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts index a71bcec9350..937aed25b45 100644 --- a/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts +++ b/src/vs/workbench/contrib/remote/browser/remoteConnectionHealth.ts @@ -9,7 +9,7 @@ import { IWorkbenchEnvironmentService } from '../../../services/environment/comm import { localize } from '../../../../nls.js'; import { isWeb } from '../../../../base/common/platform.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; -import { getRemoteName } from '../../../../platform/remote/common/remoteHosts.js'; +import { getRemoteName, getRemoteServerRootPath } from '../../../../platform/remote/common/remoteHosts.js'; import { IBannerService } from '../../../services/banner/browser/bannerService.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IHostService } from '../../../services/host/browser/host.js'; @@ -124,16 +124,19 @@ export class InitialRemoteConnectionHealthContribution implements IWorkbenchCont web: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Is web ui.' }; connectionTimeMs: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Time, in ms, until connected' }; remoteName: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'The name of the resolver.' }; + tunnelName?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The name of the tunnel for tunnel connections.' }; }; type RemoteConnectionSuccessEvent = { web: boolean; connectionTimeMs: number | undefined; remoteName: string | undefined; + tunnelName?: string; }; this._telemetryService.publicLog2('remoteConnectionSuccess', { web: isWeb, connectionTimeMs: await this._remoteAgentService.getConnection()?.getInitialConnectionTimeMs(), - remoteName: getRemoteName(this._environmentService.remoteAuthority) + remoteName: getRemoteName(this._environmentService.remoteAuthority), + tunnelName: getRemoteServerRootPath(this._environmentService.remoteAuthority) }); await this._measureExtHostLatency(); diff --git a/src/vs/workbench/contrib/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 { @@ -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..1540fea982c 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: 5 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 timeoutSec?: 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 {