diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index f48738be53a5b..cae158ea59014 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -166,7 +166,7 @@ const tasks = compilations.map(function (tsconfigFile) { const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, async () => { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const copyNonTs = util.streamToPromise(nonts.pipe(gulp.dest(out))); - const tsgo = spawnTsgo(absolutePath, { reporterId: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); + const tsgo = spawnTsgo(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); await Promise.all([copyNonTs, tsgo]); })); @@ -175,7 +175,7 @@ const tasks = compilations.map(function (tsconfigFile) { const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); - const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { reporterId: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, { taskName: 'extensions' }, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); return watchStream; @@ -276,9 +276,9 @@ gulp.task(watchWebExtensionsTask); async function buildWebExtensions(isWatch: boolean): Promise { const extensionsPath = path.join(root, 'extensions'); - // Find all esbuild-browser.ts files + // Find all esbuild.browser.mts files const esbuildConfigLocations = await nodeUtil.promisify(glob)( - path.join(extensionsPath, '**', 'esbuild-browser.ts'), + path.join(extensionsPath, '**', 'esbuild.browser.mts'), { ignore: ['**/node_modules'] } ); @@ -293,7 +293,11 @@ async function buildWebExtensions(isWatch: boolean): Promise { // Esbuild for extensions if (esbuildConfigLocations.length > 0) { - promises.push(ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script })))); + promises.push( + ext.esbuildExtensions('packaging web extension (esbuild)', isWatch, esbuildConfigLocations.map(script => ({ script }))), + // Also run type check on extensions + ...esbuildConfigLocations.map(script => ext.typeCheckExtension(path.dirname(script), true)) + ); } // Run webpack for remaining extensions diff --git a/build/lib/extensions.ts b/build/lib/extensions.ts index cea54bff8b954..fac7946fc98af 100644 --- a/build/lib/extensions.ts +++ b/build/lib/extensions.ts @@ -25,6 +25,7 @@ import { getProductionDependencies } from './dependencies.ts'; import { type IExtensionDefinition, getExtensionStream } from './builtInExtensions.ts'; import { getVersion } from './getVersion.ts'; import { fetchUrls, fetchGithub } from './fetch.ts'; +import { createTsgoStream, spawnTsgo } from './tsgo.ts'; import vzip from 'gulp-vinyl-zip'; import { createRequire } from 'module'; @@ -67,23 +68,27 @@ 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'; + ? 'esbuild.browser.mts' + : 'esbuild.mts'; 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)); + const hasWebpack = fs.existsSync(path.join(extensionPath, webpackConfigFileName)); let input: Stream; let isBundled = false; if (hasEsbuild) { - input = fromLocalEsbuild(extensionPath, esbuildConfigFileName); + // Unlike webpack, esbuild only does bundling so we still want to run a separate type check step + input = es.merge( + fromLocalEsbuild(extensionPath, esbuildConfigFileName), + typeCheckExtensionStream(extensionPath, forWeb), + ); isBundled = true; - } else if (isWebPacked) { + } else if (hasWebpack) { input = fromLocalWebpack(extensionPath, webpackConfigFileName, disableMangle); isBundled = true; } else { @@ -105,6 +110,17 @@ function fromLocal(extensionPath: string, forWeb: boolean, disableMangle: boolea return input; } +export function typeCheckExtension(extensionPath: string, forWeb: boolean): Promise { + const tsconfigFileName = forWeb ? 'tsconfig.browser.json' : 'tsconfig.json'; + const tsconfigPath = path.join(extensionPath, tsconfigFileName); + return spawnTsgo(tsconfigPath, { taskName: 'typechecking extension (tsgo)', noEmit: true }); +} + +export function typeCheckExtensionStream(extensionPath: string, forWeb: boolean): Stream { + const tsconfigFileName = forWeb ? 'tsconfig.browser.json' : 'tsconfig.json'; + const tsconfigPath = path.join(extensionPath, tsconfigFileName); + return createTsgoStream(tsconfigPath, { taskName: 'typechecking extension (tsgo)', noEmit: true }); +} function fromLocalWebpack(extensionPath: string, webpackConfigFileName: string, disableMangle: boolean): Stream { const vsce = require('@vscode/vsce') as typeof import('@vscode/vsce'); @@ -267,6 +283,7 @@ function fromLocalEsbuild(extensionPath: string, esbuildConfigFileName: string): 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 || []) { @@ -632,17 +649,6 @@ export function translatePackageJSON(packageJSON: string, packageNLSPath: string const extensionsPath = path.join(root, 'extensions'); -// Additional projects to run esbuild on. These typically build code for webviews -const esbuildMediaScripts = [ - 'ipynb/esbuild.mjs', - 'markdown-language-features/esbuild-notebook.mjs', - 'markdown-language-features/esbuild-preview.mjs', - 'markdown-math/esbuild.mjs', - 'mermaid-chat-features/esbuild-chat-webview.mjs', - 'notebook-renderers/esbuild.mjs', - 'simple-browser/esbuild-preview.mjs', -]; - export async function webpackExtensions(taskName: string, isWatch: boolean, webpackConfigLocations: { configPath: string; outputRoot?: string }[]) { const webpack = require('webpack') as typeof import('webpack'); @@ -742,6 +748,18 @@ export async function esbuildExtensions(taskName: string, isWatch: boolean, scri await Promise.all(tasks); } + +// Additional projects to run esbuild on. These typically build code for webviews +const esbuildMediaScripts = [ + 'ipynb/esbuild.notebook.mts', + 'markdown-language-features/esbuild.notebook.mts', + 'markdown-language-features/esbuild.webview.mts', + 'markdown-math/esbuild.notebook.mts', + 'mermaid-chat-features/esbuild.webview.mts', + 'notebook-renderers/esbuild.notebook.mts', + 'simple-browser/esbuild.webview.mts', +]; + export function buildExtensionMedia(isWatch: boolean, outputRoot?: string): Promise { return esbuildExtensions('esbuilding extension media', isWatch, esbuildMediaScripts.map(p => ({ script: path.join(extensionsPath, p), diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 53387c4fa3462..7489336e8144d 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -169,6 +169,20 @@ "type": "boolean", "default": true }, + { + "key": "chat.useHooks", + "name": "ChatHooks", + "category": "InteractiveSession", + "minimumVersion": "1.109", + "localization": { + "description": { + "key": "chat.useHooks.description", + "value": "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`." + } + }, + "type": "boolean", + "default": true + }, { "key": "chat.tools.terminal.enableAutoApprove", "name": "ChatToolsTerminalEnableAutoApprove", diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index 3a245fe5cb6ba..421f4c1cc1b17 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -3,37 +3,31 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import ansiColors from 'ansi-colors'; import * as cp from 'child_process'; import es from 'event-stream'; +import fancyLog from 'fancy-log'; import * as path from 'path'; -import { createReporter } from './reporter.ts'; const root = path.dirname(path.dirname(import.meta.dirname)); const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; -export function spawnTsgo(projectPath: string, config: { reporterId: string }, onComplete?: () => Promise | void): Promise { - const reporter = createReporter(config.reporterId); - let report: NodeJS.ReadWriteStream | undefined; - - const beginReport = (emitError: boolean) => { - if (report) { - report.end(); - } - report = reporter.end(emitError); - }; - - const endReport = () => { - if (!report) { - return; +export function spawnTsgo(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): Promise { + function reporter(stdError: string) { + const matches = (stdError || '').match(/^error \w+: (.+)?/g); + fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${matches ? matches.length : 0} errors.`); + for (const match of matches || []) { + fancyLog.error(match); } - report.end(); - report = undefined; - }; - - beginReport(false); - - const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--sourceMap', '--inlineSources']; + } + + const args = ['tsgo', '--project', projectPath, '--pretty', 'false']; + if (config.noEmit) { + args.push('--noEmit'); + } else { + args.push('--sourceMap', '--inlineSources'); + } const child = cp.spawn(npx, args, { cwd: root, stdio: ['ignore', 'pipe', 'pipe'], @@ -47,23 +41,13 @@ export function spawnTsgo(projectPath: string, config: { reporterId: string }, o return; } if (/Starting compilation|File change detected/i.test(trimmed)) { - beginReport(false); return; } if (/Compilation complete/i.test(trimmed)) { - endReport(); return; } - const match = /(.*\(\d+,\d+\): )(.*: )(.*)/.exec(trimmed); - - if (match) { - const fullpath = path.isAbsolute(match[1]) ? match[1] : path.join(root, match[1]); - const message = match[3]; - reporter(fullpath + message); - } else { - reporter(trimmed); - } + reporter(trimmed); }; const handleData = (data: Buffer) => { @@ -84,7 +68,7 @@ export function spawnTsgo(projectPath: string, config: { reporterId: string }, o handleLine(buffer); buffer = ''; } - endReport(); + if (code === 0) { Promise.resolve(onComplete?.()).then(() => resolve(), reject); } else { @@ -93,15 +77,13 @@ export function spawnTsgo(projectPath: string, config: { reporterId: string }, o }); child.on('error', err => { - endReport(); reject(err); }); }); } -export function createTsgoStream(projectPath: string, config: { reporterId: string }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { +export function createTsgoStream(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { const stream = es.through(); - spawnTsgo(projectPath, config, onComplete).then(() => { stream.emit('end'); }).catch(() => { diff --git a/extensions/esbuild-extension-common.ts b/extensions/esbuild-extension-common.mts similarity index 100% rename from extensions/esbuild-extension-common.ts rename to extensions/esbuild-extension-common.mts diff --git a/extensions/esbuild-webview-common.mjs b/extensions/esbuild-webview-common.mts similarity index 62% rename from extensions/esbuild-webview-common.mjs rename to extensions/esbuild-webview-common.mts index 76d03abad7dfe..a170e5e344f9f 100644 --- a/extensions/esbuild-webview-common.mjs +++ b/extensions/esbuild-webview-common.mts @@ -2,27 +2,22 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check + /** - * @fileoverview Common build script for extension scripts used in in webviews. + * Common build script for extension scripts used in in webviews. */ import path from 'node:path'; import esbuild from 'esbuild'; -/** - * @typedef {Partial & { - * entryPoints: string[] | Record | { in: string, out: string }[]; - * outdir: string; - * }} BuildOptions - */ +export type BuildOptions = Partial & { + entryPoints: string[] | Record | { in: string; out: string }[]; + outdir: string; +}; /** * Build the source code once using esbuild. - * - * @param {BuildOptions} options - * @param {(outDir: string) => unknown} [didBuild] */ -async function build(options, didBuild) { +async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { await esbuild.build({ bundle: true, minify: true, @@ -38,11 +33,8 @@ async function build(options, didBuild) { /** * Build the source code once using esbuild, logging errors instead of throwing. - * - * @param {BuildOptions} options - * @param {(outDir: string) => unknown} [didBuild] */ -async function tryBuild(options, didBuild) { +async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { try { await build(options, didBuild); } catch (err) { @@ -50,17 +42,16 @@ async function tryBuild(options, didBuild) { } } -/** - * @param {{ - * srcDir: string; - * outdir: string; - * entryPoints: string[] | Record | { in: string, out: string }[]; - * additionalOptions?: Partial - * }} config - * @param {string[]} args - * @param {(outDir: string) => unknown} [didBuild] - */ -export async function run(config, args, didBuild) { +export async function run( + config: { + srcDir: string; + outdir: string; + entryPoints: BuildOptions['entryPoints']; + additionalOptions?: Partial; + }, + args: string[], + didBuild?: (outDir: string) => unknown +): Promise { let outdir = config.outdir; const outputRootIndex = args.indexOf('--outputRoot'); if (outputRootIndex >= 0) { @@ -69,8 +60,7 @@ export async function run(config, args, didBuild) { outdir = path.join(outputRoot, outputDirName); } - /** @type {BuildOptions} */ - const resolvedOptions = { + const resolvedOptions: BuildOptions = { entryPoints: config.entryPoints, outdir, logOverride: { diff --git a/extensions/ipynb/esbuild.mjs b/extensions/ipynb/esbuild.notebook.mts similarity index 90% rename from extensions/ipynb/esbuild.mjs rename to extensions/ipynb/esbuild.notebook.mts index 3003959c1eb45..4d45f3885740d 100644 --- a/extensions/ipynb/esbuild.mjs +++ b/extensions/ipynb/esbuild.notebook.mts @@ -2,9 +2,8 @@ * 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 path from 'node:path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'notebook-src'); const outDir = path.join(import.meta.dirname, 'notebook-out'); diff --git a/extensions/ipynb/package.json b/extensions/ipynb/package.json index 89a24e5cc15c9..7396e270a47e3 100644 --- a/extensions/ipynb/package.json +++ b/extensions/ipynb/package.json @@ -158,7 +158,7 @@ "scripts": { "compile": "npx gulp compile-extension:ipynb && npm run build-notebook", "watch": "npx gulp watch-extension:ipynb", - "build-notebook": "node ./esbuild.mjs" + "build-notebook": "node ./esbuild.notebook.mts" }, "dependencies": { "@enonic/fnv-plus": "^1.3.0", diff --git a/extensions/markdown-language-features/.vscodeignore b/extensions/markdown-language-features/.vscodeignore index a5b7a3ec72ca5..315b1d7877049 100644 --- a/extensions/markdown-language-features/.vscodeignore +++ b/extensions/markdown-language-features/.vscodeignore @@ -2,16 +2,12 @@ test/** test-workspace/** src/** notebook/** -tsconfig.json -tsconfig.*.json +tsconfig*.json +esbuild* out/test/** out/** -extension.webpack.config.js -extension-browser.webpack.config.js cgmanifest.json package-lock.json preview-src/** -webpack.config.js -esbuild-* .gitignore **/*.d.ts diff --git a/extensions/markdown-language-features/esbuild-browser.ts b/extensions/markdown-language-features/esbuild.browser.mts similarity index 96% rename from extensions/markdown-language-features/esbuild-browser.ts rename to extensions/markdown-language-features/esbuild.browser.mts index 2c46e390c0669..ddf0c5a99dc4e 100644 --- a/extensions/markdown-language-features/esbuild-browser.ts +++ b/extensions/markdown-language-features/esbuild.browser.mts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); diff --git a/extensions/markdown-language-features/esbuild.ts b/extensions/markdown-language-features/esbuild.mts similarity index 95% rename from extensions/markdown-language-features/esbuild.ts rename to extensions/markdown-language-features/esbuild.mts index 67835c9a1d7d8..a1cf6eb5fa8de 100644 --- a/extensions/markdown-language-features/esbuild.ts +++ b/extensions/markdown-language-features/esbuild.mts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); diff --git a/extensions/markdown-language-features/esbuild-notebook.mjs b/extensions/markdown-language-features/esbuild.notebook.mts similarity index 90% rename from extensions/markdown-language-features/esbuild-notebook.mjs rename to extensions/markdown-language-features/esbuild.notebook.mts index 933e77d21a553..d9d511c5e8224 100644 --- a/extensions/markdown-language-features/esbuild-notebook.mjs +++ b/extensions/markdown-language-features/esbuild.notebook.mts @@ -2,9 +2,8 @@ * 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 path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'notebook'); const outDir = path.join(import.meta.dirname, 'notebook-out'); diff --git a/extensions/markdown-language-features/esbuild-preview.mjs b/extensions/markdown-language-features/esbuild.webview.mts similarity index 90% rename from extensions/markdown-language-features/esbuild-preview.mjs rename to extensions/markdown-language-features/esbuild.webview.mts index 1d3fc48b9bc66..c4141cf50a583 100644 --- a/extensions/markdown-language-features/esbuild-preview.mjs +++ b/extensions/markdown-language-features/esbuild.webview.mts @@ -2,9 +2,8 @@ * 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 path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'preview-src'); const outDir = path.join(import.meta.dirname, 'media'); diff --git a/extensions/markdown-language-features/package.json b/extensions/markdown-language-features/package.json index edffec39d748b..c9d0de68d863d 100644 --- a/extensions/markdown-language-features/package.json +++ b/extensions/markdown-language-features/package.json @@ -757,14 +757,20 @@ ] }, "scripts": { - "compile": "gulp compile-extension:markdown-language-features-languageService && gulp compile-extension:markdown-language-features && npm run build-preview && npm run build-notebook", - "watch": "npm run build-preview && gulp watch-extension:markdown-language-features watch-extension:markdown-language-features-languageService", - "vscode:prepublish": "npm run build-ext && npm run build-preview", - "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:markdown-language-features ./tsconfig.json", - "build-notebook": "node ./esbuild-notebook.mjs", - "build-preview": "node ./esbuild-preview.mjs", - "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", - "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch" + "compile": "npm-run-all2 -lp build-ext build-webview build-notebook", + "watch": "npm-run-all2 -lp watch-ext watch-webview watch-notebook", + "build-ext": "gulp compile-extension:markdown-language-features", + "watch-ext": "gulp watch-extension:markdown-language-features", + "build-notebook": "node ./esbuild.notebook.mts", + "watch-notebook": "node ./esbuild.notebook.mts --watch", + "build-webview": "node ./esbuild.webview.mts", + "watch-webview": "node ./esbuild.webview.mts --watch", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "dependencies": { "@vscode/extension-telemetry": "^0.9.8", diff --git a/extensions/markdown-language-features/tsconfig.browser.json b/extensions/markdown-language-features/tsconfig.browser.json index dbacbb22fdff4..790349e7fec3f 100644 --- a/extensions/markdown-language-features/tsconfig.browser.json +++ b/extensions/markdown-language-features/tsconfig.browser.json @@ -3,5 +3,8 @@ "compilerOptions": {}, "exclude": [ "./src/test/**" + ], + "files": [ + "./src/extension.browser.ts" ] } diff --git a/extensions/markdown-math/.vscodeignore b/extensions/markdown-math/.vscodeignore index 5df4a1cb8abf1..900988455028e 100644 --- a/extensions/markdown-math/.vscodeignore +++ b/extensions/markdown-math/.vscodeignore @@ -1,10 +1,7 @@ src/** notebook/** -extension-browser.webpack.config.js -extension.webpack.config.js -esbuild.* +tsconfig*.json +esbuild* cgmanifest.json package-lock.json -webpack.config.js -tsconfig.json .gitignore diff --git a/extensions/media-preview/esbuild-browser.ts b/extensions/markdown-math/esbuild.browser.mts similarity index 93% rename from extensions/media-preview/esbuild-browser.ts rename to extensions/markdown-math/esbuild.browser.mts index a2659e5ff4619..e3fa7792d056d 100644 --- a/extensions/media-preview/esbuild-browser.ts +++ b/extensions/markdown-math/esbuild.browser.mts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); diff --git a/extensions/mermaid-chat-features/esbuild.ts b/extensions/markdown-math/esbuild.mts similarity index 91% rename from extensions/mermaid-chat-features/esbuild.ts rename to extensions/markdown-math/esbuild.mts index 232f589197b4d..5fafb57ab75a8 100644 --- a/extensions/mermaid-chat-features/esbuild.ts +++ b/extensions/markdown-math/esbuild.mts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); diff --git a/extensions/markdown-math/esbuild.mjs b/extensions/markdown-math/esbuild.notebook.mts similarity index 89% rename from extensions/markdown-math/esbuild.mjs rename to extensions/markdown-math/esbuild.notebook.mts index 910acbb06a844..c5ac472b3bd76 100644 --- a/extensions/markdown-math/esbuild.mjs +++ b/extensions/markdown-math/esbuild.notebook.mts @@ -2,18 +2,14 @@ * 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 path from 'path'; import fse from 'fs-extra'; -import { run } from '../esbuild-webview-common.mjs'; - -const args = process.argv.slice(2); +import path from 'path'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'notebook'); const outDir = path.join(import.meta.dirname, 'notebook-out'); -function postBuild(outDir) { +function postBuild(outDir: string) { fse.copySync( path.join(import.meta.dirname, 'node_modules', 'katex', 'dist', 'katex.min.css'), path.join(outDir, 'katex.min.css')); diff --git a/extensions/markdown-math/package.json b/extensions/markdown-math/package.json index 5af72e0b51331..19f20fcd04ab8 100644 --- a/extensions/markdown-math/package.json +++ b/extensions/markdown-math/package.json @@ -108,7 +108,7 @@ "scripts": { "compile": "npm run build-notebook", "watch": "npm run build-notebook", - "build-notebook": "node ./esbuild.mjs" + "build-notebook": "node ./esbuild.notebook.mts" }, "devDependencies": { "@types/markdown-it": "^0.0.0", diff --git a/extensions/markdown-math/tsconfig.browser.json b/extensions/markdown-math/tsconfig.browser.json new file mode 100644 index 0000000000000..715a07ebfb83d --- /dev/null +++ b/extensions/markdown-math/tsconfig.browser.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": [], + "typeRoots": [ + "./node_modules/@types" + ] + } +} diff --git a/extensions/media-preview/.vscodeignore b/extensions/media-preview/.vscodeignore index 532c87f6f2e37..8621eb9e9f489 100644 --- a/extensions/media-preview/.vscodeignore +++ b/extensions/media-preview/.vscodeignore @@ -1,10 +1,9 @@ test/** src/** -tsconfig.json +tsconfig*.json +esbuild* out/test/** out/** -extension.webpack.config.js -extension-browser.webpack.config.js cgmanifest.json package-lock.json preview-src/** diff --git a/extensions/markdown-math/esbuild-browser.ts b/extensions/media-preview/esbuild.browser.mts similarity index 93% rename from extensions/markdown-math/esbuild-browser.ts rename to extensions/media-preview/esbuild.browser.mts index a2659e5ff4619..e3fa7792d056d 100644 --- a/extensions/markdown-math/esbuild-browser.ts +++ b/extensions/media-preview/esbuild.browser.mts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); diff --git a/extensions/markdown-math/esbuild.ts b/extensions/media-preview/esbuild.mts similarity index 91% rename from extensions/markdown-math/esbuild.ts rename to extensions/media-preview/esbuild.mts index 232f589197b4d..5fafb57ab75a8 100644 --- a/extensions/markdown-math/esbuild.ts +++ b/extensions/media-preview/esbuild.mts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); diff --git a/extensions/media-preview/tsconfig.browser.json b/extensions/media-preview/tsconfig.browser.json new file mode 100644 index 0000000000000..3694afc77ee63 --- /dev/null +++ b/extensions/media-preview/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig" +} diff --git a/extensions/mermaid-chat-features/esbuild-browser.ts b/extensions/mermaid-chat-features/esbuild.browser.mts similarity index 93% rename from extensions/mermaid-chat-features/esbuild-browser.ts rename to extensions/mermaid-chat-features/esbuild.browser.mts index a2659e5ff4619..e3fa7792d056d 100644 --- a/extensions/mermaid-chat-features/esbuild-browser.ts +++ b/extensions/mermaid-chat-features/esbuild.browser.mts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); diff --git a/extensions/media-preview/esbuild.ts b/extensions/mermaid-chat-features/esbuild.mts similarity index 91% rename from extensions/media-preview/esbuild.ts rename to extensions/mermaid-chat-features/esbuild.mts index 232f589197b4d..5fafb57ab75a8 100644 --- a/extensions/media-preview/esbuild.ts +++ b/extensions/mermaid-chat-features/esbuild.mts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as path from 'node:path'; -import { run } from '../esbuild-extension-common.ts'; +import { run } from '../esbuild-extension-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); diff --git a/extensions/mermaid-chat-features/esbuild-chat-webview.mjs b/extensions/mermaid-chat-features/esbuild.webview.mts similarity index 92% rename from extensions/mermaid-chat-features/esbuild-chat-webview.mjs rename to extensions/mermaid-chat-features/esbuild.webview.mts index e242585b1c3fc..41cfa12139e33 100644 --- a/extensions/mermaid-chat-features/esbuild-chat-webview.mjs +++ b/extensions/mermaid-chat-features/esbuild.webview.mts @@ -2,9 +2,8 @@ * 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 path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'chat-webview-src'); const outDir = path.join(import.meta.dirname, 'chat-webview-out'); diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 16b6a03ce48a7..64c31782461fe 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -117,7 +117,7 @@ "watch": "npm run build-chat-webview && gulp watch-extension:mermaid-chat-features", "vscode:prepublish": "npm run build-ext && npm run build-chat-webview", "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:mermaid-chat-features", - "build-chat-webview": "node ./esbuild-chat-webview.mjs", + "build-chat-webview": "node ./esbuild.webview.mts", "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" }, diff --git a/extensions/mermaid-chat-features/tsconfig.browser.json b/extensions/mermaid-chat-features/tsconfig.browser.json new file mode 100644 index 0000000000000..3694afc77ee63 --- /dev/null +++ b/extensions/mermaid-chat-features/tsconfig.browser.json @@ -0,0 +1,3 @@ +{ + "extends": "./tsconfig" +} diff --git a/extensions/notebook-renderers/esbuild.mjs b/extensions/notebook-renderers/esbuild.notebook.mts similarity index 90% rename from extensions/notebook-renderers/esbuild.mjs rename to extensions/notebook-renderers/esbuild.notebook.mts index 890aacd19bf35..ab241d8601dca 100644 --- a/extensions/notebook-renderers/esbuild.mjs +++ b/extensions/notebook-renderers/esbuild.notebook.mts @@ -2,9 +2,8 @@ * 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 path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'renderer-out'); diff --git a/extensions/notebook-renderers/package.json b/extensions/notebook-renderers/package.json index 77c042ee66390..715cfc03e85c1 100644 --- a/extensions/notebook-renderers/package.json +++ b/extensions/notebook-renderers/package.json @@ -44,7 +44,7 @@ "scripts": { "compile": "npx gulp compile-extension:notebook-renderers && npm run build-notebook", "watch": "npx gulp compile-watch:notebook-renderers", - "build-notebook": "node ./esbuild.mjs" + "build-notebook": "node ./esbuild.notebook.mts" }, "devDependencies": { "@types/jsdom": "^21.1.0", diff --git a/extensions/simple-browser/esbuild-preview.mjs b/extensions/simple-browser/esbuild.webview.mts similarity index 92% rename from extensions/simple-browser/esbuild-preview.mjs rename to extensions/simple-browser/esbuild.webview.mts index 3ce58360a30d2..0f91843610b6a 100644 --- a/extensions/simple-browser/esbuild-preview.mjs +++ b/extensions/simple-browser/esbuild.webview.mts @@ -2,9 +2,8 @@ * 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 path from 'path'; -import { run } from '../esbuild-webview-common.mjs'; +import { run } from '../esbuild-webview-common.mts'; const srcDir = path.join(import.meta.dirname, 'preview-src'); const outDir = path.join(import.meta.dirname, 'media'); diff --git a/extensions/simple-browser/package.json b/extensions/simple-browser/package.json index 0d558eeebf654..d372992c8972e 100644 --- a/extensions/simple-browser/package.json +++ b/extensions/simple-browser/package.json @@ -67,11 +67,11 @@ ] }, "scripts": { - "compile": "gulp compile-extension:simple-browser && npm run build-preview", - "watch": "npm run build-preview && gulp watch-extension:simple-browser", - "vscode:prepublish": "npm run build-ext && npm run build-preview", + "compile": "gulp compile-extension:simple-browser && npm run build-webview", + "watch": "npm run build-webview && gulp watch-extension:simple-browser", + "vscode:prepublish": "npm run build-ext && npm run build-webview", "build-ext": "node ../../node_modules/gulp/bin/gulp.js --gulpfile ../../build/gulpfile.extensions.mjs compile-extension:simple-browser ./tsconfig.json", - "build-preview": "node ./esbuild-preview.mjs", + "build-webview": "node ./esbuild.webview.mts", "compile-web": "npx webpack-cli --config extension-browser.webpack.config --mode none", "watch-web": "npx webpack-cli --config extension-browser.webpack.config --mode none --watch --info-verbosity verbose" }, diff --git a/extensions/typescript-language-features/package.json b/extensions/typescript-language-features/package.json index ecc66db7e38b8..03d98f3efe77a 100644 --- a/extensions/typescript-language-features/package.json +++ b/extensions/typescript-language-features/package.json @@ -1296,6 +1296,43 @@ "title": "%configuration.inlayHints%", "order": 24, "properties": { + "js/ts.inlayHints.parameterNames.enabled": { + "type": "string", + "enum": [ + "none", + "literals", + "all" + ], + "enumDescriptions": [ + "%inlayHints.parameterNames.none%", + "%inlayHints.parameterNames.literals%", + "%inlayHints.parameterNames.all%" + ], + "default": "none", + "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.inlayHints.parameterNames.enabled": { + "type": "string", + "enum": [ + "none", + "literals", + "all" + ], + "enumDescriptions": [ + "%inlayHints.parameterNames.none%", + "%inlayHints.parameterNames.literals%", + "%inlayHints.parameterNames.all%" + ], + "default": "none", + "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, "typescript.inlayHints.parameterNames.enabled": { "type": "string", "enum": [ @@ -1310,100 +1347,167 @@ ], "default": "none", "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, + "javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName.unifiedDeprecationMessage%", "scope": "resource" }, "typescript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { "type": "boolean", "default": true, "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName.unifiedDeprecationMessage%", "scope": "resource" }, - "typescript.inlayHints.parameterTypes.enabled": { + "js/ts.inlayHints.parameterTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", - "scope": "resource" + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] }, - "typescript.inlayHints.variableTypes.enabled": { + "javascript.inlayHints.parameterTypes.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, - "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "typescript.inlayHints.parameterTypes.enabled": { "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "default": false, + "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.parameterTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, - "typescript.inlayHints.propertyDeclarationTypes.enabled": { + "js/ts.inlayHints.variableTypes.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", - "scope": "resource" + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] }, - "typescript.inlayHints.functionLikeReturnTypes.enabled": { + "javascript.inlayHints.variableTypes.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, - "typescript.inlayHints.enumMemberValues.enabled": { + "typescript.inlayHints.variableTypes.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", + "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, - "javascript.inlayHints.parameterNames.enabled": { - "type": "string", - "enum": [ - "none", - "literals", - "all" - ], - "enumDescriptions": [ - "%inlayHints.parameterNames.none%", - "%inlayHints.parameterNames.literals%", - "%inlayHints.parameterNames.all%" - ], - "default": "none", - "markdownDescription": "%configuration.inlayHints.parameterNames.enabled%", - "scope": "resource" + "js/ts.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "type": "boolean", + "default": true, + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] }, - "javascript.inlayHints.parameterNames.suppressWhenArgumentMatchesName": { + "javascript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { "type": "boolean", "default": true, - "markdownDescription": "%configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName%", + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName.unifiedDeprecationMessage%", "scope": "resource" }, - "javascript.inlayHints.parameterTypes.enabled": { + "typescript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { "type": "boolean", - "default": false, - "markdownDescription": "%configuration.inlayHints.parameterTypes.enabled%", + "default": true, + "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "markdownDeprecationMessage": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName.unifiedDeprecationMessage%", "scope": "resource" }, - "javascript.inlayHints.variableTypes.enabled": { + "js/ts.inlayHints.propertyDeclarationTypes.enabled": { "type": "boolean", "default": false, - "markdownDescription": "%configuration.inlayHints.variableTypes.enabled%", - "scope": "resource" + "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] }, - "javascript.inlayHints.variableTypes.suppressWhenTypeMatchesName": { + "javascript.inlayHints.propertyDeclarationTypes.enabled": { "type": "boolean", - "default": true, - "markdownDescription": "%configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName%", + "default": false, + "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.propertyDeclarationTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, - "javascript.inlayHints.propertyDeclarationTypes.enabled": { + "typescript.inlayHints.propertyDeclarationTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.propertyDeclarationTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.propertyDeclarationTypes.enabled.unifiedDeprecationMessage%", "scope": "resource" }, + "js/ts.inlayHints.functionLikeReturnTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "scope": "language-overridable", + "tags": [ + "JavaScript", + "TypeScript" + ] + }, "javascript.inlayHints.functionLikeReturnTypes.enabled": { "type": "boolean", "default": false, "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.functionLikeReturnTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "typescript.inlayHints.functionLikeReturnTypes.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.functionLikeReturnTypes.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.functionLikeReturnTypes.enabled.unifiedDeprecationMessage%", + "scope": "resource" + }, + "js/ts.inlayHints.enumMemberValues.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", + "scope": "language-overridable", + "tags": [ + "TypeScript" + ] + }, + "typescript.inlayHints.enumMemberValues.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%configuration.inlayHints.enumMemberValues.enabled%", + "markdownDeprecationMessage": "%configuration.inlayHints.enumMemberValues.enabled.unifiedDeprecationMessage%", "scope": "resource" } } diff --git a/extensions/typescript-language-features/package.nls.json b/extensions/typescript-language-features/package.nls.json index 536eab3ce0370..cdbce28c5a970 100644 --- a/extensions/typescript-language-features/package.nls.json +++ b/extensions/typescript-language-features/package.nls.json @@ -103,38 +103,46 @@ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.parameterNames.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.parameterNames.enabled#` instead.", "configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName": "Suppress parameter name hints on arguments whose text is identical to the parameter name.", + "configuration.inlayHints.parameterNames.suppressWhenArgumentMatchesName.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.parameterNames.suppressWhenArgumentMatchesName#` instead.", "configuration.inlayHints.parameterTypes.enabled": { "message": "Enable/disable inlay hints for implicit parameter types:\n```typescript\n\nel.addEventListener('click', e /* :MouseEvent */ => ...)\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.parameterTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.parameterTypes.enabled#` instead.", "configuration.inlayHints.variableTypes.enabled": { "message": "Enable/disable inlay hints for implicit variable types:\n```typescript\n\nconst foo /* :number */ = Date.now();\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.variableTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.variableTypes.enabled#` instead.", "configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName": "Suppress type hints on variables whose name is identical to the type name.", + "configuration.inlayHints.variableTypes.suppressWhenTypeMatchesName.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.variableTypes.suppressWhenTypeMatchesName#` instead.", "configuration.inlayHints.propertyDeclarationTypes.enabled": { "message": "Enable/disable inlay hints for implicit types on property declarations:\n```typescript\n\nclass Foo {\n\tprop /* :number */ = Date.now();\n}\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.propertyDeclarationTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.propertyDeclarationTypes.enabled#` instead.", "configuration.inlayHints.functionLikeReturnTypes.enabled": { "message": "Enable/disable inlay hints for implicit return types on function signatures:\n```typescript\n\nfunction foo() /* :number */ {\n\treturn Date.now();\n} \n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.functionLikeReturnTypes.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.functionLikeReturnTypes.enabled#` instead.", "configuration.inlayHints.enumMemberValues.enabled": { "message": "Enable/disable inlay hints for member values in enum declarations:\n```typescript\n\nenum MyValue {\n\tA /* = 0 */;\n\tB /* = 1 */;\n}\n \n```", "comment": [ "The text inside the ``` block is code and should not be localized." ] }, + "configuration.inlayHints.enumMemberValues.enabled.unifiedDeprecationMessage": "This setting is deprecated. Use `#js/ts.inlayHints.enumMemberValues.enabled#` instead.", "taskDefinition.tsconfig.description": "The tsconfig file that defines the TS build.", "javascript.suggestionActions.enabled": "Enable/disable suggestion diagnostics for JavaScript files in the editor.", "typescript.suggestionActions.enabled": "Enable/disable suggestion diagnostics for TypeScript files in the editor.", diff --git a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts index 6da5bb74cd75b..f6ede823fcdde 100644 --- a/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts +++ b/extensions/typescript-language-features/src/languageFeatures/fileConfigurationManager.ts @@ -10,6 +10,7 @@ import { isTypeScriptDocument } from '../configuration/languageIds'; import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import { ITypeScriptServiceClient } from '../typescriptService'; +import { readUnifiedConfig } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import { equals } from '../utils/objects'; import { ResourceMap } from '../utils/resourceMap'; @@ -206,7 +207,7 @@ export default class FileConfigurationManager extends Disposable { disableLineTextInReferences: true, interactiveInlayHints: true, includeCompletionsForModuleExports: config.get('suggest.autoImports'), - ...getInlayHintsPreferences(config), + ...getInlayHintsPreferences(document, isTypeScriptDocument(document) ? 'typescript' : 'javascript'), ...this.getOrganizeImportsPreferences(preferencesConfig), maximumHoverLength: this.getMaximumHoverLength(document), }; @@ -274,31 +275,32 @@ function withDefaultAsUndefined(value: T, def: O): Exclude return value === def ? undefined : value as Exclude; } -export class InlayHintSettingNames { - static readonly parameterNamesSuppressWhenArgumentMatchesName = 'inlayHints.parameterNames.suppressWhenArgumentMatchesName'; - static readonly parameterNamesEnabled = 'inlayHints.parameterTypes.enabled'; - static readonly variableTypesEnabled = 'inlayHints.variableTypes.enabled'; - static readonly variableTypesSuppressWhenTypeMatchesName = 'inlayHints.variableTypes.suppressWhenTypeMatchesName'; - static readonly propertyDeclarationTypesEnabled = 'inlayHints.propertyDeclarationTypes.enabled'; - static readonly functionLikeReturnTypesEnabled = 'inlayHints.functionLikeReturnTypes.enabled'; - static readonly enumMemberValuesEnabled = 'inlayHints.enumMemberValues.enabled'; -} - -export function getInlayHintsPreferences(config: vscode.WorkspaceConfiguration) { +export const InlayHintSettingNames = Object.freeze({ + parameterNamesEnabled: 'inlayHints.parameterNames.enabled', + parameterNamesSuppressWhenArgumentMatchesName: 'inlayHints.parameterNames.suppressWhenArgumentMatchesName', + parameterTypesEnabled: 'inlayHints.parameterTypes.enabled', + variableTypesEnabled: 'inlayHints.variableTypes.enabled', + variableTypesSuppressWhenTypeMatchesName: 'inlayHints.variableTypes.suppressWhenTypeMatchesName', + propertyDeclarationTypesEnabled: 'inlayHints.propertyDeclarationTypes.enabled', + functionLikeReturnTypesEnabled: 'inlayHints.functionLikeReturnTypes.enabled', + enumMemberValuesEnabled: 'inlayHints.enumMemberValues.enabled', +}); + +export function getInlayHintsPreferences(scope: vscode.ConfigurationScope, fallbackSection: string) { return { - includeInlayParameterNameHints: getInlayParameterNameHintsPreference(config), - includeInlayParameterNameHintsWhenArgumentMatchesName: !config.get(InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, true), - includeInlayFunctionParameterTypeHints: config.get(InlayHintSettingNames.parameterNamesEnabled, false), - includeInlayVariableTypeHints: config.get(InlayHintSettingNames.variableTypesEnabled, false), - includeInlayVariableTypeHintsWhenTypeMatchesName: !config.get(InlayHintSettingNames.variableTypesSuppressWhenTypeMatchesName, true), - includeInlayPropertyDeclarationTypeHints: config.get(InlayHintSettingNames.propertyDeclarationTypesEnabled, false), - includeInlayFunctionLikeReturnTypeHints: config.get(InlayHintSettingNames.functionLikeReturnTypesEnabled, false), - includeInlayEnumMemberValueHints: config.get(InlayHintSettingNames.enumMemberValuesEnabled, false), + includeInlayParameterNameHints: getInlayParameterNameHintsPreference(scope, fallbackSection), + includeInlayParameterNameHintsWhenArgumentMatchesName: !readUnifiedConfig(InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, true, { scope, fallbackSection }), + includeInlayFunctionParameterTypeHints: readUnifiedConfig(InlayHintSettingNames.parameterTypesEnabled, false, { scope, fallbackSection }), + includeInlayVariableTypeHints: readUnifiedConfig(InlayHintSettingNames.variableTypesEnabled, false, { scope, fallbackSection }), + includeInlayVariableTypeHintsWhenTypeMatchesName: !readUnifiedConfig(InlayHintSettingNames.variableTypesSuppressWhenTypeMatchesName, true, { scope, fallbackSection }), + includeInlayPropertyDeclarationTypeHints: readUnifiedConfig(InlayHintSettingNames.propertyDeclarationTypesEnabled, false, { scope, fallbackSection }), + includeInlayFunctionLikeReturnTypeHints: readUnifiedConfig(InlayHintSettingNames.functionLikeReturnTypesEnabled, false, { scope, fallbackSection }), + includeInlayEnumMemberValueHints: readUnifiedConfig(InlayHintSettingNames.enumMemberValuesEnabled, false, { scope, fallbackSection }), } as const; } -function getInlayParameterNameHintsPreference(config: vscode.WorkspaceConfiguration) { - switch (config.get('inlayHints.parameterNames.enabled')) { +function getInlayParameterNameHintsPreference(scope: vscode.ConfigurationScope, fallbackSection: string) { + switch (readUnifiedConfig(InlayHintSettingNames.parameterNamesEnabled, 'none', { scope, fallbackSection })) { case 'none': return 'none'; case 'literals': return 'literals'; case 'all': return 'all'; diff --git a/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts b/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts index 4fa38e4986b9a..16bf7dd62db00 100644 --- a/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts +++ b/extensions/typescript-language-features/src/languageFeatures/inlayHints.ts @@ -11,20 +11,13 @@ import { API } from '../tsServer/api'; import type * as Proto from '../tsServer/protocol/protocol'; import { Location, Position } from '../typeConverters'; import { ClientCapability, ITypeScriptServiceClient } from '../typescriptService'; +import { unifiedConfigSection } from '../utils/configuration'; import { Disposable } from '../utils/dispose'; import FileConfigurationManager, { InlayHintSettingNames, getInlayHintsPreferences } from './fileConfigurationManager'; import { conditionalRegistration, requireMinVersion, requireSomeCapability } from './util/dependentRegistration'; -const inlayHintSettingNames = Object.freeze([ - InlayHintSettingNames.parameterNamesSuppressWhenArgumentMatchesName, - InlayHintSettingNames.parameterNamesEnabled, - InlayHintSettingNames.variableTypesEnabled, - InlayHintSettingNames.variableTypesSuppressWhenTypeMatchesName, - InlayHintSettingNames.propertyDeclarationTypesEnabled, - InlayHintSettingNames.functionLikeReturnTypesEnabled, - InlayHintSettingNames.enumMemberValuesEnabled, -]); +const inlayHintSettingNames = Object.values(InlayHintSettingNames); class TypeScriptInlayHintsProvider extends Disposable implements vscode.InlayHintsProvider { @@ -44,7 +37,10 @@ class TypeScriptInlayHintsProvider extends Disposable implements vscode.InlayHin super(); this._register(vscode.workspace.onDidChangeConfiguration(e => { - if (inlayHintSettingNames.some(settingName => e.affectsConfiguration(language.id + '.' + settingName))) { + if (inlayHintSettingNames.some(settingName => + e.affectsConfiguration(unifiedConfigSection + '.' + settingName) || + e.affectsConfiguration(language.id + '.' + settingName) + )) { this._onDidChangeInlayHints.fire(); } })); @@ -131,8 +127,7 @@ function fromProtocolInlayHintKind(kind: Proto.InlayHintKind): vscode.InlayHintK } function areInlayHintsEnabledForFile(language: LanguageDescription, document: vscode.TextDocument) { - const config = vscode.workspace.getConfiguration(language.id, document); - const preferences = getInlayHintsPreferences(config); + const preferences = getInlayHintsPreferences(document, language.id); return preferences.includeInlayParameterNameHints === 'literals' || preferences.includeInlayParameterNameHints === 'all' || diff --git a/src/vs/base/browser/ui/grid/grid.ts b/src/vs/base/browser/ui/grid/grid.ts index 60b2666cf2695..b343a0e3bc2ed 100644 --- a/src/vs/base/browser/ui/grid/grid.ts +++ b/src/vs/base/browser/ui/grid/grid.ts @@ -9,9 +9,10 @@ import { Event } from '../../../common/event.js'; import { Disposable } from '../../../common/lifecycle.js'; import './gridview.css'; import { Box, GridView, IGridViewOptions, IGridViewStyles, IView as IGridViewView, IViewSize, orthogonal, Sizing as GridViewSizing, GridLocation } from './gridview.js'; -import type { SplitView, AutoSizing as SplitViewAutoSizing } from '../splitview/splitview.js'; +import type { SplitView, AutoSizing as SplitViewAutoSizing, IViewVisibilityAnimationOptions } from '../splitview/splitview.js'; export type { IViewSize }; +export type { IViewVisibilityAnimationOptions } from '../splitview/splitview.js'; export { LayoutPriority, Orientation, orthogonal } from './gridview.js'; export const enum Direction { @@ -649,10 +650,12 @@ export class Grid extends Disposable { * Set the visibility state of a {@link IView view}. * * @param view The {@link IView view}. + * @param visible Whether the view should be visible. + * @param animation Optional animation options. */ - setViewVisible(view: T, visible: boolean): void { + setViewVisible(view: T, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { const location = this.getViewLocation(view); - this.gridview.setViewVisible(location, visible); + this.gridview.setViewVisible(location, visible, animation); } /** diff --git a/src/vs/base/browser/ui/grid/gridview.ts b/src/vs/base/browser/ui/grid/gridview.ts index 46821d6fcb8f1..f7137f0342c7e 100644 --- a/src/vs/base/browser/ui/grid/gridview.ts +++ b/src/vs/base/browser/ui/grid/gridview.ts @@ -5,7 +5,7 @@ import { $ } from '../../dom.js'; import { IBoundarySashes, Orientation, Sash } from '../sash/sash.js'; -import { DistributeSizing, ISplitViewStyles, IView as ISplitView, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js'; +import { DistributeSizing, ISplitViewStyles, IView as ISplitView, IViewVisibilityAnimationOptions, LayoutPriority, Sizing, AutoSizing, SplitView } from '../splitview/splitview.js'; import { equals as arrayEquals, tail } from '../../../common/arrays.js'; import { Color } from '../../../common/color.js'; import { Emitter, Event, Relay } from '../../../common/event.js'; @@ -615,7 +615,7 @@ class BranchNode implements ISplitView, IDisposable { return this.splitview.isViewVisible(index); } - setChildVisible(index: number, visible: boolean): void { + setChildVisible(index: number, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { index = validateIndex(index, this.children.length); if (this.splitview.isViewVisible(index) === visible) { @@ -623,7 +623,7 @@ class BranchNode implements ISplitView, IDisposable { } const wereAllChildrenHidden = this.splitview.contentSize === 0; - this.splitview.setViewVisible(index, visible); + this.splitview.setViewVisible(index, visible, animation); const areAllChildrenHidden = this.splitview.contentSize === 0; // If all children are hidden then the parent should hide the entire splitview @@ -1661,7 +1661,7 @@ export class GridView implements IDisposable { * * @param location The {@link GridLocation location} of the view. */ - setViewVisible(location: GridLocation, visible: boolean): void { + setViewVisible(location: GridLocation, visible: boolean, animation?: IViewVisibilityAnimationOptions): void { if (this.hasMaximizedView()) { this.exitMaximizedView(); return; @@ -1674,7 +1674,7 @@ export class GridView implements IDisposable { throw new Error('Invalid from location'); } - parent.setChildVisible(index, visible); + parent.setChildVisible(index, visible, animation); } /** diff --git a/src/vs/base/browser/ui/motion/motion.css b/src/vs/base/browser/ui/motion/motion.css new file mode 100644 index 0000000000000..69e257be2d380 --- /dev/null +++ b/src/vs/base/browser/ui/motion/motion.css @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Utility class applied during panel animations to prevent content overflow */ +.monaco-split-view2 > .split-view-container > .split-view-view.motion-animating { + overflow: hidden; +} diff --git a/src/vs/base/browser/ui/motion/motion.ts b/src/vs/base/browser/ui/motion/motion.ts new file mode 100644 index 0000000000000..c2e8a045d417e --- /dev/null +++ b/src/vs/base/browser/ui/motion/motion.ts @@ -0,0 +1,155 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import './motion.css'; + +//#region Easing Curves + +/** + * A pre-parsed cubic bezier easing curve that can be evaluated directly + * without reparsing a CSS string on every frame. + * + * Given control points `(x1, y1)` and `(x2, y2)` (the CSS `cubic-bezier` + * parameters), {@link solve} finds the bezier parameter `u` such that + * `Bx(u) = t` using Newton's method, then returns `By(u)`. + */ +export class CubicBezierCurve { + + constructor( + readonly x1: number, + readonly y1: number, + readonly x2: number, + readonly y2: number, + ) { } + + /** + * Evaluate the curve at time `t` (0-1), returning the eased value. + */ + solve(t: number): number { + if (t <= 0) { + return 0; + } + if (t >= 1) { + return 1; + } + + // Newton's method to find u where Bx(u) = t + let u = t; // initial guess + for (let i = 0; i < 8; i++) { + const currentX = bezierComponent(u, this.x1, this.x2); + const error = currentX - t; + if (Math.abs(error) < 1e-6) { + break; + } + const dx = bezierComponentDerivative(u, this.x1, this.x2); + if (Math.abs(dx) < 1e-6) { + break; + } + u -= error / dx; + } + + u = Math.max(0, Math.min(1, u)); + return bezierComponent(u, this.y1, this.y2); + } + + /** + * Returns the CSS `cubic-bezier(…)` string representation, for use in + * CSS `transition` or `animation` properties. + */ + toCssString(): string { + return `cubic-bezier(${this.x1}, ${this.y1}, ${this.x2}, ${this.y2})`; + } +} + +/** + * Fluent 2 ease-out curve - default for entrances and expansions. + * Starts fast and decelerates to a stop. + */ +export const EASE_OUT = new CubicBezierCurve(0.1, 0.9, 0.2, 1); + +/** + * Fluent 2 ease-in curve - for exits and collapses. + * Starts slow and accelerates out. + */ +export const EASE_IN = new CubicBezierCurve(0.9, 0.1, 1, 0.2); + +//#endregion + +//#region Cubic Bezier Evaluation + +/** + * Parses a CSS `cubic-bezier(x1, y1, x2, y2)` string into a + * {@link CubicBezierCurve}. Returns a linear curve on parse failure. + */ +export function parseCubicBezier(css: string): CubicBezierCurve { + const match = css.match(/cubic-bezier\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/); + if (!match) { + return new CubicBezierCurve(0, 0, 1, 1); + } + return new CubicBezierCurve(parseFloat(match[1]), parseFloat(match[2]), parseFloat(match[3]), parseFloat(match[4])); +} + +/** Evaluates one component of a cubic bezier: B(u) with control points p1, p2, endpoints 0 and 1. */ +function bezierComponent(u: number, p1: number, p2: number): number { + // B(u) = 3(1-u)^2*u*p1 + 3(1-u)*u^2*p2 + u^3 + const oneMinusU = 1 - u; + return 3 * oneMinusU * oneMinusU * u * p1 + 3 * oneMinusU * u * u * p2 + u * u * u; +} + +/** First derivative of a bezier component: B'(u). */ +function bezierComponentDerivative(u: number, p1: number, p2: number): number { + // B'(u) = 3(1-u)^2*p1 + 6(1-u)*u*(p2-p1) + 3*u^2*(1-p2) + const oneMinusU = 1 - u; + return 3 * oneMinusU * oneMinusU * p1 + 6 * oneMinusU * u * (p2 - p1) + 3 * u * u * (1 - p2); +} + +//#endregion + +//#region Duration Scaling + +/** + * Reference pixel distance at which the base duration constants apply. + * Duration scales linearly: a 600px animation takes twice as long as a 300px + * one, keeping perceived velocity constant. + */ +const REFERENCE_DISTANCE = 300; + +/** Minimum animation duration in milliseconds (avoids sub-frame flickers). */ +const MIN_DURATION = 50; + +/** Maximum animation duration in milliseconds (avoids sluggish feel). */ +const MAX_DURATION = 300; + +/** + * Scales a base animation duration proportionally to the pixel distance + * being animated, so that perceived velocity stays constant regardless of + * panel width. + * + * @param baseDuration The duration (ms) that applies at {@link REFERENCE_DISTANCE} pixels. + * @param pixelDistance The actual number of pixels the view will resize. + * @returns The scaled duration, clamped to [{@link MIN_DURATION}, {@link MAX_DURATION}]. + */ +export function scaleDuration(baseDuration: number, pixelDistance: number): number { + if (pixelDistance <= 0) { + return baseDuration; + } + const scaled = baseDuration * (pixelDistance / REFERENCE_DISTANCE); + return Math.round(Math.max(MIN_DURATION, Math.min(MAX_DURATION, scaled))); +} + +//#endregion + +//#region Utility Functions + +/** + * Checks whether motion is reduced by looking for the `monaco-reduce-motion` + * class on an ancestor element. This integrates with VS Code's existing + * accessibility infrastructure in {@link AccessibilityService}. + */ +export function isMotionReduced(element: HTMLElement): boolean { + return element.closest('.monaco-reduce-motion') !== null; +} + +//#endregion diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts index 35f2724c1a8e4..c6ac50b2dedc9 100644 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -8,15 +8,41 @@ import { DomEmitter } from '../../event.js'; import { ISashEvent as IBaseSashEvent, Orientation, Sash, SashState } from '../sash/sash.js'; import { SmoothScrollableElement } from '../scrollbar/scrollableElement.js'; import { pushToEnd, pushToStart, range } from '../../../common/arrays.js'; +import { CancellationToken } from '../../../common/cancellation.js'; import { Color } from '../../../common/color.js'; import { Emitter, Event } from '../../../common/event.js'; import { combinedDisposable, Disposable, dispose, IDisposable, toDisposable } from '../../../common/lifecycle.js'; import { clamp } from '../../../common/numbers.js'; import { Scrollable, ScrollbarVisibility, ScrollEvent } from '../../../common/scrollable.js'; import * as types from '../../../common/types.js'; +import { CubicBezierCurve, isMotionReduced, scaleDuration } from '../motion/motion.js'; import './splitview.css'; export { Orientation } from '../sash/sash.js'; +/** + * Options for animating a view visibility change in a {@link SplitView}. + */ +export interface IViewVisibilityAnimationOptions { + + /** Transition duration in milliseconds. */ + readonly duration: number; + + /** The easing curve applied to the animation. */ + readonly easing: CubicBezierCurve; + + /** + * Optional callback invoked when the animation finishes naturally. + * NOT called if the animation is cancelled via the {@link token}. + */ + readonly onComplete?: () => void; + + /** + * A cancellation token that allows the caller to stop the animation. + * When cancellation is requested the animation snaps to its final state. + */ + readonly token: CancellationToken; +} + export interface ISplitViewStyles { readonly separatorBorder: Color; } @@ -802,14 +828,38 @@ export class SplitView= this.viewItems.length) { throw new Error('Index out of bounds'); } + // Cancel any in-flight animation before changing visibility. + // An animated visibility change interpolates ALL view sizes each + // frame, so a concurrent change on a different view would be + // overwritten on the next frame. Snapping first prevents that. + this._cleanupMotion?.(); + this._cleanupMotion = undefined; + + if (animation && !animation.token.isCancellationRequested && !isMotionReduced(this.el) && this.viewItems[index].visible !== visible) { + this._setViewVisibleAnimated(index, visible, animation); + } else { + this._setViewVisibleInstant(index, visible); + } + } + + /** + * Apply the visibility change to the model without animation. + */ + private _setViewVisibleInstant(index: number, visible: boolean): void { const viewItem = this.viewItems[index]; viewItem.setVisible(visible); @@ -818,6 +868,146 @@ export class SplitView v.size); + + // 2. Apply the target visibility to the model instantly. + // This computes final sizes, fires events, updates sashes, etc. + this._setViewVisibleInstant(index, visible); + + // 3. Snapshot sizes AFTER the visibility change (the animation end state) + const finalSizes = this.viewItems.map(v => v.size); + + // 4. Restore start sizes so we can animate FROM them + for (let i = 0; i < this.viewItems.length; i++) { + this.viewItems[i].size = startSizes[i]; + } + + // 5. For hiding: the target container lost .visible class (→ display:none). + // Restore it so content stays visible during the animation. + if (!visible) { + container.classList.add('visible'); + } + + // 6. Clip overflow on the target container while it shrinks. + // Only apply for HIDE animations - for SHOW, we leave overflow alone + // so that box-shadow / visual effects on the child Part are not clipped + // by the parent container during the animation. + if (!visible) { + container.style.overflow = 'hidden'; + } + + // 6b. Set initial opacity for fade effect + container.style.opacity = visible ? '0' : '1'; + + // 7. Scale duration based on pixel distance for consistent perceived velocity + const pixelDistance = Math.abs(finalSizes[index] - startSizes[index]); + const duration = scaleDuration(baseDuration, pixelDistance); + + // 8. Render the start state + this.layoutViews(); + + // 9. Easing curve is pre-parsed - ready for JS evaluation + + // Helper: snap all sizes to final state and clean up + const applyFinalState = () => { + for (let i = 0; i < this.viewItems.length; i++) { + this.viewItems[i].size = finalSizes[i]; + } + container.style.opacity = ''; + if (!visible) { + container.classList.remove('visible'); + container.style.overflow = ''; + } + this.layoutViews(); + this.saveProportions(); + }; + + const cleanup = (completed: boolean) => { + if (disposed) { + return; + } + disposed = true; + tokenListener.dispose(); + if (rafId !== undefined) { + window.cancelAnimationFrame(rafId); + rafId = undefined; + } + applyFinalState(); + this._cleanupMotion = undefined; + if (completed) { + onComplete?.(); + } + }; + this._cleanupMotion = () => cleanup(false); + + // Listen to the cancellation token so the caller can stop the animation + const tokenListener = token.onCancellationRequested(() => cleanup(false)); + + // 10. Animate via requestAnimationFrame + const startTime = performance.now(); + const totalSize = this.size; + + const animate = () => { + if (disposed) { + return; + } + + const elapsed = performance.now() - startTime; + const t = Math.min(elapsed / duration, 1); + const easedT = easing.solve(t); + + // Interpolate opacity for fade effect + container.style.opacity = String(visible ? easedT : 1 - easedT); + + // Interpolate all view sizes + let runningTotal = 0; + for (let i = 0; i < this.viewItems.length; i++) { + if (i === this.viewItems.length - 1) { + // Last item absorbs rounding errors to maintain total = this.size + this.viewItems[i].size = totalSize - runningTotal; + } else { + const size = Math.round( + startSizes[i] + (finalSizes[i] - startSizes[i]) * easedT + ); + this.viewItems[i].size = size; + runningTotal += size; + } + } + + this.layoutViews(); + + if (t < 1) { + rafId = window.requestAnimationFrame(animate); + } else { + cleanup(true); + } + }; + + rafId = window.requestAnimationFrame(animate); + } + + private _cleanupMotion: (() => void) | undefined; + /** * Returns the {@link IView view}'s size previously to being hidden. * diff --git a/src/vs/platform/quickinput/browser/media/quickInput.css b/src/vs/platform/quickinput/browser/media/quickInput.css index bd509719a3cce..9afd0964022b0 100644 --- a/src/vs/platform/quickinput/browser/media/quickInput.css +++ b/src/vs/platform/quickinput/browser/media/quickInput.css @@ -525,3 +525,35 @@ outline: 1px solid var(--vscode-list-focusOutline) !important; outline-offset: -1px; } + +/* Entrance animation */ +@keyframes quick-input-entrance { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.quick-input-widget.animating-entrance { + animation: quick-input-entrance 150ms cubic-bezier(0.1, 0.9, 0.2, 1) forwards; +} + +/* Exit animation */ +@keyframes quick-input-exit { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(-8px); + } +} + +.quick-input-widget.animating-exit { + animation: quick-input-exit 50ms cubic-bezier(0.9, 0.1, 1, 0.2) forwards; +} diff --git a/src/vs/platform/quickinput/browser/quickInputController.ts b/src/vs/platform/quickinput/browser/quickInputController.ts index 8e5283ef9ad92..11964ea5a30bf 100644 --- a/src/vs/platform/quickinput/browser/quickInputController.ts +++ b/src/vs/platform/quickinput/browser/quickInputController.ts @@ -37,6 +37,7 @@ import { TriStateCheckbox, createToggleActionViewItemProvider } from '../../../b import { defaultCheckboxStyles } from '../../theme/browser/defaultStyles.js'; import { QuickInputTreeController } from './tree/quickInputTreeController.js'; import { QuickTree } from './tree/quickTree.js'; +import { isMotionReduced } 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'; @@ -80,6 +81,7 @@ export class QuickInputController extends Disposable { private viewState: QuickInputViewState | undefined; private dndController: QuickInputDragAndDropController | undefined; + private _cancelExitAnimation: (() => void) | undefined; private readonly inQuickInputContext: IContextKey; private readonly quickInputTypeContext: IContextKey; @@ -711,12 +713,26 @@ export class QuickInputController extends Disposable { const backKeybindingLabel = this.options.backKeybindingLabel(); backButton.tooltip = backKeybindingLabel ? localize('quickInput.backWithKeybinding', "Back ({0})", backKeybindingLabel) : localize('quickInput.back', "Back"); + const wasVisible = ui.container.style.display !== 'none'; ui.container.style.display = ''; + // Cancel any in-flight exit animation that would set display:none + this._cancelExitAnimation?.(); + this._cancelExitAnimation = undefined; this.updateLayout(); this.dndController?.setEnabled(!controller.anchor); this.dndController?.layoutContainer(); ui.inputBox.setFocus(); this.quickInputTypeContext.set(controller.type); + + // Animate entrance: fade in + slide down (only when first appearing) + if (!wasVisible && !isMotionReduced(ui.container)) { + ui.container.classList.add('animating-entrance'); + const onAnimationEnd = () => { + ui.container.classList.remove('animating-entrance'); + ui.container.removeEventListener('animationend', onAnimationEnd); + }; + ui.container.addEventListener('animationend', onAnimationEnd); + } } isVisible(): boolean { @@ -783,7 +799,24 @@ export class QuickInputController extends Disposable { this.controller = null; this.onHideEmitter.fire(); if (container) { - container.style.display = 'none'; + // Animate exit: fade out + slide up (faster than open) + if (!isMotionReduced(container)) { + container.classList.add('animating-exit'); + const cleanupAnimation = () => { + container.classList.remove('animating-exit'); + container.removeEventListener('animationend', onAnimationEnd); + this._cancelExitAnimation = undefined; + }; + const onAnimationEnd = () => { + // Set display after animation completes to actually hide the element + container.style.display = 'none'; + cleanupAnimation(); + }; + this._cancelExitAnimation = cleanupAnimation; + container.addEventListener('animationend', onAnimationEnd); + } else { + container.style.display = 'none'; + } } if (!focusChanged) { let currentElement = this.previousFocusElement; diff --git a/src/vs/workbench/browser/layout.ts b/src/vs/workbench/browser/layout.ts index 784c9edfadcd3..97a74fcf8fa02 100644 --- a/src/vs/workbench/browser/layout.ts +++ b/src/vs/workbench/browser/layout.ts @@ -23,7 +23,7 @@ import { IHostService } from '../services/host/browser/host.js'; import { IBrowserWorkbenchEnvironmentService } from '../services/environment/browser/environmentService.js'; import { IEditorService } from '../services/editor/common/editorService.js'; import { EditorGroupLayout, GroupOrientation, GroupsOrder, IEditorGroupsService } from '../services/editor/common/editorGroupsService.js'; -import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing } from '../../base/browser/ui/grid/grid.js'; +import { SerializableGrid, ISerializableView, ISerializedGrid, Orientation, ISerializedNode, ISerializedLeafNode, Direction, IViewSize, Sizing, IViewVisibilityAnimationOptions } from '../../base/browser/ui/grid/grid.js'; import { Part } from './part.js'; import { IStatusbarService } from '../services/statusbar/browser/statusbar.js'; import { IFileService } from '../../platform/files/common/files.js'; @@ -47,6 +47,8 @@ import { AuxiliaryBarPart } from './parts/auxiliarybar/auxiliaryBarPart.js'; import { ITelemetryService } from '../../platform/telemetry/common/telemetry.js'; import { IAuxiliaryWindowService } from '../services/auxiliaryWindow/browser/auxiliaryWindowService.js'; import { CodeWindow, mainWindow } from '../../base/browser/window.js'; +import { EASE_OUT, EASE_IN } from '../../base/browser/ui/motion/motion.js'; +import { CancellationToken } from '../../base/common/cancellation.js'; //#region Layout Implementation @@ -1864,27 +1866,32 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.stateModel.setRuntimeValue(LayoutStateKeys.SIDEBAR_HIDDEN, hidden); - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN); - } else { + // Adjust CSS - for hiding, defer adding the class until animation + // completes so the part stays visible during the exit animation. + if (!hidden) { this.mainContainer.classList.remove(LayoutClasses.SIDEBAR_HIDDEN); } // Propagate to grid - this.workbenchGrid.setViewVisible(this.sideBarPartView, !hidden); - - // If sidebar becomes hidden, also hide the current active Viewlet if any - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); - - if (!this.isAuxiliaryBarMaximized()) { - this.focusPanelOrEditor(); // do not auto focus when auxiliary bar is maximized - } - } + this.workbenchGrid.setViewVisible( + this.sideBarPartView, + !hidden, + createViewVisibilityAnimation(hidden, () => { + if (!hidden) { return; } + // Deferred to after close animation + this.mainContainer.classList.add(LayoutClasses.SIDEBAR_HIDDEN); + if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Sidebar); + + if (!this.isAuxiliaryBarMaximized()) { + this.focusPanelOrEditor(); // do not auto focus when auxiliary bar is maximized + } + } + }) + ); // If sidebar becomes visible, show last active Viewlet or default viewlet - else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Sidebar)) { const viewletToOpen = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.Sidebar); if (viewletToOpen) { this.openViewContainer(ViewContainerLocation.Sidebar, viewletToOpen); @@ -2008,13 +2015,6 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi const panelOpensMaximized = this.panelOpensMaximized(); - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN); - } else { - this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); - } - // If maximized and in process of hiding, unmaximize FIRST before // changing visibility to prevent conflict with setEditorHidden // which would force panel visible again (fixes #281772) @@ -2022,13 +2022,30 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.toggleMaximizedPanel(); } + // Adjust CSS - for hiding, defer adding the class until animation + // completes because `.nopanel .part.panel { display: none !important }` + // would instantly hide the panel content mid-animation. + if (!hidden) { + this.mainContainer.classList.remove(LayoutClasses.PANEL_HIDDEN); + } + // Propagate layout changes to grid - this.workbenchGrid.setViewVisible(this.panelPartView, !hidden); + this.workbenchGrid.setViewVisible( + this.panelPartView, + !hidden, + createViewVisibilityAnimation(hidden, () => { + if (!hidden) { return; } + // Deferred to after close animation + this.mainContainer.classList.add(LayoutClasses.PANEL_HIDDEN); + if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); + } + }) + ); - // If panel part becomes hidden, also hide the current active panel if any + // If panel part becomes hidden, focus the editor after animation starts let focusEditor = false; if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.Panel)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.Panel); if ( !isIOS && // do not auto focus on iOS (https://github.com/microsoft/vscode/issues/127832) !this.isAuxiliaryBarMaximized() // do not auto focus when auxiliary bar is maximized @@ -2202,24 +2219,30 @@ export abstract class Layout extends Disposable implements IWorkbenchLayoutServi this.stateModel.setRuntimeValue(LayoutStateKeys.AUXILIARYBAR_HIDDEN, hidden); - // Adjust CSS - if (hidden) { - this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN); - } else { + // Adjust CSS - for hiding, defer adding the class until animation + // completes because `.noauxiliarybar .part.auxiliarybar { display: none !important }` + // would instantly hide the content mid-animation. + if (!hidden) { this.mainContainer.classList.remove(LayoutClasses.AUXILIARYBAR_HIDDEN); } // Propagate to grid - this.workbenchGrid.setViewVisible(this.auxiliaryBarPartView, !hidden); - - // If auxiliary bar becomes hidden, also hide the current active pane composite if any - if (hidden && this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { - this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); - this.focusPanelOrEditor(); - } + this.workbenchGrid.setViewVisible( + this.auxiliaryBarPartView, + !hidden, + createViewVisibilityAnimation(hidden, () => { + if (!hidden) { return; } + // Deferred to after close animation + this.mainContainer.classList.add(LayoutClasses.AUXILIARYBAR_HIDDEN); + if (this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + this.paneCompositeService.hideActivePaneComposite(ViewContainerLocation.AuxiliaryBar); + this.focusPanelOrEditor(); + } + }) + ); // If auxiliary bar becomes visible, show last active pane composite or default pane composite - else if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { + if (!hidden && !this.paneCompositeService.getActivePaneComposite(ViewContainerLocation.AuxiliaryBar)) { let viewletToOpen: string | undefined = this.paneCompositeService.getLastActivePaneCompositeId(ViewContainerLocation.AuxiliaryBar); // verify that the viewlet we try to open has views before we default to it @@ -2712,6 +2735,21 @@ function getZenModeConfiguration(configurationService: IConfigurationService): Z return configurationService.getValue(WorkbenchLayoutSettings.ZEN_MODE_CONFIG); } +/** Duration (ms) for panel/sidebar open (entrance) animations. */ +const PANEL_OPEN_DURATION = 135; + +/** Duration (ms) for panel/sidebar close (exit) animations. */ +const PANEL_CLOSE_DURATION = 35; + +function createViewVisibilityAnimation(hidden: boolean, onComplete?: () => void, token: CancellationToken = CancellationToken.None): IViewVisibilityAnimationOptions { + return { + duration: hidden ? PANEL_CLOSE_DURATION : PANEL_OPEN_DURATION, + easing: hidden ? EASE_IN : EASE_OUT, + token, + onComplete, + }; +} + //#endregion //#region Layout State Model diff --git a/src/vs/workbench/browser/media/motion.css b/src/vs/workbench/browser/media/motion.css new file mode 100644 index 0000000000000..fbd8215265a84 --- /dev/null +++ b/src/vs/workbench/browser/media/motion.css @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* Motion custom properties -- only active when motion is enabled */ +.monaco-workbench.monaco-enable-motion { + --vscode-motion-panel-open-duration: 175ms; + --vscode-motion-panel-close-duration: 75ms; + --vscode-motion-quick-input-open-duration: 175ms; + --vscode-motion-quick-input-close-duration: 75ms; + --vscode-motion-ease-out: cubic-bezier(0.1, 0.9, 0.2, 1); + --vscode-motion-ease-in: cubic-bezier(0.9, 0.1, 1, 0.2); +} + +/* Disable all motion durations when reduced motion is active */ +.monaco-workbench.monaco-reduce-motion { + --vscode-motion-panel-open-duration: 0ms; + --vscode-motion-panel-close-duration: 0ms; + --vscode-motion-quick-input-open-duration: 0ms; + --vscode-motion-quick-input-close-duration: 0ms; +} diff --git a/src/vs/workbench/browser/style.ts b/src/vs/workbench/browser/style.ts index 9250ef3f28006..de344e7e46b57 100644 --- a/src/vs/workbench/browser/style.ts +++ b/src/vs/workbench/browser/style.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import './media/style.css'; +import './media/motion.css'; import { registerThemingParticipant } from '../../platform/theme/common/themeService.js'; import { WORKBENCH_BACKGROUND, TITLE_BAR_ACTIVE_BACKGROUND } from '../common/theme.js'; import { isWeb, isIOS } from '../../base/common/platform.js'; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index dc57a9885094f..158ece0a481d0 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -51,7 +51,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { IChatModel, IChatResponseModel } from '../../common/model/chatModel.js'; import { ChatMode, IChatMode, IChatModeService } from '../../common/chatModes.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { ElicitationState, IChatService, IChatToolInvocation } from '../../common/chatService/chatService.js'; import { ISCMHistoryItemChangeRangeVariableEntry, ISCMHistoryItemChangeVariableEntry } from '../../common/attachments/chatVariableEntries.js'; import { IChatRequestViewModel, IChatResponseViewModel, isRequestVM } from '../../common/model/chatViewModel.js'; import { IChatWidgetHistoryService } from '../../common/widget/chatWidgetHistoryService.js'; @@ -212,6 +212,7 @@ abstract class OpenChatGlobalAction extends Action2 { const languageModelService = accessor.get(ILanguageModelsService); const scmService = accessor.get(ISCMService); const logService = accessor.get(ILogService); + const configurationService = accessor.get(IConfigurationService); let chatWidget = widgetService.lastFocusedWidget; // When this was invoked to switch to a mode via keybinding, and some chat widget is focused, use that one. @@ -388,16 +389,38 @@ abstract class OpenChatGlobalAction extends Action2 { if (opts?.blockOnResponse) { const response = await resp; if (response) { + const autoReplyEnabled = configurationService.getValue(ChatConfiguration.AutoReply); await new Promise(resolve => { const d = response.onDidChange(async () => { - if (response.isComplete || response.isPendingConfirmation.get()) { + if (response.isComplete) { + d.dispose(); + resolve(); + return; + } + + const pendingConfirmation = response.isPendingConfirmation.get(); + if (pendingConfirmation) { + // Check if the pending confirmation is a question carousel that will be auto-replied. + // Only question carousels are auto-replied; other confirmation types (tool approvals, + // elicitations, etc.) should cause us to resolve immediately. + const hasPendingQuestionCarousel = response.response.value.some( + part => part.kind === 'questionCarousel' && !part.isUsed + ); + if (autoReplyEnabled && hasPendingQuestionCarousel) { + // Auto-reply will handle this question carousel, keep waiting + return; + } d.dispose(); resolve(); } }); }); - return { ...response.result, type: response.isPendingConfirmation.get() ? 'confirmation' : undefined }; + const confirmationInfo = getPendingConfirmationInfo(response); + if (confirmationInfo) { + return { ...response.result, ...confirmationInfo }; + } + return { ...response.result }; } } @@ -437,6 +460,66 @@ async function waitForDefaultAgent(chatAgentService: IChatAgentService, mode: Ch ]); } +/** + * Information about a pending confirmation in a chat response. + */ +export type IChatPendingConfirmationInfo = + | { type: 'confirmation'; kind: 'toolInvocation'; toolId: string } + | { type: 'confirmation'; kind: 'toolPostApproval'; toolId: string } + | { type: 'confirmation'; kind: 'confirmation'; title: string; data: unknown } + | { type: 'confirmation'; kind: 'questionCarousel'; questions: unknown[] } + | { type: 'confirmation'; kind: 'elicitation'; title: string }; + +/** + * Extracts detailed information about the pending confirmation from a chat response. + * Returns undefined if there is no pending confirmation. + */ +function getPendingConfirmationInfo(response: IChatResponseModel): IChatPendingConfirmationInfo | undefined { + for (const part of response.response.value) { + if (part.kind === 'toolInvocation') { + const state = part.state.get(); + if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation) { + return { + type: 'confirmation', + kind: 'toolInvocation', + toolId: part.toolId, + }; + } + if (state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) { + return { + type: 'confirmation', + kind: 'toolPostApproval', + toolId: part.toolId, + }; + } + } + if (part.kind === 'confirmation' && !part.isUsed) { + return { + type: 'confirmation', + kind: 'confirmation', + title: part.title, + data: part.data, + }; + } + if (part.kind === 'questionCarousel' && !part.isUsed) { + return { + type: 'confirmation', + kind: 'questionCarousel', + questions: part.questions, + }; + } + if (part.kind === 'elicitation2' && part.state.get() === ElicitationState.Pending) { + const title = part.title; + return { + type: 'confirmation', + kind: 'elicitation', + title: typeof title === 'string' ? title : title.value, + }; + } + } + return undefined; +} + class PrimaryOpenChatGlobalAction extends OpenChatGlobalAction { constructor() { super({ diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index d1938adf33f8c..2192b3827277b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -29,6 +29,7 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind, } from '../../commo import { ILanguageModelChatMetadata } from '../../common/languageModels.js'; import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; +import { isInClaudeAgentsFolder } from '../../common/promptSyntax/config/promptFileLocations.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatWidget, IChatWidgetService } from '../chat.js'; import { getAgentSessionProvider, AgentSessionProviders } from '../agentSessions/agentSessions.js'; @@ -260,6 +261,7 @@ type ChatModeChangeClassification = { extensionId?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Extension ID if the target mode is from an extension' }; toolsCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of custom tools in the target mode'; 'isMeasurement': true }; handoffsCount?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Number of handoffs in the target mode'; 'isMeasurement': true }; + isClaudeAgent?: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the target mode is a Claude agent file from .claude/agents/' }; }; type ChatModeChangeEvent = { @@ -270,6 +272,7 @@ type ChatModeChangeEvent = { extensionId?: string; toolsCount?: number; handoffsCount?: number; + isClaudeAgent?: boolean; }; class ToggleChatModeAction extends Action2 { @@ -337,6 +340,9 @@ class ToggleChatModeAction extends Action2 { return mode.name.get(); }; + const modeUri = switchToMode.uri?.get(); + const isClaudeAgent = modeUri ? isInClaudeAgentsFolder(modeUri) : undefined; + telemetryService.publicLog2('chat.modeChange', { fromMode: getModeNameForTelemetry(currentMode), mode: getModeNameForTelemetry(switchToMode), @@ -344,7 +350,8 @@ class ToggleChatModeAction extends Action2 { storage, extensionId, toolsCount, - handoffsCount + handoffsCount, + isClaudeAgent }); widget.input.setChatMode(switchToMode.id); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts index 56b2429570c7d..a442be07b038b 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatTitleActions.ts @@ -25,9 +25,7 @@ import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatService } from '. import { isResponseVM } from '../../common/model/chatViewModel.js'; import { ChatModeKind } from '../../common/constants.js'; import { IChatAccessibilityService, IChatWidgetService } from '../chat.js'; -import { triggerConfetti } from '../widget/chatConfetti.js'; import { CHAT_CATEGORY } from './chatActions.js'; -import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js'; export const MarkUnhelpfulActionId = 'workbench.action.chat.markUnhelpful'; const enableFeedbackConfig = 'config.telemetry.feedback.enabled'; @@ -77,16 +75,6 @@ export function registerChatTitleActions() { }); item.setVote(ChatAgentVoteDirection.Up); item.setVoteDownReason(undefined); - - const configurationService = accessor.get(IConfigurationService); - const accessibilityService = accessor.get(IAccessibilityService); - if (configurationService.getValue('chat.confettiOnThumbsUp') && !accessibilityService.isMotionReduced()) { - const chatWidgetService = accessor.get(IChatWidgetService); - const widget = chatWidgetService.getWidgetBySessionResource(item.session.sessionResource); - if (widget) { - triggerConfetti(widget.domNode); - } - } } }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index f51de3f44f1dd..ff1604d72b9bf 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -287,11 +287,7 @@ configurationRegistry.registerConfiguration({ mode: 'auto' } }, - 'chat.confettiOnThumbsUp': { - type: 'boolean', - description: nls.localize('chat.confettiOnThumbsUp', "Controls whether a confetti animation is shown when clicking the thumbs up button on a chat response."), - default: false, - }, + 'chat.experimental.detectParticipant.enabled': { type: 'boolean', deprecationMessage: nls.localize('chat.experimental.detectParticipant.enabled.deprecated', "This setting is deprecated. Please use `chat.detectParticipant.enabled` instead."), @@ -327,6 +323,13 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.notifyWindowOnConfirmation', "Controls whether a chat session should present the user with an OS notification when a confirmation is needed while the window is not in focus. This includes a window badge as well as notification toast."), default: true, }, + [ChatConfiguration.AutoReply]: { + default: false, + markdownDescription: nls.localize('chat.autoReply.description', "Automatically answer chat question carousels using the current model. This is an advanced setting and can lead to unintended choices or actions based on incomplete context."), + type: 'boolean', + scope: ConfigurationScope.APPLICATION_MACHINE, + tags: ['experimental', 'advanced'], + }, [ChatConfiguration.GlobalAutoApprove]: { default: false, markdownDescription: globalAutoApproveDescription.value, @@ -962,7 +965,7 @@ configurationRegistry.registerConfiguration({ patternErrorMessage: nls.localize('chat.hookFilesLocations.invalidPath', "Paths must be relative or start with '~/'. Absolute paths and '\\' separators are not supported."), }, restricted: true, - tags: ['prompts', 'hooks', 'agent'], + tags: ['preview', 'prompts', 'hooks', 'agent'], examples: [ { [DEFAULT_HOOK_FILE_PATHS[0].path]: true, @@ -975,12 +978,24 @@ configurationRegistry.registerConfiguration({ }, [PromptsConfig.USE_CHAT_HOOKS]: { type: 'boolean', - title: nls.localize('chat.useChatHooks.title', "Use Chat Hooks",), - markdownDescription: nls.localize('chat.useChatHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + title: nls.localize('chat.useHooks.title', "Use Chat Hooks",), + markdownDescription: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), default: true, restricted: true, disallowConfigurationDefault: true, - tags: ['prompts', 'hooks', 'agent'] + tags: ['preview', 'prompts', 'hooks', 'agent'], + policy: { + name: 'ChatHooks', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.109', + value: (policyData) => policyData.chat_preview_features_enabled === false ? false : undefined, + localization: { + description: { + key: 'chat.useHooks.description', + value: nls.localize('chat.useHooks.description', "Controls whether chat hooks are executed at strategic points during an agent's workflow. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",) + } + }, + } }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 726d06a3df5ad..bea60daa877a6 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -26,6 +26,7 @@ export const IChatTipService = createDecorator('chatTipService' export interface IChatTip { readonly id: string; readonly content: MarkdownString; + readonly enabledCommands?: readonly string[]; } export interface IChatTipService { @@ -67,7 +68,7 @@ export interface IChatTipService { /** * 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. + * The dismissed tip will not be shown again in this profile. */ dismissTip(): void; @@ -543,7 +544,7 @@ export class ChatTipService extends Disposable implements IChatTipService { 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._storageService.store(ChatTipService._DISMISSED_TIP_KEY, JSON.stringify(dismissed), StorageScope.PROFILE, StorageTarget.MACHINE); } this._hasShownRequestTip = false; this._shownTip = undefined; @@ -552,7 +553,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } private _getDismissedTipIds(): string[] { - const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.WORKSPACE); + const raw = this._storageService.get(ChatTipService._DISMISSED_TIP_KEY, StorageScope.PROFILE); if (!raw) { return []; } @@ -600,6 +601,14 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(this._shownTip); } + // A new request arrived while we already showed a tip, hide the old one + if (this._hasShownRequestTip && this._tipRequestId && this._tipRequestId !== requestId) { + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidDismissTip.fire(); + return undefined; + } + // Only show one tip per session if (this._hasShownRequestTip) { return undefined; @@ -643,7 +652,7 @@ export class ChatTipService extends Disposable implements IChatTipService { let selectedTip: ITipDefinition | undefined; // Determine where to start in the catalog based on the last-shown tip. - const lastTipId = this._storageService.get(ChatTipService._LAST_TIP_ID_KEY, StorageScope.WORKSPACE); + const lastTipId = this._storageService.get(ChatTipService._LAST_TIP_ID_KEY, StorageScope.PROFILE); const lastCatalogIndex = lastTipId ? TIP_CATALOG.findIndex(tip => tip.id === lastTipId) : -1; const startIndex = lastCatalogIndex === -1 ? 0 : (lastCatalogIndex + 1) % TIP_CATALOG.length; @@ -679,7 +688,7 @@ export class ChatTipService extends Disposable implements IChatTipService { } // Persist the selected tip id so the next use advances to the following one. - this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.WORKSPACE, StorageTarget.MACHINE); + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, selectedTip.id, StorageScope.PROFILE, StorageTarget.USER); // Record that we've shown a tip this session this._hasShownRequestTip = sourceId !== 'welcome'; @@ -713,6 +722,7 @@ export class ChatTipService extends Disposable implements IChatTipService { return { id: tipDef.id, content: markdown, + enabledCommands: tipDef.enabledCommands, }; } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts index d59d0c0494959..e37b11c22cd3d 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -3,6 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { parse as parseJSONC } from '../../../../../base/common/jsonc.js'; +import { setProperty, applyEdits } from '../../../../../base/common/jsonEdit.js'; +import { FormattingOptions } from '../../../../../base/common/jsonFormatter.js'; import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; @@ -102,7 +105,7 @@ async function addHookToFile( if (fileExists) { const existingContent = await fileService.readFile(hookFileUri); try { - hooksContent = JSON.parse(existingContent.value.toString()); + hooksContent = parseJSONC(existingContent.value.toString()); // Ensure hooks object exists if (!hooksContent.hooks) { hooksContent.hooks = {}; @@ -144,20 +147,26 @@ async function addHookToFile( // Use existing key if found, otherwise use the detected naming convention const keyToUse = existingKeyForType ?? hookTypeKeyName; - // Add the new hook entry (append if hook type already exists) + // Determine the new hook index (append if hook type already exists) const newHookEntry = buildNewHookEntry(sourceFormat); - let newHookIndex: number; - if (!hooksContent.hooks[keyToUse]) { - hooksContent.hooks[keyToUse] = [newHookEntry]; - newHookIndex = 0; + const existingHooks = hooksContent.hooks[keyToUse]; + const newHookIndex = Array.isArray(existingHooks) ? existingHooks.length : 0; + + // Generate the new JSON content using setProperty to preserve comments + let jsonContent: string; + if (fileExists) { + // Use setProperty to make targeted edits that preserve comments + const originalText = (await fileService.readFile(hookFileUri)).value.toString(); + const detectedEol = originalText.includes('\r\n') ? '\r\n' : '\n'; + const formattingOptions: FormattingOptions = { tabSize: 1, insertSpaces: false, eol: detectedEol }; + const edits = setProperty(originalText, ['hooks', keyToUse, newHookIndex], newHookEntry, formattingOptions); + jsonContent = applyEdits(originalText, edits); } else { - hooksContent.hooks[keyToUse].push(newHookEntry); - newHookIndex = hooksContent.hooks[keyToUse].length - 1; + // New file - use JSON.stringify since there are no comments to preserve + const newContent = { hooks: { [keyToUse]: [newHookEntry] } }; + jsonContent = JSON.stringify(newContent, null, '\t'); } - // Write the file - const jsonContent = JSON.stringify(hooksContent, null, '\t'); - // Check if the file is already open in an editor const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index d99c14f550b9f..30d8e85c0976d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -10,11 +10,13 @@ margin-bottom: 8px; padding: 6px 10px; border-radius: 4px; - border: 1px solid var(--vscode-focusBorder); + border: 1px solid var(--vscode-editorWidget-border, var(--vscode-input-border, transparent)); background-color: var(--vscode-editorWidget-background); font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); + position: relative; + overflow: hidden; } .interactive-item-container .chat-tip-widget .codicon-lightbulb { @@ -59,10 +61,13 @@ font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); + position: relative; + overflow: hidden; } .chat-getting-started-tip-container .chat-tip-widget .codicon-lightbulb { - display: none; + font-size: 12px; + color: var(--vscode-notificationsWarningIcon-foreground); } .chat-getting-started-tip-container .chat-tip-widget .rendered-markdown p { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index c533f527c545b..dd074702d2bec 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -109,6 +109,7 @@ import { IAccessibilityService } from '../../../../../platform/accessibility/com import { ChatHookContentPart } from './chatContentParts/chatHookContentPart.js'; import { ChatPendingDragController } from './chatPendingDragAndDrop.js'; import { HookType } from '../../common/promptSyntax/hookSchema.js'; +import { ChatQuestionCarouselAutoReply } from './chatQuestionCarouselAutoReply.js'; const $ = dom.$; @@ -183,10 +184,12 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer>(); + private readonly _autoRepliedQuestionCarousels = new Set(); + private readonly _autoReply: ChatQuestionCarouselAutoReply; private _activeTipPart: ChatTipContentPart | undefined; - private readonly _notifiedQuestionCarousels = new WeakSet(); + private readonly _notifiedQuestionCarousels = new Set(); private readonly _questionCarouselToast = this._register(new DisposableStore()); private readonly chatContentMarkdownRenderer: IMarkdownRenderer; @@ -272,6 +275,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { @@ -358,6 +362,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined, part: ChatQuestionCarouselPart) => { // Mark the carousel as used and store the answers @@ -2141,13 +2149,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer handleSubmit(answers, fallbackPart) }); + this.maybeAutoReplyToQuestionCarousel(context, carousel, fallbackPart, answers => handleSubmit(answers, fallbackPart), modelName, requestMessageText); return fallbackPart; } - // If global auto-approve (yolo mode) is enabled, skip with defaults immediately - if (!carousel.isUsed && this.configService.getValue(ChatConfiguration.GlobalAutoApprove)) { - part.skip(); - } + this.maybeAutoReplyToQuestionCarousel(context, carousel, part, answers => handleSubmit(answers, part), modelName, requestMessageText); // Track the carousel for auto-skip when user submits a new message // Only add tracking if not already tracked (prevents duplicate tracking on re-render) @@ -2186,13 +2192,23 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.notifyWindowOnConfirmation')) { @@ -2261,6 +2279,61 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer | undefined) => Promise, + modelName: string | undefined, + requestMessageText: string | undefined, + ): void { + if (carousel.isUsed) { + return; + } + + // Use a stable key based on requestId + resolveId to prevent duplicate + // auto-replies across re-renders of the same logical carousel. + const stableKey = this._getCarouselStableKey(context, carousel); + if (stableKey) { + if (this._autoRepliedQuestionCarousels.has(stableKey)) { + return; + } + // Mark as in-progress before the async opt-in check to prevent + // duplicate prompts/requests from concurrent re-renders. + this._autoRepliedQuestionCarousels.add(stableKey); + } + + void this._autoReply.shouldAutoReply().then(shouldAutoReply => { + if (!shouldAutoReply) { + // Roll back the in-progress mark if auto-reply is not enabled. + if (stableKey) { + this._autoRepliedQuestionCarousels.delete(stableKey); + } + return; + } + + const cts = new CancellationTokenSource(); + part.addDisposable(toDisposable(() => { + cts.cancel(); + cts.dispose(); + })); + + this._autoReply.autoReply(carousel, submit, modelName, requestMessageText, cts.token).catch(err => { + this.logService.debug('#ChatQuestionCarousel: Auto reply failed', toErrorMessage(err)); + }); + }); + } + + + private getRequestMessageText(response: IChatResponseViewModel): string | undefined { + const requestId = response.requestId; + const items = response.session.getItems(); + const request = items.find(item => isRequestVM(item) && item.id === requestId) as IChatRequestViewModel | undefined; + return request?.messageText; + } + + + private removeCarouselFromTracking(context: IChatContentPartRenderContext, part: ChatQuestionCarouselPart): void { if (isResponseVM(context.element)) { const carousels = this.pendingQuestionCarousels.get(context.element.sessionResource); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts b/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts new file mode 100644 index 0000000000000..eca6e1608144a --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatQuestionCarouselAutoReply.ts @@ -0,0 +1,455 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Codicon } from '../../../../../base/common/codicons.js'; +import { toErrorMessage } from '../../../../../base/common/errorMessage.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import Severity from '../../../../../base/common/severity.js'; +import { hasKey } from '../../../../../base/common/types.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { ExtensionIdentifier } from '../../../../../platform/extensions/common/extensions.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IChatQuestion, IChatQuestionCarousel } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { ChatMessageRole, getTextResponseFromStream, ILanguageModelsService } from '../../common/languageModels.js'; +import { Event } from '../../../../../base/common/event.js'; + +const enum AutoReplyStorageKeys { + AutoReplyOptIn = 'chat.autoReply.optIn' +} + +/** + * Encapsulates the logic for automatically replying to question carousels, + * including opt-in state management, LLM-based answer resolution, fallback + * answer generation, and answer parsing/merging. + */ +export class ChatQuestionCarouselAutoReply extends Disposable { + + constructor( + @IConfigurationService private readonly configService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, + @ILogService private readonly logService: ILogService, + @IStorageService private readonly storageService: IStorageService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + ) { + super(); + + // Clear out warning accepted state if the setting is disabled + this._register(Event.runAndSubscribe(this.configService.onDidChangeConfiguration, e => { + if (!e || e.affectsConfiguration(ChatConfiguration.AutoReply)) { + if (this.configService.getValue(ChatConfiguration.AutoReply) !== true) { + this.storageService.remove(AutoReplyStorageKeys.AutoReplyOptIn, StorageScope.APPLICATION); + } + } + })); + } + + async shouldAutoReply(): Promise { + if (!this.configService.getValue(ChatConfiguration.AutoReply)) { + return false; + } + return this.checkOptIn(); + } + + async autoReply( + carousel: IChatQuestionCarousel, + submit: (answers: Map | undefined) => Promise, + modelName: string | undefined, + requestMessageText: string | undefined, + token: CancellationToken, + ): Promise { + if (token.isCancellationRequested || carousel.isUsed || carousel.questions.length === 0) { + return; + } + + const fallbackAnswers = this.buildFallbackCarouselAnswers(carousel, requestMessageText); + let resolvedAnswers = fallbackAnswers; + + const modelId = await this.getModelId(modelName); + if (modelId && !token.isCancellationRequested) { + try { + const parsedAnswers = await this.requestAnswers(modelId, carousel, requestMessageText, token); + if (parsedAnswers.size > 0) { + resolvedAnswers = this.mergeAnswers(carousel, parsedAnswers, fallbackAnswers); + } + } catch (err) { + this.logService.debug('#ChatQuestionCarousel: Failed to resolve auto reply', toErrorMessage(err)); + } + } + + if (token.isCancellationRequested || carousel.isUsed) { + return; + } + + await submit(resolvedAnswers); + } + + // #region Opt-in + + private async checkOptIn(): Promise { + const optedIn = this.storageService.getBoolean(AutoReplyStorageKeys.AutoReplyOptIn, StorageScope.APPLICATION, false); + if (optedIn) { + return true; + } + + const promptResult = await this.dialogService.prompt({ + type: Severity.Warning, + message: localize('chat.autoReply.enable.title', 'Enable chat auto reply?'), + buttons: [ + { + label: localize('chat.autoReply.enable', 'Enable'), + run: () => true + }, + { + label: localize('chat.autoReply.disable', 'Disable'), + run: () => false + }, + ], + custom: { + icon: Codicon.warning, + disableCloseAction: true, + markdownDetails: [{ + markdown: new MarkdownString(localize('chat.autoReply.enable.details', 'Chat auto reply answers question carousels using the current model and may make unintended choices. Review your settings and outputs carefully.')), + }], + } + }); + + if (promptResult.result !== true) { + await this.configService.updateValue(ChatConfiguration.AutoReply, false); + return false; + } + + this.storageService.store(AutoReplyStorageKeys.AutoReplyOptIn, true, StorageScope.APPLICATION, StorageTarget.USER); + return true; + } + + // #endregion + + // #region LLM interaction + + private async getModelId(modelName: string | undefined): Promise { + if (!modelName) { + return undefined; + } + + let models = await this.languageModelsService.selectLanguageModels({ id: modelName }); + if (models.length > 0) { + return models[0]; + } + + if (modelName.startsWith('copilot/')) { + models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', family: modelName.replace(/^copilot\//, '') }); + return models[0]; + } + + return undefined; + } + + private buildPrompt(carousel: IChatQuestionCarousel, requestMessageText: string | undefined, strict: boolean): string { + const questions = carousel.questions.map(question => ({ + id: question.id, + type: question.type, + title: question.title, + message: typeof question.message === 'string' ? question.message : question.message?.value, + options: question.options?.map(option => ({ id: option.id, label: option.label })) ?? [], + allowFreeformInput: question.allowFreeformInput ?? false, + })); + + const contextLines: string[] = []; + if (requestMessageText) { + contextLines.push(`Original user request: ${JSON.stringify(requestMessageText)}`); + } + + return [ + 'Choose default answers for the following questions.', + 'Return a JSON object keyed by question id.', + 'For text questions, the value should be a string.', + 'For singleSelect questions, the value should be { "selectedId": string } or { "freeform": string }.', + 'For multiSelect questions, the value should be { "selectedIds": string[] } and may include { "freeform": string }.', + 'If a question allows freeform input and has no options, return a freeform answer based on the user request when possible.', + 'Use option ids from the provided options.', + ...contextLines, + 'Questions:', + JSON.stringify(questions), + strict ? 'Return ONLY valid JSON. Do not include markdown or explanations.' : undefined, + ].filter(Boolean).join('\n'); + } + + private async requestAnswers( + modelId: string, + carousel: IChatQuestionCarousel, + requestMessageText: string | undefined, + token: CancellationToken, + ): Promise> { + const prompt = this.buildPrompt(carousel, requestMessageText, false); + const response = await this.languageModelsService.sendChatRequest( + modelId, + new ExtensionIdentifier('core'), + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], + {}, + token, + ); + const responseText = await getTextResponseFromStream(response); + const parsedAnswers = this.parseAnswers(responseText, carousel); + if (parsedAnswers.size > 0 || token.isCancellationRequested) { + return parsedAnswers; + } + + const retryPrompt = this.buildPrompt(carousel, requestMessageText, true); + const retryResponse = await this.languageModelsService.sendChatRequest( + modelId, + new ExtensionIdentifier('core'), + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: retryPrompt }] }], + {}, + token, + ); + const retryText = await getTextResponseFromStream(retryResponse); + return this.parseAnswers(retryText, carousel); + } + + // #endregion + + // #region Answer parsing and resolution + + private parseAnswers(responseText: string, carousel: IChatQuestionCarousel): Map { + const parsed = this.tryParseJsonObject(responseText); + if (!parsed) { + return new Map(); + } + + const answers = new Map(); + for (const question of carousel.questions) { + const rawAnswer = parsed[question.id]; + const resolved = this.resolveAnswerFromRaw(question, rawAnswer); + if (resolved !== undefined) { + answers.set(question.id, resolved); + } + } + return answers; + } + + private mergeAnswers( + carousel: IChatQuestionCarousel, + resolvedAnswers: Map, + fallbackAnswers: Map, + ): Map { + const merged = new Map(); + for (const question of carousel.questions) { + const fallback = fallbackAnswers.get(question.id); + if (this.hasDefaultValue(question) && fallback !== undefined) { + merged.set(question.id, fallback); + continue; + } + if (resolvedAnswers.has(question.id)) { + merged.set(question.id, resolvedAnswers.get(question.id)!); + continue; + } + if (fallback !== undefined) { + merged.set(question.id, fallback); + } + } + return merged; + } + + private hasDefaultValue(question: IChatQuestion): boolean { + switch (question.type) { + case 'text': + return question.defaultValue !== undefined; + case 'singleSelect': + return typeof question.defaultValue === 'string'; + case 'multiSelect': + return Array.isArray(question.defaultValue) + ? question.defaultValue.length > 0 + : typeof question.defaultValue === 'string'; + } + } + + private resolveAnswerFromRaw(question: IChatQuestion, raw: unknown): unknown | undefined { + switch (question.type) { + case 'text': { + if (typeof raw === 'string') { + const value = raw.trim(); + return value.length > 0 ? value : undefined; + } + if (raw && typeof raw === 'object' && hasKey(raw, { value: true }) && typeof (raw as { value: unknown }).value === 'string') { + const value = (raw as { value: string }).value.trim(); + return value.length > 0 ? value : undefined; + } + return undefined; + } + case 'singleSelect': { + let selectedInput: string | undefined; + let freeformInput: string | undefined; + if (typeof raw === 'string') { + selectedInput = raw; + } else if (raw && typeof raw === 'object') { + if (hasKey(raw, { selectedId: true }) && typeof (raw as { selectedId: unknown }).selectedId === 'string') { + selectedInput = (raw as { selectedId: string }).selectedId; + } else if (hasKey(raw, { selectedLabel: true }) && typeof (raw as { selectedLabel: unknown }).selectedLabel === 'string') { + selectedInput = (raw as { selectedLabel: string }).selectedLabel; + } + if (hasKey(raw, { freeform: true }) && typeof (raw as { freeform: unknown }).freeform === 'string') { + freeformInput = (raw as { freeform: string }).freeform; + } + } + + if (freeformInput && freeformInput.trim().length > 0) { + return { selectedValue: undefined, freeformValue: freeformInput.trim() }; + } + + const match = selectedInput ? this.matchQuestionOption(question, selectedInput) : undefined; + if (match) { + return { selectedValue: match.value, freeformValue: undefined }; + } + return undefined; + } + case 'multiSelect': { + let selectedInputs: string[] = []; + let freeformInput: string | undefined; + if (Array.isArray(raw)) { + selectedInputs = raw.filter(item => typeof item === 'string') as string[]; + } else if (typeof raw === 'string') { + selectedInputs = raw.split(',').map(item => item.trim()).filter(item => item.length > 0); + } else if (raw && typeof raw === 'object') { + if (hasKey(raw, { selectedIds: true })) { + const selectedIdsValue = (raw as { selectedIds?: unknown }).selectedIds; + if (Array.isArray(selectedIdsValue)) { + selectedInputs = selectedIdsValue.filter((item: unknown): item is string => typeof item === 'string'); + } + } + if (hasKey(raw, { freeform: true }) && typeof (raw as { freeform?: unknown }).freeform === 'string') { + freeformInput = (raw as { freeform: string }).freeform; + } + } + + const selectedValues = selectedInputs + .map(input => this.matchQuestionOption(question, input)?.value) + .filter(value => value !== undefined); + const freeformValue = freeformInput?.trim(); + + if (selectedValues.length > 0 || (freeformValue && freeformValue.length > 0)) { + return { selectedValues, freeformValue }; + } + return undefined; + } + } + } + + private matchQuestionOption(question: IChatQuestion, rawInput: string): { id: string; value: unknown } | undefined { + const options = question.options ?? []; + if (!options.length) { + return undefined; + } + + const normalized = rawInput.trim().toLowerCase(); + const numeric = Number.parseInt(normalized, 10); + if (!Number.isNaN(numeric) && numeric > 0 && numeric <= options.length) { + const option = options[numeric - 1]; + return { id: option.id, value: option.value }; + } + + const exactId = options.find(option => option.id.toLowerCase() === normalized); + if (exactId) { + return { id: exactId.id, value: exactId.value }; + } + const exactLabel = options.find(option => option.label.toLowerCase() === normalized); + if (exactLabel) { + return { id: exactLabel.id, value: exactLabel.value }; + } + const partialLabel = options.find(option => option.label.toLowerCase().includes(normalized)); + if (partialLabel) { + return { id: partialLabel.id, value: partialLabel.value }; + } + + return undefined; + } + + // #endregion + + // #region Fallback answers + + buildFallbackCarouselAnswers(carousel: IChatQuestionCarousel, requestMessageText: string | undefined): Map { + const answers = new Map(); + for (const question of carousel.questions) { + const answer = this.getFallbackAnswerForQuestion(question, requestMessageText); + if (answer !== undefined) { + answers.set(question.id, answer); + } + } + return answers; + } + + private getFallbackAnswerForQuestion(question: IChatQuestion, requestMessageText: string | undefined): unknown { + const fallbackFreeform = requestMessageText?.trim() || localize('chat.questionCarousel.autoReplyFallback', 'OK'); + + switch (question.type) { + case 'text': + return question.defaultValue ?? fallbackFreeform; + case 'singleSelect': { + const defaultOptionId = typeof question.defaultValue === 'string' ? question.defaultValue : undefined; + const defaultOption = defaultOptionId ? question.options?.find(opt => opt.id === defaultOptionId) : undefined; + if (defaultOption) { + return { selectedValue: defaultOption.value, freeformValue: undefined }; + } + if (question.options && question.options.length > 0) { + return { selectedValue: question.options[0].value, freeformValue: undefined }; + } + if (question.allowFreeformInput) { + return { selectedValue: undefined, freeformValue: fallbackFreeform }; + } + return undefined; + } + case 'multiSelect': { + const defaultIds = Array.isArray(question.defaultValue) + ? question.defaultValue + : (typeof question.defaultValue === 'string' ? [question.defaultValue] : []); + const selectedValues = question.options + ?.filter(opt => defaultIds.includes(opt.id)) + .map(opt => opt.value) + .filter(value => value !== undefined) ?? []; + if (selectedValues.length > 0) { + return { selectedValues, freeformValue: undefined }; + } + if (question.options && question.options.length > 0) { + return { selectedValues: [question.options[0].value], freeformValue: undefined }; + } + if (question.allowFreeformInput) { + return { selectedValues: [], freeformValue: fallbackFreeform }; + } + return undefined; + } + } + } + + // #endregion + + // #region Utilities + + private tryParseJsonObject(text: string): Record | undefined { + const trimmed = text.trim(); + if (!trimmed) { + return undefined; + } + const start = trimmed.indexOf('{'); + const end = trimmed.lastIndexOf('}'); + const candidate = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed; + try { + const parsed = JSON.parse(candidate) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return undefined; + } + return undefined; + } + + // #endregion +} diff --git a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts index 306f8b863c92a..43fc982f081a2 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -21,8 +21,8 @@ export interface IChatContextUsagePromptTokenDetail { } export interface IChatContextUsageData { - promptTokens: number; - maxInputTokens: number; + usedTokens: number; + totalContextWindow: number; percentage: number; promptTokenDetails?: readonly IChatContextUsagePromptTokenDetail[]; } @@ -102,14 +102,14 @@ export class ChatContextUsageDetails extends Disposable { } update(data: IChatContextUsageData): void { - const { percentage, promptTokens, maxInputTokens, promptTokenDetails } = data; + const { percentage, usedTokens, totalContextWindow, promptTokenDetails } = data; // Update token count and percentage on same line this.tokenCountLabel.textContent = localize( 'tokenCount', "{0} / {1} tokens", - this.formatTokenCount(promptTokens, 1), - this.formatTokenCount(maxInputTokens, 0) + this.formatTokenCount(usedTokens, 1), + this.formatTokenCount(totalContextWindow, 0) ); this.percentageLabel.textContent = `• ${percentage.toFixed(0)}%`; @@ -132,7 +132,10 @@ export class ChatContextUsageDetails extends Disposable { } private formatTokenCount(count: number, decimals: number): string { - if (count >= 1000000) { + // Use M when count is >= 1M, or when K representation would round to 1000K + const mThreshold = 1000000 - 500 * Math.pow(10, -decimals); + + if (count >= mThreshold) { return `${(count / 1000000).toFixed(decimals)}M`; } else if (count >= 1000) { return `${(count / 1000).toFixed(decimals)}K`; @@ -172,6 +175,16 @@ export class ChatContextUsageDetails extends Disposable { // Render each category for (const [category, items] of categoryMap) { + // Filter out items with 0% usage + const visibleItems = items.filter(item => { + const contextRelativePercentage = (item.percentageOfPrompt / 100) * contextWindowPercentage; + return contextRelativePercentage >= 0.05; // Show if at least 0.1% when rounded + }); + + if (visibleItems.length === 0) { + continue; + } + const categorySection = this.tokenDetailsContainer.appendChild($('.token-category')); // Category header @@ -179,7 +192,7 @@ export class ChatContextUsageDetails extends Disposable { categoryHeader.textContent = category; // Category items - for (const item of items) { + for (const item of visibleItems) { const itemRow = categorySection.appendChild($('.token-detail-item')); const itemLabel = itemRow.appendChild($('.token-detail-label')); 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 aa6c0db52aa11..5b48266f16a4a 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageWidget.ts @@ -248,23 +248,26 @@ export class ChatContextUsageWidget extends Disposable { const usage = response.usage; const modelMetadata = this.languageModelsService.lookupLanguageModel(modelId); const maxInputTokens = modelMetadata?.maxInputTokens; + const maxOutputTokens = modelMetadata?.maxOutputTokens; - if (!usage || !maxInputTokens || maxInputTokens <= 0) { + if (!usage || !maxInputTokens || maxInputTokens <= 0 || !maxOutputTokens || maxOutputTokens <= 0) { this.hide(); return; } const promptTokens = usage.promptTokens; const promptTokenDetails = usage.promptTokenDetails; - const percentage = Math.min(100, (promptTokens / maxInputTokens) * 100); + const totalContextWindow = maxInputTokens + maxOutputTokens; + const usedTokens = promptTokens + maxOutputTokens; + const percentage = Math.min(100, (usedTokens / totalContextWindow) * 100); - this.render(percentage, promptTokens, maxInputTokens, promptTokenDetails); + this.render(percentage, usedTokens, totalContextWindow, promptTokenDetails); this.show(); } - private render(percentage: number, promptTokens: number, maxTokens: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { + private render(percentage: number, usedTokens: number, totalContextWindow: number, promptTokenDetails?: readonly { category: string; label: string; percentageOfPrompt: number }[]): void { // Store current data for use in details popup - this.currentData = { promptTokens, maxInputTokens: maxTokens, percentage, promptTokenDetails }; + this.currentData = { usedTokens, totalContextWindow, percentage, promptTokenDetails }; // Update pie chart progress this.progressIndicator.setProgress(percentage); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index a5eec8a6ad882..2ac63ee13eabe 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -25,6 +25,7 @@ export enum ChatConfiguration { RepoInfoEnabled = 'chat.repoInfo.enabled', EditRequests = 'chat.editRequests', InlineReferencesStyle = 'chat.inlineReferences.style', + AutoReply = 'chat.autoReply', GlobalAutoApprove = 'chat.tools.global.autoApprove', AutoApproveEdits = 'chat.tools.edits.autoApprove', AutoApprovedUrls = 'chat.tools.urls.autoApprove', diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 4067c77f7e79b..f01c341dd9a54 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -221,6 +221,36 @@ export interface ILanguageModelChatResponse { result: Promise; } +export async function getTextResponseFromStream(response: ILanguageModelChatResponse): Promise { + let responseText = ''; + const streaming = (async () => { + if (!response?.stream) { + return; + } + for await (const part of response.stream) { + if (Array.isArray(part)) { + for (const item of part) { + if (item.type === 'text') { + responseText += item.value; + } + } + } else if (part.type === 'text') { + responseText += part.value; + } + } + })(); + + try { + await Promise.all([response.result, streaming]); + return responseText; + } catch (err) { + if (responseText) { + return responseText; + } + throw err; + } +} + export interface ILanguageModelChatProvider { readonly onDidChange: Event; provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index e0dd7ca927a38..94097d98c96e5 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -186,18 +186,6 @@ export interface IChatAgentResultTimings { totalElapsed: number; } -export interface IChatAgentPromptTokenDetail { - category: string; - label: string; - percentageOfPrompt: number; -} - -export interface IChatAgentResultUsage { - promptTokens: number; - completionTokens: number; - promptTokenDetails?: readonly IChatAgentPromptTokenDetail[]; -} - export interface IChatAgentResult { errorDetails?: IChatResponseErrorDetails; timings?: IChatAgentResultTimings; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 7a603df5251b9..2ca88074c2675 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -19,7 +19,7 @@ import { IWorkspaceContextService } from '../../../../../platform/workspace/comm import { ChatRequestVariableSet, IChatRequestVariableEntry, isPromptFileVariableEntry, toPromptFileVariableEntry, toPromptTextVariableEntry, PromptFileVariableKind, IPromptTextVariableEntry, ChatRequestToolReferenceEntry, toToolVariableEntry } from '../attachments/chatVariableEntries.js'; import { ILanguageModelToolsService, IToolData, VSCodeToolReference } from '../tools/languageModelToolsService.js'; import { PromptsConfig } from './config/config.js'; -import { isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; +import { isInClaudeAgentsFolder, isInClaudeRulesFolder, isPromptOrInstructionsFile } from './config/promptFileLocations.js'; import { PromptsType } from './promptTypes.js'; import { ParsedPromptFile } from './promptFileParser.js'; import { AgentFileType, ICustomAgent, IPromptPath, IPromptsService } from './service/promptsService.js'; @@ -33,9 +33,12 @@ export type InstructionsCollectionEvent = { agentInstructionsCount: number; listedInstructionsCount: number; totalInstructionsCount: number; + claudeRulesCount: number; + claudeMdCount: number; + claudeAgentsCount: number; }; export function newInstructionsCollectionEvent(): InstructionsCollectionEvent { - return { applyingInstructionsCount: 0, referencedInstructionsCount: 0, agentInstructionsCount: 0, listedInstructionsCount: 0, totalInstructionsCount: 0 }; + return { applyingInstructionsCount: 0, referencedInstructionsCount: 0, agentInstructionsCount: 0, listedInstructionsCount: 0, totalInstructionsCount: 0, claudeRulesCount: 0, claudeMdCount: 0, claudeAgentsCount: 0 }; } type InstructionsCollectionClassification = { @@ -44,6 +47,9 @@ type InstructionsCollectionClassification = { agentInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of agent instructions added (copilot-instructions.md and agents.md).' }; listedInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of instruction patterns added.' }; totalInstructionsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Total number of instruction entries added to variables.' }; + claudeRulesCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Claude rules files (.claude/rules/) added via pattern matching.' }; + claudeMdCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of CLAUDE.md agent instruction files added.' }; + claudeAgentsCount: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of Claude agent files (.claude/agents/) listed as subagents.' }; owner: 'digitarald'; comment: 'Tracks automatic instruction collection usage in chat prompt system.'; }; @@ -100,7 +106,7 @@ export class ComputeAutomaticInstructions { // get copilot instructions await this._addAgentInstructions(variables, telemetryEvent, token); - const instructionsListVariable = await this._getInstructionsWithPatternsList(instructionFiles, variables, token); + const instructionsListVariable = await this._getInstructionsWithPatternsList(instructionFiles, variables, telemetryEvent, token); if (instructionsListVariable) { variables.add(instructionsListVariable); telemetryEvent.listedInstructionsCount++; @@ -159,6 +165,9 @@ export class ComputeAutomaticInstructions { variables.add(toPromptFileVariableEntry(uri, PromptFileVariableKind.Instruction, reason, true)); telemetryEvent.applyingInstructionsCount++; + if (isClaudeRules) { + telemetryEvent.claudeRulesCount++; + } } else { this._logService.trace(`[InstructionsContextComputer] No match for ${uri} with ${pattern}`); } @@ -199,6 +208,9 @@ export class ComputeAutomaticInstructions { } telemetryEvent.agentInstructionsCount++; + if (type === AgentFileType.claudeMd) { + telemetryEvent.claudeMdCount++; + } logger.logInfo(`Agent instruction file added: ${uri.toString()}`); } @@ -278,7 +290,7 @@ export class ComputeAutomaticInstructions { return undefined; } - private async _getInstructionsWithPatternsList(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, token: CancellationToken): Promise { + private async _getInstructionsWithPatternsList(instructionFiles: readonly IPromptPath[], _existingVariables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const readTool = this._getTool('readFile'); const runSubagentTool = this._getTool(VSCodeToolReference.runSubagent); @@ -398,6 +410,9 @@ export class ComputeAutomaticInstructions { entries.push(`${agent.argumentHint}`); } entries.push(''); + if (isInClaudeAgentsFolder(agent.uri)) { + telemetryEvent.claudeAgentsCount++; + } } } entries.push('', '', ''); // add trailing newline diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 4b9621c55474d..89d415a99ef4f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -108,7 +108,7 @@ export namespace PromptsConfig { /** * Configuration key for chat hooks usage. */ - export const USE_CHAT_HOOKS = 'chat.useChatHooks'; + export const USE_CHAT_HOOKS = 'chat.useHooks'; /** * Configuration key for enabling stronger skill adherence prompt (experimental). diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts index 186fee36470cf..db61519b2b852 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -204,6 +204,14 @@ function isInAgentsFolder(fileUri: URI): boolean { return dir.endsWith('/' + AGENTS_SOURCE_FOLDER) || dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); } +/** + * Helper function to check if a file is directly in the .claude/agents/ folder. + */ +export function isInClaudeAgentsFolder(fileUri: URI): boolean { + const dir = dirname(fileUri.path); + return dir.endsWith('/' + CLAUDE_AGENTS_SOURCE_FOLDER); +} + /** * Helper function to check if a file is inside the .claude/rules/ folder (including subfolders). * Claude rules files (.md) in this folder are treated as instruction files. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts index c159acfa4c327..eb567363a8ece 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -128,28 +128,9 @@ export function parseClaudeHooks( const commands: IHookCommand[] = []; for (const item of hookArray) { - if (!item || typeof item !== 'object') { - continue; - } - - const itemObj = item as Record; - - // Claude can have nested hooks with matchers: { matcher: "Bash", hooks: [...] } - const nestedHooks = (itemObj as { hooks?: unknown }).hooks; - if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { - for (const nestedHook of nestedHooks) { - const resolved = resolveClaudeCommand(nestedHook as Record, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } - } else { - // Direct hook command - const resolved = resolveClaudeCommand(itemObj, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } - } + // Use shared helper that handles both direct commands and nested matcher structures + const extracted = extractHookCommandsFromItem(item, workspaceRootUri, userHome); + commands.push(...extracted); } if (commands.length > 0) { @@ -166,19 +147,59 @@ export function parseClaudeHooks( } /** - * Resolves a Claude hook command to our IHookCommand format. - * Claude commands can be: { type: "command", command: "..." } or { command: "..." } + * Helper to extract hook commands from an item that could be: + * 1. A direct command object: { type: 'command', command: '...' } + * 2. A nested structure with matcher (Claude style): { matcher: '...', hooks: [{ type: 'command', command: '...' }] } + * + * This allows Copilot format to handle Claude-style entries if pasted. + * Also handles Claude's leniency where 'type' field can be omitted. */ -function resolveClaudeCommand( - raw: Record, +export function extractHookCommandsFromItem( + item: unknown, workspaceRootUri: URI | undefined, userHome: string -): IHookCommand | undefined { - // Claude might not require 'type' field, so we're more lenient - const hasValidType = raw.type === undefined || raw.type === 'command'; - if (!hasValidType) { - return undefined; +): IHookCommand[] { + if (!item || typeof item !== 'object') { + return []; } - return resolveHookCommand(raw, workspaceRootUri, userHome); + const itemObj = item as Record; + const commands: IHookCommand[] = []; + + // Check for nested hooks with matcher (Claude style): { matcher: "...", hooks: [...] } + const nestedHooks = itemObj.hooks; + if (nestedHooks !== undefined && Array.isArray(nestedHooks)) { + for (const nestedHook of nestedHooks) { + if (!nestedHook || typeof nestedHook !== 'object') { + continue; + } + const normalized = normalizeForResolve(nestedHook as Record); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + } else { + // Direct command object + const normalized = normalizeForResolve(itemObj); + const resolved = resolveHookCommand(normalized, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + + return commands; +} + +/** + * Normalizes a hook command object for resolving. + * Claude format allows omitting the 'type' field, treating it as 'command'. + * This ensures compatibility when Claude-style hooks are pasted into Copilot format. + */ +function normalizeForResolve(raw: Record): Record { + // If type is missing or already 'command', ensure it's set to 'command' + if (raw.type === undefined || raw.type === 'command') { + return { ...raw, type: 'command' }; + } + return raw; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts index 6bdf4afdc8910..d00fd26cb1eef 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -5,8 +5,8 @@ import { URI } from '../../../../../base/common/uri.js'; import { basename, dirname } from '../../../../../base/common/path.js'; -import { HookType, IHookCommand, toHookType, resolveHookCommand } from './hookSchema.js'; -import { parseClaudeHooks } from './hookClaudeCompat.js'; +import { HookType, IHookCommand, toHookType } from './hookSchema.js'; +import { parseClaudeHooks, extractHookCommandsFromItem } from './hookClaudeCompat.js'; import { resolveCopilotCliHookType } from './hookCopilotCliCompat.js'; /** @@ -97,10 +97,9 @@ export function parseCopilotHooks( const commands: IHookCommand[] = []; for (const item of hookArray) { - const resolved = resolveHookCommand(item as Record, workspaceRootUri, userHome); - if (resolved) { - commands.push(resolved); - } + // Use helper that handles both direct commands and Claude-style nested matcher structures + const extracted = extractHookCommandsFromItem(item, workspaceRootUri, userHome); + commands.push(...extracted); } if (commands.length > 0) { 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 7d4cb5cc35248..e32abc667f020 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -177,7 +177,10 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedHooks = this._register(new CachedPromise( (token) => this.computeHooks(token), - () => this.getFileLocatorEvent(PromptsType.hook) + () => Event.any( + this.getFileLocatorEvent(PromptsType.hook), + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS)), + ) )); // Hack: Subscribe to activate caching (CachedPromise only caches when onDidChange has listeners) @@ -1000,6 +1003,11 @@ export class PromptsService extends Disposable implements IPromptsService { } private async computeHooks(token: CancellationToken): Promise { + const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); + if (!useChatHooks) { + return undefined; + } + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); if (hookFiles.length === 0) { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index b53e9e8fd1b43..5733ddff8275a 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -27,7 +27,7 @@ import { testWorkspace } from '../../../../../../platform/workspace/test/common/ import { IUserDataProfileService } from '../../../../../services/userDataProfile/common/userDataProfile.js'; import { TestContextService, TestUserDataProfileService } from '../../../../../test/common/workbenchTestServices.js'; import { ChatRequestVariableSet, isPromptFileVariableEntry, isPromptTextVariableEntry, toFileVariableEntry } from '../../../common/attachments/chatVariableEntries.js'; -import { ComputeAutomaticInstructions } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; +import { ComputeAutomaticInstructions, InstructionsCollectionEvent } from '../../../common/promptSyntax/computeAutomaticInstructions.js'; import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; import { AGENTS_SOURCE_FOLDER, CLAUDE_RULES_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION } from '../../../common/promptSyntax/config/promptFileLocations.js'; import { INSTRUCTIONS_LANGUAGE_ID, PROMPT_LANGUAGE_ID } from '../../../common/promptSyntax/promptTypes.js'; @@ -871,13 +871,9 @@ suite('ComputeAutomaticInstructions', () => { '---', 'applyTo: "**/*.ts"', '---', - 'TS instructions [](./referenced.instructions.md)', + 'TS instructions', ] }, - { - path: `${rootFolder}/.github/instructions/referenced.instructions.md`, - contents: ['Referenced content'], - }, { path: `${rootFolder}/.github/copilot-instructions.md`, contents: ['Copilot instructions'], @@ -908,16 +904,174 @@ suite('ComputeAutomaticInstructions', () => { const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); assert.ok(telemetryEvent, 'Should emit telemetry event'); - const data = telemetryEvent.data as { - applyingInstructionsCount: number; - referencedInstructionsCount: number; - agentInstructionsCount: number; - totalInstructionsCount: number; - }; - assert.ok(data.applyingInstructionsCount >= 0, 'Should have applying count'); - assert.ok(data.referencedInstructionsCount >= 0, 'Should have referenced count'); - assert.ok(data.agentInstructionsCount >= 0, 'Should have agent count'); - assert.ok(data.totalInstructionsCount >= 0, 'Should have total count'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + assert.deepStrictEqual(data, { + applyingInstructionsCount: 1, + referencedInstructionsCount: 0, + agentInstructionsCount: 2, + listedInstructionsCount: 0, + totalInstructionsCount: 3, + claudeRulesCount: 0, + claudeMdCount: 0, + claudeAgentsCount: 0, + }); + }); + + test('should track Claude rules in telemetry', async () => { + const rootFolderName = 'telemetry-claude-rules-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/rules/code-style.md`, + contents: ['Code style guidelines'], + }, + { + path: `${rootFolder}/.claude/rules/testing.md`, + contents: [ + '---', + 'paths:', + ' - "**/*.test.ts"', + '---', + 'Testing guidelines', + ], + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + // code-style.md defaults to ** so should match; testing.md only matches *.test.ts so should not match + assert.strictEqual(data.claudeRulesCount, 1, 'Should count 1 Claude rules file (code-style.md matches **)'); + assert.strictEqual(data.applyingInstructionsCount, 1, 'Claude rules count as applying instructions'); + assert.strictEqual(data.claudeMdCount, 0, 'Should have no CLAUDE.md count'); + }); + + test('should track CLAUDE.md in telemetry', async () => { + const rootFolderName = 'telemetry-claudemd-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + testConfigService.setUserConfiguration(PromptsConfig.USE_CLAUDE_MD, true); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/CLAUDE.md`, + contents: ['Claude guidelines'], + }, + { + path: `${rootFolder}/.claude/CLAUDE.md`, + contents: ['More Claude guidelines'], + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + assert.strictEqual(data.claudeMdCount, 2, 'Should count both CLAUDE.md files'); + assert.strictEqual(data.claudeRulesCount, 0, 'Should have no Claude rules count'); + }); + + test('should track Claude agents in telemetry', async () => { + const rootFolderName = 'telemetry-claude-agents-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + testConfigService.setUserConfiguration('chat.customAgentInSubagent.enabled', true); + testConfigService.setUserConfiguration(PromptsConfig.AGENTS_LOCATION_KEY, { + [AGENTS_SOURCE_FOLDER]: true, + '.claude/agents': true, + }); + + await mockFiles(fileService, [ + { + path: `${rootFolder}/.claude/agents/claude-agent.agent.md`, + contents: [ + '---', + 'description: \'A Claude agent\'', + '---', + 'Claude agent content', + ] + }, + { + path: `${rootFolder}/.github/agents/gh-agent.agent.md`, + contents: [ + '---', + 'description: \'A GitHub agent\'', + '---', + 'GitHub agent content', + ] + }, + { + path: `${rootFolder}/src/file.ts`, + contents: ['code'], + }, + ]); + + const telemetryEvents: { eventName: string; data: unknown }[] = []; + const mockTelemetryService = { + publicLog2: (eventName: string, data: unknown) => { + telemetryEvents.push({ eventName, data }); + } + } as unknown as ITelemetryService; + instaService.stub(ITelemetryService, mockTelemetryService); + + const contextComputer = instaService.createInstance( + ComputeAutomaticInstructions, + ChatModeKind.Agent, + { 'vscode_runSubagent': true }, + ['*'] + ); + const variables = new ChatRequestVariableSet(); + variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); + + await contextComputer.collect(variables, CancellationToken.None); + + const telemetryEvent = telemetryEvents.find(e => e.eventName === 'instructionsCollected'); + assert.ok(telemetryEvent, 'Should emit telemetry event'); + const data = telemetryEvent.data as InstructionsCollectionEvent; + assert.strictEqual(data.claudeAgentsCount, 1, 'Should count 1 Claude agent'); }); }); 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 6f852ed728582..76b2ac54b072c 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 @@ -6,13 +6,98 @@ 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 { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName, extractHookCommandsFromItem } from '../../../common/promptSyntax/hookClaudeCompat.js'; import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../../common/promptSyntax/hookCompatibility.js'; import { URI } from '../../../../../../base/common/uri.js'; suite('HookClaudeCompat', () => { ensureNoDisposablesAreLeakedInTestSuite(); + suite('extractHookCommandsFromItem', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + test('extracts direct command object', () => { + const item = { type: 'command', command: 'echo "test"' }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "test"'); + }); + + test('extracts from nested matcher structure', () => { + const item = { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "nested"' } + ] + }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "nested"'); + }); + + test('extracts multiple hooks from matcher structure', () => { + const item = { + matcher: 'Write', + hooks: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 2); + assert.strictEqual(result[0].command, 'echo "first"'); + assert.strictEqual(result[1].command, 'echo "second"'); + }); + + test('handles command without type field (Claude format)', () => { + const item = { command: 'echo "no type"' }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "no type"'); + }); + + test('handles nested command without type field', () => { + const item = { + matcher: 'Bash', + hooks: [ + { command: 'echo "no type nested"' } + ] + }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 1); + assert.strictEqual(result[0].command, 'echo "no type nested"'); + }); + + test('returns empty array for null item', () => { + const result = extractHookCommandsFromItem(null, workspaceRoot, userHome); + assert.strictEqual(result.length, 0); + }); + + test('returns empty array for undefined item', () => { + const result = extractHookCommandsFromItem(undefined, workspaceRoot, userHome); + assert.strictEqual(result.length, 0); + }); + + test('returns empty array for invalid type', () => { + const item = { type: 'script', command: 'echo "wrong type"' }; + + const result = extractHookCommandsFromItem(item, workspaceRoot, userHome); + + assert.strictEqual(result.length, 0); + }); + }); + suite('resolveClaudeHookType', () => { test('resolves PreToolUse', () => { assert.strictEqual(resolveClaudeHookType('PreToolUse'), HookType.PreToolUse); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts index 7d4ba6ffe51d1..ede5eeb5e52c8 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookCompatibility.test.ts @@ -52,6 +52,94 @@ suite('HookCompatibility', () => { assert.strictEqual(result.size, 0); }); }); + + suite('Claude-style matcher compatibility', () => { + test('parses Claude-style nested matcher structure', () => { + // When Claude format is pasted into Copilot hooks file + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "from matcher"' } + ] + } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "from matcher"'); + }); + + test('parses Claude-style nested matcher with multiple hooks', () => { + const json = { + hooks: { + PostToolUse: [ + { + matcher: 'Write', + hooks: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PostToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "first"'); + assert.strictEqual(entry.hooks[1].command, 'echo "second"'); + }); + + test('handles mixed direct and nested matcher entries', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "direct"' }, + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "nested"' } + ] + } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "direct"'); + assert.strictEqual(entry.hooks[1].command, 'echo "nested"'); + }); + + test('handles Claude-style hook without type field', () => { + // Claude allows omitting the type field + const json = { + hooks: { + SessionStart: [ + { command: 'echo "no type"' } + ] + } + }; + + const result = parseCopilotHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.SessionStart)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "no type"'); + }); + }); }); suite('parseHooksFromFile', () => { diff --git a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css index 2c59b8933266c..982fbc1d50b8b 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css +++ b/src/vs/workbench/contrib/preferences/browser/media/settingsEditor2.css @@ -109,7 +109,6 @@ } .settings-editor > .settings-header > .settings-header-controls .last-synced-label { - padding-top: 7px; opacity: 0.9; } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index bc3a037c246e4..63a328d454b56 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -209,8 +209,7 @@ export const tocData: ITOCEntry = { 'chat.notifyWindow*', 'chat.statusWidget.*', 'chat.tips.*', - 'chat.unifiedAgentsBar.*', - 'chat.confettiOnThumbsUp' + 'chat.unifiedAgentsBar.*' ] }, { @@ -258,7 +257,7 @@ export const tocData: ITOCEntry = { 'chat.useNestedAgentsMdFiles', 'chat.useAgentSkills', 'chat.experimental.useSkillAdherencePrompt', - 'chat.useChatHooks', + 'chat.useHooks', 'chat.includeApplyingInstructions', 'chat.includeReferencedInstructions', 'chat.sendElementsToChat.*', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index 081534d503168..fdda1dc2b01fe 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -19,12 +19,11 @@ import { ChatElicitationRequestPart } from '../../../../../chat/common/model/cha import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../../../../chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../../../chat/common/constants.js'; -import { ChatMessageRole, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; +import { ChatMessageRole, getTextResponseFromStream, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; import { IToolInvocationContext } from '../../../../../chat/common/tools/languageModelToolsService.js'; import { ITaskService } from '../../../../../tasks/common/taskService.js'; import { ILinkLocation } from '../../taskHelpers.js'; import { IConfirmationPrompt, IExecution, IPollingResult, OutputMonitorState, PollingConsts } from './types.js'; -import { getTextResponseFromStream } from './utils.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { TerminalChatAgentToolsSettingId } from '../../../common/terminalChatAgentToolsConfiguration.js'; import { ITerminalService } from '../../../../../terminal/browser/terminal.js'; @@ -470,9 +469,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { ); try { - const responseFromStream = getTextResponseFromStream(response); - await Promise.all([response.result, responseFromStream]); - return await responseFromStream; + return await getTextResponseFromStream(response); } catch (err) { return 'Error occurred ' + err; } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts index 3a22b30f9fa27..0ec550b09610c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/utils.ts @@ -3,31 +3,4 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { ILanguageModelChatResponse } from '../../../../../chat/common/languageModels.js'; - -export async function getTextResponseFromStream(response: ILanguageModelChatResponse): Promise { - let responseText = ''; - const streaming = (async () => { - if (!response || !response.stream) { - return; - } - for await (const part of response.stream) { - if (Array.isArray(part)) { - for (const p of part) { - if (p.type === 'text') { - responseText += p.value; - } - } - } else if (part.type === 'text') { - responseText += part.value; - } - } - })(); - - try { - await Promise.all([response.result, streaming]); - return responseText; - } catch (err) { - return 'Error occurred ' + err; - } -} +export { getTextResponseFromStream } from '../../../../../chat/common/languageModels.js'; diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index cebab4bb5db09..34b84089d180d 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -717,7 +717,7 @@ export class AgentSessionsWelcomePage extends EditorPane { // Content const content = append(tosCard, $('.agentSessionsWelcome-walkthroughCard-content')); const title = append(content, $('.agentSessionsWelcome-walkthroughCard-title')); - title.textContent = localize('tosTitle', "Your GitHub Copilot trial is active"); + title.textContent = localize('tosTitle', "Try GitHub Copilot for free, no sign-in required!"); const desc = append(content, $('.agentSessionsWelcome-walkthroughCard-description')); const descriptionMarkdown = new MarkdownString(