diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts index 165855ae6eb..ba06d2632ad 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/vscodeTestRunner.ts @@ -312,10 +312,10 @@ export class DarwinTestRunner extends PosixTestRunner { /** @override */ protected override async binaryPath() { - const { nameLong } = await this.readProductJson(); + const { nameLong, nameShort } = await this.readProductJson(); return path.join( this.repoLocation.uri.fsPath, - `.build/electron/${nameLong}.app/Contents/MacOS/Electron` + `.build/electron/${nameLong}.app/Contents/MacOS/${nameShort}` ); } } diff --git a/.vscode/launch.json b/.vscode/launch.json index a7a15cc31a6..9dbed82ee94 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -505,7 +505,7 @@ "request": "launch", "name": "Run Unit Tests", "program": "${workspaceFolder}/test/unit/electron/index.js", - "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Electron", + "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS", "windows": { "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.exe" }, @@ -535,7 +535,7 @@ "request": "launch", "name": "Run Unit Tests For Current File", "program": "${workspaceFolder}/test/unit/electron/index.js", - "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Electron", + "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS", "windows": { "runtimeExecutable": "${workspaceFolder}/.build/electron/Code - OSS.exe" }, @@ -571,7 +571,7 @@ "timeout": 240000, "args": [ "-l", - "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Electron" + "${workspaceFolder}/.build/electron/Code - OSS.app/Contents/MacOS/Code - OSS" ], "outFiles": [ "${cwd}/out/**/*.js" diff --git a/.vscode/settings.json b/.vscode/settings.json index ec8c556838b..da775f21244 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -24,11 +24,9 @@ "files.insertFinalNewline": false }, "[typescript]": { - "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true }, "[javascript]": { - "editor.defaultFormatter": "vscode.typescript-language-features", "editor.formatOnSave": true }, "[rust]": { diff --git a/build/azure-pipelines/darwin/product-build-darwin-universal.yml b/build/azure-pipelines/darwin/product-build-darwin-universal.yml index ed94a170791..88722aecc7a 100644 --- a/build/azure-pipelines/darwin/product-build-darwin-universal.yml +++ b/build/azure-pipelines/darwin/product-build-darwin-universal.yml @@ -98,6 +98,20 @@ jobs: DEBUG=* node build/darwin/create-universal-app.ts $(agent.builddirectory) displayName: Create Universal App + - script: | + set -e + APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + EXEC_NAME=$(node -p "require(\"$APP_PATH/Contents/Resources/app/product.json\").nameShort") + # Create a symlink from 'Electron' to the actual executable for backward compatibility + # This ensures apps that relied on the hardcoded path 'Contents/MacOS/Electron' continue to work + # Remove this step once main branch is on 1.112 release. + if [ "$EXEC_NAME" != "Electron" ] && [ ! -L "$APP_PATH/Contents/MacOS/Electron" ]; then + ln -s "$EXEC_NAME" "$APP_PATH/Contents/MacOS/Electron" + fi + displayName: Create Electron symlink for backward compatibility + - script: | set -e APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml index 7604d54909f..2854c1f9417 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-compile.yml @@ -166,6 +166,21 @@ steps: chmod +x "$APP_PATH/Contents/Resources/app/bin/$CLI_APP_NAME" displayName: Make CLI executable + - script: | + set -e + APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" + APP_NAME="`ls $APP_ROOT | head -n 1`" + APP_PATH="$APP_ROOT/$APP_NAME" + EXEC_NAME=$(node -p "require(\"$APP_PATH/Contents/Resources/app/product.json\").nameShort") + # Create a symlink from 'Electron' to the actual executable for backward compatibility + # This ensures apps that relied on the hardcoded path 'Contents/MacOS/Electron' continue to work + # Remove this step once main branch is on 1.112 release. + if [ "$EXEC_NAME" != "Electron" ] && [ ! -L "$APP_PATH/Contents/MacOS/Electron" ]; then + ln -s "$EXEC_NAME" "$APP_PATH/Contents/MacOS/Electron" + fi + condition: eq(variables['BUILT_CLIENT'], 'true') + displayName: Create Electron symlink for backward compatibility + - script: | set -e APP_ROOT="$(Agent.BuildDirectory)/VSCode-darwin-$(VSCODE_ARCH)" diff --git a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml index 80be5496d97..2028b862f8f 100644 --- a/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml +++ b/build/azure-pipelines/darwin/steps/product-build-darwin-test.yml @@ -58,7 +58,9 @@ steps: set -e APP_ROOT="$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH)" APP_NAME="`ls $APP_ROOT | head -n 1`" - INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/Electron" \ + ProductJsonPath=$(find "$APP_ROOT" -name "product.json" -type f | head -n 1) + BINARY_NAME=$(jq -r '.nameShort' "$ProductJsonPath") + INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/$BINARY_NAME" \ ./scripts/test-integration.sh --build --tfs "Integration Tests" env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) @@ -77,7 +79,9 @@ steps: set -e APP_ROOT=$(agent.builddirectory)/VSCode-darwin-$(VSCODE_ARCH) APP_NAME="`ls $APP_ROOT | head -n 1`" - INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/Electron" \ + ProductJsonPath=$(find "$APP_ROOT" -name "product.json" -type f | head -n 1) + BINARY_NAME=$(jq -r '.nameShort' "$ProductJsonPath") + INTEGRATION_TEST_ELECTRON_PATH="$APP_ROOT/$APP_NAME/Contents/MacOS/$BINARY_NAME" \ ./scripts/test-remote-integration.sh env: VSCODE_REMOTE_SERVER_PATH: $(agent.builddirectory)/vscode-server-darwin-$(VSCODE_ARCH) diff --git a/build/azure-pipelines/product-npm-package-validate.yml b/build/azure-pipelines/product-npm-package-validate.yml index b256107437d..37483396b23 100644 --- a/build/azure-pipelines/product-npm-package-validate.yml +++ b/build/azure-pipelines/product-npm-package-validate.yml @@ -36,7 +36,7 @@ jobs: echo "$CHANGED_FILES" # Check if package.json or package-lock.json are in the changed files - if echo "$CHANGED_FILES" | grep -E '^(package\.json|package-lock\.json)$'; then + if echo "$CHANGED_FILES" | grep -E '(^|/)package(-lock)?\.json$'; then echo "##vso[task.setvariable variable=SHOULD_VALIDATE]true" echo "Package files were modified, proceeding with validation" else diff --git a/build/checker/layersChecker.ts b/build/checker/layersChecker.ts index 87341dcffd0..174ec273780 100644 --- a/build/checker/layersChecker.ts +++ b/build/checker/layersChecker.ts @@ -61,6 +61,12 @@ const RULES: IRule[] = [ disallowedTypes: NATIVE_TYPES, }, + // Browser view preload script + { + target: '**/vs/platform/browserView/electron-browser/preload-browserView.ts', + disallowedTypes: NATIVE_TYPES, + }, + // Common { target: '**/vs/**/common/**', diff --git a/build/checker/tsconfig.electron-browser.json b/build/checker/tsconfig.electron-browser.json index 2cbe3d3bd33..80828443aa0 100644 --- a/build/checker/tsconfig.electron-browser.json +++ b/build/checker/tsconfig.electron-browser.json @@ -16,6 +16,7 @@ "../../src/**/test/**", "../../src/**/fixtures/**", "../../src/vs/base/parts/sandbox/electron-browser/preload.ts", // Preload scripts for Electron sandbox - "../../src/vs/base/parts/sandbox/electron-browser/preload-aux.ts" // have limited access to node.js APIs + "../../src/vs/base/parts/sandbox/electron-browser/preload-aux.ts", // have limited access to node.js APIs + "../../src/vs/platform/browserView/electron-browser/preload-browserView.ts" // Browser view preload script ] } diff --git a/build/gulpfile.extensions.ts b/build/gulpfile.extensions.ts index 6f5cf0d25d8..d77b2931d9b 100644 --- a/build/gulpfile.extensions.ts +++ b/build/gulpfile.extensions.ts @@ -7,21 +7,22 @@ import { EventEmitter } from 'events'; EventEmitter.defaultMaxListeners = 100; +import es from 'event-stream'; +import glob from 'glob'; import gulp from 'gulp'; +import filter from 'gulp-filter'; +import plumber from 'gulp-plumber'; +import sourcemaps from 'gulp-sourcemaps'; import * as path from 'path'; import * as nodeUtil from 'util'; -import es from 'event-stream'; -import filter from 'gulp-filter'; -import * as util from './lib/util.ts'; -import { getVersion } from './lib/getVersion.ts'; -import * as task from './lib/task.ts'; -import watcher from './lib/watch/index.ts'; -import { createReporter } from './lib/reporter.ts'; -import glob from 'glob'; -import plumber from 'gulp-plumber'; import * as ext from './lib/extensions.ts'; +import { getVersion } from './lib/getVersion.ts'; +import { createReporter } from './lib/reporter.ts'; +import * as task from './lib/task.ts'; import * as tsb from './lib/tsb/index.ts'; -import sourcemaps from 'gulp-sourcemaps'; +import { createTsgoStream, spawnTsgo } from './lib/tsgo.ts'; +import * as util from './lib/util.ts'; +import watcher from './lib/watch/index.ts'; const root = path.dirname(import.meta.dirname); const commit = getVersion(root); @@ -78,6 +79,18 @@ const compilations = [ const getBaseUrl = (out: string) => `https://main.vscode-cdn.net/sourcemaps/${commit}/${out}`; +function rewriteTsgoSourceMappingUrlsIfNeeded(build: boolean, out: string, baseUrl: string): Promise { + if (!build) { + return Promise.resolve(); + } + + return util.streamToPromise( + gulp.src(path.join(out, '**', '*.js'), { base: out }) + .pipe(util.rewriteSourceMappingURL(baseUrl)) + .pipe(gulp.dest(out)) + ); +} + const tasks = compilations.map(function (tsconfigFile) { const absolutePath = path.join(root, tsconfigFile); const relativeDirname = path.dirname(tsconfigFile.replace(/^(.*\/)?extensions\//i, '')); @@ -150,25 +163,22 @@ const tasks = compilations.map(function (tsconfigFile) { .pipe(gulp.dest(out)); })); - const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, () => { - const pipeline = createPipeline(false, true); - const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'])); - const input = es.merge(nonts, pipeline.tsProjectSrc()); + const compileTask = task.define(`compile-extension:${name}`, task.series(cleanTask, async () => { + const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); + const copyNonTs = util.streamToPromise(nonts.pipe(gulp.dest(out))); + const tsgo = spawnTsgo(absolutePath, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)); - return input - .pipe(pipeline()) - .pipe(gulp.dest(out)); + await Promise.all([copyNonTs, tsgo]); })); const watchTask = task.define(`watch-extension:${name}`, task.series(cleanTask, () => { - const pipeline = createPipeline(false); - const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'])); - const input = es.merge(nonts, pipeline.tsProjectSrc()); + const nonts = gulp.src(src, srcOpts).pipe(filter(['**', '!**/*.ts'], { dot: true })); const watchInput = watcher(src, { ...srcOpts, ...{ readDelay: 200 } }); + const watchNonTs = watchInput.pipe(filter(['**', '!**/*.ts'], { dot: true })).pipe(gulp.dest(out)); + const tsgoStream = watchInput.pipe(util.debounce(() => createTsgoStream(absolutePath, () => rewriteTsgoSourceMappingUrlsIfNeeded(false, out, baseUrl)), 200)); + const watchStream = es.merge(nonts.pipe(gulp.dest(out)), watchNonTs, tsgoStream); - return watchInput - .pipe(util.incremental(pipeline, input)) - .pipe(gulp.dest(out)); + return watchStream; })); // Tasks diff --git a/build/gulpfile.ts b/build/gulpfile.ts index a8e2917035e..a57218b8445 100644 --- a/build/gulpfile.ts +++ b/build/gulpfile.ts @@ -3,6 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import { EventEmitter } from 'events'; +EventEmitter.defaultMaxListeners = 100; + import glob from 'glob'; import gulp from 'gulp'; import { createRequire } from 'node:module'; @@ -12,29 +14,25 @@ import * as compilation from './lib/compilation.ts'; import * as task from './lib/task.ts'; import * as util from './lib/util.ts'; -EventEmitter.defaultMaxListeners = 100; - const require = createRequire(import.meta.url); -const { transpileTask, compileTask, watchTask, compileApiProposalNamesTask, watchApiProposalNamesTask } = compilation; - // API proposal names -gulp.task(compileApiProposalNamesTask); -gulp.task(watchApiProposalNamesTask); +gulp.task(compilation.compileApiProposalNamesTask); +gulp.task(compilation.watchApiProposalNamesTask); // SWC Client Transpile -const transpileClientSWCTask = task.define('transpile-client-esbuild', task.series(util.rimraf('out'), transpileTask('src', 'out', true))); +const transpileClientSWCTask = task.define('transpile-client-esbuild', task.series(util.rimraf('out'), compilation.transpileTask('src', 'out', true))); gulp.task(transpileClientSWCTask); // Transpile only -const transpileClientTask = task.define('transpile-client', task.series(util.rimraf('out'), transpileTask('src', 'out'))); +const transpileClientTask = task.define('transpile-client', task.series(util.rimraf('out'), compilation.transpileTask('src', 'out'))); gulp.task(transpileClientTask); // Fast compile for development time -const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compileApiProposalNamesTask, compileTask('src', 'out', false))); +const compileClientTask = task.define('compile-client', task.series(util.rimraf('out'), compilation.copyCodiconsTask, compilation.compileApiProposalNamesTask, compilation.compileTask('src', 'out', false))); gulp.task(compileClientTask); -const watchClientTask = task.define('watch-client', task.series(util.rimraf('out'), task.parallel(watchTask('out', false), watchApiProposalNamesTask, compilation.watchCodiconsTask))); +const watchClientTask = task.define('watch-client', task.series(util.rimraf('out'), task.parallel(compilation.watchTask('out', false), compilation.watchApiProposalNamesTask, compilation.watchCodiconsTask))); gulp.task(watchClientTask); // All diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index 81fc22f9f0e..b0955f2d14a 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -70,6 +70,7 @@ const vscodeResourceIncludes = [ // Electron Preload 'out-build/vs/base/parts/sandbox/electron-browser/preload.js', 'out-build/vs/base/parts/sandbox/electron-browser/preload-aux.js', + 'out-build/vs/platform/browserView/electron-browser/preload-browserView.js', // Node Scripts 'out-build/vs/base/node/{terminateProcess.sh,cpuUsage.sh,ps.sh}', @@ -435,6 +436,7 @@ function packageTask(platform: string, arch: string, sourceFolderName: string, d } else if (platform === 'darwin') { const shortcut = gulp.src('resources/darwin/bin/code.sh') .pipe(replace('@@APPNAME@@', product.applicationName)) + .pipe(replace('@@NAME@@', product.nameShort)) .pipe(rename('bin/code')); const policyDest = gulp.src('.build/policies/darwin/**', { base: '.build/policies/darwin' }) .pipe(rename(f => f.dirname = `policies/${f.dirname}`)); diff --git a/build/lib/electron.ts b/build/lib/electron.ts index aadc9b5fbe7..64786cb2de7 100644 --- a/build/lib/electron.ts +++ b/build/lib/electron.ts @@ -109,6 +109,7 @@ export const config = { productAppName: product.nameLong, companyName: 'Microsoft Corporation', copyright: 'Copyright (C) 2026 Microsoft. All rights reserved', + darwinExecutable: product.nameShort, darwinIcon: 'resources/darwin/code.icns', darwinBundleIdentifier: product.darwinBundleIdentifier, darwinApplicationCategoryType: 'public.app-category.developer-tools', diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts new file mode 100644 index 00000000000..c6422deded4 --- /dev/null +++ b/build/lib/tsgo.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as cp from 'child_process'; +import es from 'event-stream'; +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, onComplete?: () => Promise | void): Promise { + const reporter = createReporter('extensions'); + let report: NodeJS.ReadWriteStream | undefined; + + const beginReport = (emitError: boolean) => { + if (report) { + report.end(); + } + report = reporter.end(emitError); + }; + + const endReport = () => { + if (!report) { + return; + } + report.end(); + report = undefined; + }; + + const args = ['tsgo', '--project', projectPath, '--pretty', 'false', '--sourceMap', '--inlineSources']; + + beginReport(false); + + const child = cp.spawn(npx, args, { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + shell: true + }); + + let buffer = ''; + const handleLine = (line: string) => { + const trimmed = line.replace(ansiRegex, '').trim(); + if (!trimmed) { + 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); + } + }; + + const handleData = (data: Buffer) => { + buffer += data.toString('utf8'); + const lines = buffer.split(/\r?\n/); + buffer = lines.pop() ?? ''; + for (const line of lines) { + handleLine(line); + } + }; + + child.stdout?.on('data', handleData); + child.stderr?.on('data', handleData); + + const done = new Promise((resolve, reject) => { + child.on('exit', code => { + if (buffer.trim()) { + handleLine(buffer); + buffer = ''; + } + endReport(); + if (code === 0) { + Promise.resolve(onComplete?.()).then(() => resolve(), reject); + return; + } + reject(new Error(`tsgo exited with code ${code ?? 'unknown'}`)); + }); + child.on('error', err => { + endReport(); + reject(err); + }); + }); + + return done; +} + +export function createTsgoStream(projectPath: string, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { + const stream = es.through(); + + spawnTsgo(projectPath, onComplete).then(() => { + stream.emit('end'); + }).catch(() => { + // Errors are already reported by spawnTsgo via the reporter. + // Don't emit 'error' on the stream as that would exit the watch process. + stream.emit('end'); + }); + + return stream; +} diff --git a/build/win32/code.iss b/build/win32/code.iss index 101dc2d3548..197ff3e9e26 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1603,7 +1603,7 @@ begin begin #ifdef AppxPackageName // Remove the old context menu registry keys - if IsWindows11OrLater() and WizardIsTaskSelected('addcontextmenufiles') then begin + if IsWindows11OrLater() then begin RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\shell\{#RegValueName}'); RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\directory\background\shell\{#RegValueName}'); diff --git a/extensions/git-base/src/decorators.ts b/extensions/git-base/src/decorators.ts index 067d32cdb5f..23f8dfd357f 100644 --- a/extensions/git-base/src/decorators.ts +++ b/extensions/git-base/src/decorators.ts @@ -47,11 +47,11 @@ function _throttle(fn: Function, key: string): Function { return trigger; } -function decorate(decorator: (fn: Function, key: string) => Function): Function { - return function (original: any, context: ClassMethodDecoratorContext) { - if (context.kind !== 'method') { +function decorate(decorator: (fn: Function, key: string) => Function): MethodDecorator { + return (_target: any, key: string | symbol, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value !== 'function') { throw new Error('not supported'); } - return decorator(original, context.name.toString()); + descriptor.value = decorator(descriptor.value, String(key)); }; } diff --git a/extensions/git/src/api/extension.ts b/extensions/git/src/api/extension.ts index a716fa00dae..7b0313b6c26 100644 --- a/extensions/git/src/api/extension.ts +++ b/extensions/git/src/api/extension.ts @@ -9,14 +9,14 @@ import { ApiRepository, ApiImpl } from './api1'; import { Event, EventEmitter } from 'vscode'; import { CloneManager } from '../cloneManager'; -function deprecated(original: unknown, context: ClassMemberDecoratorContext) { - if (typeof original !== 'function' || context.kind !== 'method') { +function deprecated(_target: unknown, key: string | symbol, descriptor: PropertyDescriptor): void { + if (typeof descriptor.value !== 'function') { throw new Error('not supported'); } - const key = context.name.toString(); - return function (this: unknown, ...args: unknown[]) { - console.warn(`Git extension API method '${key}' is deprecated.`); + const original = descriptor.value; + descriptor.value = function (this: unknown, ...args: unknown[]) { + console.warn(`Git extension API method '${String(key)}' is deprecated.`); return original.apply(this, args); }; } diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index b3e05e0016b..4451a5e4620 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -372,13 +372,12 @@ interface ScmCommand { const Commands: ScmCommand[] = []; -function command(commandId: string, options: ScmCommandOptions = {}): Function { - return (value: unknown, context: ClassMethodDecoratorContext) => { - if (typeof value !== 'function' || context.kind !== 'method') { +function command(commandId: string, options: ScmCommandOptions = {}): MethodDecorator { + return (_target: any, key: string | symbol, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value !== 'function') { throw new Error('not supported'); } - const key = context.name.toString(); - Commands.push({ commandId, key, method: value, options }); + Commands.push({ commandId, key: String(key), method: descriptor.value, options }); }; } diff --git a/extensions/git/src/decorators.ts b/extensions/git/src/decorators.ts index 0e59a849ed2..3aa7d5dc557 100644 --- a/extensions/git/src/decorators.ts +++ b/extensions/git/src/decorators.ts @@ -6,11 +6,14 @@ import { done } from './util'; function decorate(decorator: (fn: Function, key: string) => Function): Function { - return function (original: unknown, context: ClassMethodDecoratorContext) { - if (typeof original === 'function' && (context.kind === 'method' || context.kind === 'getter' || context.kind === 'setter')) { - return decorator(original, context.name.toString()); + return (_target: any, key: string, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value === 'function') { + descriptor.value = decorator(descriptor.value, key); + } else if (typeof descriptor.get === 'function') { + descriptor.get = decorator(descriptor.get, key) as () => any; + } else { + throw new Error('not supported'); } - throw new Error('not supported'); }; } @@ -85,5 +88,5 @@ export function debounce(delay: number): Function { clearTimeout(this[timerKey]); this[timerKey] = setTimeout(() => fn.apply(this, args), delay); }; - }); + }) as MethodDecorator; } diff --git a/extensions/github/src/util.ts b/extensions/github/src/util.ts index f7f54ec5f3f..2247292dd93 100644 --- a/extensions/github/src/util.ts +++ b/extensions/github/src/util.ts @@ -24,11 +24,14 @@ export class DisposableStore { } function decorate(decorator: (fn: Function, key: string) => Function): Function { - return function (original: any, context: ClassMethodDecoratorContext) { - if (context.kind === 'method' || context.kind === 'getter' || context.kind === 'setter') { - return decorator(original, context.name.toString()); + return (_target: any, key: string, descriptor: PropertyDescriptor): void => { + if (typeof descriptor.value === 'function') { + descriptor.value = decorator(descriptor.value, key); + } else if (typeof descriptor.get === 'function') { + descriptor.get = decorator(descriptor.get, key) as () => any; + } else { + throw new Error('not supported'); } - throw new Error('not supported'); }; } diff --git a/extensions/notebook-renderers/tsconfig.json b/extensions/notebook-renderers/tsconfig.json index 0bc7baa21be..0c1d35312f7 100644 --- a/extensions/notebook-renderers/tsconfig.json +++ b/extensions/notebook-renderers/tsconfig.json @@ -12,7 +12,8 @@ ], "typeRoots": [ "./node_modules/@types" - ] + ], + "skipLibCheck": true }, "include": [ "src/**/*", diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index fb34d532498..95730ae8713 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -540,6 +540,84 @@ "settings": { "foreground": "#A8CAAD" } + }, + { + "name": "Markup Heading", + "scope": "markup.heading", + "settings": { + "foreground": "#64b0df", + "fontStyle": "bold" + } + }, + { + "name": "Markup Bold", + "scope": "markup.bold", + "settings": { + "foreground": "#C48081", + "fontStyle": "bold" + } + }, + { + "name": "Markup Italic", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "Markup Strikethrough", + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markup Underline", + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#C184C6" + } + }, + { + "name": "Markup List", + "scope": "markup.list", + "settings": { + "foreground": "#48C9C4" + } + }, + { + "name": "Markup Inline Raw", + "scope": "markup.inline.raw", + "settings": { + "foreground": "#D1D6AE" + } + }, + { + "name": "Markup Raw/Fenced Code Block", + "scope": [ + "markup.raw", + "markup.fenced_code" + ], + "settings": { + "foreground": "#888888" + } + }, + { + "name": "Markup Link", + "scope": [ + "meta.link", + "markup.underline.link" + ], + "settings": { + "foreground": "#48A0C7" + } } ], "semanticHighlighting": true, diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index f27220f626b..0e34cf29aa2 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -546,6 +546,84 @@ "settings": { "foreground": "#2B9A69" } + }, + { + "name": "Markup Heading", + "scope": "markup.heading", + "settings": { + "foreground": "#5460C1", + "fontStyle": "bold" + } + }, + { + "name": "Markup Bold", + "scope": "markup.bold", + "settings": { + "foreground": "#B86855", + "fontStyle": "bold" + } + }, + { + "name": "Markup Italic", + "scope": "markup.italic", + "settings": { + "fontStyle": "italic" + } + }, + { + "name": "Markup Strikethrough", + "scope": "markup.strikethrough", + "settings": { + "fontStyle": "strikethrough" + } + }, + { + "name": "Markup Underline", + "scope": "markup.underline", + "settings": { + "fontStyle": "underline" + } + }, + { + "name": "Markup Quote", + "scope": "markup.quote", + "settings": { + "foreground": "#8F41AD" + } + }, + { + "name": "Markup List", + "scope": "markup.list", + "settings": { + "foreground": "#46969A" + } + }, + { + "name": "Markup Inline Raw", + "scope": "markup.inline.raw", + "settings": { + "foreground": "#98863B" + } + }, + { + "name": "Markup Raw/Fenced Code Block", + "scope": [ + "markup.raw", + "markup.fenced_code" + ], + "settings": { + "foreground": "#666666" + } + }, + { + "name": "Markup Link", + "scope": [ + "meta.link", + "markup.underline.link" + ], + "settings": { + "foreground": "#0069CC" + } } ], "semanticHighlighting": true, diff --git a/extensions/tsconfig.base.json b/extensions/tsconfig.base.json index 9d939dd568a..db32799b3ba 100644 --- a/extensions/tsconfig.base.json +++ b/extensions/tsconfig.base.json @@ -15,6 +15,7 @@ "noImplicitOverride": true, "noUnusedLocals": true, "noUnusedParameters": true, - "forceConsistentCasingInFileNames": true + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true } } diff --git a/package-lock.json b/package-lock.json index a08917c0455..8eccd8dcd31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", @@ -3364,9 +3364,9 @@ } }, "node_modules/@vscode/proxy-agent": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.37.0.tgz", - "integrity": "sha512-FDBc/3qf7fLMp4fmdRBav2dy3UZ/Vao4PN6a5IeTYvcgh9erd9HfOcVoU3ogy2uwCii6vZNvmEeF9+gr64spVQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.38.0.tgz", + "integrity": "sha512-f8fOGbYhCVG9FUbtVcL/90yjCyo6ZuuKQpA7hs7iIEMD8kesnoo04TUI3/29vifCZ2DCiyUN12CFgA+ktc2RTw==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/package.json b/package.json index 913cad90dbf..c5633f6bad5 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "6a427d4e06fa83b1b299fde50735094cb6562065", + "distro": "56aa42f9c6baf6359f28840af043e27a1311455d", "author": { "name": "Microsoft Corporation" }, @@ -82,7 +82,7 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", "@vscode/policy-watcher": "^1.3.2", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/sqlite3": "5.1.12-vscode", diff --git a/remote/package-lock.json b/remote/package-lock.json index cec191c66d1..b0b3663832f 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -15,7 +15,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", @@ -468,9 +468,9 @@ "license": "MIT" }, "node_modules/@vscode/proxy-agent": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.37.0.tgz", - "integrity": "sha512-FDBc/3qf7fLMp4fmdRBav2dy3UZ/Vao4PN6a5IeTYvcgh9erd9HfOcVoU3ogy2uwCii6vZNvmEeF9+gr64spVQ==", + "version": "0.38.0", + "resolved": "https://registry.npmjs.org/@vscode/proxy-agent/-/proxy-agent-0.38.0.tgz", + "integrity": "sha512-f8fOGbYhCVG9FUbtVcL/90yjCyo6ZuuKQpA7hs7iIEMD8kesnoo04TUI3/29vifCZ2DCiyUN12CFgA+ktc2RTw==", "license": "MIT", "dependencies": { "@tootallnate/once": "^3.0.0", diff --git a/remote/package.json b/remote/package.json index fa80a9ace31..a89be0e24eb 100644 --- a/remote/package.json +++ b/remote/package.json @@ -10,7 +10,7 @@ "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", - "@vscode/proxy-agent": "^0.37.0", + "@vscode/proxy-agent": "^0.38.0", "@vscode/ripgrep": "^1.15.13", "@vscode/spdlog": "^0.15.7", "@vscode/tree-sitter-wasm": "^0.3.0", diff --git a/resources/darwin/bin/code.sh b/resources/darwin/bin/code.sh index de5c3bfcab0..9410de8763e 100755 --- a/resources/darwin/bin/code.sh +++ b/resources/darwin/bin/code.sh @@ -29,7 +29,7 @@ if [ -z "$APP_PATH" ]; then exit 1 fi CONTENTS="$APP_PATH/Contents" -ELECTRON="$CONTENTS/MacOS/Electron" +ELECTRON="$CONTENTS/MacOS/@@NAME@@" CLI="$CONTENTS/Resources/app/out/cli.js" export VSCODE_NODE_OPTIONS=$NODE_OPTIONS export VSCODE_NODE_REPL_EXTERNAL_MODULE=$NODE_REPL_EXTERNAL_MODULE diff --git a/scripts/code-cli.sh b/scripts/code-cli.sh index 220c34d1a7e..ef466e50d07 100755 --- a/scripts/code-cli.sh +++ b/scripts/code-cli.sh @@ -12,7 +12,8 @@ function code() { if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="./.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="./.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE=".build/electron/$NAME" diff --git a/scripts/code-perf.js b/scripts/code-perf.js index 4bc431479f3..f1fbcb4f0ef 100644 --- a/scripts/code-perf.js +++ b/scripts/code-perf.js @@ -6,6 +6,7 @@ // @ts-check const path = require('path'); +const fs = require('fs'); const perf = require('@vscode/vscode-perf'); const VSCODE_FOLDER = path.join(__dirname, '..'); @@ -62,9 +63,14 @@ function getExePath(buildPath) { } let relativeExePath; switch (process.platform) { - case 'darwin': - relativeExePath = path.join('Contents', 'MacOS', 'Electron'); + case 'darwin': { + const product = require(path.join(buildPath, 'Contents', 'Resources', 'app', 'product.json')); + relativeExePath = path.join('Contents', 'MacOS', product.nameShort); + if (!fs.existsSync(path.join(buildPath, relativeExePath))) { + relativeExePath = path.join('Contents', 'MacOS', 'Electron'); + } break; + } case 'linux': { const product = require(path.join(buildPath, 'resources', 'app', 'product.json')); relativeExePath = product.applicationName; diff --git a/scripts/code.sh b/scripts/code.sh index 1ddbfce7d1a..16fdefde552 100755 --- a/scripts/code.sh +++ b/scripts/code.sh @@ -18,7 +18,8 @@ function code() { if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="./.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="./.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE=".build/electron/$NAME" diff --git a/scripts/node-electron.sh b/scripts/node-electron.sh index 102fe073e4f..187bfe314bb 100755 --- a/scripts/node-electron.sh +++ b/scripts/node-electron.sh @@ -11,7 +11,8 @@ pushd $ROOT if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="$ROOT/.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="$ROOT/.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE="$ROOT/.build/electron/$NAME" diff --git a/scripts/test.sh b/scripts/test.sh index 9ba8dedee0f..bc4661ecb7f 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -12,7 +12,8 @@ cd $ROOT if [[ "$OSTYPE" == "darwin"* ]]; then NAME=`node -p "require('./product.json').nameLong"` - CODE="./.build/electron/$NAME.app/Contents/MacOS/Electron" + EXE_NAME=`node -p "require('./product.json').nameShort"` + CODE="./.build/electron/$NAME.app/Contents/MacOS/$EXE_NAME" else NAME=`node -p "require('./product.json').applicationName"` CODE=".build/electron/$NAME" diff --git a/src/tsconfig.monaco.json b/src/tsconfig.monaco.json index 6293f59ba2b..0fbd2240799 100644 --- a/src/tsconfig.monaco.json +++ b/src/tsconfig.monaco.json @@ -11,7 +11,7 @@ "moduleResolution": "nodenext", "removeComments": false, "preserveConstEnums": true, - "target": "ES2022", + "target": "ES2024", "sourceMap": false, "declaration": true, "skipLibCheck": true diff --git a/src/tsconfig.vscode-dts.json b/src/tsconfig.vscode-dts.json index 3df2c2292ef..b83f686e4f3 100644 --- a/src/tsconfig.vscode-dts.json +++ b/src/tsconfig.vscode-dts.json @@ -13,7 +13,7 @@ "forceConsistentCasingInFileNames": true, "types": [], "lib": [ - "ES2022" + "ES2024" ], }, "include": [ diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index 5b32ddc9d85..8b7fc2d9b33 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -351,10 +351,10 @@ export class Button extends Disposable implements IButton { set checked(value: boolean) { if (value) { this._element.classList.add('checked'); - this._element.setAttribute('aria-checked', 'true'); + this._element.setAttribute('aria-pressed', 'true'); } else { this._element.classList.remove('checked'); - this._element.setAttribute('aria-checked', 'false'); + this._element.setAttribute('aria-pressed', 'false'); } } diff --git a/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts b/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts index 4140b645fab..d982d6a4614 100644 --- a/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts +++ b/src/vs/code/electron-utility/sharedProcess/contrib/extensions.ts @@ -10,21 +10,30 @@ import { migrateUnsupportedExtensions } from '../../../../platform/extensionMana import { INativeServerExtensionManagementService } from '../../../../platform/extensionManagement/node/extensionManagementService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IUserDataProfilesService } from '../../../../platform/userDataProfile/common/userDataProfile.js'; export class ExtensionsContributions extends Disposable { constructor( - @INativeServerExtensionManagementService extensionManagementService: INativeServerExtensionManagementService, - @IExtensionGalleryService extensionGalleryService: IExtensionGalleryService, - @IExtensionStorageService extensionStorageService: IExtensionStorageService, - @IGlobalExtensionEnablementService extensionEnablementService: IGlobalExtensionEnablementService, + @INativeServerExtensionManagementService private readonly extensionManagementService: INativeServerExtensionManagementService, + @IExtensionGalleryService private readonly extensionGalleryService: IExtensionGalleryService, + @IExtensionStorageService private readonly extensionStorageService: IExtensionStorageService, + @IGlobalExtensionEnablementService private readonly extensionEnablementService: IGlobalExtensionEnablementService, + @IUserDataProfilesService private readonly userDataProfilesService: IUserDataProfilesService, @IStorageService storageService: IStorageService, - @ILogService logService: ILogService, + @ILogService private readonly logService: ILogService, ) { super(); extensionManagementService.cleanUp(); - migrateUnsupportedExtensions(extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); + + this.migrateUnsupportedExtensions(); ExtensionStorageService.removeOutdatedExtensionVersions(extensionManagementService, storageService); } + private async migrateUnsupportedExtensions(): Promise { + for (const profile of this.userDataProfilesService.profiles) { + await migrateUnsupportedExtensions(profile, this.extensionManagementService, this.extensionGalleryService, this.extensionStorageService, this.extensionEnablementService, this.logService); + } + } + } diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index b3bdca721dd..8e29f492476 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -73,7 +73,7 @@ export async function main(argv: string[]): Promise { tunnelProcess = spawn('cargo', ['run', '--', subcommand, ...tunnelArgs], { cwd: join(getAppRoot(), 'cli'), stdio, env }); } else { const appPath = process.platform === 'darwin' - // ./Contents/MacOS/Electron => ./Contents/Resources/app/bin/code-tunnel-insiders + // ./Contents/MacOS/Code => ./Contents/Resources/app/bin/code-tunnel-insiders ? join(dirname(dirname(process.execPath)), 'Resources', 'app') : dirname(process.execPath); const tunnelCommand = join(appPath, 'bin', `${product.tunnelApplicationName}${isWindows ? '.exe' : ''}`); diff --git a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts index 1b3e2d595b6..96e95059620 100644 --- a/src/vs/platform/actions/browser/menuEntryActionViewItem.ts +++ b/src/vs/platform/actions/browser/menuEntryActionViewItem.ts @@ -427,6 +427,7 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { private readonly _dropdown: DropdownMenuActionViewItem; private _container: HTMLElement | null = null; private readonly _storageKey: string; + private readonly _primaryActionListener = this._register(new MutableDisposable()); get onDidChangeDropdownVisibility(): Event { return this._dropdown.onDidChangeVisibility; @@ -468,14 +469,18 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { this._dropdown = this._register(new DropdownMenuActionViewItem(submenuAction, submenuAction.actions, this._contextMenuService, dropdownOptions)); if (options?.togglePrimaryAction) { - this._register(this._dropdown.actionRunner.onDidRun((e: IRunEvent) => { - if (e.action instanceof MenuItemAction) { - this.update(e.action); - } - })); + this.registerTogglePrimaryActionListener(); } } + private registerTogglePrimaryActionListener(): void { + this._primaryActionListener.value = this._dropdown.actionRunner.onDidRun((e: IRunEvent) => { + if (e.action instanceof MenuItemAction) { + this.update(e.action); + } + }); + } + private update(lastAction: MenuItemAction): void { if (this._options?.togglePrimaryAction) { this._storageService.store(this._storageKey, lastAction.id, StorageScope.WORKSPACE, StorageTarget.MACHINE); @@ -516,6 +521,9 @@ export class DropdownWithDefaultActionViewItem extends BaseActionViewItem { this._defaultAction.actionRunner = actionRunner; this._dropdown.actionRunner = actionRunner; + if (this._primaryActionListener.value) { + this.registerTogglePrimaryActionListener(); + } } override get actionRunner(): IActionRunner { diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index 1901394a710..b81e7b5e878 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -251,6 +251,7 @@ export class MenuId { static readonly ChatWelcomeContext = new MenuId('ChatWelcomeContext'); static readonly ChatMessageFooter = new MenuId('ChatMessageFooter'); static readonly ChatExecute = new MenuId('ChatExecute'); + static readonly ChatExecuteQueue = new MenuId('ChatExecuteQueue'); static readonly ChatInput = new MenuId('ChatInput'); static readonly ChatInputSide = new MenuId('ChatInputSide'); static readonly ChatModePicker = new MenuId('ChatModePicker'); diff --git a/src/vs/platform/browserView/common/browserView.ts b/src/vs/platform/browserView/common/browserView.ts index 5e76962350b..f22fd39e70b 100644 --- a/src/vs/platform/browserView/common/browserView.ts +++ b/src/vs/platform/browserView/common/browserView.ts @@ -5,6 +5,7 @@ import { Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; +import { URI } from '../../../base/common/uri.js'; export interface IBrowserViewBounds { windowId: number; @@ -83,10 +84,16 @@ export interface IBrowserViewFaviconChangeEvent { favicon: string; } +export enum BrowserNewPageLocation { + Foreground = 'foreground', + Background = 'background', + NewWindow = 'newWindow' +} export interface IBrowserViewNewPageRequest { - url: string; - name?: string; - background: boolean; + resource: URI; + location: BrowserNewPageLocation; + // Only applicable if location is NewWindow + position?: { x?: number; y?: number; width?: number; height?: number }; } export interface IBrowserViewFindInPageOptions { @@ -110,6 +117,11 @@ export enum BrowserViewStorageScope { export const ipcBrowserViewChannelName = 'browserView'; +/** + * This should match the isolated world ID defined in `preload-browserView.ts`. + */ +export const browserViewIsolatedWorldId = 999; + export interface IBrowserViewService { /** * Dynamic events that return an Event for a specific browser view ID. @@ -239,6 +251,14 @@ export interface IBrowserViewService { */ stopFindInPage(id: string, keepSelection?: boolean): Promise; + /** + * Get the currently selected text in the browser view. + * Returns immediately with empty string if the page is still loading. + * @param id The browser view identifier + * @returns The selected text, or empty string if no selection or page is loading + */ + getSelectedText(id: string): Promise; + /** * Clear all storage data for the global browser session */ diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts new file mode 100644 index 00000000000..29832f220ff --- /dev/null +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* eslint-disable no-restricted-globals */ + +/** + * Preload script for pages loaded in Integrated Browser + * + * It runs in an isolated context that Electron calls an "isolated world". + * Specifically the isolated world with worldId 999, which shows in DevTools as "Electron Isolated Context". + * Despite being isolated, it still runs on the same page as the JS from the actual loaded website + * which runs on the so-called "main world" (worldId 0. In DevTools as "top"). + * + * Learn more: see Electron docs for Security, contextBridge, and Context Isolation. + */ +(function () { + + const { contextBridge } = require('electron'); + + // ####################################################################### + // ### ### + // ### !!! DO NOT USE GET/SET PROPERTIES ANYWHERE HERE !!! ### + // ### !!! UNLESS THE ACCESS IS WITHOUT SIDE EFFECTS !!! ### + // ### (https://github.com/electron/electron/issues/25516) ### + // ### ### + // ####################################################################### + const globals = { + /** + * Get the currently selected text in the page. + */ + getSelectedText(): string { + try { + // Even if the page has overridden window.getSelection, our call here will still reach the original + // implementation. That's because Electron proxies functions, such as getSelectedText here, that are + // exposed to a different context via exposeInIsolatedWorld or exposeInMainWorld. + return window.getSelection()?.toString() ?? ''; + } catch { + return ''; + } + } + }; + + try { + // Use `contextBridge` APIs to expose globals to the same isolated world where this preload script runs (worldId 999). + // The globals object will be recursively frozen (and for functions also proxied) by Electron to prevent + // modification within the given context. + contextBridge.exposeInIsolatedWorld(999, 'browserViewAPI', globals); + } catch (error) { + console.error(error); + } +}()); diff --git a/src/vs/platform/browserView/electron-main/browserView.ts b/src/vs/platform/browserView/electron-main/browserView.ts index 33717cb54c2..3154da6ccb1 100644 --- a/src/vs/platform/browserView/electron-main/browserView.ts +++ b/src/vs/platform/browserView/electron-main/browserView.ts @@ -4,16 +4,18 @@ *--------------------------------------------------------------------------------------------*/ import { WebContentsView, webContents } from 'electron'; +import { FileAccess } from '../../../base/common/network.js'; import { Disposable } from '../../../base/common/lifecycle.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { VSBuffer } from '../../../base/common/buffer.js'; -import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent } from '../common/browserView.js'; +import { IBrowserViewBounds, IBrowserViewDevToolsStateEvent, IBrowserViewFocusEvent, IBrowserViewKeyDownEvent, IBrowserViewState, IBrowserViewNavigationEvent, IBrowserViewLoadingEvent, IBrowserViewLoadError, IBrowserViewTitleChangeEvent, IBrowserViewFaviconChangeEvent, IBrowserViewNewPageRequest, BrowserViewStorageScope, IBrowserViewCaptureScreenshotOptions, IBrowserViewFindInPageOptions, IBrowserViewFindInPageResult, IBrowserViewVisibilityEvent, BrowserNewPageLocation, browserViewIsolatedWorldId } from '../common/browserView.js'; import { EVENT_KEY_CODE_MAP, KeyCode, KeyMod, SCAN_CODE_STR_TO_EVENT_KEY_CODE } from '../../../base/common/keyCodes.js'; import { IWindowsMainService } from '../../windows/electron-main/windows.js'; import { IBaseWindow, ICodeWindow } from '../../window/electron-main/window.js'; import { IAuxiliaryWindowsMainService } from '../../auxiliaryWindow/electron-main/auxiliaryWindows.js'; import { IAuxiliaryWindow } from '../../auxiliaryWindow/electron-main/auxiliaryWindow.js'; import { isMacintosh } from '../../../base/common/platform.js'; +import { BrowserViewUri } from '../common/browserViewUri.js'; /** Key combinations that are used in system-level shortcuts. */ const nativeShortcuts = new Set([ @@ -38,6 +40,7 @@ export class BrowserView extends Disposable { private _lastScreenshot: VSBuffer | undefined = undefined; private _lastFavicon: string | undefined = undefined; private _lastError: IBrowserViewLoadError | undefined = undefined; + private _lastUserGestureTimestamp: number = -Infinity; private _window: IBaseWindow | undefined; private _isSendingKeyEvent = false; @@ -76,40 +79,69 @@ export class BrowserView extends Disposable { readonly onDidClose: Event = this._onDidClose.event; constructor( + public readonly id: string, private readonly viewSession: Electron.Session, private readonly storageScope: BrowserViewStorageScope, + createChildView: (options?: Electron.WebContentsViewConstructorOptions) => BrowserView, + options: Electron.WebContentsViewConstructorOptions | undefined, @IWindowsMainService private readonly windowsMainService: IWindowsMainService, @IAuxiliaryWindowsMainService private readonly auxiliaryWindowsMainService: IAuxiliaryWindowsMainService ) { super(); const webPreferences: Electron.WebPreferences & { type: ReturnType } = { + ...options?.webPreferences, + nodeIntegration: false, contextIsolation: true, sandbox: true, webviewTag: false, session: viewSession, + preload: FileAccess.asFileUri('vs/platform/browserView/electron-browser/preload-browserView.js').fsPath, // TODO@kycutler: Remove this once https://github.com/electron/electron/issues/42578 is fixed type: 'browserView' }; - this._view = new WebContentsView({ webPreferences }); + this._view = new WebContentsView({ + webPreferences, + // Passing an `undefined` webContents triggers an error in Electron. + ...(options?.webContents ? { webContents: options.webContents } : {}) + }); this._view.setBackgroundColor('#FFFFFF'); this._view.webContents.setWindowOpenHandler((details) => { - // For new tab requests, fire event for workbench to handle - if (details.disposition === 'background-tab' || details.disposition === 'foreground-tab') { - this._onDidRequestNewPage.fire({ - url: details.url, - name: details.frameName || undefined, - background: details.disposition === 'background-tab' - }); - return { action: 'deny' }; // Deny the default browser behavior since we're handling it + const location = (() => { + switch (details.disposition) { + case 'background-tab': return BrowserNewPageLocation.Background; + case 'foreground-tab': return BrowserNewPageLocation.Foreground; + case 'new-window': return BrowserNewPageLocation.NewWindow; + default: return undefined; + } + })(); + + if (!location || !this.consumePopupPermission(location)) { + // Eventually we may want to surface this. For now, just silently block it. + return { action: 'deny' }; } - // Deny other requests like new windows. - return { action: 'deny' }; + return { + action: 'allow', + createWindow: (options) => { + const childView = createChildView(options); + const resource = BrowserViewUri.forUrl(details.url, childView.id); + + // Fire event for the workbench to open this view + this._onDidRequestNewPage.fire({ + resource, + location, + position: { x: options.x, y: options.y, width: options.width, height: options.height } + }); + + // Return the webContents so Electron can complete the window.open() call + return childView.webContents; + } + }; }); this._view.webContents.on('destroyed', () => { @@ -250,6 +282,20 @@ export class BrowserView extends Disposable { } }); + // Track user gestures for popup blocking logic. + // Roughly based on https://html.spec.whatwg.org/multipage/interaction.html#tracking-user-activation. + webContents.on('input-event', (_event, input) => { + switch (input.type) { + case 'rawKeyDown': + case 'keyDown': + case 'mouseDown': + case 'pointerDown': + case 'pointerUp': + case 'touchEnd': + this._lastUserGestureTimestamp = Date.now(); + } + }); + // For now, always prevent sites from blocking unload. // In the future we may want to show a dialog to ask the user, // with heavy restrictions regarding interaction and repeated prompts. @@ -268,6 +314,22 @@ export class BrowserView extends Disposable { }); } + private consumePopupPermission(location: BrowserNewPageLocation): boolean { + switch (location) { + case BrowserNewPageLocation.Foreground: + case BrowserNewPageLocation.Background: + return true; + case BrowserNewPageLocation.NewWindow: + // Each user gesture allows one popup window within 1 second + if (this._lastUserGestureTimestamp > Date.now() - 1000) { + this._lastUserGestureTimestamp = -Infinity; + return true; + } + + return false; + } + } + get webContents(): Electron.WebContents { return this._view.webContents; } @@ -475,6 +537,23 @@ export class BrowserView extends Disposable { this._view.webContents.stopFindInPage(keepSelection ? 'keepSelection' : 'clearSelection'); } + /** + * Get the currently selected text in the browser view. + * Returns immediately with empty string if the page is still loading. + */ + async getSelectedText(): Promise { + // we don't want to wait for the page to finish loading, which executeJavaScript normally does. + if (this._view.webContents.isLoading()) { + return ''; + } + try { + // Uses our preloaded contextBridge-exposed API. + return await this._view.webContents.executeJavaScriptInIsolatedWorld(browserViewIsolatedWorldId, [{ code: 'window.browserViewAPI?.getSelectedText?.() ?? ""' }]); + } catch { + return ''; + } + } + /** * Clear all storage data for this browser view's session */ diff --git a/src/vs/platform/browserView/electron-main/browserViewMainService.ts b/src/vs/platform/browserView/electron-main/browserViewMainService.ts index a462d108ca0..7932a442087 100644 --- a/src/vs/platform/browserView/electron-main/browserViewMainService.ts +++ b/src/vs/platform/browserView/electron-main/browserViewMainService.ts @@ -78,6 +78,27 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa }); } + /** + * Create a child browser view (used by window.open handler) + */ + private createBrowserView(id: string, session: Electron.Session, scope: BrowserViewStorageScope, options?: Electron.WebContentsViewConstructorOptions): BrowserView { + if (this.browserViews.has(id)) { + throw new Error(`Browser view with id ${id} already exists`); + } + + const view = this.instantiationService.createInstance( + BrowserView, + id, + session, + scope, + // Recursive factory for nested windows + (options) => this.createBrowserView(generateUuid(), session, scope, options), + options + ); + this.browserViews.set(id, view); + return view; + } + async getOrCreateBrowserView(id: string, scope: BrowserViewStorageScope, workspaceId?: string): Promise { if (this.browserViews.has(id)) { // Note: scope will be ignored if the view already exists. @@ -90,8 +111,7 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa this.configureSession(session); BrowserViewMainService.knownSessions.add(session); - const view = this.instantiationService.createInstance(BrowserView, session, resolvedScope); - this.browserViews.set(id, view); + const view = this.createBrowserView(id, session, resolvedScope); return view.getState(); } @@ -223,6 +243,10 @@ export class BrowserViewMainService extends Disposable implements IBrowserViewMa return this._getBrowserView(id).stopFindInPage(keepSelection); } + async getSelectedText(id: string): Promise { + return this._getBrowserView(id).getSelectedText(); + } + async clearStorage(id: string): Promise { return this._getBrowserView(id).clearStorage(); } diff --git a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts index 8bb3e5144d0..e9595bd406a 100644 --- a/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts +++ b/src/vs/platform/extensionManagement/common/abstractExtensionManagementService.ts @@ -939,6 +939,9 @@ export abstract class AbstractExtensionManagementService extends CommontExtensio if (checked.indexOf(extension) !== -1) { return []; } + if (areSameExtensions(extension.identifier, { id: this.productService.defaultChatAgent.extensionId })) { + return []; + } checked.push(extension); const extensionsPack = extension.manifest.extensionPack ? extension.manifest.extensionPack : []; if (extensionsPack.length) { diff --git a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts index 9772de66828..40349546ec8 100644 --- a/src/vs/platform/extensionManagement/common/extensionGalleryService.ts +++ b/src/vs/platform/extensionManagement/common/extensionGalleryService.ts @@ -1157,8 +1157,23 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle const runQuery = async (query: Query, token: CancellationToken) => { const { extensions, total } = await this.queryGalleryExtensions(query, { targetPlatform: CURRENT_TARGET_PLATFORM, compatible: false, includePreRelease: !!options.includePreRelease, productVersion: options.productVersion ?? { version: this.productService.version, date: this.productService.date } }, extensionGalleryManifest, token); - extensions.forEach((e, index) => setTelemetry(e, ((query.pageNumber - 1) * query.pageSize) + index, options.source)); - return { extensions, total }; + + const result: IGalleryExtension[] = []; + let defaultChatAgentExtension: IGalleryExtension | undefined; + for (let index = 0; index < extensions.length; index++) { + const extension = extensions[index]; + setTelemetry(extension, ((query.pageNumber - 1) * query.pageSize) + index, options.source); + if (areSameExtensions(extension.identifier, { id: this.productService.defaultChatAgent.extensionId, })) { + defaultChatAgentExtension = extension; + } else { + result.push(extension); + } + } + if (defaultChatAgentExtension) { + result.push(defaultChatAgentExtension); + } + + return { extensions: result, total }; }; const { extensions, total } = await runQuery(query, token); const getPage = async (pageIndex: number, ct: CancellationToken) => { @@ -1976,6 +1991,16 @@ export abstract class AbstractExtensionGalleryService implements IExtensionGalle } } + deprecated[this.productService.defaultChatAgent.extensionId.toLowerCase()] = { + disallowInstall: true, + extension: { + id: this.productService.defaultChatAgent.chatExtensionId, + displayName: 'GitHub Copilot Chat', + autoMigrate: { storage: false, donotDisable: true }, + preRelease: this.productService.quality !== 'stable' + } + }; + return { malicious, deprecated, search, autoUpdate }; } diff --git a/src/vs/platform/extensionManagement/common/extensionManagement.ts b/src/vs/platform/extensionManagement/common/extensionManagement.ts index 865a17fe6a8..ab11f6bb950 100644 --- a/src/vs/platform/extensionManagement/common/extensionManagement.ts +++ b/src/vs/platform/extensionManagement/common/extensionManagement.ts @@ -339,7 +339,10 @@ export interface IDeprecationInfo { readonly extension?: { readonly id: string; readonly displayName: string; - readonly autoMigrate?: { readonly storage: boolean }; + readonly autoMigrate?: { + readonly storage: boolean; + readonly donotDisable?: boolean; + }; readonly preRelease?: boolean; }; readonly settings?: readonly string[]; diff --git a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts index 1e75ad85b3d..79c8c7985fb 100644 --- a/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts +++ b/src/vs/platform/extensionManagement/common/unsupportedExtensionsMigration.ts @@ -10,6 +10,7 @@ import { IExtensionStorageService } from './extensionStorage.js'; import { ExtensionType } from '../../extensions/common/extensions.js'; import { ILogService } from '../../log/common/log.js'; import * as semver from '../../../base/common/semver/semver.js'; +import { IUserDataProfile } from '../../userDataProfile/common/userDataProfile.js'; /** * Migrates the installed unsupported nightly extension to a supported pre-release extension. It includes following: @@ -18,13 +19,13 @@ import * as semver from '../../../base/common/semver/semver.js'; * - the extension is not installed * - or it is a release version and the unsupported extension is enabled. */ -export async function migrateUnsupportedExtensions(extensionManagementService: IExtensionManagementService, galleryService: IExtensionGalleryService, extensionStorageService: IExtensionStorageService, extensionEnablementService: IGlobalExtensionEnablementService, logService: ILogService): Promise { +export async function migrateUnsupportedExtensions(profile: IUserDataProfile | undefined, extensionManagementService: IExtensionManagementService, galleryService: IExtensionGalleryService, extensionStorageService: IExtensionStorageService, extensionEnablementService: IGlobalExtensionEnablementService, logService: ILogService): Promise { try { const extensionsControlManifest = await extensionManagementService.getExtensionsControlManifest(); if (!extensionsControlManifest.deprecated) { return; } - const installed = await extensionManagementService.getInstalled(ExtensionType.User); + const installed = await extensionManagementService.getInstalled(ExtensionType.User, profile?.extensionsResource); for (const [unsupportedExtensionId, deprecated] of Object.entries(extensionsControlManifest.deprecated)) { if (!deprecated?.extension) { continue; @@ -49,14 +50,14 @@ export async function migrateUnsupportedExtensions(extensionManagementService: I logService.info(`Migrating '${unsupportedExtension.identifier.id}' extension to '${preReleaseExtensionId}' extension...`); const isUnsupportedExtensionEnabled = !extensionEnablementService.getDisabledExtensions().some(e => areSameExtensions(e, unsupportedExtension.identifier)); - await extensionManagementService.uninstall(unsupportedExtension); + await extensionManagementService.uninstall(unsupportedExtension, { profileLocation: profile?.extensionsResource }); logService.info(`Uninstalled the unsupported extension '${unsupportedExtension.identifier.id}'`); let preReleaseExtension = installed.find(i => areSameExtensions(i.identifier, { id: preReleaseExtensionId })); - if (!preReleaseExtension || (!preReleaseExtension.isPreReleaseVersion && isUnsupportedExtensionEnabled)) { - preReleaseExtension = await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: true, isMachineScoped: unsupportedExtension.isMachineScoped, operation: InstallOperation.Migrate, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); + if (!preReleaseExtension || (preReleaseExtension.isPreReleaseVersion !== !!preRelease && isUnsupportedExtensionEnabled)) { + preReleaseExtension = await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: preRelease, isMachineScoped: unsupportedExtension.isMachineScoped, operation: InstallOperation.Migrate, profileLocation: profile?.extensionsResource, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); logService.info(`Installed the pre-release extension '${preReleaseExtension.identifier.id}'`); - if (!isUnsupportedExtensionEnabled) { + if (!autoMigrate.donotDisable && !isUnsupportedExtensionEnabled) { await extensionEnablementService.disableExtension(preReleaseExtension.identifier); logService.info(`Disabled the pre-release extension '${preReleaseExtension.identifier.id}' because the unsupported extension '${unsupportedExtension.identifier.id}' is disabled`); } @@ -85,7 +86,7 @@ export async function migrateUnsupportedExtensions(extensionManagementService: I continue; } - await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: extensionToAutoUpdate.preRelease, isMachineScoped: extensionToAutoUpdate.isMachineScoped, operation: InstallOperation.Update, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); + await extensionManagementService.installFromGallery(gallery, { installPreReleaseVersion: extensionToAutoUpdate.preRelease, isMachineScoped: extensionToAutoUpdate.isMachineScoped, operation: InstallOperation.Update, profileLocation: profile?.extensionsResource, context: { [EXTENSION_INSTALL_SKIP_PUBLISHER_TRUST_CONTEXT]: true } }); logService.info(`Autoupdated '${extensionToAutoUpdate.identifier.id}' extension to '${gallery.version}' extension.`); } catch (error) { logService.error(error); diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index 0d612da6381..8b89e5b53a8 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -43,6 +43,10 @@ const _allApiProposals = { chatContextProvider: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatContextProvider.d.ts', }, + chatHooks: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatHooks.d.ts', + version: 1 + }, chatOutputRenderer: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.chatOutputRenderer.d.ts', }, diff --git a/src/vs/platform/update/common/update.config.contribution.ts b/src/vs/platform/update/common/update.config.contribution.ts index e5fb1abc0b6..5d1a419f8f2 100644 --- a/src/vs/platform/update/common/update.config.contribution.ts +++ b/src/vs/platform/update/common/update.config.contribution.ts @@ -77,6 +77,18 @@ configurationRegistry.registerConfiguration({ scope: ConfigurationScope.APPLICATION, description: localize('showReleaseNotes', "Show Release Notes after an update. The Release Notes are fetched from a Microsoft online service."), tags: ['usesOnlineServices'] + }, + 'update.statusBar': { + type: 'string', + enum: ['hidden', 'actionable', 'detailed'], + default: 'detailed', + scope: ConfigurationScope.APPLICATION, + description: localize('statusBar', "Controls the visibility of the update status bar entry."), + enumDescriptions: [ + localize('hidden', "The status bar entry is never shown."), + localize('actionable', "The status bar entry is shown when an action is required (e.g., download, install, or restart)."), + localize('detailed', "The status bar entry is shown for all update states including progress.") + ] } } }); diff --git a/src/vs/platform/update/common/update.ts b/src/vs/platform/update/common/update.ts index b859dfd4c11..e1be2b01e36 100644 --- a/src/vs/platform/update/common/update.ts +++ b/src/vs/platform/update/common/update.ts @@ -69,11 +69,11 @@ export type Disabled = { type: StateType.Disabled; reason: DisablementReason }; export type Idle = { type: StateType.Idle; updateType: UpdateType; error?: string }; export type CheckingForUpdates = { type: StateType.CheckingForUpdates; explicit: boolean }; export type AvailableForDownload = { type: StateType.AvailableForDownload; update: IUpdate }; -export type Downloading = { type: StateType.Downloading; explicit: boolean; overwrite: boolean }; +export type Downloading = { type: StateType.Downloading; update?: IUpdate; explicit: boolean; overwrite: boolean; downloadedBytes?: number; totalBytes?: number; startTime?: number }; export type Downloaded = { type: StateType.Downloaded; update: IUpdate; explicit: boolean; overwrite: boolean }; export type Updating = { type: StateType.Updating; update: IUpdate }; export type Ready = { type: StateType.Ready; update: IUpdate; explicit: boolean; overwrite: boolean }; -export type Overwriting = { type: StateType.Overwriting; explicit: boolean }; +export type Overwriting = { type: StateType.Overwriting; update: IUpdate; explicit: boolean }; export type State = Uninitialized | Disabled | Idle | CheckingForUpdates | AvailableForDownload | Downloading | Downloaded | Updating | Ready | Overwriting; @@ -83,11 +83,11 @@ export const State = { Idle: (updateType: UpdateType, error?: string): Idle => ({ type: StateType.Idle, updateType, error }), CheckingForUpdates: (explicit: boolean): CheckingForUpdates => ({ type: StateType.CheckingForUpdates, explicit }), AvailableForDownload: (update: IUpdate): AvailableForDownload => ({ type: StateType.AvailableForDownload, update }), - Downloading: (explicit: boolean, overwrite: boolean): Downloading => ({ type: StateType.Downloading, explicit, overwrite }), + Downloading: (update: IUpdate | undefined, explicit: boolean, overwrite: boolean, downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading => ({ type: StateType.Downloading, update, explicit, overwrite, downloadedBytes, totalBytes, startTime }), Downloaded: (update: IUpdate, explicit: boolean, overwrite: boolean): Downloaded => ({ type: StateType.Downloaded, update, explicit, overwrite }), Updating: (update: IUpdate): Updating => ({ type: StateType.Updating, update }), Ready: (update: IUpdate, explicit: boolean, overwrite: boolean): Ready => ({ type: StateType.Ready, update, explicit, overwrite }), - Overwriting: (explicit: boolean): Overwriting => ({ type: StateType.Overwriting, explicit }), + Overwriting: (update: IUpdate, explicit: boolean): Overwriting => ({ type: StateType.Overwriting, update, explicit }), }; export interface IAutoUpdater extends Event.NodeEventEmitter { diff --git a/src/vs/platform/update/electron-main/abstractUpdateService.ts b/src/vs/platform/update/electron-main/abstractUpdateService.ts index 0e63d401839..e3bea75cf1f 100644 --- a/src/vs/platform/update/electron-main/abstractUpdateService.ts +++ b/src/vs/platform/update/electron-main/abstractUpdateService.ts @@ -248,7 +248,7 @@ export abstract class AbstractUpdateService implements IUpdateService { this.logService.info('update#readyStateCheck: newer update available, restarting update machinery'); await this.cancelPendingUpdate(); this._overwrite = true; - this.setState(State.Overwriting(explicit)); + this.setState(State.Overwriting(this._state.update, explicit)); this.doCheckForUpdates(explicit, pendingUpdateCommit); return true; } diff --git a/src/vs/platform/update/electron-main/updateService.darwin.ts b/src/vs/platform/update/electron-main/updateService.darwin.ts index b20673b8ccf..d2d0579bac5 100644 --- a/src/vs/platform/update/electron-main/updateService.darwin.ts +++ b/src/vs/platform/update/electron-main/updateService.darwin.ts @@ -118,7 +118,7 @@ export class DarwinUpdateService extends AbstractUpdateService implements IRelau return; } - this.setState(State.Downloading(this.state.explicit, this._overwrite)); + this.setState(State.Downloading(this.state.type === StateType.Overwriting ? this.state.update : undefined, this.state.explicit, this._overwrite)); } private onUpdateDownloaded(update: IUpdate): void { diff --git a/src/vs/platform/update/electron-main/updateService.win32.ts b/src/vs/platform/update/electron-main/updateService.win32.ts index 3edbd9d9f9a..72d6af1c48f 100644 --- a/src/vs/platform/update/electron-main/updateService.win32.ts +++ b/src/vs/platform/update/electron-main/updateService.win32.ts @@ -8,11 +8,13 @@ import { existsSync, unlinkSync } from 'fs'; import { mkdir, readFile, unlink } from 'fs/promises'; import { tmpdir } from 'os'; import { app } from 'electron'; -import { timeout } from '../../../base/common/async.js'; +import { Delayer, timeout } from '../../../base/common/async.js'; +import { VSBuffer } from '../../../base/common/buffer.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { memoize } from '../../../base/common/decorators.js'; import { hash } from '../../../base/common/hash.js'; import * as path from '../../../base/common/path.js'; +import { transform } from '../../../base/common/stream.js'; import { URI } from '../../../base/common/uri.js'; import { checksum } from '../../../base/node/crypto.js'; import * as pfs from '../../../base/node/pfs.js'; @@ -188,7 +190,8 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun return Promise.resolve(null); } - this.setState(State.Downloading(explicit, this._overwrite)); + const startTime = Date.now(); + this.setState(State.Downloading(update, explicit, this._overwrite, 0, undefined, startTime)); return this.cleanup(update.version).then(() => { return this.getUpdatePackagePath(update.version).then(updatePackagePath => { @@ -200,7 +203,32 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const downloadPath = `${updatePackagePath}.tmp`; return this.requestService.request({ url: update.url }, CancellationToken.None) - .then(context => this.fileService.writeFile(URI.file(downloadPath), context.stream)) + .then(context => { + // Get total size from Content-Length header + const contentLengthHeader = context.res.headers['content-length']; + const contentLength = typeof contentLengthHeader === 'string' ? contentLengthHeader : undefined; + const totalBytes = contentLength ? parseInt(contentLength, 10) : undefined; + + // Track downloaded bytes and update state periodically using Delayer + let downloadedBytes = 0; + const progressDelayer = new Delayer(500); + const progressStream = transform( + context.stream, + { + data: data => { + downloadedBytes += data.byteLength; + progressDelayer.trigger(() => { + this.setState(State.Downloading(update, explicit, this._overwrite, downloadedBytes, totalBytes, startTime)); + }); + return data; + } + }, + chunks => VSBuffer.concat(chunks) + ); + + return this.fileService.writeFile(URI.file(downloadPath), progressStream) + .finally(() => progressDelayer.dispose()); + }) .then(update.sha256hash ? () => checksum(downloadPath, update.sha256hash) : () => undefined) .then(() => pfs.Promises.rename(downloadPath, updatePackagePath, false /* no retry */)) .then(() => updatePackagePath); @@ -326,7 +354,7 @@ export class Win32UpdateService extends AbstractUpdateService implements IRelaun const fastUpdatesEnabled = this.configurationService.getValue('update.enableWindowsBackgroundUpdates'); const update: IUpdate = { version: 'unknown', productVersion: 'unknown' }; - this.setState(State.Downloading(true, false)); + this.setState(State.Downloading(update, true, false)); this.availableUpdate = { packagePath }; this.setState(State.Downloaded(update, true, false)); diff --git a/src/vs/workbench/api/browser/extensionHost.contribution.ts b/src/vs/workbench/api/browser/extensionHost.contribution.ts index bfb284d9511..de3f735424e 100644 --- a/src/vs/workbench/api/browser/extensionHost.contribution.ts +++ b/src/vs/workbench/api/browser/extensionHost.contribution.ts @@ -96,6 +96,7 @@ import './mainThreadChatStatus.js'; import './mainThreadChatOutputRenderer.js'; import './mainThreadChatSessions.js'; import './mainThreadDataChannels.js'; +import './mainThreadHooks.js'; export class ExtensionPoints implements IWorkbenchContribution { diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 4da025ba8ad..986f8f27139 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -419,61 +419,66 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat } const originalEditor = this._editorService.editors.find(editor => editor.resource?.toString() === originalResource.toString()); - const originalModel = this._chatService.getSession(originalResource); + const originalModel = this._chatService.getActiveSessionReference(originalResource); const contribution = this._chatSessionsService.getAllChatSessionContributions().find(c => c.type === chatSessionType); - // Migrate todos from old session to new session - this._chatTodoListService.migrateTodos(originalResource, modifiedResource); + try { - // Find the group containing the original editor - const originalGroup = - this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) - ?? this.editorGroupService.activeGroup; + // Migrate todos from old session to new session + this._chatTodoListService.migrateTodos(originalResource, modifiedResource); - const options: IChatEditorOptions = { - title: { - preferred: originalEditor?.getName() || undefined, - fallback: localize('chatEditorContributionName', "{0}", contribution?.displayName), - } - }; + // Find the group containing the original editor + const originalGroup = + this.editorGroupService.groups.find(group => group.editors.some(editor => isEqual(editor.resource, originalResource))) + ?? this.editorGroupService.activeGroup; - // Prefetch the chat session content to make the subsequent editor swap quick - const newSession = await this._chatSessionsService.getOrCreateChatSession( - URI.revive(modifiedResource), - CancellationToken.None, - ); - - if (originalEditor) { - newSession.transferredState = originalEditor instanceof ChatEditorInput - ? { editingSession: originalEditor.transferOutEditingSession(), inputState: originalModel?.inputModel.toJSON() } - : undefined; - - this._editorService.replaceEditors([{ - editor: originalEditor, - replacement: { - resource: modifiedResource, - options, - }, - }], originalGroup); - return; - } - - // If chat editor is in the side panel, then those are not listed as editors. - // In that case we need to transfer editing session using the original model. - if (originalModel) { - newSession.transferredState = { - editingSession: originalModel.editingSession, - inputState: originalModel.inputModel.toJSON() + const options: IChatEditorOptions = { + title: { + preferred: originalEditor?.getName() || undefined, + fallback: localize('chatEditorContributionName', "{0}", contribution?.displayName), + } }; - } - const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource); - if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) { - await this._chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true }); - } else { - // Loading the session to ensure the session is created and editing session is transferred. - const ref = await this._chatService.loadSessionForResource(modifiedResource, ChatAgentLocation.Chat, CancellationToken.None); - ref?.dispose(); + // Prefetch the chat session content to make the subsequent editor swap quick + const newSession = await this._chatSessionsService.getOrCreateChatSession( + URI.revive(modifiedResource), + CancellationToken.None, + ); + + if (originalEditor) { + newSession.transferredState = originalEditor instanceof ChatEditorInput + ? { editingSession: originalEditor.transferOutEditingSession(), inputState: originalModel?.object?.inputModel.toJSON() } + : undefined; + + await this._editorService.replaceEditors([{ + editor: originalEditor, + replacement: { + resource: modifiedResource, + options, + }, + }], originalGroup); + return; + } + + // If chat editor is in the side panel, then those are not listed as editors. + // In that case we need to transfer editing session using the original model. + if (originalModel) { + newSession.transferredState = { + editingSession: originalModel.object.editingSession, + inputState: originalModel.object.inputModel.toJSON() + }; + } + + const chatViewWidget = this._chatWidgetService.getWidgetBySessionResource(originalResource); + if (chatViewWidget && isIChatViewViewContext(chatViewWidget.viewContext)) { + await this._chatWidgetService.openSession(modifiedResource, undefined, { preserveFocus: true }); + } else { + // Loading the session to ensure the session is created and editing session is transferred. + const ref = await this._chatService.loadSessionForResource(modifiedResource, ChatAgentLocation.Chat, CancellationToken.None); + ref?.dispose(); + } + } finally { + originalModel?.dispose(); } } diff --git a/src/vs/workbench/api/browser/mainThreadHooks.ts b/src/vs/workbench/api/browser/mainThreadHooks.ts new file mode 100644 index 00000000000..0283383ac65 --- /dev/null +++ b/src/vs/workbench/api/browser/mainThreadHooks.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI, UriComponents } from '../../../base/common/uri.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; +import { ExtHostContext, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; +import { HookResultKind, IHookResult, IHooksExecutionProxy, IHooksExecutionService } from '../../contrib/chat/common/hooksExecutionService.js'; +import { HookTypeValue, IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; + +@extHostNamedCustomer(MainContext.MainThreadHooks) +export class MainThreadHooks extends Disposable implements MainThreadHooksShape { + + constructor( + extHostContext: IExtHostContext, + @IHooksExecutionService private readonly _hooksExecutionService: IHooksExecutionService, + ) { + super(); + const extHostProxy = extHostContext.getProxy(ExtHostContext.ExtHostHooks); + + const proxy: IHooksExecutionProxy = { + runHookCommand: async (hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise => { + const result = await extHostProxy.$runHookCommand(hookCommand, input, token); + return { + kind: result.kind as HookResultKind, + result: result.result + }; + } + }; + + this._hooksExecutionService.setProxy(proxy); + } + + async $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise { + const uri = URI.revive(sessionResource); + return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token }); + } +} diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index 3c5f6403370..04e055c5b92 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -65,6 +65,7 @@ import { IExtHostConsumerFileSystem } from './extHostFileSystemConsumer.js'; import { ExtHostFileSystemEventService, FileSystemWatcherCreateOptions } from './extHostFileSystemEventService.js'; import { IExtHostFileSystemInfo } from './extHostFileSystemInfo.js'; import { IExtHostInitDataService } from './extHostInitDataService.js'; +import { IExtHostHooks } from './extHostHooks.js'; import { ExtHostInteractive } from './extHostInteractive.js'; import { ExtHostLabelService } from './extHostLabelService.js'; import { ExtHostLanguageFeatures } from './extHostLanguageFeatures.js'; @@ -238,6 +239,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostEmbeddings = rpcProtocol.set(ExtHostContext.ExtHostEmbeddings, new ExtHostEmbeddings(rpcProtocol)); rpcProtocol.set(ExtHostContext.ExtHostMcp, accessor.get(IExtHostMpcService)); + rpcProtocol.set(ExtHostContext.ExtHostHooks, accessor.get(IExtHostHooks)); // Check that no named customers are missing const expected = Object.values>(ExtHostContext); @@ -249,6 +251,7 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I const extHostMessageService = new ExtHostMessageService(rpcProtocol, extHostLogService); const extHostDialogs = new ExtHostDialogs(rpcProtocol); const extHostChatStatus = new ExtHostChatStatus(rpcProtocol); + const extHostHooks = accessor.get(IExtHostHooks); // Register API-ish commands ExtHostApiCommands.register(extHostCommands); @@ -1591,6 +1594,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatPromptFiles'); return extHostChatAgents2.registerPromptFileProvider(extension, PromptsType.skill, provider); }, + async executeHook(hookType: vscode.ChatHookType, options: vscode.ChatHookExecutionOptions, token?: vscode.CancellationToken): Promise { + checkProposedApiEnabled(extension, 'chatHooks'); + return extHostHooks.executeHook(hookType, options, token); + }, }; // namespace: lm @@ -2013,7 +2020,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I McpToolAvailability: extHostTypes.McpToolAvailability, McpToolInvocationContentData: extHostTypes.McpToolInvocationContentData, SettingsSearchResultKind: extHostTypes.SettingsSearchResultKind, - ChatTodoStatus: extHostTypes.ChatTodoStatus + ChatHookResultKind: extHostTypes.ChatHookResultKind, + ChatTodoStatus: extHostTypes.ChatTodoStatus, }; }; } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 34f6cb4649a..cb91ae6e736 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -99,6 +99,8 @@ import { IExtHostDocumentSaveDelegate } from './extHostDocumentData.js'; import { TerminalShellExecutionCommandLineConfidence } from './extHostTypes.js'; import * as tasks from './shared/tasks.js'; import { PromptsType } from '../../contrib/chat/common/promptSyntax/promptTypes.js'; +import { IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; +import { IHookCommand } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; export type IconPathDto = | UriComponents @@ -3197,7 +3199,11 @@ export interface IStartMcpOptions { errorOnUserInteraction?: boolean; } +export type IHookCommandDto = Dto; +export interface ExtHostHooksShape { + $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise; +} export interface ExtHostMcpShape { $substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise; @@ -3252,6 +3258,10 @@ export interface MainThreadMcpShape { export interface MainThreadDataChannelsShape extends IDisposable { } +export interface MainThreadHooksShape extends IDisposable { + $executeHook(hookType: string, sessionResource: UriComponents, input: unknown, token: CancellationToken): Promise; +} + export interface ExtHostDataChannelsShape { $onDidReceiveData(channelId: string, data: unknown): void; } @@ -3485,6 +3495,7 @@ export const MainContext = { MainThreadChatStatus: createProxyIdentifier('MainThreadChatStatus'), MainThreadAiSettingsSearch: createProxyIdentifier('MainThreadAiSettingsSearch'), MainThreadDataChannels: createProxyIdentifier('MainThreadDataChannels'), + MainThreadHooks: createProxyIdentifier('MainThreadHooks'), MainThreadChatSessions: createProxyIdentifier('MainThreadChatSessions'), MainThreadChatOutputRenderer: createProxyIdentifier('MainThreadChatOutputRenderer'), MainThreadChatContext: createProxyIdentifier('MainThreadChatContext'), @@ -3562,6 +3573,7 @@ export const ExtHostContext = { ExtHostTelemetry: createProxyIdentifier('ExtHostTelemetry'), ExtHostLocalization: createProxyIdentifier('ExtHostLocalization'), ExtHostMcp: createProxyIdentifier('ExtHostMcp'), + ExtHostHooks: createProxyIdentifier('ExtHostHooks'), ExtHostDataChannels: createProxyIdentifier('ExtHostDataChannels'), ExtHostChatSessions: createProxyIdentifier('ExtHostChatSessions'), }; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 2be0167e401..c86cd00bc3e 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -22,6 +22,7 @@ import { ILogService } from '../../../platform/log/common/log.js'; import { isChatViewTitleActionContext } from '../../contrib/chat/common/actions/chatActions.js'; import { IChatAgentRequest, IChatAgentResult, IChatAgentResultTimings, UserSelectedTools } from '../../contrib/chat/common/participants/chatAgents.js'; import { ChatAgentVoteDirection, IChatContentReference, IChatFollowup, IChatResponseErrorDetails, IChatUserActionEvent, IChatVoteAction } from '../../contrib/chat/common/chatService/chatService.js'; +import { IChatRequestHooks } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; @@ -447,6 +448,7 @@ interface InFlightChatRequest { requestId: string; extRequest: vscode.ChatRequest; extension: IRelaxedExtensionDescription; + hooks?: IChatRequestHooks; } export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsShape2 { @@ -623,7 +625,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return detector.provider.provideParticipantDetection( extRequest, - { history }, + { history, yieldRequested: false }, { participants: options.participants, location: typeConvert.ChatLocation.to(options.location) }, token ); @@ -715,7 +717,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS agent.extension, this._logService ); - inFlightRequest = { requestId: requestDto.requestId, extRequest, extension: agent.extension }; + inFlightRequest = { requestId: requestDto.requestId, extRequest, extension: agent.extension, hooks: request.hooks }; this._inFlightRequests.add(inFlightRequest); @@ -731,7 +733,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS }; } - const chatContext: vscode.ChatContext = { history, chatSessionContext }; + const chatContext: vscode.ChatContext = { history, chatSessionContext, yieldRequested: request.yieldRequested ?? false }; const task = agent.invoke( extRequest, chatContext, @@ -865,7 +867,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const convertedHistory = await this.prepareHistoryTurns(agent.extension, agent.id, context); const ehResult = typeConvert.ChatAgentResult.to(result); - return (await agent.provideFollowups(ehResult, { history: convertedHistory }, token)) + return (await agent.provideFollowups(ehResult, { history: convertedHistory, yieldRequested: false }, token)) .filter(f => { // The followup must refer to a participant that exists from the same extension const isValid = !f.participant || Iterable.some( @@ -965,7 +967,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } const history = await this.prepareHistoryTurns(agent.extension, agent.id, { history: context }); - return await agent.provideTitle({ history }, token); + return await agent.provideTitle({ history, yieldRequested: false }, token); } async $provideChatSummary(handle: number, context: IChatAgentHistoryEntryDto[], token: CancellationToken): Promise { @@ -975,7 +977,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS } const history = await this.prepareHistoryTurns(agent.extension, agent.id, { history: context }); - return await agent.provideSummary({ history }, token); + return await agent.provideSummary({ history, yieldRequested: false }, token); } } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 8ce6edc8ebc..e900c111d9e 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -661,7 +661,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio const chatRequest = typeConvert.ChatAgentRequest.to(request, undefined, await this.getModelForRequest(request, entry.sessionObj.extension), [], new Map(), entry.sessionObj.extension, this._logService); const stream = entry.sessionObj.getActiveRequestStream(request); - await entry.sessionObj.session.requestHandler(chatRequest, { history: history }, stream.apiObject, token); + await entry.sessionObj.session.requestHandler(chatRequest, { history, yieldRequested: false }, stream.apiObject, token); // TODO: do we need to dispose the stream object? return {}; diff --git a/src/vs/workbench/api/common/extHostHooks.ts b/src/vs/workbench/api/common/extHostHooks.ts new file mode 100644 index 00000000000..d03d803c47c --- /dev/null +++ b/src/vs/workbench/api/common/extHostHooks.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { createDecorator } from '../../../platform/instantiation/common/instantiation.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { ExtHostHooksShape } from './extHost.protocol.js'; + +export const IExtHostHooks = createDecorator('IExtHostHooks'); + +export interface IChatHookExecutionOptions { + readonly input?: unknown; + readonly toolInvocationToken: unknown; +} + +export interface IExtHostHooks extends ExtHostHooksShape { + executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise; +} diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 24451432ff5..316a4e5606f 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -45,6 +45,7 @@ import { IChatAgentMarkdownContentWithVulnerability, IChatCodeCitation, IChatCom import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImageVariableEntry, isPromptFileVariableEntry, isPromptTextVariableEntry } from '../../contrib/chat/common/attachments/chatVariableEntries.js'; import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; +import { HookResultKind, IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; import { IToolInvocationContext, IToolResult, IToolResultInputOutputDetails, IToolResultOutputDetails, ToolDataSource, ToolInvocationPresentation } from '../../contrib/chat/common/tools/languageModelToolsService.js'; import * as chatProvider from '../../contrib/chat/common/languageModels.js'; import { IChatMessageDataPart, IChatResponseDataPart, IChatResponsePromptTsxPart, IChatResponseTextPart } from '../../contrib/chat/common/languageModels.js'; @@ -3999,3 +4000,14 @@ export namespace SourceControlInputBoxValidationType { } } } + +export namespace ChatHookResult { + export function to(result: IHookResult): vscode.ChatHookResult { + return { + kind: result.kind === HookResultKind.Success + ? types.ChatHookResultKind.Success + : types.ChatHookResultKind.Error, + result: result.result + }; + } +} diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index a0a9ed3a0a5..d927b08e96e 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3935,6 +3935,15 @@ export enum SettingsSearchResultKind { //#endregion +//#region Chat Hooks + +export enum ChatHookResultKind { + Success = 1, + Error = 2 +} + +//#endregion + //#region Speech export enum SpeechToTextStatus { diff --git a/src/vs/workbench/api/node/extHost.node.services.ts b/src/vs/workbench/api/node/extHost.node.services.ts index 55acd8bd9c1..5f52766f40a 100644 --- a/src/vs/workbench/api/node/extHost.node.services.ts +++ b/src/vs/workbench/api/node/extHost.node.services.ts @@ -31,6 +31,8 @@ import { IExtHostMpcService } from '../common/extHostMcp.js'; import { NodeExtHostMpcService } from './extHostMcpNode.js'; import { IExtHostAuthentication } from '../common/extHostAuthentication.js'; import { NodeExtHostAuthentication } from './extHostAuthentication.js'; +import { IExtHostHooks } from '../common/extHostHooks.js'; +import { NodeExtHostHooks } from './extHostHooksNode.js'; // ######################################################################### // ### ### @@ -53,3 +55,4 @@ registerSingleton(IExtHostTerminalService, ExtHostTerminalService, Instantiation registerSingleton(IExtHostTunnelService, NodeExtHostTunnelService, InstantiationType.Eager); registerSingleton(IExtHostVariableResolverProvider, NodeExtHostVariableResolverProviderService, InstantiationType.Eager); registerSingleton(IExtHostMpcService, NodeExtHostMpcService, InstantiationType.Eager); +registerSingleton(IExtHostHooks, NodeExtHostHooks, InstantiationType.Eager); diff --git a/src/vs/workbench/api/node/extHostHooksNode.ts b/src/vs/workbench/api/node/extHostHooksNode.ts new file mode 100644 index 00000000000..db4d738600a --- /dev/null +++ b/src/vs/workbench/api/node/extHostHooksNode.ts @@ -0,0 +1,173 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { spawn } from 'child_process'; +import { homedir } from 'os'; +import { disposableTimeout } from '../../../base/common/async.js'; +import { CancellationToken } from '../../../base/common/cancellation.js'; +import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { URI } from '../../../base/common/uri.js'; +import { ILogService } from '../../../platform/log/common/log.js'; +import { HookTypeValue } from '../../contrib/chat/common/promptSyntax/hookSchema.js'; +import { isToolInvocationContext, IToolInvocationContext } from '../../contrib/chat/common/tools/languageModelToolsService.js'; +import { IHookCommandDto, MainContext, MainThreadHooksShape } from '../common/extHost.protocol.js'; +import { IChatHookExecutionOptions, IExtHostHooks } from '../common/extHostHooks.js'; +import { IExtHostRpcService } from '../common/extHostRpcService.js'; +import { HookResultKind, IHookResult } from '../../contrib/chat/common/hooksExecutionService.js'; +import * as typeConverters from '../common/extHostTypeConverters.js'; + +const SIGKILL_DELAY_MS = 5000; + +export class NodeExtHostHooks implements IExtHostHooks { + + private readonly _mainThreadProxy: MainThreadHooksShape; + + constructor( + @IExtHostRpcService extHostRpc: IExtHostRpcService, + @ILogService private readonly _logService: ILogService + ) { + this._mainThreadProxy = extHostRpc.getProxy(MainContext.MainThreadHooks); + } + + async executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise { + if (!options.toolInvocationToken || !isToolInvocationContext(options.toolInvocationToken)) { + throw new Error('Invalid or missing tool invocation token'); + } + + const context = options.toolInvocationToken as IToolInvocationContext; + + const results = await this._mainThreadProxy.$executeHook(hookType, context.sessionResource, options.input, token ?? CancellationToken.None); + return results.map(r => typeConverters.ChatHookResult.to({ + kind: r.kind as HookResultKind, + result: r.result + })); + } + + async $runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise { + this._logService.debug(`[ExtHostHooks] Running hook command: ${JSON.stringify(hookCommand)}`); + + try { + return await this._executeCommand(hookCommand, input, token); + } catch (err) { + return { + kind: HookResultKind.Error, + result: err instanceof Error ? err.message : String(err) + }; + } + } + + private _executeCommand(hook: IHookCommandDto, input: unknown, token?: CancellationToken): Promise { + const home = homedir(); + const cwdUri = hook.cwd ? URI.revive(hook.cwd) : undefined; + const cwd = cwdUri ? cwdUri.fsPath : home; + + // Determine command and args based on which property is specified + // For bash/powershell: spawn the shell directly with explicit args to avoid double shell wrapping + // For generic command: use shell=true to let the system shell handle it + let command: string; + let args: string[]; + let shell: boolean; + if (hook.bash) { + command = 'bash'; + args = ['-c', hook.bash]; + shell = false; + } else if (hook.powershell) { + command = 'powershell'; + args = ['-Command', hook.powershell]; + shell = false; + } else { + command = hook.command!; + args = []; + shell = true; + } + + const child = spawn(command, args, { + stdio: 'pipe', + cwd, + env: { ...process.env, ...hook.env }, + shell, + }); + + return new Promise((resolve, reject) => { + const stdout: string[] = []; + const stderr: string[] = []; + let exitCode: number | null = null; + let exited = false; + + const disposables = new DisposableStore(); + const sigkillTimeout = disposables.add(new MutableDisposable()); + + const killWithEscalation = () => { + if (exited) { + return; + } + child.kill('SIGTERM'); + sigkillTimeout.value = disposableTimeout(() => { + if (!exited) { + child.kill('SIGKILL'); + } + }, SIGKILL_DELAY_MS); + }; + + const cleanup = () => { + exited = true; + disposables.dispose(); + }; + + // Collect output + child.stdout.on('data', data => stdout.push(data.toString())); + child.stderr.on('data', data => stderr.push(data.toString())); + + // Set up timeout (default 30 seconds) + disposables.add(disposableTimeout(killWithEscalation, (hook.timeoutSec ?? 30) * 1000)); + + // Set up cancellation + if (token) { + disposables.add(token.onCancellationRequested(killWithEscalation)); + } + + // Write input to stdin + if (input !== undefined && input !== null) { + try { + child.stdin.write(JSON.stringify(input)); + } catch { + // Ignore stdin write errors + } + } + child.stdin.end(); + + // Capture exit code + child.on('exit', code => { exitCode = code; }); + + // Resolve on close (after streams flush) + child.on('close', () => { + cleanup(); + const code = exitCode ?? 1; + const stdoutStr = stdout.join(''); + const stderrStr = stderr.join(''); + + if (code === 0) { + // Success - try to parse stdout as JSON, otherwise return as string + let result: string | object = stdoutStr; + try { + result = JSON.parse(stdoutStr); + } catch { + // Keep as string if not valid JSON + } + resolve({ kind: HookResultKind.Success, result }); + } else { + // Error + resolve({ kind: HookResultKind.Error, result: stderrStr }); + } + }); + + child.on('error', err => { + cleanup(); + reject(err); + }); + }); + } +} diff --git a/src/vs/workbench/api/node/proxyResolver.ts b/src/vs/workbench/api/node/proxyResolver.ts index 8ee357c7ee5..48be8437039 100644 --- a/src/vs/workbench/api/node/proxyResolver.ts +++ b/src/vs/workbench/api/node/proxyResolver.ts @@ -424,6 +424,7 @@ async function lookupProxyAuthorization( proxyAuthenticate: string | string[] | undefined, state: { kerberosRequested?: boolean; basicAuthCacheUsed?: boolean; basicAuthAttempt?: number } ): Promise { + proxyURL = proxyURL.replace(/\/+$/, ''); const cached = proxyAuthenticateCache[proxyURL]; if (proxyAuthenticate) { proxyAuthenticateCache[proxyURL] = proxyAuthenticate; diff --git a/src/vs/workbench/api/test/node/extHostHooks.test.ts b/src/vs/workbench/api/test/node/extHostHooks.test.ts new file mode 100644 index 00000000000..0ed5021c079 --- /dev/null +++ b/src/vs/workbench/api/test/node/extHostHooks.test.ts @@ -0,0 +1,119 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../platform/log/common/log.js'; +import { NodeExtHostHooks } from '../../node/extHostHooksNode.js'; +import { IHookCommandDto, MainThreadHooksShape } from '../../common/extHost.protocol.js'; +import { IHookResult, HookResultKind } from '../../../contrib/chat/common/hooksExecutionService.js'; +import { IExtHostRpcService } from '../../common/extHostRpcService.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; + +function createHookCommandDto(command: string, options?: Partial>): IHookCommandDto { + return { + type: 'command', + command, + ...options, + }; +} + +function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService { + return { + _serviceBrand: undefined, + getProxy(): T { + return mainThreadProxy as unknown as T; + }, + set(_identifier: unknown, instance: R): R { + return instance; + }, + dispose(): void { }, + assertRegistered(): void { }, + drain(): Promise { return Promise.resolve(); }, + } as IExtHostRpcService; +} + +suite.skip('ExtHostHooks', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + let hooksService: NodeExtHostHooks; + + setup(() => { + const mockMainThreadProxy: MainThreadHooksShape = { + $executeHook: async (): Promise => { + return []; + }, + dispose: () => { } + }; + + const mockRpcService = createMockExtHostRpcService(mockMainThreadProxy); + hooksService = new NodeExtHostHooks(mockRpcService, new NullLogService()); + }); + + test('$runHookCommand runs command and returns success result', async () => { + const hookCommand = createHookCommandDto('echo "hello world"'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Success); + assert.strictEqual((result.result as string).trim(), 'hello world'); + }); + + test('$runHookCommand parses JSON output', async () => { + const hookCommand = createHookCommandDto('echo \'{"key": "value"}\''); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Success); + assert.deepStrictEqual(result.result, { key: 'value' }); + }); + + test('$runHookCommand returns error result for non-zero exit code', async () => { + const hookCommand = createHookCommandDto('exit 1'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Error); + }); + + test('$runHookCommand captures stderr on failure', async () => { + const hookCommand = createHookCommandDto('echo "error message" >&2 && exit 1'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Error); + assert.strictEqual((result.result as string).trim(), 'error message'); + }); + + test('$runHookCommand passes input to stdin as JSON', async () => { + const hookCommand = createHookCommandDto('cat'); + const input = { tool: 'bash', args: { command: 'ls' } }; + const result = await hooksService.$runHookCommand(hookCommand, input, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Success); + assert.deepStrictEqual(result.result, input); + }); + + test('$runHookCommand returns error for invalid command', async () => { + const hookCommand = createHookCommandDto('/nonexistent/command/that/does/not/exist'); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Error); + }); + + test('$runHookCommand uses custom environment variables', async () => { + const hookCommand = createHookCommandDto('echo $MY_VAR', { env: { MY_VAR: 'custom_value' } }); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Success); + assert.strictEqual((result.result as string).trim(), 'custom_value'); + }); + + test('$runHookCommand uses custom cwd', async () => { + const hookCommand = createHookCommandDto('pwd', { cwd: URI.file('/tmp') }); + const result = await hooksService.$runHookCommand(hookCommand, undefined, CancellationToken.None); + + assert.strictEqual(result.kind, HookResultKind.Success); + // The result should contain /tmp or /private/tmp (macOS symlink) + assert.ok((result.result as string).includes('tmp')); + }); +}); diff --git a/src/vs/workbench/browser/parts/compositeBarActions.ts b/src/vs/workbench/browser/parts/compositeBarActions.ts index 337ec9a0d4e..fe7bce2f083 100644 --- a/src/vs/workbench/browser/parts/compositeBarActions.ts +++ b/src/vs/workbench/browser/parts/compositeBarActions.ts @@ -237,11 +237,11 @@ export class CompositeBarActionViewItem extends BaseActionViewItem { this.container.classList.add('icon'); } + // Use 'tab' inside tablist, 'button' for popup items outside tablist + const role = this.options.isTabList || !this.options.hasPopup ? 'tab' : 'button'; + this.container.setAttribute('role', role); if (this.options.hasPopup) { - this.container.setAttribute('role', 'button'); this.container.setAttribute('aria-haspopup', 'true'); - } else { - this.container.setAttribute('role', 'tab'); } // Try hard to prevent keyboard only focus feedback when using mouse @@ -479,7 +479,7 @@ export class CompositeOverflowActivityActionViewItem extends CompositeBarActionV @IConfigurationService configurationService: IConfigurationService, @IKeybindingService keybindingService: IKeybindingService, ) { - super(action, { icon: true, colors, hasPopup: true, hoverOptions }, () => true, themeService, hoverService, configurationService, keybindingService); + super(action, { icon: true, colors, hasPopup: true, hoverOptions, isTabList: true }, () => true, themeService, hoverService, configurationService, keybindingService); } showMenu(): void { diff --git a/src/vs/workbench/contrib/browserView/common/browserView.ts b/src/vs/workbench/contrib/browserView/common/browserView.ts index a292a3a1ba2..732fa1974e4 100644 --- a/src/vs/workbench/contrib/browserView/common/browserView.ts +++ b/src/vs/workbench/contrib/browserView/common/browserView.ts @@ -118,6 +118,7 @@ export interface IBrowserViewModel extends IDisposable { focus(): Promise; findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise; stopFindInPage(keepSelection?: boolean): Promise; + getSelectedText(): Promise; clearStorage(): Promise; } @@ -336,6 +337,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel { return this.browserViewService.stopFindInPage(this.id, keepSelection); } + async getSelectedText(): Promise { + return this.browserViewService.getSelectedText(this.id); + } + async clearStorage(): Promise { return this.browserViewService.clearStorage(this.id); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts index 2d79adc5c8d..445507485ca 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserEditor.ts @@ -12,7 +12,7 @@ import { RawContextKey, IContextKey, IContextKeyService } from '../../../../plat import { MenuId } from '../../../../platform/actions/common/actions.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; -import { IEditorService } from '../../../services/editor/common/editorService.js'; +import { AUX_WINDOW_GROUP, IEditorService } from '../../../services/editor/common/editorService.js'; import { EditorPane } from '../../../browser/parts/editor/editorPane.js'; import { IEditorOpenContext } from '../../../common/editor.js'; import { BrowserEditorInput } from './browserEditorInput.js'; @@ -21,7 +21,7 @@ import { IBrowserViewModel } from '../../browserView/common/browserView.js'; import { IThemeService } from '../../../../platform/theme/common/themeService.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; -import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError } from '../../../../platform/browserView/common/browserView.js'; +import { IBrowserViewKeyDownEvent, IBrowserViewNavigationEvent, IBrowserViewLoadError, BrowserNewPageLocation } from '../../../../platform/browserView/common/browserView.js'; import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js'; import { IEditorOptions } from '../../../../platform/editor/common/editor.js'; import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; @@ -45,6 +45,7 @@ import { encodeBase64, VSBuffer } from '../../../../base/common/buffer.js'; import { IChatRequestVariableEntry } from '../../chat/common/attachments/chatVariableEntries.js'; import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../platform/browserElements/common/browserElements.js'; import { logBrowserOpen } from './browserViewTelemetry.js'; +import { URI } from '../../../../base/common/uri.js'; export const CONTEXT_BROWSER_CAN_GO_BACK = new RawContextKey('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back")); export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey('browserCanGoForward', false, localize('browser.canGoForward', "Whether the browser can go forward")); @@ -342,7 +343,11 @@ export class BrowserEditor extends EditorPane { this.setBackgroundImage(this._model.screenshot); if (context.newInGroup) { - this.focusUrlInput(); + if (this._model.url) { + this._browserContainer.focus(); + } else { + this.focusUrlInput(); + } } // Start / stop screenshots when the model visibility changes @@ -378,18 +383,27 @@ export class BrowserEditor extends EditorPane { this._devToolsOpenContext.set(e.isDevToolsOpen); })); - this._inputDisposables.add(this._model.onDidRequestNewPage(({ url, name, background }) => { - logBrowserOpen(this.telemetryService, background ? 'browserLinkBackground' : 'browserLinkForeground'); + this._inputDisposables.add(this._model.onDidRequestNewPage(({ resource, location, position }) => { + logBrowserOpen(this.telemetryService, (() => { + switch (location) { + case BrowserNewPageLocation.Background: return 'browserLinkBackground'; + case BrowserNewPageLocation.Foreground: return 'browserLinkForeground'; + case BrowserNewPageLocation.NewWindow: return 'browserLinkNewWindow'; + } + })()); - // Open a new browser tab for the requested URL - const browserUri = BrowserViewUri.forUrl(url, name ? `${input.id}-${name}` : undefined); + const targetGroup = location === BrowserNewPageLocation.NewWindow ? AUX_WINDOW_GROUP : this.group; this.editorService.openEditor({ - resource: browserUri, + resource: URI.from(resource), options: { pinned: true, - inactive: background + inactive: location === BrowserNewPageLocation.Background, + auxiliary: { + bounds: position, + compact: true + } } - }, this.group); + }, targetGroup); })); this._inputDisposables.add(this.overlayManager!.onDidChangeOverlayState(() => { @@ -586,10 +600,15 @@ export class BrowserEditor extends EditorPane { } /** - * Show the find widget + * Show the find widget, optionally pre-populated with selected text from the browser view */ - showFind(): void { - this._findWidget.value.reveal(); + async showFind(): Promise { + // Get selected text from the browser view to pre-populate the search box. + const selectedText = await this._model?.getSelectedText(); + + // Only use the selected text if it doesn't contain newlines (single line selection) + const textToReveal = selectedText && !/[\r\n]/.test(selectedText) ? selectedText : undefined; + this._findWidget.value.reveal(textToReveal); this._findWidget.value.layout(this._findWidgetContainer.clientWidth); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts index b17981d752b..201d199804b 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewActions.ts @@ -66,14 +66,14 @@ class NewTabAction extends Action2 { title: localize2('browser.newTabAction', "New Tab"), category: BrowserCategory, f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupTabs, order: 1, }, + // When already in a browser, Ctrl/Cmd + T opens a new tab keybinding: { - // When already in a browser, Ctrl/Cmd + T opens a new tab - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over search actions primary: KeyMod.CtrlCmd | KeyCode.KeyT, } @@ -100,15 +100,14 @@ class GoBackAction extends Action2 { title: localize2('browser.goBackAction', 'Go Back'), category: BrowserCategory, icon: Codicon.arrowLeft, - f1: false, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_BACK), menu: { id: MenuId.BrowserNavigationToolbar, group: 'navigation', order: 1, }, - precondition: CONTEXT_BROWSER_CAN_GO_BACK, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over editor navigation primary: KeyMod.Alt | KeyCode.LeftArrow, secondary: [KeyCode.BrowserBack], @@ -133,16 +132,15 @@ class GoForwardAction extends Action2 { title: localize2('browser.goForwardAction', 'Go Forward'), category: BrowserCategory, icon: Codicon.arrowRight, - f1: false, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_CAN_GO_FORWARD), menu: { id: MenuId.BrowserNavigationToolbar, group: 'navigation', order: 2, when: CONTEXT_BROWSER_CAN_GO_FORWARD }, - precondition: CONTEXT_BROWSER_CAN_GO_FORWARD, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over editor navigation primary: KeyMod.Alt | KeyCode.RightArrow, secondary: [KeyCode.BrowserForward], @@ -167,7 +165,8 @@ class ReloadAction extends Action2 { title: localize2('browser.reloadAction', 'Reload'), category: BrowserCategory, icon: Codicon.refresh, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserNavigationToolbar, group: 'navigation', @@ -198,9 +197,9 @@ class FocusUrlInputAction extends Action2 { id: FocusUrlInputAction.ID, title: localize2('browser.focusUrlInputAction', 'Focus URL Input'), category: BrowserCategory, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyL, } @@ -222,9 +221,10 @@ class AddElementToChatAction extends Action2 { super({ id: AddElementToChatAction.ID, title: localize2('browser.addElementToChatAction', 'Add Element to Chat'), + category: BrowserCategory, icon: Codicon.inspect, - f1: false, - precondition: enabled, + f1: true, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, enabled), toggled: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, @@ -233,11 +233,10 @@ class AddElementToChatAction extends Action2 { when: enabled }, keybinding: [{ - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib + 50, // Priority over terminal primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyC, }, { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE), + when: CONTEXT_BROWSER_ELEMENT_SELECTION_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.Escape }] @@ -260,7 +259,8 @@ class ToggleDevToolsAction extends Action2 { title: localize2('browser.toggleDevToolsAction', 'Toggle Developer Tools'), category: BrowserCategory, icon: Codicon.console, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, toggled: ContextKeyExpr.equals(CONTEXT_BROWSER_DEVTOOLS_OPEN.key, true), menu: { id: MenuId.BrowserActionsToolbar, @@ -268,7 +268,6 @@ class ToggleDevToolsAction extends Action2 { order: 5, }, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.WorkbenchContrib, primary: KeyCode.F12 } @@ -291,7 +290,8 @@ class OpenInExternalBrowserAction extends Action2 { title: localize2('browser.openExternalAction', 'Open in External Browser'), category: BrowserCategory, icon: Codicon.linkExternal, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupPage, @@ -371,7 +371,7 @@ class ClearEphemeralBrowserStorageAction extends Action2 { category: BrowserCategory, icon: Codicon.clearAll, f1: true, - precondition: BROWSER_EDITOR_ACTIVE, + precondition: ContextKeyExpr.equals(CONTEXT_BROWSER_STORAGE_SCOPE.key, BrowserViewStorageScope.Ephemeral), menu: { id: MenuId.BrowserActionsToolbar, group: '3_settings', @@ -422,14 +422,14 @@ class ShowBrowserFindAction extends Action2 { id: ShowBrowserFindAction.ID, title: localize2('browser.showFindAction', 'Find in Page'), category: BrowserCategory, - f1: false, + f1: true, + precondition: BROWSER_EDITOR_ACTIVE, menu: { id: MenuId.BrowserActionsToolbar, group: ActionGroupPage, order: 1, }, keybinding: { - when: BROWSER_EDITOR_ACTIVE, weight: KeybindingWeight.EditorContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyF } @@ -452,8 +452,8 @@ class HideBrowserFindAction extends Action2 { title: localize2('browser.hideFindAction', 'Close Find Widget'), category: BrowserCategory, f1: false, + precondition: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), keybinding: { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), weight: KeybindingWeight.EditorContrib + 5, primary: KeyCode.Escape } @@ -477,12 +477,13 @@ class BrowserFindNextAction extends Action2 { title: localize2('browser.findNextAction', 'Find Next'), category: BrowserCategory, f1: false, + precondition: BROWSER_EDITOR_ACTIVE, keybinding: [{ - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED), + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, weight: KeybindingWeight.EditorContrib, primary: KeyCode.Enter }, { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, weight: KeybindingWeight.EditorContrib, primary: KeyCode.F3, mac: { primary: KeyMod.CtrlCmd | KeyCode.KeyG } @@ -507,12 +508,13 @@ class BrowserFindPreviousAction extends Action2 { title: localize2('browser.findPreviousAction', 'Find Previous'), category: BrowserCategory, f1: false, + precondition: BROWSER_EDITOR_ACTIVE, keybinding: [{ - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_FOCUSED), + when: CONTEXT_BROWSER_FIND_WIDGET_FOCUSED, weight: KeybindingWeight.EditorContrib, primary: KeyMod.Shift | KeyCode.Enter }, { - when: ContextKeyExpr.and(BROWSER_EDITOR_ACTIVE, CONTEXT_BROWSER_FIND_WIDGET_VISIBLE), + when: CONTEXT_BROWSER_FIND_WIDGET_VISIBLE, weight: KeybindingWeight.EditorContrib, primary: KeyMod.Shift | KeyCode.F3, mac: { primary: KeyMod.CtrlCmd | KeyMod.Shift | KeyCode.KeyG } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts index b5792e924d0..3f6a4f848f3 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/browserViewTelemetry.ts @@ -20,10 +20,12 @@ import { ITelemetryService } from '../../../../platform/telemetry/common/telemet * opens in a new focused editor (e.g., links with target="_blank"). * - `'browserLinkBackground'`: opened when clicking a link inside the Integrated Browser that * opens in a new background editor (e.g., Ctrl/Cmd+click). + * - `'browserLinkNewWindow'`: opened when clicking a link inside the Integrated Browser that + * opens in a new window (e.g., Shift+click). * - `'copyToNewWindow'`: opened when the user copies a browser editor to a new window * via "Copy into New Window". */ -export type IntegratedBrowserOpenSource = 'commandWithoutUrl' | 'commandWithUrl' | 'newTabCommand' | 'localhostLinkOpener' | 'browserLinkForeground' | 'browserLinkBackground' | 'copyToNewWindow'; +export type IntegratedBrowserOpenSource = 'commandWithoutUrl' | 'commandWithUrl' | 'newTabCommand' | 'localhostLinkOpener' | 'browserLinkForeground' | 'browserLinkBackground' | 'browserLinkNewWindow' | 'copyToNewWindow'; type IntegratedBrowserOpenEvent = { source: IntegratedBrowserOpenSource; diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts index 35f0fbfdbd8..f5d589353f4 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatContinueInAction.ts @@ -32,7 +32,7 @@ import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { chatEditingWidgetFileStateContextKey, ModifiedFileEntryState } from '../../common/editing/chatEditingService.js'; import { ChatModel } from '../../common/model/chatModel.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; -import { IChatService } from '../../common/chatService/chatService.js'; +import { ChatSendResult, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsExtensionPoint, IChatSessionsService } from '../../common/chatSessionsService.js'; import { ChatAgentLocation } from '../../common/constants.js'; import { PROMPT_LANGUAGE_ID } from '../../common/promptSyntax/promptTypes.js'; @@ -289,15 +289,15 @@ export class CreateRemoteAgentJobAction { ); await chatService.removeRequest(sessionResource, addedRequest.id); - const requestData = await chatService.sendRequest(sessionResource, userPrompt, { + const sendResult = await chatService.sendRequest(sessionResource, userPrompt, { agentIdSilent: continuationTargetType, attachedContext: attachedContext.asArray(), userSelectedModelId: widget.input.currentLanguageModel, ...widget.getModeRequestOptions() }); - if (requestData) { - await widget.handleDelegationExitIfNeeded(defaultAgent, requestData.agent); + if (ChatSendResult.isSent(sendResult)) { + await widget.handleDelegationExitIfNeeded(defaultAgent, sendResult.data.agent); } } catch (e) { console.error('Error creating remote coding agent job', e); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts index fb757671e5c..e0647b11c4a 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCopyActions.ts @@ -33,7 +33,7 @@ export function registerChatCopyActions() { run(accessor: ServicesAccessor, context?: ChatTreeItem) { const clipboardService = accessor.get(IClipboardService); const chatWidgetService = accessor.get(IChatWidgetService); - const widget = (context?.sessionResource && chatWidgetService.getWidgetBySessionResource(context.sessionResource)) || chatWidgetService.lastFocusedWidget; + const widget = ((isRequestVM(context) || isResponseVM(context)) && chatWidgetService.getWidgetBySessionResource(context.sessionResource)) || chatWidgetService.lastFocusedWidget; if (widget) { const viewModel = widget.viewModel; const sessionAsText = viewModel?.getItems() @@ -84,6 +84,10 @@ export function registerChatCopyActions() { return; } + if (!isRequestVM(item) && !isResponseVM(item)) { + return; + } + const text = stringifyItem(item, false); await clipboardService.writeText(text); } diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts index 3e4f7655a1e..7605b5b69fe 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatExecuteActions.ts @@ -822,7 +822,8 @@ export class CancelAction extends Action2 { id: MenuId.ChatExecute, when: ContextKeyExpr.and( ChatContextKeys.requestInProgress, - ChatContextKeys.remoteJobCreating.negate() + ChatContextKeys.remoteJobCreating.negate(), + ChatContextKeys.currentlyEditing.negate(), ), order: 4, group: 'navigation', diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts new file mode 100644 index 00000000000..bac6879be0d --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/chatQueueActions.ts @@ -0,0 +1,295 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { KeyCode, KeyMod } from '../../../../../base/common/keyCodes.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Action2, MenuId, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { KeybindingWeight } from '../../../../../platform/keybinding/common/keybindingsRegistry.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ChatRequestQueueKind, IChatService } from '../../common/chatService/chatService.js'; +import { ChatConfiguration } from '../../common/constants.js'; +import { isRequestVM } from '../../common/model/chatViewModel.js'; +import { IChatWidgetService } from '../chat.js'; +import { CHAT_CATEGORY } from './chatActions.js'; + +const queueingEnabledCondition = ContextKeyExpr.equals(`config.${ChatConfiguration.RequestQueueingEnabled}`, true); + +export interface IChatRemovePendingRequestContext { + sessionResource: URI; + pendingRequestId: string; +} + +function isRemovePendingRequestContext(context: unknown): context is IChatRemovePendingRequestContext { + return !!context && + typeof context === 'object' && + 'sessionResource' in context && + 'pendingRequestId' in context && + URI.isUri((context as IChatRemovePendingRequestContext).sessionResource) && + typeof (context as IChatRemovePendingRequestContext).pendingRequestId === 'string'; +} + +export class ChatQueueMessageAction extends Action2 { + static readonly ID = 'workbench.action.chat.queueMessage'; + + constructor() { + super({ + id: ChatQueueMessageAction.ID, + title: localize2('chat.queueMessage', "Add to Queue"), + tooltip: localize('chat.queueMessage.tooltip', "Queue this message to send after the current request completes"), + icon: Codicon.add, + f1: false, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.requestInProgress, + ChatContextKeys.inputHasText + ), + keybinding: { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + ChatContextKeys.requestInProgress, + queueingEnabledCondition + ), + primary: KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + 1 + }, + menu: [{ + id: MenuId.ChatExecuteQueue, + group: 'navigation', + order: 1, + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (!widget?.viewModel) { + return; + } + + const inputValue = widget.getInput(); + if (!inputValue.trim()) { + return; + } + + widget.acceptInput(undefined, { queue: ChatRequestQueueKind.Queued }); + } +} + +export class ChatSteerWithMessageAction extends Action2 { + static readonly ID = 'workbench.action.chat.steerWithMessage'; + + constructor() { + super({ + id: ChatSteerWithMessageAction.ID, + title: localize2('chat.steerWithMessage', "Steer with Message"), + tooltip: localize('chat.steerWithMessage.tooltip', "Send this message at the next opportunity, signaling the current request to yield"), + icon: Codicon.arrowRight, + f1: false, + category: CHAT_CATEGORY, + precondition: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.requestInProgress, + ChatContextKeys.inputHasText + ), + keybinding: { + when: ContextKeyExpr.and( + ChatContextKeys.inChatInput, + ChatContextKeys.requestInProgress, + queueingEnabledCondition + ), + primary: KeyMod.Alt | KeyCode.Enter, + weight: KeybindingWeight.EditorContrib + 1 + }, + menu: [{ + id: MenuId.ChatExecuteQueue, + group: 'navigation', + order: 2, + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const widgetService = accessor.get(IChatWidgetService); + const widget = widgetService.lastFocusedWidget; + if (!widget?.viewModel) { + return; + } + + const inputValue = widget.getInput(); + if (!inputValue.trim()) { + return; + } + + widget.acceptInput(undefined, { queue: ChatRequestQueueKind.Steering }); + } +} + +export class ChatRemovePendingRequestAction extends Action2 { + static readonly ID = 'workbench.action.chat.removePendingRequest'; + + constructor() { + super({ + id: ChatRemovePendingRequestAction.ID, + title: localize2('chat.removePendingRequest', "Remove from Queue"), + icon: Codicon.close, + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatMessageTitle, + group: 'navigation', + order: 4, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.isRequest, + ChatContextKeys.isPendingRequest + ) + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const chatService = accessor.get(IChatService); + const [context] = args; + + // Support both toolbar context (IChatRequestViewModel) and command context (IChatRemovePendingRequestContext) + if (isRequestVM(context) && context.pendingKind) { + chatService.removePendingRequest(context.sessionResource, context.id); + return; + } + + if (isRemovePendingRequestContext(context)) { + chatService.removePendingRequest(context.sessionResource, context.pendingRequestId); + return; + } + } +} + +export class ChatSendPendingImmediatelyAction extends Action2 { + static readonly ID = 'workbench.action.chat.sendPendingImmediately'; + + constructor() { + super({ + id: ChatSendPendingImmediatelyAction.ID, + title: localize2('chat.sendPendingImmediately', "Send Immediately"), + icon: Codicon.arrowUp, + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatMessageTitle, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.isRequest, + ChatContextKeys.isPendingRequest + ) + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const chatService = accessor.get(IChatService); + const widgetService = accessor.get(IChatWidgetService); + const [context] = args; + + if (!isRequestVM(context) || !context.pendingKind) { + return; + } + + const widget = widgetService.getWidgetBySessionResource(context.sessionResource); + const model = widget?.viewModel?.model; + if (!model) { + return; + } + + const pendingRequests = model.getPendingRequests(); + const targetIndex = pendingRequests.findIndex(r => r.request.id === context.id); + if (targetIndex === -1) { + return; + } + + // Keep the target item's kind (queued vs steering) + const targetRequest = pendingRequests[targetIndex]; + + // Reorder: move target to front, keep others in their relative order + const reordered = [ + { requestId: targetRequest.request.id, kind: targetRequest.kind }, + ...pendingRequests.filter((_, i) => i !== targetIndex).map(r => ({ requestId: r.request.id, kind: r.kind })) + ]; + + chatService.setPendingRequests(context.sessionResource, reordered); + chatService.cancelCurrentRequestForSession(context.sessionResource); + chatService.processPendingRequests(context.sessionResource); + } +} + +export class ChatRemoveAllPendingRequestsAction extends Action2 { + static readonly ID = 'workbench.action.chat.removeAllPendingRequests'; + + constructor() { + super({ + id: ChatRemoveAllPendingRequestsAction.ID, + title: localize2('chat.removeAllPendingRequests', "Remove All Queued"), + icon: Codicon.clearAll, + f1: false, + category: CHAT_CATEGORY, + menu: [{ + id: MenuId.ChatContext, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.hasPendingRequests + ) + }] + }); + } + + override run(accessor: ServicesAccessor, ...args: unknown[]): void { + const chatService = accessor.get(IChatService); + const widgetService = accessor.get(IChatWidgetService); + const [context] = args; + + const widget = (isRequestVM(context) && widgetService.getWidgetBySessionResource(context.sessionResource)) || widgetService.lastFocusedWidget; + const model = widget?.viewModel?.model; + if (!model) { + return; + } + + for (const pendingRequest of [...model.getPendingRequests()]) { + chatService.removePendingRequest(model.sessionResource, pendingRequest.request.id); + } + } +} + +export function registerChatQueueActions(): void { + registerAction2(ChatQueueMessageAction); + registerAction2(ChatSteerWithMessageAction); + registerAction2(ChatRemovePendingRequestAction); + registerAction2(ChatSendPendingImmediatelyAction); + registerAction2(ChatRemoveAllPendingRequestsAction); + + // Register the queue submenu as a split button dropdown in the execute toolbar + // This shows "Add to Queue" / "Steer with Message" when a request is in progress and input has text + MenuRegistry.appendMenuItem(MenuId.ChatExecute, { + submenu: MenuId.ChatExecuteQueue, + title: localize2('chat.queueSubmenu', "Queue"), + icon: Codicon.listOrdered, + when: ContextKeyExpr.and( + queueingEnabledCondition, + ChatContextKeys.requestInProgress, + ChatContextKeys.inputHasText + ), + group: 'navigation', + order: 4, + isSplitButton: { togglePrimaryAction: true } + }); +} diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts index a206e4e20f5..209ea059bcc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.ts @@ -22,7 +22,7 @@ export enum AgentSessionsGrouping { export interface IAgentSessionsFilterOptions extends Partial { - readonly filterMenuId: MenuId; + readonly filterMenuId?: MenuId; readonly limitResults?: () => number | undefined; notifyResults?(count: number): void; @@ -41,7 +41,7 @@ const DEFAULT_EXCLUDES: IAgentSessionsFilterExcludes = Object.freeze({ export class AgentSessionsFilter extends Disposable implements Required { - private readonly STORAGE_KEY: string; + private readonly STORAGE_KEY = `agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu`; private readonly _onDidChange = this._register(new Emitter()); readonly onDidChange = this._onDidChange.event; @@ -61,8 +61,6 @@ export class AgentSessionsFilter extends Disposable implements Required ({ id: provider, label: getAgentSessionProviderName(provider) @@ -143,10 +146,10 @@ export class AgentSessionsFilter extends Disposable implements Required this.resolve(provider))); + this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType }) => this.resolve(chatSessionType))); this._register(this.chatSessionsService.onDidChangeAvailability(() => this.resolve(undefined))); - this._register(this.chatSessionsService.onDidChangeSessionItems(provider => this.resolve(provider))); + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => this.resolve(chatSessionType))); // State this._register(this.storageService.onWillSaveState(() => { @@ -725,7 +726,7 @@ class AgentSessionsCache { metadata: session.metadata } satisfies ISerializedAgentSession)); - this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, JSON.stringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE); + this.storageService.store(AgentSessionsCache.SESSIONS_STORAGE_KEY, safeStringify(serialized), StorageScope.WORKSPACE, StorageTarget.MACHINE); } loadCachedSessions(): IInternalAgentSessionData[] { @@ -764,6 +765,7 @@ class AgentSessionsCache { insertions: change.insertions, deletions: change.deletions, })) : session.changes, + metadata: session.metadata, })); } catch { return []; // invalid data in storage, fallback to empty sessions list diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts index f1e0637de08..9251a44e9fe 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsPicker.ts @@ -16,6 +16,7 @@ import { IAgentSession, isLocalAgentSessionItem } from './agentSessionsModel.js' import { IAgentSessionsService } from './agentSessionsService.js'; import { AgentSessionsSorter, groupAgentSessionsByDate, sessionDateFromNow } from './agentSessionsViewer.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID, getAgentSessionTime } from './agentSessions.js'; +import { AgentSessionsFilter } from './agentSessionsFilter.js'; interface ISessionPickItem extends IQuickPickItem { readonly session: IAgentSession; @@ -75,8 +76,9 @@ export class AgentSessionsPicker { async pickAgentSession(): Promise { const disposables = new DisposableStore(); const picker = disposables.add(this.quickInputService.createQuickPick({ useSeparators: true })); + const filter = disposables.add(this.instantiationService.createInstance(AgentSessionsFilter, {})); - picker.items = this.createPickerItems(); + picker.items = this.createPickerItems(filter); picker.canAcceptInBackground = true; picker.placeholder = localize('chatAgentPickerPlaceholder', "Search agent sessions by name"); @@ -116,7 +118,7 @@ export class AgentSessionsPicker { await this.agentSessionsService.model.resolve(session.providerType); this.pickAgentSession(); } else { - picker.items = this.createPickerItems(); + picker.items = this.createPickerItems(filter); } })); @@ -124,8 +126,10 @@ export class AgentSessionsPicker { picker.show(); } - private createPickerItems(): (ISessionPickItem | IQuickPickSeparator)[] { - const sessions = this.agentSessionsService.model.sessions.sort(this.sorter.compare.bind(this.sorter)); + private createPickerItems(filter: AgentSessionsFilter): (ISessionPickItem | IQuickPickSeparator)[] { + const sessions = this.agentSessionsService.model.sessions + .filter(session => !filter.exclude(session)) + .sort(this.sorter.compare.bind(this.sorter)); const items: (ISessionPickItem | IQuickPickSeparator)[] = []; const groupedSessions = groupAgentSessionsByDate(sessions); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts index 4ad06c8d0e1..7ab8f018143 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsQuickAccess.ts @@ -16,12 +16,14 @@ import { openSession } from './agentSessionsOpener.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { AGENT_SESSION_DELETE_ACTION_ID, AGENT_SESSION_RENAME_ACTION_ID } from './agentSessions.js'; import { archiveButton, deleteButton, getSessionButtons, getSessionDescription, renameButton, unarchiveButton } from './agentSessionsPicker.js'; +import { AgentSessionsFilter } from './agentSessionsFilter.js'; export const AGENT_SESSIONS_QUICK_ACCESS_PREFIX = 'agent '; export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider { private readonly sorter = new AgentSessionsSorter(); + private readonly filter: AgentSessionsFilter; constructor( @IAgentSessionsService private readonly agentSessionsService: IAgentSessionsService, @@ -34,12 +36,16 @@ export class AgentSessionsQuickAccessProvider extends PickerQuickAccessProvider< label: localize('noAgentSessionResults', "No matching agent sessions") } }); + + this.filter = this._register(this.instantiationService.createInstance(AgentSessionsFilter, {})); } protected async _getPicks(filter: string): Promise<(IQuickPickSeparator | IPickerQuickAccessItem)[]> { const picks: Array = []; - const sessions = this.agentSessionsService.model.sessions.sort(this.sorter.compare.bind(this.sorter)); + const sessions = this.agentSessionsService.model.sessions + .filter(session => !this.filter.exclude(session)) + .sort(this.sorter.compare.bind(this.sorter)); const groupedSessions = groupAgentSessionsByDate(sessions); for (const group of groupedSessions.values()) { diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts index b4d2442497f..d44024fa6de 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/agentTitleBarStatusWidget.ts @@ -749,7 +749,7 @@ export class AgentTitleBarStatusWidget extends BaseActionViewItem { // Create dropdown action (empty label prevents default tooltip - we have our own hover) const dropdownAction = toAction({ id: 'agentStatus.sparkle.dropdown', - label: '', + label: localize('agentStatus.sparkle.dropdown', "More Actions"), run() { } }); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/unifiedQuickAccess.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/unifiedQuickAccess.ts index ca0c1961db4..91d2a01a067 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/unifiedQuickAccess.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/experiments/unifiedQuickAccess.ts @@ -89,6 +89,7 @@ export class UnifiedQuickAccess extends Disposable { private _tabBarContainer: HTMLElement | undefined; private _isInternalValueChange = false; // Flag to prevent recursive tab detection private _isUpdatingSendToAgent = false; // Guard to prevent infinite loop + private _arrivedViaShortcut: '<' | '>' | undefined; // Track if we arrived at current tab via shortcut key private _sendToAgentTimeout: ReturnType | undefined; private _sendButton: HTMLButtonElement | undefined; private _sendButtonLabel: HTMLSpanElement | undefined; @@ -158,6 +159,20 @@ export class UnifiedQuickAccess extends Disposable { if (this._isInternalValueChange) { return; } + + // Check if user removed the shortcut character (including when input is emptied) - switch back to Files + if (this._arrivedViaShortcut) { + const shortcut = this._arrivedViaShortcut; + if (!value.startsWith(shortcut)) { + const filesTab = this._tabs.find(t => t.id === 'files'); + if (filesTab && filesTab !== this._currentTab) { + this._arrivedViaShortcut = undefined; + this._switchTab(filesTab, picker, false); + return; + } + } + } + const matchingTab = this._detectTabFromValue(value); if (matchingTab && matchingTab !== this._currentTab) { this._switchTab(matchingTab, picker, true); @@ -185,10 +200,15 @@ export class UnifiedQuickAccess extends Disposable { (item as IQuickPickItem & { id?: string }).id !== SEND_TO_AGENT_ID ); - // Get the filter text - const filterText = this._currentTab - ? picker.value.substring(this._currentTab.prefix.length).trim() - : picker.value.trim(); + // Get the filter text (without prefix or shortcut character) + let filterText: string; + if (this._arrivedViaShortcut && picker.value.startsWith(this._arrivedViaShortcut)) { + filterText = picker.value.substring(1).trim(); + } else if (this._currentTab) { + filterText = picker.value.substring(this._currentTab.prefix.length).trim(); + } else { + filterText = picker.value.trim(); + } // Send to agent if: // 1. Send-to-agent item is explicitly selected, OR @@ -205,6 +225,7 @@ export class UnifiedQuickAccess extends Disposable { this._providerCts = undefined; this._currentPicker = undefined; this._currentTab = undefined; + this._arrivedViaShortcut = undefined; // Clear any pending timeout if (this._sendToAgentTimeout) { clearTimeout(this._sendToAgentTimeout); @@ -407,12 +428,17 @@ export class UnifiedQuickAccess extends Disposable { } /** - * Send the current message to a new agent session (strips prefix). + * Send the current message to a new agent session (strips prefix or shortcut character). */ private async _sendMessage(value: string): Promise { - // Strip any prefix from the value + // Strip any prefix or shortcut character from the value let message = value; - if (this._currentTab) { + + // First, strip shortcut character if we arrived via shortcut + if (this._arrivedViaShortcut && message.startsWith(this._arrivedViaShortcut)) { + message = message.substring(1).trim(); + } else if (this._currentTab) { + // Otherwise strip the normal prefix if (value.startsWith(this._currentTab.prefix)) { message = value.substring(this._currentTab.prefix.length).trim(); } @@ -446,10 +472,16 @@ export class UnifiedQuickAccess extends Disposable { return; } - // Get the filter text (without prefix) - const filterText = this._currentTab - ? picker.value.substring(this._currentTab.prefix.length).trim() - : picker.value.trim(); + // Get the filter text (without prefix or shortcut character) + let filterText: string; + if (this._arrivedViaShortcut && picker.value.startsWith(this._arrivedViaShortcut)) { + // Strip shortcut character + filterText = picker.value.substring(1).trim(); + } else if (this._currentTab) { + filterText = picker.value.substring(this._currentTab.prefix.length).trim(); + } else { + filterText = picker.value.trim(); + } // Use full input if filter text is empty but there's input (user typed without prefix) const fullInput = picker.value.trim(); @@ -529,16 +561,40 @@ export class UnifiedQuickAccess extends Disposable { // Update picker value (with flag to prevent recursive tab detection) this._isInternalValueChange = true; if (preserveFilterText && previousTab) { - // User typed a prefix - keep the filter text, just change prefix - const filterText = picker.value.substring(previousTab.prefix.length); - picker.value = tab.prefix + filterText; + // User typed a shortcut prefix - normalize the value to show just the shortcut character + const currentValue = picker.value; + + // Strip previous tab's prefix if present + let filterText = currentValue; + if (currentValue.startsWith(previousTab.prefix)) { + filterText = currentValue.substring(previousTab.prefix.length); + } + + // Handle shortcut transitions - ensure only one shortcut char is shown + if (this._arrivedViaShortcut === '<' && tab.id === 'agentSessions') { + // Strip any leading "<" chars and set just one + filterText = filterText.replace(/^<+/, ''); + picker.value = '<' + filterText; + } else if (this._arrivedViaShortcut === '>' && tab.id === 'commands') { + // Strip any leading ">" chars and set just one + filterText = filterText.replace(/^>+/, ''); + picker.value = '>' + filterText; + } else { + // Normal prefix-based switching + picker.value = tab.prefix + filterText; + } } else if (previousTab) { // User clicked tab - keep current text but strip old prefix (don't add new prefix) const currentValue = picker.value; if (currentValue.startsWith(previousTab.prefix)) { picker.value = currentValue.substring(previousTab.prefix.length); } - // else: keep current value as-is + // Also strip shortcut character if present + if (picker.value.startsWith('<') || picker.value.startsWith('>')) { + picker.value = picker.value.substring(1); + } + // Clear shortcut tracking when switching via click + this._arrivedViaShortcut = undefined; } // else: first tab activation, value already set this._isInternalValueChange = false; @@ -552,8 +608,27 @@ export class UnifiedQuickAccess extends Disposable { /** * Detect which tab matches the current value based on prefix. * Only switches away from current tab if user explicitly typed a different prefix. + * Supports shortcut keys: ">" for Commands, "<" for Sessions. */ private _detectTabFromValue(value: string): IUnifiedQuickAccessTab | undefined { + // Check for "<" shortcut to switch to Sessions (from Files or Commands) + if (value === '<' || value.startsWith('<')) { + const sessionsTab = this._tabs.find(t => t.id === 'agentSessions'); + if (sessionsTab && this._currentTab?.id !== 'agentSessions') { + this._arrivedViaShortcut = '<'; + return sessionsTab; + } + } + + // Check for ">" shortcut to switch to Commands (from Files or Sessions) + if (value === '>' || value.startsWith('>')) { + const commandsTab = this._tabs.find(t => t.id === 'commands'); + if (commandsTab && this._currentTab?.id !== 'commands') { + this._arrivedViaShortcut = '>'; + return commandsTab; + } + } + // Don't auto-switch if current tab matches (user is just typing) if (this._currentTab && value.startsWith(this._currentTab.prefix)) { return this._currentTab; @@ -596,9 +671,15 @@ export class UnifiedQuickAccess extends Disposable { const [provider] = this._getOrInstantiateProvider(tab.prefix); if (provider) { - // Configure filtering - strip the tab's prefix from the filter value + // Configure filtering - strip the tab's prefix or shortcut character from the filter value const tabPrefix = tab.prefix; + const arrivedViaShortcut = this._arrivedViaShortcut; picker.filterValue = (value: string) => { + // If arrived via shortcut, strip the shortcut character + if (arrivedViaShortcut && value.startsWith(arrivedViaShortcut)) { + return value.substring(1); + } + // Otherwise strip the normal prefix if (value.startsWith(tabPrefix)) { return value.substring(tabPrefix.length); } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts index 985cc88e959..14bd1a005fc 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/localAgentSessionsProvider.ts @@ -52,8 +52,8 @@ export class LocalAgentsSessionsProvider extends Disposable implements IChatSess () => this._onDidChangeChatSessionItems.fire() )); - this._register(this.chatSessionsService.onDidChangeSessionItems(sessionType => { - if (sessionType === this.chatSessionType) { + this._register(this.chatSessionsService.onDidChangeSessionItems(({ chatSessionType }) => { + if (chatSessionType === this.chatSessionType) { this._onDidChange.fire(); } })); diff --git a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts index 8ef8c035627..d15972e2b3a 100644 --- a/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts +++ b/src/vs/workbench/contrib/chat/browser/attachments/simpleBrowserEditorOverlay.ts @@ -37,6 +37,8 @@ import { IAction, toAction } from '../../../../../base/common/actions.js'; import { WebviewInput } from '../../../webviewPanel/browser/webviewEditorInput.js'; import { IBrowserTargetLocator, getDisplayNameFromOuterHTML } from '../../../../../platform/browserElements/common/browserElements.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { observableConfigValue, observableContextKey } from '../../../../../platform/observable/common/platformObservableUtils.js'; type BrowserType = 'simpleBrowser' | 'livePreview'; @@ -366,12 +368,9 @@ class SimpleBrowserOverlayController { @IInstantiationService instaService: IInstantiationService, @IConfigurationService private readonly configurationService: IConfigurationService, @IBrowserElementsService private readonly _browserElementsService: IBrowserElementsService, + @IContextKeyService private readonly contextKeyService: IContextKeyService, ) { - if (!this.configurationService.getValue('chat.sendElementsToChat.enabled')) { - return; - } - this._domNode.classList.add('chat-simple-browser-overlay'); this._domNode.style.position = 'absolute'; this._domNode.style.bottom = `5px`; @@ -444,11 +443,18 @@ class SimpleBrowserOverlayController { return undefined; }); + // Observe chat enabled state and sendElementsToChat configuration + const chatEnabledObs = observableContextKey(ChatContextKeys.enabled.key, this.contextKeyService); + const sendElementsEnabledObs = observableConfigValue('chat.sendElementsToChat.enabled', true, this.configurationService); + this._store.add(autorun(r => { const activeEditor = activeIdObs.read(r); + const isChatEnabled = chatEnabledObs.read(r); + const isSendElementsEnabled = sendElementsEnabledObs.read(r); - if (!activeEditor) { + // Hide if chat is not enabled, sendElementsToChat is not enabled, or no active editor + if (!isChatEnabled || !isSendElementsEnabled || !activeEditor) { hide(); return; } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index d211a1d0929..b3d6bca7d50 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -52,11 +52,14 @@ import { ILanguageModelsService, LanguageModelsService } from '../common/languag import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; import { ILanguageModelToolsConfirmationService } from '../common/tools/languageModelToolsConfirmationService.js'; import { ILanguageModelToolsService } from '../common/tools/languageModelToolsService.js'; +import { HooksExecutionService, IHooksExecutionService } from '../common/hooksExecutionService.js'; import { ChatPromptFilesExtensionPointHandler } from '../common/promptSyntax/chatPromptFilesContribution.js'; import { PromptsConfig } from '../common/promptSyntax/config/config.js'; -import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME } from '../common/promptSyntax/config/promptFileLocations.js'; +import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, INSTRUCTION_FILE_EXTENSION, LEGACY_MODE_DEFAULT_SOURCE_FOLDER, LEGACY_MODE_FILE_EXTENSION, PROMPT_DEFAULT_SOURCE_FOLDER, PROMPT_FILE_EXTENSION, DEFAULT_SKILL_SOURCE_FOLDERS, AGENTS_SOURCE_FOLDER, AGENT_FILE_EXTENSION, SKILL_FILENAME, DEFAULT_HOOK_FILE_PATHS } from '../common/promptSyntax/config/promptFileLocations.js'; import { PromptLanguageFeaturesProvider } from '../common/promptSyntax/promptFileContributions.js'; -import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; +import { AGENT_DOCUMENTATION_URL, INSTRUCTIONS_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../common/promptSyntax/promptTypes.js'; +import { hookFileSchema, HOOK_SCHEMA_URI, HOOK_FILE_GLOB } from '../common/promptSyntax/hookSchema.js'; +import { Extensions as JSONExtensions, IJSONContributionRegistry } from '../../../../platform/jsonschemas/common/jsonContributionRegistry.js'; import { IPromptsService } from '../common/promptSyntax/service/promptsService.js'; import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; @@ -78,6 +81,7 @@ import { registerLanguageModelActions } from './actions/chatLanguageModelActions import { registerMoveActions } from './actions/chatMoveActions.js'; import { registerNewChatActions } from './actions/chatNewActions.js'; import { registerChatPromptNavigationActions } from './actions/chatPromptNavigationActions.js'; +import { registerChatQueueActions } from './actions/chatQueueActions.js'; import { registerQuickChatActions } from './actions/chatQuickInputActions.js'; import { ChatAgentRecommendation } from './actions/chatAgentRecommendationActions.js'; import { registerChatTitleActions } from './actions/chatTitleActions.js'; @@ -141,6 +145,11 @@ import { ChatTipService, IChatTipService } from './chatTipService.js'; const toolReferenceNameEnumValues: string[] = []; const toolReferenceNameEnumDescriptions: string[] = []; +// Register JSON schema for hook files +const jsonContributionRegistry = Registry.as(JSONExtensions.JSONContribution); +jsonContributionRegistry.registerSchema(HOOK_SCHEMA_URI, hookFileSchema); +jsonContributionRegistry.registerSchemaAssociation(HOOK_SCHEMA_URI, HOOK_FILE_GLOB); + // Register configuration const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); configurationRegistry.registerConfiguration({ @@ -597,6 +606,12 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.RequestQueueingEnabled]: { + type: 'boolean', + description: nls.localize('chat.requestQueuing.enabled.description', "When enabled, allows queuing additional messages while a request is in progress and steering the current request with a new message."), + default: false, + tags: ['experimental'], + }, [ChatConfiguration.EditModeHidden]: { type: 'boolean', description: nls.localize('chat.editMode.hidden', "When enabled, hides the Edit mode from the chat mode picker."), @@ -891,6 +906,43 @@ configurationRegistry.registerConfiguration({ }, ], }, + [PromptsConfig.HOOKS_LOCATION_KEY]: { + type: 'object', + title: nls.localize('chat.hookFilesLocations.title', "Hook File Locations",), + markdownDescription: nls.localize( + 'chat.hookFilesLocations.description', + "Specify paths to hook configuration files that define custom shell commands to execute at strategic points in an agent's workflow. [Learn More]({0}).\n\nRelative paths are resolved from the root folder(s) of your workspace. Supports Copilot hooks (`hooks.json`) and Claude Code hooks (`settings.json`, `settings.local.json`).", + HOOK_DOCUMENTATION_URL, + ), + default: { + ...DEFAULT_HOOK_FILE_PATHS.map((f) => ({ [f.path]: true })).reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }, + additionalProperties: { type: 'boolean' }, + propertyNames: { + pattern: VALID_PROMPT_FOLDER_PATTERN, + 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'], + examples: [ + { + [DEFAULT_HOOK_FILE_PATHS[0].path]: true, + }, + { + [DEFAULT_HOOK_FILE_PATHS[0].path]: true, + 'custom-hooks/hooks.json': true, + }, + ], + }, + [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#`.",), + default: true, + restricted: true, + disallowConfigurationDefault: true, + tags: ['prompts', 'hooks', 'agent'] + }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, @@ -1398,6 +1450,7 @@ registerChatFileTreeActions(); registerChatPromptNavigationActions(); registerChatTitleActions(); registerChatExecuteActions(); +registerChatQueueActions(); registerQuickChatActions(); registerChatExportActions(); registerMoveActions(); @@ -1433,6 +1486,7 @@ registerSingleton(ICodeMapperService, CodeMapperService, InstantiationType.Delay registerSingleton(IChatEditingService, ChatEditingService, InstantiationType.Delayed); registerSingleton(IChatMarkdownAnchorService, ChatMarkdownAnchorService, InstantiationType.Delayed); registerSingleton(ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService, InstantiationType.Delayed); +registerSingleton(IHooksExecutionService, HooksExecutionService, InstantiationType.Delayed); registerSingleton(IPromptsService, PromptsService, InstantiationType.Delayed); registerSingleton(IChatContextPickService, ChatContextPickService, InstantiationType.Delayed); registerSingleton(IChatModeService, ChatModeService, InstantiationType.Delayed); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 860903d3493..b96a8a4b807 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -19,8 +19,8 @@ import { IChatResponseModel, IChatModelInputState } from '../common/model/chatMo import { IChatMode } from '../common/chatModes.js'; import { IParsedChatRequest } from '../common/requestParser/chatParserTypes.js'; import { CHAT_PROVIDER_ID } from '../common/participants/chatParticipantContribTypes.js'; -import { IChatElicitationRequest, IChatLocationData, IChatSendRequestOptions } from '../common/chatService/chatService.js'; -import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel } from '../common/model/chatViewModel.js'; +import { ChatRequestQueueKind, IChatElicitationRequest, IChatLocationData, IChatSendRequestOptions } from '../common/chatService/chatService.js'; +import { IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, IChatPendingDividerViewModel } from '../common/model/chatViewModel.js'; import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { ChatAttachmentModel } from './attachments/chatAttachmentModel.js'; import { IChatEditorOptions } from './widgetHosts/editor/chatEditor.js'; @@ -207,7 +207,7 @@ export interface IChatFileTreeInfo { focus(): void; } -export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel; +export type ChatTreeItem = IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel; export interface IChatListItemRendererOptions { readonly renderStyle?: 'compact' | 'minimal'; @@ -306,6 +306,11 @@ export interface IChatAcceptInputOptions { // box's current content is being accepted, or 'false' if a specific input // is being submitted to the widget. storeToHistory?: boolean; + /** + * When set, queues this message to be sent after the current request completes. + * If Steering, also sets yieldRequested on any active request to signal it should wrap up. + */ + queue?: ChatRequestQueueKind; } export interface IChatWidgetViewModelChangeEvent { diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index d67e1a23322..a7a60c021a1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -259,11 +259,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ private readonly _alternativeIdMap: Map = new Map(); private readonly _contextKeys = new Set(); - private readonly _onDidChangeItemsProviders = this._register(new Emitter()); - readonly onDidChangeItemsProviders: Event = this._onDidChangeItemsProviders.event; + private readonly _onDidChangeItemsProviders = this._register(new Emitter<{ readonly chatSessionType: string }>()); + readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; - private readonly _onDidChangeSessionItems = this._register(new Emitter()); - readonly onDidChangeSessionItems: Event = this._onDidChangeSessionItems.event; + private readonly _onDidChangeSessionItems = this._register(new Emitter<{ readonly chatSessionType: string }>()); + readonly onDidChangeSessionItems = this._onDidChangeSessionItems.event; private readonly _onDidChangeAvailability = this._register(new Emitter()); readonly onDidChangeAvailability: Event = this._onDidChangeAvailability.event; @@ -339,7 +339,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ } })); - this._register(this.onDidChangeSessionItems(chatSessionType => { + this._register(this.onDidChangeSessionItems(({ chatSessionType }) => { this.updateInProgressStatus(chatSessionType).catch(error => { this._logService.warn(`Failed to update progress status for '${chatSessionType}':`, error); }); @@ -637,7 +637,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ this._onDidChangeItemsProviders.fire(provider); } for (const { contribution } of this._contributions.values()) { - this._onDidChangeSessionItems.fire(contribution.type); + this._onDidChangeSessionItems.fire({ chatSessionType: contribution.type }); } } this._updateHasCanDelegateProvidersContextKey(); @@ -730,7 +730,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._isContributionAvailable(contribution) ? contribution : undefined; } - async activateChatSessionItemProvider(chatViewType: string): Promise { + async activateChatSessionItemProvider(chatViewType: string): Promise { + await this.doActivateChatSessionItemProvider(chatViewType); + } + + private async doActivateChatSessionItemProvider(chatViewType: string): Promise { await this._extensionService.whenInstalledExtensionsRegistered(); const resolvedType = this._resolveToPrimaryType(chatViewType); if (resolvedType) { @@ -777,7 +781,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ continue; // skip: not considered for resolving } - const provider = await this.activateChatSessionItemProvider(contrib.type); + const provider = await this.doActivateChatSessionItemProvider(contrib.type); if (!provider) { // We requested this provider but it is not available if (providersToResolve?.includes(contrib.type)) { @@ -828,7 +832,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ const disposables = new DisposableStore(); disposables.add(provider.onDidChangeChatSessionItems(() => { - this._onDidChangeSessionItems.fire(chatSessionType); + this._onDidChangeSessionItems.fire({ chatSessionType }); })); this.updateInProgressStatus(chatSessionType).catch(error => { @@ -1009,10 +1013,6 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return !!session?.setOption(optionId, value); } - public notifySessionItemsChanged(chatSessionType: string): void { - this._onDidChangeSessionItems.fire(chatSessionType); - } - /** * Store option groups for a session type */ diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts new file mode 100644 index 00000000000..214b76c56c0 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts @@ -0,0 +1,239 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ChatViewId } from '../chat.js'; +import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; +import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js'; +import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; +import { IPromptsService } from '../../common/promptSyntax/service/promptsService.js'; +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { ICommandService } from '../../../../../platform/commands/common/commands.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { formatHookCommandLabel, HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; +import { NEW_HOOK_COMMAND_ID } from './newPromptFileActions.js'; +import { ILabelService } from '../../../../../platform/label/common/label.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; +import { findHookCommandSelection } from './hookUtils.js'; +import { getHookSourceFormatLabel, HookSourceFormat, isReadOnlyHookSource, parseHooksFromFile } from '../../common/promptSyntax/hookCompatibility.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../services/path/common/pathService.js'; + +/** + * Action ID for the `Configure Hooks` action. + */ +const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks'; + +interface IHookEntry { + readonly hookType: HookType; + readonly hookTypeLabel: string; + /** The original hook type ID as it appears in the JSON file (for selection lookup) */ + readonly originalHookTypeId: string; + readonly fileUri: URI; + readonly filePath: string; + readonly displayLabel: string; + readonly commandFieldName: 'command' | 'bash' | 'powershell' | undefined; + readonly index: number; + /** The source format (Copilot, Claude) */ + readonly sourceFormat: HookSourceFormat; + /** Whether this hook is from a read-only source (Claude settings) */ + readonly isReadOnly: boolean; +} + +interface IHookQuickPickItem extends IQuickPickItem { + readonly hookEntry?: IHookEntry; + readonly commandId?: string; +} + +class ManageHooksAction extends Action2 { + constructor() { + super({ + id: CONFIGURE_HOOKS_ACTION_ID, + title: localize2('configure-hooks', "Configure Hooks..."), + shortTitle: localize2('configure-hooks.short', "Hooks"), + icon: Codicon.zap, + f1: true, + precondition: ChatContextKeys.enabled, + category: CHAT_CATEGORY, + menu: { + id: CHAT_CONFIG_MENU_ID, + when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)), + order: 12, + group: '1_level' + } + }); + } + + public override async run( + accessor: ServicesAccessor, + ): Promise { + const promptsService = accessor.get(IPromptsService); + const quickInputService = accessor.get(IQuickInputService); + const fileService = accessor.get(IFileService); + const labelService = accessor.get(ILabelService); + const commandService = accessor.get(ICommandService); + const editorService = accessor.get(IEditorService); + const workspaceService = accessor.get(IWorkspaceContextService); + const pathService = accessor.get(IPathService); + + // Get workspace root and user home for path resolution + const workspaceFolder = workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + const userHomeUri = await pathService.userHome(); + const userHome = userHomeUri.fsPath ?? userHomeUri.path; + + // Get all hook files + const hookFiles = await promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None); + + // Parse hook files to extract hook entries using format-aware parsing + const hookEntries: IHookEntry[] = []; + + for (const hookFile of hookFiles) { + try { + const content = await fileService.readFile(hookFile.uri); + const json = JSON.parse(content.value.toString()); + + // Use format-aware parsing + const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + const isReadOnly = isReadOnlyHookSource(format); + + for (const [hookType, { hooks: commands, originalId }] of hooks) { + const hookTypeMeta = HOOK_TYPES.find(h => h.id === hookType); + if (!hookTypeMeta) { + continue; + } + + for (let i = 0; i < commands.length; i++) { + const hookCommand = commands[i]; + const displayLabel = formatHookCommandLabel(hookCommand) || localize('commands.hook.emptyCommand', '(empty command)'); + hookEntries.push({ + hookType, + hookTypeLabel: hookTypeMeta.label, + originalHookTypeId: originalId, + fileUri: hookFile.uri, + filePath: labelService.getUriLabel(hookFile.uri, { relative: true }), + displayLabel, + commandFieldName: hookCommand.command !== undefined ? 'command' : hookCommand.bash !== undefined ? 'bash' : 'powershell', + index: i, + sourceFormat: format, + isReadOnly + }); + } + } + } catch { + // Skip files that can't be parsed + } + } + + // Build quick pick items grouped by hook type + const items: (IHookQuickPickItem | IQuickPickSeparator)[] = []; + + // Add "New Hook..." option at the top + items.push({ + label: `$(plus) ${localize('commands.new-hook.label', 'Add new hook...')}`, + commandId: NEW_HOOK_COMMAND_ID, + alwaysShow: true + }); + + // Group entries by hook type + const groupedByType = new Map(); + for (const entry of hookEntries) { + const existing = groupedByType.get(entry.hookType) ?? []; + existing.push(entry); + groupedByType.set(entry.hookType, existing); + } + + // Sort hook types by their position in HOOK_TYPES + const sortedHookTypes = Array.from(groupedByType.keys()).sort((a, b) => { + const indexA = HOOK_TYPES.findIndex(h => h.id === a); + const indexB = HOOK_TYPES.findIndex(h => h.id === b); + return indexA - indexB; + }); + + // Add entries grouped by hook type + for (const hookTypeId of sortedHookTypes) { + const entries = groupedByType.get(hookTypeId)!; + const hookType = HOOK_TYPES.find(h => h.id === hookTypeId)!; + + items.push({ + type: 'separator', + label: hookType.label + }); + + for (const entry of entries) { + // Build description with source format indicator for read-only hooks + let description = entry.filePath; + if (entry.isReadOnly) { + description = `$(lock) ${getHookSourceFormatLabel(entry.sourceFormat)} · ${description}`; + } + + items.push({ + label: entry.displayLabel, + description, + hookEntry: entry + }); + } + } + + // Show empty state message if no hooks found + if (hookEntries.length === 0) { + items.push({ + type: 'separator', + label: localize('noHooks', "No hooks configured") + }); + } + + const selected = await quickInputService.pick(items, { + placeHolder: localize('commands.hooks.placeholder', 'Select a hook to open or add a new hook'), + title: localize('commands.hooks.title', 'Hooks') + }); + + if (selected) { + if (selected.commandId) { + await commandService.executeCommand(selected.commandId); + } else if (selected.hookEntry) { + const entry = selected.hookEntry; + let selection: ITextEditorSelection | undefined; + + // Try to find the command field to highlight + if (entry.commandFieldName) { + try { + const content = await fileService.readFile(entry.fileUri); + selection = findHookCommandSelection( + content.value.toString(), + entry.originalHookTypeId, + entry.index, + entry.commandFieldName + ); + } catch { + // Ignore errors and just open without selection + } + } + + await editorService.openEditor({ + resource: entry.fileUri, + options: { + selection, + pinned: false + } + }); + } + } + } +} + +/** + * Helper to register the `Manage Hooks` action. + */ +export function registerHookActions(): void { + registerAction2(ManageHooksAction); +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts new file mode 100644 index 00000000000..6f54d823156 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { findNodeAtLocation, Node, parseTree } from '../../../../../base/common/json.js'; +import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js'; + +/** + * Converts an offset in content to a 1-based line and column. + */ +function offsetToPosition(content: string, offset: number): { line: number; column: number } { + let line = 1; + let column = 1; + for (let i = 0; i < offset && i < content.length; i++) { + if (content[i] === '\n') { + line++; + column = 1; + } else { + column++; + } + } + return { line, column }; +} + +/** + * Finds the n-th command field node in a hook type array, handling both simple and nested formats. + * This iterates through the structure in the same order as the parser flattens hooks. + */ +function findNthCommandNode(tree: Node, hookType: string, targetIndex: number, fieldName: string): Node | undefined { + const hookTypeArray = findNodeAtLocation(tree, ['hooks', hookType]); + if (!hookTypeArray || hookTypeArray.type !== 'array' || !hookTypeArray.children) { + return undefined; + } + + let currentIndex = 0; + + for (let i = 0; i < hookTypeArray.children.length; i++) { + const item = hookTypeArray.children[i]; + if (item.type !== 'object') { + continue; + } + + // Check if this item has nested hooks (matcher format) + const nestedHooksNode = findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks']); + if (nestedHooksNode && nestedHooksNode.type === 'array' && nestedHooksNode.children) { + // Iterate through nested hooks + for (let j = 0; j < nestedHooksNode.children.length; j++) { + if (currentIndex === targetIndex) { + return findNodeAtLocation(tree, ['hooks', hookType, i, 'hooks', j, fieldName]); + } + currentIndex++; + } + } else { + // Simple format - direct command + if (currentIndex === targetIndex) { + return findNodeAtLocation(tree, ['hooks', hookType, i, fieldName]); + } + currentIndex++; + } + } + + return undefined; +} + +/** + * Finds the selection range for a hook command field value in JSON content. + * Supports both simple format and nested matcher format: + * - Simple: { hooks: { hookType: [{ command: "..." }] } } + * - Nested: { hooks: { hookType: [{ matcher: "", hooks: [{ command: "..." }] }] } } + * + * The index is a flattened index across all commands in the hook type, regardless of nesting. + * + * @param content The JSON file content + * @param hookType The hook type (e.g., "sessionStart") + * @param index The flattened index of the hook command within the hook type + * @param fieldName The field name to find ('command', 'bash', or 'powershell') + * @returns The selection range for the field value, or undefined if not found + */ +export function findHookCommandSelection(content: string, hookType: string, index: number, fieldName: string): ITextEditorSelection | undefined { + const tree = parseTree(content); + if (!tree) { + return undefined; + } + + const node = findNthCommandNode(tree, hookType, index, fieldName); + if (!node || node.type !== 'string') { + return undefined; + } + + // Node offset/length includes quotes, so adjust to select only the value content + const valueStart = node.offset + 1; // After opening quote + const valueEnd = node.offset + node.length - 1; // Before closing quote + + const start = offsetToPosition(content, valueStart); + const end = offsetToPosition(content, valueEnd); + + return { + startLineNumber: start.line, + startColumn: start.column, + endLineNumber: end.line, + endColumn: end.column + }; +} diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 3b1f279ef88..1d19f566d16 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -5,6 +5,7 @@ import { isEqual } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; +import { VSBuffer } from '../../../../../base/common/buffer.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { SnippetController2 } from '../../../../../editor/contrib/snippet/browser/snippetController2.js'; import { localize, localize2 } from '../../../../../nls.js'; @@ -25,7 +26,11 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js'; import { askForPromptFileName } from './pickers/askForPromptName.js'; import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js'; import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; -import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { getCleanPromptName, SKILL_FILENAME, HOOKS_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; +import { HOOK_TYPES, HookType } from '../../common/promptSyntax/hookSchema.js'; +import { findHookCommandSelection } from './hookUtils.js'; +import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js'; +import { Range } from '../../../../../editor/common/core/range.js'; class AbstractNewPromptFileAction extends Action2 { @@ -175,6 +180,11 @@ function getDefaultContentSnippet(promptType: PromptsType, name: string | undefi `---`, `\${3:Define the functionality provided by this skill, including detailed instructions and examples}`, ].join('\n'); + case PromptsType.hook: + return JSON.stringify({ + version: 1, + hooks: {} + }, null, 4); default: throw new Error(`Unsupported prompt type: ${promptType}`); } @@ -186,6 +196,7 @@ export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt'; export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions'; export const NEW_AGENT_COMMAND_ID = 'workbench.command.new.agent'; export const NEW_SKILL_COMMAND_ID = 'workbench.command.new.skill'; +export const NEW_HOOK_COMMAND_ID = 'workbench.command.new.hook'; class NewPromptFileAction extends AbstractNewPromptFileAction { constructor() { @@ -288,6 +299,168 @@ class NewSkillFileAction extends Action2 { } } +class NewHookFileAction extends Action2 { + constructor() { + super({ + id: NEW_HOOK_COMMAND_ID, + title: localize('commands.new.hook.local.title', "New Hook..."), + f1: false, + precondition: ChatContextKeys.enabled, + category: CHAT_CATEGORY, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib + }, + menu: { + id: MenuId.CommandPalette, + when: ChatContextKeys.enabled + } + }); + } + + public override async run(accessor: ServicesAccessor) { + const editorService = accessor.get(IEditorService); + const fileService = accessor.get(IFileService); + const instaService = accessor.get(IInstantiationService); + const quickInputService = accessor.get(IQuickInputService); + const bulkEditService = accessor.get(IBulkEditService); + + const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.hook); + if (!selectedFolder) { + return; + } + + // Ask which hook type to add + const hookTypeItems = HOOK_TYPES.map(hookType => ({ + id: hookType.id, + label: hookType.label, + description: hookType.description + })); + + const selectedHookType = await quickInputService.pick(hookTypeItems, { + placeHolder: localize('commands.new.hook.type.placeholder', "Select a hook type to add"), + title: localize('commands.new.hook.type.title', "Add Hook") + }); + + if (!selectedHookType) { + return; + } + + // Create the hooks folder if it doesn't exist + await fileService.createFolder(selectedFolder.uri); + + // Use fixed hooks.json filename + const hookFileUri = URI.joinPath(selectedFolder.uri, HOOKS_FILENAME); + + // Check if hooks.json already exists + let hooksContent: { version: number; hooks: Record }; + const fileExists = await fileService.exists(hookFileUri); + + if (fileExists) { + // Parse existing file + const existingContent = await fileService.readFile(hookFileUri); + try { + hooksContent = JSON.parse(existingContent.value.toString()); + // Ensure hooks object exists + if (!hooksContent.hooks) { + hooksContent.hooks = {}; + } + } catch { + // If parsing fails, show error and open file for user to fix + const notificationService = accessor.get(INotificationService); + notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks.json. Please fix the JSON syntax errors and try again.")); + await editorService.openEditor({ resource: hookFileUri }); + return; + } + } else { + // Create new structure + hooksContent = { version: 1, hooks: {} }; + } + + // Add the new hook entry (append if hook type already exists) + const hookTypeId = selectedHookType.id as HookType; + const newHookEntry = { + type: 'command', + command: '' + }; + let newHookIndex: number; + if (!hooksContent.hooks[hookTypeId]) { + hooksContent.hooks[hookTypeId] = [newHookEntry]; + newHookIndex = 0; + } else { + hooksContent.hooks[hookTypeId].push(newHookEntry); + newHookIndex = hooksContent.hooks[hookTypeId].length - 1; + } + + // Write the file + const jsonContent = JSON.stringify(hooksContent, null, '\t'); + + // Check if the file is already open in an editor + const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri)); + + if (existingEditor) { + // File is already open - first focus the editor, then update its model directly + await editorService.openEditor({ + resource: hookFileUri, + options: { + pinned: false + } + }); + + // Get the code editor and update its content directly + const editor = getCodeEditor(editorService.activeTextEditorControl); + if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) { + const model = editor.getModel(); + // Apply the full content replacement using executeEdits + model.pushEditOperations([], [{ + range: model.getFullModelRange(), + text: jsonContent + }], () => null); + + // Find and apply the selection + const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); + if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) { + editor.setSelection({ + startLineNumber: selection.startLineNumber, + startColumn: selection.startColumn, + endLineNumber: selection.endLineNumber, + endColumn: selection.endColumn + }); + editor.revealLineInCenter(selection.startLineNumber); + } + } + } else { + // File is not currently open in an editor + if (!fileExists) { + // File doesn't exist - write new file directly and open + await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent)); + } else { + // File exists but isn't open - open it first, then use bulk edit for undo support + await editorService.openEditor({ + resource: hookFileUri, + options: { pinned: false } + }); + + // Apply the edit via bulk edit service for proper undo support + await bulkEditService.apply([ + new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent }) + ], { label: localize('addHook', "Add Hook") }); + } + + // Find the selection for the new hook's command field + const selection = findHookCommandSelection(jsonContent, hookTypeId, newHookIndex, 'command'); + + // Open editor with selection (or re-focus if already open) + await editorService.openEditor({ + resource: hookFileUri, + options: { + selection, + pinned: false + } + }); + } + } +} + class NewUntitledPromptFileAction extends Action2 { constructor() { super({ @@ -333,5 +506,6 @@ export function registerNewPromptFileActions(): void { registerAction2(NewInstructionsFileAction); registerAction2(NewAgentFileAction); registerAction2(NewSkillFileAction); + registerAction2(NewHookFileAction); registerAction2(NewUntitledPromptFileAction); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts index 1db063db181..42b0a3841b5 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/askForPromptSourceFolder.ts @@ -113,6 +113,8 @@ function getPlaceholderStringforNew(type: PromptsType): string { return localize('workbench.command.agent.create.location.placeholder', "Select a location to create the agent file"); case PromptsType.skill: return localize('workbench.command.skill.create.location.placeholder', "Select a location to create the skill"); + case PromptsType.hook: + return localize('workbench.command.hook.create.location.placeholder', "Select a location to create the hook file"); default: throw new Error('Unknown prompt type'); } @@ -129,6 +131,8 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string return localize('agent.move.location.placeholder', "Select a location to move the agent file to"); case PromptsType.skill: return localize('skill.move.location.placeholder', "Select a location to move the skill to"); + case PromptsType.hook: + throw new Error('Hooks cannot be moved'); default: throw new Error('Unknown prompt type'); } @@ -142,6 +146,8 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string return localize('agent.copy.location.placeholder', "Select a location to copy the agent file to"); case PromptsType.skill: return localize('skill.copy.location.placeholder', "Select a location to copy the skill to"); + case PromptsType.hook: + throw new Error('Hooks cannot be copied'); default: throw new Error('Unknown prompt type'); } @@ -187,6 +193,8 @@ function getLearnLabel(type: PromptsType): string { return localize('commands.agent.create.ask-folder.empty.docs-label', 'Learn how to configure custom agents'); case PromptsType.skill: return localize('commands.skill.create.ask-folder.empty.docs-label', 'Learn how to configure skills'); + case PromptsType.hook: + return localize('commands.hook.create.ask-folder.empty.docs-label', 'Learn how to configure hooks'); default: throw new Error('Unknown prompt type'); } @@ -202,6 +210,8 @@ function getMissingSourceFolderString(type: PromptsType): string { return localize('commands.agent.create.ask-folder.empty.placeholder', 'No agent source folders found.'); case PromptsType.skill: return localize('commands.skill.create.ask-folder.empty.placeholder', 'No skill source folders found.'); + case PromptsType.hook: + return localize('commands.hook.create.ask-folder.empty.placeholder', 'No hook source folders found.'); default: throw new Error('Unknown prompt type'); } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts index e350c3b6289..9bc9508463c 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/pickers/promptFilePickers.ts @@ -15,7 +15,7 @@ import { IOpenerService } from '../../../../../../platform/opener/common/opener. import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; import { ICommandService } from '../../../../../../platform/commands/common/commands.js'; import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js'; -import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; +import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL, HOOK_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js'; import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js'; import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator } from '../../../../../../platform/quickinput/common/quickInput.js'; import { askForPromptFileName } from './askForPromptName.js'; @@ -98,6 +98,12 @@ function newHelpButton(type: PromptsType): IQuickInputButton & { helpURI: URI } helpURI: URI.parse(SKILL_DOCUMENTATION_URL), iconClass }; + case PromptsType.hook: + return { + tooltip: localize('help.hook', "Show help on hook files"), + helpURI: URI.parse(HOOK_DOCUMENTATION_URL), + iconClass + }; } } diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts index 45423904593..670d4335f79 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptFileActions.ts @@ -8,6 +8,7 @@ import { registerAgentActions } from './chatModeActions.js'; import { registerRunPromptActions } from './runPromptAction.js'; import { registerNewPromptFileActions } from './newPromptFileActions.js'; import { registerSkillActions } from './skillActions.js'; +import { registerHookActions } from './hookActions.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { SaveAsAgentFileAction, SaveAsInstructionsFileAction, SaveAsPromptFileAction } from './saveAsPromptFileActions.js'; @@ -19,6 +20,7 @@ export function registerPromptActions(): void { registerRunPromptActions(); registerAttachPromptActions(); registerSkillActions(); + registerHookActions(); registerAction2(SaveAsPromptFileAction); registerAction2(SaveAsInstructionsFileAction); registerAction2(SaveAsAgentFileAction); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts index 07ec74d925f..e7e7ba79921 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts @@ -7,7 +7,7 @@ import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle import { localize } from '../../../../../../nls.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IChatProgressRenderableResponseContent } from '../../../common/model/chatModel.js'; -import { IChatConfirmation, IChatSendRequestOptions, IChatService } from '../../../common/chatService/chatService.js'; +import { ChatSendResult, IChatConfirmation, IChatSendRequestOptions, IChatService } from '../../../common/chatService/chatService.js'; import { isResponseVM } from '../../../common/model/chatViewModel.js'; import { IChatWidgetService } from '../../chat.js'; import { SimpleChatConfirmationWidget } from './chatConfirmationWidget.js'; @@ -54,7 +54,8 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont options.location = widget?.location; Object.assign(options, widget?.getModeRequestOptions()); - if (await this.chatService.sendRequest(element.sessionResource, prompt, options)) { + const result = await this.chatService.sendRequest(element.sessionResource, prompt, options); + if (ChatSendResult.isSent(result)) { confirmation.isUsed = true; confirmationWidget.setShowButtons(false); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts index 472f6e5c35c..e15b3d1fafd 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatContentParts.ts @@ -5,7 +5,7 @@ import { IDisposable } from '../../../../../../base/common/lifecycle.js'; import { ChatTreeItem, IChatCodeBlockInfo } from '../../chat.js'; -import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel } from '../../../common/model/chatViewModel.js'; import { CodeBlockModelCollection } from '../../../common/widget/codeBlockModelCollection.js'; import { DiffEditorPool, EditorPool } from './chatContentCodePools.js'; import { IObservable } from '../../../../../../base/common/observable.js'; @@ -41,7 +41,7 @@ export interface IChatContentPart extends IDisposable { } export interface IChatContentPartRenderContext { - readonly element: ChatTreeItem; + readonly element: IChatRequestViewModel | IChatResponseViewModel; readonly elementIndex: number; readonly container: HTMLElement; readonly content: ReadonlyArray; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts index d98df9be8a8..4bf4872c009 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatQuestionCarouselPart.ts @@ -5,6 +5,8 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../../../base/browser/keyboardEvent.js'; +import { getBaseLayerHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegate2.js'; +import { getDefaultHoverDelegate } from '../../../../../../base/browser/ui/hover/hoverDelegateFactory.js'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { KeyCode } from '../../../../../../base/common/keyCodes.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; @@ -161,6 +163,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent e.stopPropagation(); this.handleNext(); } + } else if ((event.ctrlKey || event.metaKey) && (event.keyCode === KeyCode.Backspace || event.keyCode === KeyCode.Delete)) { + e.stopPropagation(); } })); @@ -377,6 +381,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent ? questionText : questionText.value; + title.setAttribute('aria-label', messageContent); + // Check for subtitle in parentheses at the end const parenMatch = messageContent.match(/^(.+?)\s*(\([^)]+\))\s*$/); if (parenMatch) { @@ -547,6 +553,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const listItem = dom.$('.chat-question-list-item'); listItem.setAttribute('role', 'option'); listItem.setAttribute('aria-selected', String(isSelected)); + listItem.setAttribute('aria-label', localize('chat.questionCarousel.optionLabel', "Option {0}: {1}", index + 1, option.label)); listItem.id = `option-${question.id}-${index}`; listItem.tabIndex = -1; @@ -582,6 +589,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent listItem.classList.add('selected'); } + this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); + // Click handler this._inputBoxes.add(dom.addDisposableListener(listItem, dom.EventType.CLICK, (e: MouseEvent) => { e.preventDefault(); @@ -727,6 +736,7 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent const listItem = dom.$('.chat-question-list-item.multi-select'); listItem.setAttribute('role', 'option'); listItem.setAttribute('aria-selected', String(isChecked)); + listItem.setAttribute('aria-label', localize('chat.questionCarousel.optionLabel', "Option {0}: {1}", index + 1, option.label)); listItem.id = `option-${question.id}-${index}`; listItem.tabIndex = -1; @@ -781,6 +791,8 @@ export class ChatQuestionCarouselPart extends Disposable implements IChatContent } })); + this._inputBoxes.add(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), listItem, option.label)); + selectContainer.appendChild(listItem); checkboxes.push(checkbox); listItems.push(listItem); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts index c58fdfc1206..d21b5f1af40 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTodoListWidget.ts @@ -199,6 +199,7 @@ export class ChatTodoListWidget extends Disposable { private createClearButton(): void { this.clearButton = new Button(this.clearButtonContainer, { supportIcons: true, + ariaLabel: localize('chat.todoList.clearButton', 'Clear all todos'), }); this.clearButton.element.tabIndex = 0; this.clearButton.icon = Codicon.clearAll; diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 2c33c66fe4e..8ba0e1da646 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -239,19 +239,26 @@ } .interactive-session .interactive-response .value { - .chat-question-list-item:focus, + .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; } } -/* Single-select: highlight entire row when selected and also outline */ +/* Single-select: highlight entire row when selected */ .chat-question-list-item.selected { background-color: var(--vscode-list-activeSelectionBackground); color: var(--vscode-list-activeSelectionForeground); } +.chat-question-list:focus-within .chat-question-list-item.selected { + outline-width: 1px; + outline-style: solid; + outline-offset: -1px; + outline-color: var(--vscode-focusBorder); +} + .chat-question-list-item.selected:hover { background-color: var(--vscode-list-activeSelectionBackground); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index a28f9beb051..383d91fa9f0 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -54,11 +54,11 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; -import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM } from '../../common/model/chatViewModel.js'; +import { IChatChangesSummaryPart, IChatCodeCitations, IChatErrorDetailsPart, IChatReferences, IChatRendererContent, IChatRequestViewModel, IChatResponseViewModel, IChatViewModel, isRequestVM, isResponseVM, IChatPendingDividerViewModel, isPendingDividerVM } from '../../common/model/chatViewModel.js'; import { getNWords } from '../../common/model/chatWordCounter.js'; import { CodeBlockModelCollection } from '../../common/widget/codeBlockModelCollection.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind, CollapsedToolsDisplayMode, ThinkingDisplayMode } from '../../common/constants.js'; @@ -632,14 +632,23 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer(ChatConfiguration.CheckpointsEnabled) && (this.rendererOptions.restorable ?? true); + const isPendingRequest = isRequestVM(element) && !!element.pendingKind; - templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || !(checkpointEnabled)); + templateData.checkpointContainer.classList.toggle('hidden', isResponseVM(element) || isPendingRequest || !(checkpointEnabled)); - // Only show restore container when we have a checkpoint and not editing - const shouldShowRestore = this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1); + // Only show restore container when we have a checkpoint and not editing, and not a pending request + const shouldShowRestore = this.viewModel?.model.checkpoint && !this.viewModel?.editing && (index === this.delegate.getListLength() - 1) && !isPendingRequest; templateData.checkpointRestoreContainer.classList.toggle('hidden', !(shouldShowRestore && checkpointEnabled)); const editing = element.id === this.viewModel?.editing?.id; @@ -764,6 +776,39 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('img.icon'); avatarIcon.src = FileAccess.uriToBrowserUri(icon).toString(true); @@ -923,6 +976,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer { - return element.dataId + + // Pending types only have 'id', request/response have 'dataId' + const baseId = (isRequestVM(element) || isResponseVM(element)) ? element.dataId : element.id; + const disablement = (isRequestVM(element) || isResponseVM(element)) ? element.shouldBeRemovedOnSend : undefined; + return baseId + // If a response is in the process of progressive rendering, we need to ensure that it will // be re-rendered so progressive rendering is restarted, even if the model wasn't updated. `${isResponseVM(element) && element.renderData ? `_${this._visibleChangeCount}` : ''}` + // Re-render once content references are loaded (isResponseVM(element) ? `_${element.contentReferences.length}` : '') + // Re-render if element becomes hidden due to undo/redo - `_${element.shouldBeRemovedOnSend ? `${element.shouldBeRemovedOnSend.afterUndoStop || '1'}` : '0'}` + + `_${disablement ? `${disablement.afterUndoStop || '1'}` : '0'}` + // Re-render if we have an element currently being edited `_${editing ? '1' : '0'}` + // Re-render if we have an element currently being checkpointed @@ -812,7 +815,7 @@ export class ChatListWidget extends Disposable { this._container.style.removeProperty('--chat-current-response-min-height'); } else { const secondToLastItem = this._viewModel?.getItems().at(-2); - const secondToLastItemHeight = Math.min(secondToLastItem?.currentRenderedHeight ?? 150, 150); + const secondToLastItemHeight = Math.min((isRequestVM(secondToLastItem) || isResponseVM(secondToLastItem)) ? secondToLastItem.currentRenderedHeight ?? 150 : 150, 150); const lastItemMinHeight = Math.max(contentHeight - (secondToLastItemHeight + 10), 0); this._container.style.setProperty('--chat-current-response-min-height', lastItemMinHeight + 'px'); if (lastItemMinHeight !== this._previousLastItemMinHeight) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 56c7f2ed371..6920f80ebb2 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -31,6 +31,7 @@ import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { ITextResourceEditorInput } from '../../../../../platform/editor/common/editor.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -54,7 +55,7 @@ import { IChatModel, IChatModelInputState, IChatResponseModel } from '../../comm import { ChatMode, IChatModeService } from '../../common/chatModes.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestDynamicVariablePart, ChatRequestSlashPromptPart, ChatRequestToolPart, ChatRequestToolSetPart, chatSubcommandLeader, formatChatQuestion, IParsedChatRequest } from '../../common/requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../../common/requestParser/chatRequestParser.js'; -import { IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatLocationData, IChatSendRequestOptions, IChatService } from '../../common/chatService/chatService.js'; import { IChatSessionsService } from '../../common/chatSessionsService.js'; import { IChatSlashCommandService } from '../../common/participants/chatSlashCommands.js'; import { IChatTodoListService } from '../../common/tools/chatTodoListService.js'; @@ -237,7 +238,6 @@ export class ChatWidget extends Disposable implements IChatWidget { private visibleChangeCount = 0; private requestInProgress: IContextKey; private agentInInput: IContextKey; - private currentRequest: Promise | undefined; private _visible = false; get visible() { return this._visible; } @@ -257,6 +257,7 @@ export class ChatWidget extends Disposable implements IChatWidget { private readonly _lockedToCodingAgentContextKey: IContextKey; private readonly _agentSupportsAttachmentsContextKey: IContextKey; private readonly _sessionIsEmptyContextKey: IContextKey; + private readonly _hasPendingRequestsContextKey: IContextKey; private _attachmentCapabilities: IChatAgentAttachmentCapabilities = supportsAllAttachments; // Cache for prompt file descriptions to avoid async calls during rendering @@ -288,7 +289,6 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug('ChatWidget#setViewModel: no viewModel'); } - this.currentRequest = undefined; this._onDidChangeViewModel.fire({ previousSessionResource, currentSessionResource: this._viewModel?.sessionResource }); } @@ -344,6 +344,7 @@ export class ChatWidget extends Disposable implements IChatWidget { @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IEditorService private readonly editorService: IEditorService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IDialogService private readonly dialogService: IDialogService, @IContextKeyService private readonly contextKeyService: IContextKeyService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IChatService private readonly chatService: IChatService, @@ -371,6 +372,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this._lockedToCodingAgentContextKey = ChatContextKeys.lockedToCodingAgent.bindTo(this.contextKeyService); this._agentSupportsAttachmentsContextKey = ChatContextKeys.agentSupportsAttachments.bindTo(this.contextKeyService); this._sessionIsEmptyContextKey = ChatContextKeys.chatSessionIsEmpty.bindTo(this.contextKeyService); + this._hasPendingRequestsContextKey = ChatContextKeys.hasPendingRequests.bindTo(this.contextKeyService); this.viewContext = viewContext ?? {}; @@ -1666,7 +1668,7 @@ export class ChatWidget extends Disposable implements IChatWidget { widgetViewKindTag: this.getWidgetViewKindTag(), defaultMode: this.viewOptions.defaultMode, sessionTypePickerDelegate: this.viewOptions.sessionTypePickerDelegate, - workspacePickerDelegate: this.viewOptions.workspacePickerDelegate + workspacePickerDelegate: this.viewOptions.workspacePickerDelegate, }; if (this.viewModel?.editing) { @@ -1806,6 +1808,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } this.viewModel = undefined; this.onDidChangeItems(); + this._hasPendingRequestsContextKey.set(false); return; } @@ -1869,6 +1872,12 @@ export class ChatWidget extends Disposable implements IChatWidget { this.onDidChangeItems(); })); this._sessionIsEmptyContextKey.set(model.getRequests().length === 0); + const updatePendingRequestKeys = () => { + const pendingCount = model.getPendingRequests().length; + this._hasPendingRequestsContextKey.set(pendingCount > 0); + }; + updatePendingRequestKeys(); + this.viewModelDisposables.add(model.onDidChangePendingRequests(() => updatePendingRequestKeys())); this.refreshParsedInput(); this.viewModelDisposables.add(model.onDidChange((e) => { @@ -2057,11 +2066,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } } - private async _acceptInput(query: { query: string } | undefined, options?: IChatAcceptInputOptions): Promise { - if (this.viewModel?.model.requestInProgress.get()) { - return; - } - + private async _acceptInput(query: { query: string } | undefined, options: IChatAcceptInputOptions = {}): Promise { if (!query && this.input.generating) { // if the user submits the input and generation finishes quickly, just submit it for them const generatingAutoSubmitWindow = 500; @@ -2101,10 +2106,26 @@ export class ChatWidget extends Disposable implements IChatWidget { const isUserQuery = !query; if (this.viewModel?.editing) { + const editingPendingRequest = this.viewModel.editing.pendingKind; + if (editingPendingRequest !== undefined) { + const editingRequestId = this.viewModel.editing!.id; + this.chatService.removePendingRequest(this.viewModel.sessionResource, editingRequestId); + options.queue ??= editingPendingRequest; + } + this.finishedEditing(true); this.viewModel.model?.setCheckpoint(undefined); } + const model = this.viewModel.model; + const requestInProgress = model.requestInProgress.get(); + if (requestInProgress) { + options.queue ??= ChatRequestQueueKind.Queued; + } + if (!requestInProgress && !(await this.confirmPendingRequestsBeforeSend(model, options))) { + return; + } + // process the prompt command await this._applyPromptFileIfSet(requestInputs); await this._autoAttachInstructions(requestInputs); @@ -2140,12 +2161,6 @@ export class ChatWidget extends Disposable implements IChatWidget { }; this.telemetryService.publicLog2('chatEditing/workingSetSize', { originalSize: uniqueWorkingSetEntries.size, actualSize: uniqueWorkingSetEntries.size }); } - this.chatService.cancelCurrentRequestForSession(this.viewModel.sessionResource); - if (this.currentRequest) { - // We have to wait the current request to be properly cancelled so that it has a chance to update the model with its result metadata. - // This is awkward, it's basically a limitation of the chat provider-based agent. - await Promise.race([this.currentRequest, timeout(1000)]); - } this.input.validateAgentMode(); @@ -2158,7 +2173,8 @@ export class ChatWidget extends Disposable implements IChatWidget { } } } - if (this.viewModel.sessionResource) { + if (this.viewModel.sessionResource && !options.queue) { + // todo@connor4312: move chatAccessibilityService.acceptRequest to a refcount model to handle queue messages this.chatAccessibilityService.acceptRequest(this._viewModel!.sessionResource); } @@ -2172,20 +2188,29 @@ export class ChatWidget extends Disposable implements IChatWidget { ...this.getModeRequestOptions(), modeInfo: this.input.currentModeInfo, agentIdSilent: this._lockedAgent?.id, + queue: options?.queue, }); - if (!result) { + if (this.viewModel.sessionResource && !options.queue) { this.chatAccessibilityService.disposeRequest(this.viewModel.sessionResource); + } + + if (ChatSendResult.isRejected(result)) { return; } - // visibility sync before we accept input to hide the welcome view + // visibility sync before firing events to hide the welcome view this.updateChatViewVisibility(); - this.input.acceptInput(options?.storeToHistory ?? isUserQuery); - this._onDidSubmitAgent.fire({ agent: result.agent, slashCommand: result.slashCommand }); - this.handleDelegationExitIfNeeded(this._lockedAgent, result.agent); - this.currentRequest = result.responseCompletePromise.then(() => { + + const sent = ChatSendResult.isQueued(result) ? await result.deferred : result; + if (!ChatSendResult.isSent(sent)) { + return; + } + + this._onDidSubmitAgent.fire({ agent: sent.data.agent, slashCommand: sent.data.slashCommand }); + this.handleDelegationExitIfNeeded(this._lockedAgent, sent.data.agent); + sent.data.responseCompletePromise.then(() => { const responses = this.viewModel?.getItems().filter(isResponseVM); const lastResponse = responses?.[responses.length - 1]; this.chatAccessibilityService.acceptResponse(this, this.container, lastResponse, this.viewModel?.sessionResource, options?.isVoiceInput); @@ -2196,10 +2221,49 @@ export class ChatWidget extends Disposable implements IChatWidget { this.input.setValue(question, false); } } - this.currentRequest = undefined; }); - return result.responseCreatedPromise; + return sent.data.responseCreatedPromise; + } + + private async confirmPendingRequestsBeforeSend(model: IChatModel, options: IChatAcceptInputOptions): Promise { + if (options.queue) { + return true; + } + + const hasPendingRequests = model.getPendingRequests().length > 0; + if (!hasPendingRequests) { + return true; + } + + const promptResult = await this.dialogService.prompt({ + type: 'question', + message: localize('chat.pendingRequests.prompt.message', "You already have pending requests."), + detail: localize('chat.pendingRequests.prompt.detail', "Do you want to keep them in the queue or remove them before sending this message?"), + buttons: [ + { + label: localize('chat.pendingRequests.prompt.keep', "Keep Pending Requests"), + run: () => 'keep' + }, + { + label: localize('chat.pendingRequests.prompt.remove', "Remove Pending Requests"), + run: () => 'remove' + } + ], + cancelButton: true + }); + + if (!promptResult.result) { + return false; + } + + if (promptResult.result === 'remove') { + for (const pendingRequest of [...model.getPendingRequests()]) { + this.chatService.removePendingRequest(model.sessionResource, pendingRequest.request.id); + } + } + + return true; } getModeRequestOptions(): Partial { @@ -2427,7 +2491,7 @@ export class ChatWidget extends Disposable implements IChatWidget { this.logService.debug(`ChatWidget#_autoAttachInstructions: prompt files are always enabled`); const enabledTools = this.input.currentModeKind === ChatModeKind.Agent ? this.input.selectedToolsModel.userSelectedTools.get() : undefined; const enabledSubAgents = this.input.currentModeKind === ChatModeKind.Agent ? this.input.currentModeObs.get().agents?.get() : undefined; - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeObs.get(), enabledTools, enabledSubAgents); + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, this.input.currentModeKind, enabledTools, enabledSubAgents); await computer.collect(attachedContext, CancellationToken.None); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 846fa671dc3..91ca4894116 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -2140,6 +2140,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.height.set(newHeight, undefined); })); this._register(inputResizeObserver.observe(this.container)); + + if (this.options.renderStyle === 'compact') { + const toolbarsResizeObserver = this._register(new dom.DisposableResizeObserver(() => { + // Have to layout the editor when the toolbars change size, when they share width with the editor. + // This handles ensuring we layout when quick chat is shown/hidden. + // The toolbar may have changed since the last time it was visible. + if (this.cachedWidth) { + this.layout(this.cachedWidth); + } + })); + this._register(toolbarsResizeObserver.observe(toolbarsContainer)); + } } public toggleChatInputOverlay(editing: boolean): void { diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index d0831ab9c95..d94ccd6dbac 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -263,6 +263,9 @@ color: var(--vscode-descriptionForeground); line-height: 16px; margin-left: auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .interactive-item-container .chat-footer-details.hidden { @@ -787,6 +790,11 @@ have to be updated for changes to the rules above, or to support more deeply nes z-index: 1; } +/* Hide context usage widget in compact mode (quick chat) */ +.interactive-session .interactive-input-part.compact .chat-context-usage-container { + display: none; +} + .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container, .interactive-input-part:has(.chat-todo-list-widget-container > .chat-todo-list-widget.has-todos) .chat-input-container, .interactive-input-part:has(.chat-input-widgets-container > .chat-status-widget:not([style*="display: none"])) .chat-input-container { @@ -2529,7 +2537,6 @@ have to be updated for changes to the rules above, or to support more deeply nes top: -13px; right: 20px; border-radius: 3px; - width: 28px; height: 26px; } @@ -2546,7 +2553,6 @@ have to be updated for changes to the rules above, or to support more deeply nes } .request-hover:not(.expanded) .actions-container { - width: 22px; height: 22px; } @@ -2555,11 +2561,8 @@ have to be updated for changes to the rules above, or to support more deeply nes } .request-hover:not(.expanded) .actions-container { - - .action-label.codicon-discard, - .action-label.codicon-x, - .action-label.codicon-edit { - margin-top: 4px; + .action-label { + margin: 4px 2px 0; padding: 3px 3px; } } @@ -2847,3 +2850,38 @@ have to be updated for changes to the rules above, or to support more deeply nes font-style: italic; color: var(--vscode-descriptionForeground); } + +/* Pending request styles */ +.interactive-item-container.pending-request { + opacity: 0.7; + + .request-hover { + top: -17px !important; + } +} + +.interactive-item-container .chat-request-status { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-chat-font-size-body-xs); + margin-top: 2px; + text-align: right; +} + +/* Pending divider styles */ +.interactive-item-container.pending-divider { + padding: 8px 16px; +} + +.interactive-item-container.pending-divider .pending-divider-content { + display: flex; + align-items: center; + gap: 8px; +} + +.interactive-item-container.pending-divider .pending-divider-label { + font-size: var(--vscode-chat-font-size-body-xs); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); +} 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 647486aa963..306f8b863c9 100644 --- a/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts +++ b/src/vs/workbench/contrib/chat/browser/widgetHosts/viewPane/chatContextUsageDetails.ts @@ -51,7 +51,6 @@ export class ChatContextUsageDetails extends Disposable { super(); this.domNode = $('.chat-context-usage-details'); - this.domNode.setAttribute('tabindex', '0'); // Using same structure as ChatUsageWidget quota items this.quotaItem = this.domNode.appendChild($('.quota-item')); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 90858649e66..4331b69fa59 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -23,6 +23,7 @@ export namespace ChatContextKeys { export const isResponse = new RawContextKey('chatResponse', false, { type: 'boolean', description: localize('chatResponse', "The chat item is a response.") }); export const isRequest = new RawContextKey('chatRequest', false, { type: 'boolean', description: localize('chatRequest', "The chat item is a request") }); + export const isPendingRequest = new RawContextKey('chatRequestIsPending', false, { type: 'boolean', description: localize('chatRequestIsPending', "True when the chat request item is pending in the queue.") }); export const itemId = new RawContextKey('chatItemId', '', { type: 'string', description: localize('chatItemId', "The id of the chat item.") }); export const lastItemId = new RawContextKey('chatLastItemId', [], { type: 'string', description: localize('chatLastItemId', "The id of the last chat item.") }); @@ -69,6 +70,7 @@ export namespace ChatContextKeys { export const chatSessionType = new RawContextKey('chatSessionType', '', { type: 'string', description: localize('chatSessionType', "The type of the current chat session.") }); export const hasFileAttachments = new RawContextKey('chatHasFileAttachments', false, { type: 'boolean', description: localize('chatHasFileAttachments', "True when the chat has file attachments.") }); export const chatSessionIsEmpty = new RawContextKey('chatSessionIsEmpty', true, { type: 'boolean', description: localize('chatSessionIsEmpty', "True when the current chat session has no requests.") }); + export const hasPendingRequests = new RawContextKey('chatHasPendingRequests', false, { type: 'boolean', description: localize('chatHasPendingRequests', "True when there are pending requests in the queue.") }); export const remoteJobCreating = new RawContextKey('chatRemoteJobCreating', false, { type: 'boolean', description: localize('chatRemoteJobCreating', "True when a remote coding agent job is being created.") }); export const hasRemoteCodingAgent = new RawContextKey('hasRemoteCodingAgent', false, localize('hasRemoteCodingAgent', "Whether any remote coding agent is available")); diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts index ed2b28d0265..ff9fdf7e038 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatService.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatService.ts @@ -1154,6 +1154,54 @@ export interface IChatSendRequestData extends IChatSendRequestResponseState { slashCommand?: IChatAgentCommand; } +/** + * Result of a sendRequest call - a discriminated union of possible outcomes. + */ +export type ChatSendResult = + | ChatSendResultRejected + | ChatSendResultSent + | ChatSendResultQueued; + +export interface ChatSendResultRejected { + readonly kind: 'rejected'; + readonly reason: string; +} + +export interface ChatSendResultSent { + readonly kind: 'sent'; + readonly data: IChatSendRequestData; +} + +export interface ChatSendResultQueued { + readonly kind: 'queued'; + /** + * Promise that resolves when the queued message is actually processed. + * Will resolve to a 'sent' or 'rejected' result. + */ + readonly deferred: Promise; +} + +export namespace ChatSendResult { + export function isSent(result: ChatSendResult): result is ChatSendResultSent { + return result.kind === 'sent'; + } + + export function isRejected(result: ChatSendResult): result is ChatSendResultRejected { + return result.kind === 'rejected'; + } + + export function isQueued(result: ChatSendResult): result is ChatSendResultQueued { + return result.kind === 'queued'; + } + + /** Assertion function for tests - asserts that the result is a sent result */ + export function assertSent(result: ChatSendResult): asserts result is ChatSendResultSent { + if (result.kind !== 'sent') { + throw new Error(`Expected ChatSendResult to be 'sent', but was '${result.kind}'`); + } + } +} + export interface IChatEditorLocationData { type: ChatAgentLocation.EditorInline; id: string; @@ -1174,6 +1222,16 @@ export interface IChatTerminalLocationData { export type IChatLocationData = IChatEditorLocationData | IChatNotebookLocationData | IChatTerminalLocationData; +/** + * The kind of queue request. + */ +export const enum ChatRequestQueueKind { + /** Request is queued to be sent after current request completes */ + Queued = 'queued', + /** Request is queued and signals the active request to yield */ + Steering = 'steering' +} + export interface IChatSendRequestOptions { modeInfo?: IChatRequestModeInfo; userSelectedModelId?: string; @@ -1200,6 +1258,12 @@ export interface IChatSendRequestOptions { */ confirmation?: string; + /** + * When set, queues this message to be sent after the current request completes. + * If Steering, also sets yieldRequested on any active request to signal it should wrap up. + */ + queue?: ChatRequestQueueKind; + } export type IChatModelReference = IReference; @@ -1241,9 +1305,10 @@ export interface IChatService { getChatSessionFromInternalUri(sessionResource: URI): IChatSessionContext | undefined; /** - * Returns whether the request was accepted.` + * Sends a chat request for the given session. + * @returns A result indicating whether the request was sent, queued, or rejected. */ - sendRequest(sessionResource: URI, message: string, options?: IChatSendRequestOptions): Promise; + sendRequest(sessionResource: URI, message: string, options?: IChatSendRequestOptions): Promise; /** * Sets a custom title for a chat model. @@ -1254,6 +1319,26 @@ export interface IChatService { adoptRequest(sessionResource: URI, request: IChatRequestModel): Promise; removeRequest(sessionResource: URI, requestId: string): Promise; cancelCurrentRequestForSession(sessionResource: URI): void; + /** + * Sets yieldRequested on the active request for the given session. + */ + setYieldRequested(sessionResource: URI): void; + /** + * Removes a pending request from the session's queue. + */ + removePendingRequest(sessionResource: URI, requestId: string): void; + /** + * Sets the pending requests for a session, allowing for deletions/reordering. + * Adding new requests should go through sendRequest with the queue option. + */ + setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void; + /** + * Ensures pending requests for the session are processing. If restoring from + * storage or after an error, pending requests may be present without an + * active chat message 'loop' happening. THis triggers the loop to happen + * as needed. Idempotent, safe to call at any time. + */ + processPendingRequests(sessionResource: URI): void; addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void; setChatSessionTitle(sessionResource: URI, title: string): void; getLocalSessionHistory(): Promise; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index fb9131a929f..23926462645 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -37,7 +37,7 @@ import { ChatModel, ChatRequestModel, ChatRequestRemovalReason, IChatModel, ICha import { ChatModelStore, IStartSessionProps } from '../model/chatModelStore.js'; import { chatAgentLeader, ChatRequestAgentPart, ChatRequestAgentSubcommandPart, ChatRequestSlashCommandPart, ChatRequestTextPart, chatSubcommandLeader, getPromptText, IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { ChatRequestParser } from '../requestParser/chatRequestParser.js'; -import { ChatMcpServersStarting, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestData, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; +import { ChatMcpServersStarting, ChatRequestQueueKind, ChatSendResult, ChatSendResultQueued, IChatCompleteResponse, IChatDetail, IChatFollowup, IChatModelReference, IChatProgress, IChatSendRequestOptions, IChatSendRequestResponseState, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent, ResponseModelState } from './chatService.js'; import { ChatRequestTelemetry, ChatServiceTelemetry } from './chatServiceTelemetry.js'; import { IChatSessionsService } from '../chatSessionsService.js'; import { ChatSessionStore, IChatSessionEntryMetadata } from '../model/chatSessionStore.js'; @@ -49,10 +49,19 @@ import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants import { ChatMessageRole, IChatMessage } from '../languageModels.js'; import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js'; import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js'; +import { IPromptsService } from '../promptSyntax/service/promptsService.js'; +import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; +import { IHooksExecutionService } from '../hooksExecutionService.js'; const serializedChatKey = 'interactive.sessions'; class CancellableRequest implements IDisposable { + private _yieldRequested = false; + + get yieldRequested(): boolean { + return this._yieldRequested; + } + constructor( public readonly cancellationTokenSource: CancellationTokenSource, public requestId: string | undefined, @@ -70,6 +79,10 @@ class CancellableRequest implements IDisposable { this.cancellationTokenSource.cancel(); } + + setYieldRequested(): void { + this._yieldRequested = true; + } } export class ChatService extends Disposable implements IChatService { @@ -77,6 +90,7 @@ export class ChatService extends Disposable implements IChatService { private readonly _sessionModels: ChatModelStore; private readonly _pendingRequests = this._register(new DisposableResourceMap()); + private readonly _queuedRequestDeferreds = new Map>(); private _saveModelsEnabled = true; private _transferredSessionResource: URI | undefined; @@ -141,6 +155,8 @@ export class ChatService extends Disposable implements IChatService { @IChatTransferService private readonly chatTransferService: IChatTransferService, @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, + @IPromptsService private readonly promptsService: IPromptsService, + @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, ) { super(); @@ -715,13 +731,41 @@ export class ChatService extends Disposable implements IChatService { await this._sendRequestAsync(model, model.sessionResource, request.message, attempt, enableCommandDetection, defaultAgent, location, resendOptions).responseCompletePromise; } - async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { + private queuePendingRequest(model: ChatModel, sessionResource: URI, request: string, options: IChatSendRequestOptions): ChatSendResultQueued { + const location = options.location ?? model.initialLocation; + const parsedRequest = this.parseChatRequest(sessionResource, request, location, options); + const requestModel = new ChatRequestModel({ + session: model, + message: parsedRequest, + variableData: { variables: [] }, + timestamp: Date.now(), + modeInfo: options.modeInfo, + locationData: options.locationData, + attachedContext: options.attachedContext, + modelId: options.userSelectedModelId, + userSelectedTools: options.userSelectedTools?.get(), + }); + + const deferred = new DeferredPromise(); + this._queuedRequestDeferreds.set(requestModel.id, deferred); + + model.addPendingRequest(requestModel, options.queue ?? ChatRequestQueueKind.Queued, { ...options, queue: undefined }); + + if (options.queue === ChatRequestQueueKind.Steering) { + this.setYieldRequested(sessionResource); + } + + this.trace('sendRequest', `Queued message for session ${sessionResource}`); + return { kind: 'queued', deferred: deferred.p }; + } + + async sendRequest(sessionResource: URI, request: string, options?: IChatSendRequestOptions): Promise { this.trace('sendRequest', `sessionResource: ${sessionResource.toString()}, message: ${request.substring(0, 20)}${request.length > 20 ? '[...]' : ''}}`); if (!request.trim() && !options?.slashCommand && !options?.agentId && !options?.agentIdSilent) { this.trace('sendRequest', 'Rejected empty message'); - return; + return { kind: 'rejected', reason: 'Empty message' }; } const model = this._sessionModels.get(sessionResource); @@ -729,9 +773,23 @@ export class ChatService extends Disposable implements IChatService { throw new Error(`Unknown session: ${sessionResource}`); } - if (this._pendingRequests.has(sessionResource)) { + const hasPendingRequest = this._pendingRequests.has(sessionResource); + const hasPendingQueue = model.getPendingRequests().length > 0; + + if (hasPendingRequest) { + // A request is already in progress + if (options?.queue) { + // Queue this message to be sent after the current request completes + return this.queuePendingRequest(model, sessionResource, request, options); + } this.trace('sendRequest', `Session ${sessionResource} already has a pending request`); - return; + return { kind: 'rejected', reason: 'Request already in progress' }; + } + + if (options?.queue && hasPendingQueue) { + const queued = this.queuePendingRequest(model, sessionResource, request, options); + this.processNextPendingRequest(model); + return queued; } const requests = model.getRequests(); @@ -757,9 +815,12 @@ export class ChatService extends Disposable implements IChatService { // This method is only returning whether the request was accepted - don't block on the actual request return { - ...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options), - agent, - slashCommand: agentSlashCommandPart?.command, + kind: 'sent', + data: { + ...this._sendRequestAsync(model, sessionResource, parsedRequest, attempt, !options?.noCommandDetection, silentAgent ?? defaultAgent, location, options), + agent, + slashCommand: agentSlashCommandPart?.command, + }, }; } @@ -845,6 +906,18 @@ export class ChatService extends Disposable implements IChatService { let detectedAgent: IChatAgentData | undefined; let detectedCommand: IChatAgentCommand | undefined; + // Collect hooks from hooks.json files + let collectedHooks: IChatRequestHooks | undefined; + try { + collectedHooks = await this.promptsService.getHooks(token); + } catch (error) { + this.logService.warn('[ChatService] Failed to collect hooks:', error); + } + + if (collectedHooks) { + store.add(this.hooksExecutionService.registerHooks(model.sessionResource, collectedHooks)); + } + const stopWatch = new StopWatch(false); store.add(token.onCancellationRequested(() => { this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`); @@ -905,6 +978,7 @@ export class ChatService extends Disposable implements IChatService { userSelectedTools: options?.userSelectedTools?.get(), modeInstructions: options?.modeInfo?.modeInstructions, editedFileEvents: request.editedFileEvents, + hooks: collectedHooks, }; let isInitialTools = true; @@ -1032,6 +1106,7 @@ export class ChatService extends Disposable implements IChatService { completeResponseCreated(); this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`); + shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); if (agentOrCommandFollowups) { agentOrCommandFollowups.then(followups => { @@ -1061,11 +1136,16 @@ export class ChatService extends Disposable implements IChatService { store.dispose(); } }; + let shouldProcessPending = false; const rawResponsePromise = sendRequestInternal(); // Note- requestId is not known at this point, assigned later this._pendingRequests.set(model.sessionResource, this.instantiationService.createInstance(CancellableRequest, source, undefined)); rawResponsePromise.finally(() => { this._pendingRequests.deleteAndDispose(model.sessionResource); + // Process the next pending request from the queue if any + if (shouldProcessPending) { + this.processNextPendingRequest(model); + } }); this._onDidSubmitRequest.fire({ chatSessionResource: model.sessionResource }); return { @@ -1074,6 +1154,56 @@ export class ChatService extends Disposable implements IChatService { }; } + processPendingRequests(sessionResource: URI): void { + const model = this._sessionModels.get(sessionResource); + if (model && !this._pendingRequests.has(sessionResource)) { + this.processNextPendingRequest(model); + } + } + + /** + * Process the next pending request from the model's queue, if any. + * Called after a request completes to continue processing queued requests. + */ + private processNextPendingRequest(model: ChatModel): void { + const pendingRequest = model.dequeuePendingRequest(); + if (!pendingRequest) { + return; + } + + this.trace('processNextPendingRequest', `Processing queued request for session ${model.sessionResource}`); + + const deferred = this._queuedRequestDeferreds.get(pendingRequest.request.id); + this._queuedRequestDeferreds.delete(pendingRequest.request.id); + + const sendOptions = pendingRequest.sendOptions; + const location = sendOptions.location ?? sendOptions.locationData?.type ?? model.initialLocation; + const defaultAgent = this.chatAgentService.getDefaultAgent(location, sendOptions.modeInfo?.kind); + if (!defaultAgent) { + this.logService.warn('processNextPendingRequest', `No default agent for location ${location}`); + deferred?.complete({ kind: 'rejected', reason: 'No default agent available' }); + return; + } + + const parsedRequest = pendingRequest.request.message; + const silentAgent = sendOptions.agentIdSilent ? this.chatAgentService.getAgent(sendOptions.agentIdSilent) : undefined; + const agent = silentAgent ?? parsedRequest.parts.find((r): r is ChatRequestAgentPart => r instanceof ChatRequestAgentPart)?.agent ?? defaultAgent; + const agentSlashCommandPart = parsedRequest.parts.find((r): r is ChatRequestAgentSubcommandPart => r instanceof ChatRequestAgentSubcommandPart); + + // Send the queued request - this will add it to _pendingRequests and handle it normally + const responseState = this._sendRequestAsync(model, model.sessionResource, parsedRequest, pendingRequest.request.attempt, !sendOptions.noCommandDetection, silentAgent ?? defaultAgent, location, sendOptions); + + // Resolve the deferred with the sent result + deferred?.complete({ + kind: 'sent', + data: { + ...responseState, + agent, + slashCommand: agentSlashCommandPart?.command, + }, + }); + } + private generateInitialChatTitleIfNeeded(model: ChatModel, request: IChatAgentRequest, defaultAgent: IChatAgentData, token: CancellationToken): void { // Generate a title only for the first request, and only via the default agent. // Use a single-entry history based on the current request (no full chat history). @@ -1221,6 +1351,34 @@ export class ChatService extends Disposable implements IChatService { this._pendingRequests.deleteAndDispose(sessionResource); } + setYieldRequested(sessionResource: URI): void { + const pendingRequest = this._pendingRequests.get(sessionResource); + if (pendingRequest) { + pendingRequest.setYieldRequested(); + } + } + + removePendingRequest(sessionResource: URI, requestId: string): void { + const model = this._sessionModels.get(sessionResource) as ChatModel | undefined; + if (model) { + model.removePendingRequest(requestId); + } + + // Reject the deferred promise for the removed request + const deferred = this._queuedRequestDeferreds.get(requestId); + if (deferred) { + deferred.complete({ kind: 'rejected', reason: 'Request was removed from queue' }); + this._queuedRequestDeferreds.delete(requestId); + } + } + + setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { + const model = this._sessionModels.get(sessionResource) as ChatModel | undefined; + if (model) { + model.setPendingRequests(requests); + } + } + public hasSessions(): boolean { return this._chatSessionStore.hasSessions(); } diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index ac53b31a898..45bfafb6564 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -206,8 +206,8 @@ export interface IChatSessionsService { readonly _serviceBrand: undefined; // #region Chat session item provider support - readonly onDidChangeItemsProviders: Event; - readonly onDidChangeSessionItems: Event; + readonly onDidChangeItemsProviders: Event<{ readonly chatSessionType: string }>; + readonly onDidChangeSessionItems: Event<{ readonly chatSessionType: string }>; readonly onDidChangeAvailability: Event; readonly onDidChangeInProgress: Event; @@ -215,7 +215,7 @@ export interface IChatSessionsService { getChatSessionContribution(chatSessionType: string): IChatSessionsExtensionPoint | undefined; registerChatSessionItemProvider(provider: IChatSessionItemProvider): IDisposable; - activateChatSessionItemProvider(chatSessionType: string): Promise; + activateChatSessionItemProvider(chatSessionType: string): Promise; getAllChatSessionContributions(): IChatSessionsExtensionPoint[]; getIconForSessionType(chatSessionType: string): ThemeIcon | URI | undefined; @@ -227,13 +227,11 @@ export interface IChatSessionsService { * Get the list of chat session items grouped by session type. * @param providerTypeFilter If specified, only returns items from the given providers. If undefined, returns items from all providers. */ - getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; + getChatSessionItems(providerTypeFilter: readonly string[] | undefined, token: CancellationToken): Promise>; reportInProgress(chatSessionType: string, count: number): void; getInProgress(): { displayName: string; count: number }[]; - // Notify providers about session items changes - notifySessionItemsChanged(chatSessionType: string): void; // #endregion // #region Content provider support diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index 13056d6991a..baa3145870c 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -11,6 +11,7 @@ import { RawContextKey } from '../../../../platform/contextkey/common/contextkey export enum ChatConfiguration { AIDisabled = 'chat.disableAIFeatures', AgentEnabled = 'chat.agent.enabled', + RequestQueueingEnabled = 'chat.requestQueuing.enabled', AgentStatusEnabled = 'chat.agentsControl.enabled', EditorAssociations = 'chat.editorAssociations', UnifiedAgentsBar = 'chat.unifiedAgentsBar.enabled', diff --git a/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts new file mode 100644 index 00000000000..d79d2f59199 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/hooksExecutionService.ts @@ -0,0 +1,187 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; +import { URI } from '../../../../base/common/uri.js'; +import { HookTypeValue, IChatRequestHooks, IHookCommand } from './promptSyntax/hookSchema.js'; +import { IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { StopWatch } from '../../../../base/common/stopwatch.js'; +import { Extensions, IOutputChannelRegistry, IOutputService } from '../../../services/output/common/output.js'; +import { Registry } from '../../../../platform/registry/common/platform.js'; +import { localize } from '../../../../nls.js'; + +export const hooksOutputChannelId = 'hooksExecution'; +const hooksOutputChannelLabel = localize('hooksExecutionChannel', "Hooks"); + +export const enum HookResultKind { + Success = 1, + Error = 2 +} + +export interface IHookResult { + readonly kind: HookResultKind; + readonly result: string | object; +} + +export interface IHooksExecutionOptions { + readonly input?: unknown; + readonly token?: CancellationToken; +} + +/** + * Callback interface for hook execution proxies. + * MainThreadHooks implements this to forward calls to the extension host. + */ +export interface IHooksExecutionProxy { + runHookCommand(hookCommand: IHookCommand, input: unknown, token: CancellationToken): Promise; +} + +export const IHooksExecutionService = createDecorator('hooksExecutionService'); + +export interface IHooksExecutionService { + _serviceBrand: undefined; + + /** + * Called by mainThreadHooks when extension host is ready + */ + setProxy(proxy: IHooksExecutionProxy): void; + + /** + * Register hooks for a session. Returns a disposable that unregisters them. + */ + registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable; + + /** + * Get hooks registered for a session. + */ + getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined; + + /** + * Execute hooks of the given type for the given session + */ + executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise; +} + +export class HooksExecutionService implements IHooksExecutionService { + declare readonly _serviceBrand: undefined; + + private _proxy: IHooksExecutionProxy | undefined; + private readonly _sessionHooks = new Map(); + private _channelRegistered = false; + private _requestCounter = 0; + + constructor( + @ILogService private readonly _logService: ILogService, + @IOutputService private readonly _outputService: IOutputService, + ) { } + + setProxy(proxy: IHooksExecutionProxy): void { + this._proxy = proxy; + } + + private _ensureOutputChannel(): void { + if (this._channelRegistered) { + return; + } + Registry.as(Extensions.OutputChannels).registerChannel({ + id: hooksOutputChannelId, + label: hooksOutputChannelLabel, + log: false + }); + this._channelRegistered = true; + } + + private _log(requestId: number, hookType: HookTypeValue, message: string): void { + this._ensureOutputChannel(); + const channel = this._outputService.getChannel(hooksOutputChannelId); + if (channel) { + channel.append(`[${new Date().toISOString()}] [#${requestId}] [${hookType}] ${message}\n`); + } + } + + private async _runSingleHook( + requestId: number, + hookType: HookTypeValue, + hookCommand: IHookCommand, + input: unknown, + token: CancellationToken + ): Promise { + const hookCommandJson = JSON.stringify({ + ...hookCommand, + cwd: hookCommand.cwd?.fsPath + }); + this._log(requestId, hookType, `Running: ${hookCommandJson}`); + if (input !== undefined) { + this._log(requestId, hookType, `Input: ${JSON.stringify(input)}`); + } + + const sw = StopWatch.create(); + try { + const result = await this._proxy!.runHookCommand(hookCommand, input, token); + this._logResult(requestId, hookType, result, sw.elapsed()); + return result; + } catch (err) { + const errMessage = err instanceof Error ? err.message : String(err); + this._log(requestId, hookType, `Error in ${sw.elapsed()}ms: ${errMessage}`); + return { kind: HookResultKind.Error, result: errMessage }; + } + } + + private _logResult(requestId: number, hookType: HookTypeValue, result: IHookResult, elapsed: number): void { + const resultKindStr = result.kind === HookResultKind.Success ? 'Success' : 'Error'; + const resultStr = typeof result.result === 'string' ? result.result : JSON.stringify(result.result); + const hasOutput = resultStr.length > 0 && resultStr !== '{}' && resultStr !== '[]'; + if (hasOutput) { + this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms`); + this._log(requestId, hookType, `Output: ${resultStr}`); + } else { + this._log(requestId, hookType, `Completed (${resultKindStr}) in ${elapsed}ms, no output`); + } + } + + registerHooks(sessionResource: URI, hooks: IChatRequestHooks): IDisposable { + const key = sessionResource.toString(); + this._sessionHooks.set(key, hooks); + return toDisposable(() => { + this._sessionHooks.delete(key); + }); + } + + getHooksForSession(sessionResource: URI): IChatRequestHooks | undefined { + return this._sessionHooks.get(sessionResource.toString()); + } + + async executeHook(hookType: HookTypeValue, sessionResource: URI, options?: IHooksExecutionOptions): Promise { + if (!this._proxy) { + return []; + } + + const hooks = this.getHooksForSession(sessionResource); + if (!hooks) { + return []; + } + + const hookCommands = hooks[hookType]; + if (!hookCommands || hookCommands.length === 0) { + return []; + } + + const requestId = this._requestCounter++; + const token = options?.token ?? CancellationToken.None; + + this._logService.debug(`[HooksExecutionService] Executing ${hookCommands.length} hook(s) for type '${hookType}'`); + this._log(requestId, hookType, `Executing ${hookCommands.length} hook(s)`); + + const results: IHookResult[] = []; + for (const hookCommand of hookCommands) { + const result = await this._runSingleHook(requestId, hookType, hookCommand, options?.input, token); + results.push(result); + } + + return results; + } +} diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 0df77c1014f..d3366a2de9a 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -300,7 +300,7 @@ export interface ILanguageModelsService { /** * Find a model by its qualified name. The qualified name is what is used in prompt and agent files and is in the format "Model Name (Vendor)". */ - lookupLanguageModelByQualifiedName(qualifiedName: string): ILanguageModelChatMetadata | undefined; + lookupLanguageModelByQualifiedName(qualifiedName: string): ILanguageModelChatMetadataAndIdentifier | undefined; getLanguageModelGroups(vendor: string): ILanguageModelsGroup[]; @@ -642,10 +642,10 @@ export class LanguageModelsService implements ILanguageModelsService { return model; } - lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadata | undefined { - for (const model of this._modelCache.values()) { + lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadataAndIdentifier | undefined { + for (const [identifier, model] of this._modelCache.entries()) { if (ILanguageModelChatMetadata.matchesQualifiedName(referenceName, model)) { - return model; + return { metadata: model, identifier }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 343dfce4a59..056abf3a60f 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -14,7 +14,7 @@ import { ResourceMap } from '../../../../../base/common/map.js'; import { revive } from '../../../../../base/common/marshalling.js'; import { Schemas } from '../../../../../base/common/network.js'; import { equals } from '../../../../../base/common/objects.js'; -import { IObservable, autorun, autorunSelfDisposable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js'; +import { IObservable, autorun, autorunSelfDisposable, constObservable, derived, observableFromEvent, observableSignalFromEvent, observableValue, observableValueOpts } from '../../../../../base/common/observable.js'; import { basename, isEqual } from '../../../../../base/common/resources.js'; import { hasKey, WithDefinedProps } from '../../../../../base/common/types.js'; import { URI, UriDto } from '../../../../../base/common/uri.js'; @@ -29,7 +29,7 @@ import { ILogService } from '../../../../../platform/log/common/log.js'; import { CellUri, ICellEditOperation } from '../../../notebook/common/notebookCommon.js'; import { ChatRequestToolReferenceEntry, IChatRequestVariableEntry, isImplicitVariableEntry, isStringImplicitContextValue, isStringVariableEntry } from '../attachments/chatVariableEntries.js'; import { migrateLegacyTerminalToolSpecificData } from '../chat.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, ChatResponseClearToPreviousToolInvocationReason, ElicitationState, IChatAgentMarkdownContentWithVulnerability, IChatClearToPreviousToolInvocation, IChatCodeCitation, IChatCommandButton, IChatConfirmation, IChatContentInlineReference, IChatContentReference, IChatEditingSessionAction, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatLocationData, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatModelReference, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatNotebookEdit, IChatProgress, IChatProgressMessage, IChatPullRequestContent, IChatQuestionCarousel, IChatResponseCodeblockUriPart, IChatResponseProgressFileTreeData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionTiming, IChatTask, IChatTaskSerialized, IChatTextEdit, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, IChatUsage, IChatUsedContext, IChatWarningMessage, IChatWorkspaceEdit, ResponseModelState, isIUsedContext } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; import { IChatEditingService, IChatEditingSession, ModifiedFileEntryState } from '../editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier } from '../languageModels.js'; @@ -39,6 +39,48 @@ import { LocalChatSessionUri } from './chatUri.js'; import { ObjectMutationLog } from './objectMutationLog.js'; +/** + * Represents a queued chat request waiting to be processed. + */ +export interface IChatPendingRequest { + readonly request: IChatRequestModel; + readonly kind: ChatRequestQueueKind; + /** + * The options that were passed to sendRequest when this request was queued. + * userSelectedTools is snapshotted to a static observable at queue time. + */ + readonly sendOptions: IChatSendRequestOptions; +} + +/** + * Serializable version of IChatSendRequestOptions for pending requests. + * Excludes observables and non-serializable fields. + */ +export interface ISerializableSendOptions { + modeInfo?: IChatRequestModeInfo; + userSelectedModelId?: string; + /** Static snapshot of user-selected tools (not an observable) */ + userSelectedTools?: UserSelectedTools; + location?: ChatAgentLocation; + locationData?: IChatLocationData; + attempt?: number; + noCommandDetection?: boolean; + agentId?: string; + agentIdSilent?: string; + slashCommand?: string; + confirmation?: string; +} + +/** + * Serializable representation of a pending chat request. + */ +export interface ISerializablePendingRequestData { + id: string; + request: ISerializableChatRequestData; + kind: ChatRequestQueueKind; + sendOptions: ISerializableSendOptions; +} + export const CHAT_ATTACHABLE_IMAGE_MIME_TYPES: Record = { png: 'image/png', jpg: 'image/jpeg', @@ -1284,6 +1326,9 @@ export interface IChatModel extends IDisposable { readonly repoData: IExportableRepoData | undefined; setRepoData(data: IExportableRepoData | undefined): void; + + readonly onDidChangePendingRequests: Event; + getPendingRequests(): readonly IChatPendingRequest[]; } export interface ISerializableChatsData { @@ -1462,6 +1507,8 @@ export interface ISerializableChatData3 extends Omit()); readonly onDidChange = this._onDidChange.event; + private readonly _pendingRequests: IChatPendingRequest[] = []; + private readonly _onDidChangePendingRequests = this._register(new Emitter()); + readonly onDidChangePendingRequests = this._onDidChangePendingRequests.event; + private _requests: ChatRequestModel[]; private _contributedChatSession: IChatSessionContext | undefined; @@ -1799,6 +1850,88 @@ export class ChatModel extends Disposable implements IChatModel { this._repoData = data; } + getPendingRequests(): readonly IChatPendingRequest[] { + return this._pendingRequests; + } + + setPendingRequests(requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { + const existingMap = new Map(this._pendingRequests.map(p => [p.request.id, p])); + const newPending: IChatPendingRequest[] = []; + for (const { requestId, kind } of requests) { + const existing = existingMap.get(requestId); + if (existing) { + // Update kind if changed, keep existing request and sendOptions + newPending.push(existing.kind === kind ? existing : { request: existing.request, kind, sendOptions: existing.sendOptions }); + } + } + this._pendingRequests.length = 0; + this._pendingRequests.push(...newPending); + this._onDidChangePendingRequests.fire(); + } + + /** + * @internal Used by ChatService to add a request to the queue. + * Steering messages are placed before queued messages. + */ + addPendingRequest(request: ChatRequestModel, kind: ChatRequestQueueKind, sendOptions: IChatSendRequestOptions): IChatPendingRequest { + const pendingRequest: IChatPendingRequest = { + request, + kind, + sendOptions, + }; + + if (kind === ChatRequestQueueKind.Steering) { + // Insert after the last steering message, or at the beginning if there is none + let insertIndex = 0; + for (let i = 0; i < this._pendingRequests.length; i++) { + if (this._pendingRequests[i].kind === ChatRequestQueueKind.Steering) { + insertIndex = i + 1; + } else { + break; + } + } + this._pendingRequests.splice(insertIndex, 0, pendingRequest); + } else { + // Queued messages always go at the end + this._pendingRequests.push(pendingRequest); + } + + this._onDidChangePendingRequests.fire(); + return pendingRequest; + } + + /** + * @internal Used by ChatService to remove a pending request + */ + removePendingRequest(id: string): void { + const index = this._pendingRequests.findIndex(r => r.request.id === id); + if (index !== -1) { + this._pendingRequests.splice(index, 1); + this._onDidChangePendingRequests.fire(); + } + } + + /** + * @internal Used by ChatService to dequeue the next pending request + */ + dequeuePendingRequest(): IChatPendingRequest | undefined { + const request = this._pendingRequests.shift(); + if (request) { + this._onDidChangePendingRequests.fire(); + } + return request; + } + + /** + * @internal Used by ChatService to clear all pending requests + */ + clearPendingRequests(): void { + if (this._pendingRequests.length > 0) { + this._pendingRequests.length = 0; + this._onDidChangePendingRequests.fire(); + } + } + readonly lastRequestObs: IObservable; // TODO to be clear, this is not the same as the id from the session object, which belongs to the provider. @@ -1945,6 +2078,11 @@ export class ChatModel extends Disposable implements IChatModel { this._repoData = isValidFullData && initialData.repoData ? initialData.repoData : undefined; + // Hydrate pending requests from serialized data + if (isValidFullData && initialData.pendingRequests) { + this._pendingRequests = this._deserializePendingRequests(initialData.pendingRequests); + } + this._initialLocation = initialData?.initialLocation ?? initialModelProps.initialLocation; this._canUseTools = initialModelProps.canUseTools; @@ -2048,72 +2186,74 @@ export class ChatModel extends Disposable implements IChatModel { } try { - return requests.map((raw: ISerializableChatRequestData) => { - const parsedRequest = - typeof raw.message === 'string' - ? this.getParsedRequestFromString(raw.message) - : reviveParsedChatRequest(raw.message); - - // Old messages don't have variableData, or have it in the wrong (non-array) shape - const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); - const request = new ChatRequestModel({ - session: this, - message: parsedRequest, - variableData, - timestamp: raw.timestamp ?? -1, - restoredId: raw.requestId, - confirmation: raw.confirmation, - editedFileEvents: raw.editedFileEvents, - modelId: raw.modelId, - }); - request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; - // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts - if (raw.response || raw.result || (raw as any).responseErrorDetails) { - const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format - reviveSerializedAgent(raw.agent) : undefined; - - // Port entries from old format - const result = 'responseErrorDetails' in raw ? - // eslint-disable-next-line local/code-no-dangerous-type-assertions - { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; - let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }; - if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) { - modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; - } - - request.response = new ChatResponseModel({ - responseContent: raw.response ?? [new MarkdownString(raw.response)], - session: this, - agent, - slashCommand: raw.slashCommand, - requestId: request.id, - modelState, - vote: raw.vote, - timestamp: raw.timestamp, - voteDownReason: raw.voteDownReason, - result, - followups: raw.followups, - restoredId: raw.responseId, - timeSpentWaiting: raw.timeSpentWaiting, - shouldBeBlocked: request.shouldBeBlocked.get(), - codeBlockInfos: raw.responseMarkdownInfo?.map(info => ({ suggestionId: info.suggestionId })), - }); - request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; - if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? - request.response.applyReference(revive(raw.usedContext)); - } - - raw.contentReferences?.forEach(r => request.response!.applyReference(revive(r))); - raw.codeCitations?.forEach(c => request.response!.applyCodeCitation(revive(c))); - } - return request; - }); + return requests.map(r => this._deserializeRequest(r)); } catch (error) { this.logService.error('Failed to parse chat data', error); return []; } } + private _deserializeRequest(raw: ISerializableChatRequestData): ChatRequestModel { + const parsedRequest = + typeof raw.message === 'string' + ? this.getParsedRequestFromString(raw.message) + : reviveParsedChatRequest(raw.message); + + // Old messages don't have variableData, or have it in the wrong (non-array) shape + const variableData: IChatRequestVariableData = this.reviveVariableData(raw.variableData); + const request = new ChatRequestModel({ + session: this, + message: parsedRequest, + variableData, + timestamp: raw.timestamp ?? -1, + restoredId: raw.requestId, + confirmation: raw.confirmation, + editedFileEvents: raw.editedFileEvents, + modelId: raw.modelId, + }); + request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts + if (raw.response || raw.result || (raw as any).responseErrorDetails) { + const agent = (raw.agent && 'metadata' in raw.agent) ? // Check for the new format, ignore entries in the old format + reviveSerializedAgent(raw.agent) : undefined; + + // Port entries from old format + const result = 'responseErrorDetails' in raw ? + // eslint-disable-next-line local/code-no-dangerous-type-assertions + { errorDetails: raw.responseErrorDetails } as IChatAgentResult : raw.result; + let modelState = raw.modelState || { value: raw.isCanceled ? ResponseModelState.Cancelled : ResponseModelState.Complete, completedAt: Date.now() }; + if (modelState.value === ResponseModelState.Pending || modelState.value === ResponseModelState.NeedsInput) { + modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; + } + + request.response = new ChatResponseModel({ + responseContent: raw.response ?? [new MarkdownString(raw.response)], + session: this, + agent, + slashCommand: raw.slashCommand, + requestId: request.id, + modelState, + vote: raw.vote, + timestamp: raw.timestamp, + voteDownReason: raw.voteDownReason, + result, + followups: raw.followups, + restoredId: raw.responseId, + timeSpentWaiting: raw.timeSpentWaiting, + shouldBeBlocked: request.shouldBeBlocked.get(), + codeBlockInfos: raw.responseMarkdownInfo?.map(info => ({ suggestionId: info.suggestionId })), + }); + request.response.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; + if (raw.usedContext) { // @ulugbekna: if this's a new vscode sessions, doc versions are incorrect anyway? + request.response.applyReference(revive(raw.usedContext)); + } + + raw.contentReferences?.forEach(r => request.response!.applyReference(revive(r))); + raw.codeCitations?.forEach(c => request.response!.applyCodeCitation(revive(c))); + } + return request; + } + private reviveVariableData(raw: IChatRequestVariableData): IChatRequestVariableData { const variableData = raw && Array.isArray(raw.variables) ? raw : @@ -2133,6 +2273,29 @@ export class ChatModel extends Disposable implements IChatModel { }; } + /** + * Hydrates pending requests from serialized data. + * For each serialized pending request, finds the matching request model and adds it to the pending queue. + */ + private _deserializePendingRequests(pendingRequests: ISerializablePendingRequestData[]): IChatPendingRequest[] { + try { + return pendingRequests.map(pending => ({ + id: pending.id, + request: this._deserializeRequest(pending.request), + kind: pending.kind, + sendOptions: { + ...pending.sendOptions, + userSelectedTools: pending.sendOptions.userSelectedTools + ? constObservable(pending.sendOptions.userSelectedTools) + : undefined, + } + })); + } catch (e) { + this.logService.error('Failed to parse pending chat requests', e); + return []; + } + } + getRequests(): ChatRequestModel[] { @@ -2449,6 +2612,26 @@ export function getCodeCitationsMessage(citations: ReadonlyArray i.contrib, objectsEqual), }); +const pendingRequestSchema = Adapt.object({ + id: Adapt.t(p => p.request.id, Adapt.key()), + request: Adapt.t(p => p.request, requestSchema), + kind: Adapt.v(p => p.kind), + sendOptions: Adapt.v(p => serializeSendOptions(p.sendOptions), objectsEqual), +}); + export const storageSchema = Adapt.object({ version: Adapt.v(() => 3), creationDate: Adapt.v(m => m.timestamp), @@ -170,6 +177,7 @@ export const storageSchema = Adapt.object({ requests: Adapt.t(m => m.getRequests(), Adapt.array(requestSchema)), hasPendingEdits: Adapt.v(m => m.editingSession?.entries.get().some(e => e.state.get() === ModifiedFileEntryState.Modified)), repoData: Adapt.v(m => m.repoData, objectsEqual), + pendingRequests: Adapt.t(m => m.getPendingRequests(), Adapt.array(pendingRequestSchema)), }); export class ChatSessionOperationLog extends Adapt.ObjectMutationLog implements IChatDataSerializerLog { diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index fb7bd5d8a3d..e392bdd13b0 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -12,7 +12,7 @@ import { ThemeIcon } from '../../../../../base/common/themables.js'; import { URI } from '../../../../../base/common/uri.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IChatRequestVariableEntry } from '../attachments/chatVariableEntries.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatRequestQueueKind, IChatCodeCitation, IChatContentReference, IChatFollowup, IChatMcpServersStarting, IChatProgressMessage, IChatQuestionCarousel, IChatResponseErrorDetails, IChatTask, IChatUsedContext } from '../chatService/chatService.js'; import { getFullyQualifiedId, IChatAgentCommand, IChatAgentData, IChatAgentNameService, IChatAgentResult } from '../participants/chatAgents.js'; import { IParsedChatRequest } from '../requestParser/chatParserTypes.js'; import { CodeBlockModelCollection } from '../widget/codeBlockModelCollection.js'; @@ -28,6 +28,10 @@ export function isResponseVM(item: unknown): item is IChatResponseViewModel { return !!item && typeof (item as IChatResponseViewModel).setVote !== 'undefined'; } +export function isPendingDividerVM(item: unknown): item is IChatPendingDividerViewModel { + return !!item && typeof item === 'object' && (item as IChatPendingDividerViewModel).kind === 'pendingDivider'; +} + export function isChatTreeItem(item: unknown): item is IChatRequestViewModel | IChatResponseViewModel { return isRequestVM(item) || isResponseVM(item); } @@ -62,7 +66,7 @@ export interface IChatViewModel { readonly onDidDisposeModel: Event; readonly onDidChange: Event; readonly inputPlaceholder?: string; - getItems(): (IChatRequestViewModel | IChatResponseViewModel)[]; + getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[]; setInputPlaceholder(text: string): void; resetInputPlaceholder(): void; editing?: IChatRequestViewModel; @@ -91,6 +95,8 @@ export interface IChatRequestViewModel { readonly shouldBeBlocked: IObservable; readonly modelId?: string; readonly timestamp: number; + /** The kind of pending request, or undefined if not pending */ + readonly pendingKind?: ChatRequestQueueKind; } export interface IChatResponseMarkdownRenderData { @@ -217,6 +223,15 @@ export interface IChatResponseViewModel { readonly shouldBeBlocked: IObservable; } +export interface IChatPendingDividerViewModel { + readonly kind: 'pendingDivider'; + readonly id: string; // e.g., 'pending-divider-steering' or 'pending-divider-queued' + readonly sessionResource: URI; + readonly isComplete: true; + readonly dividerKind: ChatRequestQueueKind; + currentRenderedHeight: number | undefined; +} + export interface IChatViewModelOptions { /** * Maximum number of items to return from getItems(). @@ -276,6 +291,7 @@ export class ChatViewModel extends Disposable implements IChatViewModel { }); this._register(_model.onDidDispose(() => this._onDidDisposeModel.fire())); + this._register(_model.onDidChangePendingRequests(() => this._onDidChange.fire(null))); this._register(_model.onDidChange(e => { if (e.kind === 'addRequest') { const requestModel = this.instantiationService.createInstance(ChatRequestViewModel, e.request); @@ -319,11 +335,37 @@ export class ChatViewModel extends Disposable implements IChatViewModel { this._items.push(response); } - getItems(): (IChatRequestViewModel | IChatResponseViewModel)[] { - const items = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop); + getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] { + let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop); if (this._options?.maxVisibleItems !== undefined && items.length > this._options.maxVisibleItems) { - return items.slice(-this._options.maxVisibleItems); + items = items.slice(-this._options.maxVisibleItems); } + + const pendingRequests = this._model.getPendingRequests(); + if (pendingRequests.length > 0) { + // Separate steering and queued requests + const steeringRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Steering); + const queuedRequests = pendingRequests.filter(p => p.kind === ChatRequestQueueKind.Queued); + + // Add steering requests with their divider first + if (steeringRequests.length > 0) { + items.push({ kind: 'pendingDivider', id: 'pending-divider-steering', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Steering, currentRenderedHeight: undefined }); + for (const pending of steeringRequests) { + const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind); + items.push(requestVM); + } + } + + // Add queued requests with their divider + if (queuedRequests.length > 0) { + items.push({ kind: 'pendingDivider', id: 'pending-divider-queued', sessionResource: this._model.sessionResource, isComplete: true, dividerKind: ChatRequestQueueKind.Queued, currentRenderedHeight: undefined }); + for (const pending of queuedRequests) { + const requestVM = this.instantiationService.createInstance(ChatRequestViewModel, pending.request, pending.kind); + items.push(requestVM); + } + } + } + return items; } @@ -429,8 +471,13 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._model.timestamp; } + get pendingKind() { + return this._pendingKind; + } + constructor( private readonly _model: IChatRequestModel, + private readonly _pendingKind?: ChatRequestQueueKind, ) { } } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 45e990bf75e..e0362e6b8f8 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -26,6 +26,7 @@ import { asJson, IRequestService } from '../../../../../platform/request/common/ import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { ChatContextKeys } from '../actions/chatContextKeys.js'; import { IChatAgentEditedFileEvent, IChatProgressHistoryResponseContent, IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatAgentData } from '../model/chatModel.js'; +import { IChatRequestHooks } from '../promptSyntax/hookSchema.js'; import { IRawChatCommandContribution } from './chatParticipantContribTypes.js'; import { IChatFollowup, IChatLocationData, IChatProgress, IChatResponseErrorDetails, IChatTaskDto } from '../chatService/chatService.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../constants.js'; @@ -149,6 +150,11 @@ export interface IChatAgentRequest { userSelectedTools?: UserSelectedTools; modeInstructions?: IChatRequestModeInstructions; editedFileEvents?: IChatAgentEditedFileEvent[]; + /** + * Collected hooks configuration for this request. + * Contains all hooks defined in hooks.json files, organized by hook type. + */ + hooks?: IChatRequestHooks; /** * Unique ID for the subagent invocation, used to group tool calls from the same subagent run together. */ @@ -157,6 +163,10 @@ export interface IChatAgentRequest { * Display name of the subagent that is invoking this request. */ subAgentName?: string; + /** + * Set to true by the editor to request the language model gracefully stop after its next opportunity. + */ + yieldRequested?: boolean; /** * The request ID of the parent request that invoked this subagent. */ diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts index 6468c60cdfa..13bb2e6a70c 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/chatPromptFilesContribution.ts @@ -29,7 +29,7 @@ enum ChatContributionPoint { chatInstructions = 'chatInstructions', chatAgents = 'chatAgents', chatPromptFiles = 'chatPromptFiles', - chatSkills = 'chatSkills' + chatSkills = 'chatSkills', } function registerChatFilesExtensionPoint(point: ChatContributionPoint) { @@ -79,6 +79,10 @@ function pointToType(contributionPoint: ChatContributionPoint): PromptsType { case ChatContributionPoint.chatInstructions: return PromptsType.instructions; case ChatContributionPoint.chatAgents: return PromptsType.agent; case ChatContributionPoint.chatSkills: return PromptsType.skill; + default: { + const exhaustiveCheck: never = contributionPoint; + throw new Error(`Unknown contribution point: ${exhaustiveCheck}`); + } } } @@ -148,16 +152,17 @@ CommandsRegistry.registerCommand('_listExtensionPromptFiles', async (accessor): const promptsService = accessor.get(IPromptsService); // Get extension prompt files for all prompt types in parallel - const [agents, instructions, prompts, skills] = await Promise.all([ + const [agents, instructions, prompts, skills, hooks] = await Promise.all([ promptsService.listPromptFiles(PromptsType.agent, CancellationToken.None), promptsService.listPromptFiles(PromptsType.instructions, CancellationToken.None), promptsService.listPromptFiles(PromptsType.prompt, CancellationToken.None), promptsService.listPromptFiles(PromptsType.skill, CancellationToken.None), + promptsService.listPromptFiles(PromptsType.hook, CancellationToken.None), ]); // Combine all files and collect extension-contributed ones const result: IExtensionPromptFileResult[] = []; - for (const file of [...agents, ...instructions, ...prompts, ...skills]) { + for (const file of [...agents, ...instructions, ...prompts, ...skills, ...hooks]) { if (file.storage === PromptsStorage.extension) { result.push({ uri: file.uri.toJSON(), type: file.type }); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 070cbefa7ef..7731011cf47 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -26,7 +26,6 @@ import { ICustomAgent, IPromptPath, IPromptsService } from './service/promptsSer import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatConfiguration, ChatModeKind } from '../constants.js'; import { UserSelectedTools } from '../participants/chatAgents.js'; -import { IChatMode } from '../chatModes.js'; export type InstructionsCollectionEvent = { applyingInstructionsCount: number; @@ -54,7 +53,7 @@ export class ComputeAutomaticInstructions { private _parseResults: ResourceMap = new ResourceMap(); constructor( - private readonly _agent: IChatMode, + private readonly _modeKind: ChatModeKind, private readonly _enabledTools: UserSelectedTools | undefined, private readonly _enabledSubagents: (readonly string[]) | undefined, @IPromptsService private readonly _promptsService: IPromptsService, @@ -119,7 +118,7 @@ export class ComputeAutomaticInstructions { /** public for testing */ public async addApplyingInstructions(instructionFiles: readonly IPromptPath[], context: { files: ResourceSet; instructions: ResourceSet }, variables: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const includeApplyingInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS); - if (!includeApplyingInstructions && this._agent.kind !== ChatModeKind.Edit) { + if (!includeApplyingInstructions && this._modeKind !== ChatModeKind.Edit) { this._logService.trace(`[InstructionsContextComputer] includeApplyingInstructions is disabled and agent kind is not Edit. No applying instructions will be added.`); return; } @@ -400,7 +399,7 @@ export class ComputeAutomaticInstructions { private async _addReferencedInstructions(attachedContext: ChatRequestVariableSet, telemetryEvent: InstructionsCollectionEvent, token: CancellationToken): Promise { const includeReferencedInstructions = this._configurationService.getValue(PromptsConfig.INCLUDE_REFERENCED_INSTRUCTIONS); - if (!includeReferencedInstructions && this._agent.kind !== ChatModeKind.Edit) { + if (!includeReferencedInstructions && this._modeKind !== ChatModeKind.Edit) { this._logService.trace(`[InstructionsContextComputer] includeReferencedInstructions is disabled and agent kind is not Edit. No referenced instructions will be added.`); return; } 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 c543b912bd9..f32674092b0 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -70,6 +70,11 @@ export namespace PromptsConfig { */ export const SKILLS_LOCATION_KEY = 'chat.agentSkillsLocations'; + /** + * Configuration key for the locations of hook files. + */ + export const HOOKS_LOCATION_KEY = 'chat.hookFilesLocations'; + /** * Configuration key for prompt file suggestions. */ @@ -95,6 +100,11 @@ export namespace PromptsConfig { */ export const USE_AGENT_SKILLS = 'chat.useAgentSkills'; + /** + * Configuration key for chat hooks usage. + */ + export const USE_CHAT_HOOKS = 'chat.useChatHooks'; + /** * Configuration key for enabling stronger skill adherence prompt (experimental). */ @@ -245,6 +255,8 @@ export function getPromptFileLocationsConfigKey(type: PromptsType): string { return PromptsConfig.AGENTS_LOCATION_KEY; case PromptsType.skill: return PromptsConfig.SKILLS_LOCATION_KEY; + case PromptsType.hook: + return PromptsConfig.HOOKS_LOCATION_KEY; default: throw new Error('Unknown prompt type'); } 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 3e7dcd49add..48caf2eb37f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/promptFileLocations.ts @@ -33,6 +33,11 @@ export const AGENT_FILE_EXTENSION = '.agent.md'; */ export const SKILL_FILENAME = 'SKILL.md'; +/** + * Default hook file name (case insensitive). + */ +export const HOOKS_FILENAME = 'hooks.json'; + /** * Copilot custom instructions file name. */ @@ -59,6 +64,11 @@ export const LEGACY_MODE_DEFAULT_SOURCE_FOLDER = '.github/chatmodes'; */ export const AGENTS_SOURCE_FOLDER = '.github/agents'; +/** + * Hooks folder. + */ +export const HOOKS_SOURCE_FOLDER = '.github/hooks'; + /** * Tracks where prompt files originate from. */ @@ -67,6 +77,7 @@ export enum PromptFileSource { CopilotPersonal = 'copilot-personal', ClaudePersonal = 'claude-personal', ClaudeWorkspace = 'claude-workspace', + ClaudeWorkspaceLocal = 'claude-workspace-local', AgentsWorkspace = 'agents-workspace', AgentsPersonal = 'agents-personal', ConfigWorkspace = 'config-workspace', @@ -144,6 +155,16 @@ export const DEFAULT_AGENT_SOURCE_FOLDERS: readonly IPromptSourceFolder[] = [ { path: AGENTS_SOURCE_FOLDER, source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, ]; +/** + * Default hook file paths. + */ +export const DEFAULT_HOOK_FILE_PATHS: readonly IPromptSourceFolder[] = [ + { path: '.github/hooks/hooks.json', source: PromptFileSource.GitHubWorkspace, storage: PromptsStorage.local }, + { path: '.claude/settings.local.json', source: PromptFileSource.ClaudeWorkspaceLocal, storage: PromptsStorage.local }, + { path: '.claude/settings.json', source: PromptFileSource.ClaudeWorkspace, storage: PromptsStorage.local }, + { path: '~/.claude/settings.json', source: PromptFileSource.ClaudePersonal, storage: PromptsStorage.user }, +]; + /** * Helper function to check if a file is directly in the .github/agents/ folder (not in subfolders). */ @@ -180,6 +201,19 @@ export function getPromptFileType(fileUri: URI): PromptsType | undefined { return PromptsType.agent; } + // Check if it's a hooks.json file (case insensitive) + if (filename.toLowerCase() === HOOKS_FILENAME.toLowerCase()) { + return PromptsType.hook; + } + + // Check if it's a settings.local.json or settings.json file in a .claude folder + if (filename.toLowerCase() === 'settings.local.json' || filename.toLowerCase() === 'settings.json') { + const dir = dirname(fileUri.path); + if (dir.endsWith('/.claude') || dir === '.claude') { + return PromptsType.hook; + } + } + return undefined; } @@ -200,6 +234,8 @@ export function getPromptFileExtension(type: PromptsType): string { return AGENT_FILE_EXTENSION; case PromptsType.skill: return SKILL_FILENAME; + case PromptsType.hook: + return HOOKS_FILENAME; default: throw new Error('Unknown prompt type'); } @@ -215,6 +251,8 @@ export function getPromptFileDefaultLocations(type: PromptsType): readonly IProm return DEFAULT_AGENT_SOURCE_FOLDERS; case PromptsType.skill: return DEFAULT_SKILL_SOURCE_FOLDERS; + case PromptsType.hook: + return DEFAULT_HOOK_FILE_PATHS; default: throw new Error('Unknown prompt type'); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts new file mode 100644 index 00000000000..8421a6bba75 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookClaudeCompat.ts @@ -0,0 +1,163 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from './hookSchema.js'; + +/** + * Maps Claude hook type names to our abstract HookType. + * Claude uses PascalCase and slightly different names. + * @see https://docs.anthropic.com/en/docs/claude-code/hooks + */ +export const CLAUDE_HOOK_TYPE_MAP: Record = { + 'SessionStart': HookType.SessionStart, + 'UserPromptSubmit': HookType.UserPromptSubmitted, + 'PreToolUse': HookType.PreToolUse, + 'PostToolUse': HookType.PostToolUse, + 'SubagentStart': HookType.SubagentStart, + 'SubagentStop': HookType.SubagentStop, + 'Stop': HookType.Stop, +}; + +/** + * Cached inverse mapping from HookType to Claude hook type name. + * Lazily computed on first access. + */ +let _hookTypeToClaudeName: Map | undefined; + +function getHookTypeToClaudeNameMap(): Map { + if (!_hookTypeToClaudeName) { + _hookTypeToClaudeName = new Map(); + for (const [claudeName, hookType] of Object.entries(CLAUDE_HOOK_TYPE_MAP)) { + _hookTypeToClaudeName.set(hookType, claudeName); + } + } + return _hookTypeToClaudeName; +} + +/** + * Resolves a Claude hook type name to our abstract HookType. + */ +export function resolveClaudeHookType(name: string): HookType | undefined { + return CLAUDE_HOOK_TYPE_MAP[name]; +} + +/** + * Gets the Claude hook type name for a given abstract HookType. + * Returns undefined if the hook type is not supported in Claude. + */ +export function getClaudeHookTypeName(hookType: HookType): string | undefined { + return getHookTypeToClaudeNameMap().get(hookType); +} + +/** + * Parses hooks from a Claude settings.json file. + * Claude format: + * { + * "hooks": { + * "PreToolUse": [ + * { "matcher": "Bash", "hooks": [{ "type": "command", "command": "..." }] } + * ] + * } + * } + * + * Or simpler format: + * { + * "hooks": { + * "PreToolUse": [{ "type": "command", "command": "..." }] + * } + * } + */ +export function parseClaudeHooks( + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): Map { + const result = new Map(); + + if (!json || typeof json !== 'object') { + return result; + } + + const root = json as Record; + const hooks = root.hooks; + + if (!hooks || typeof hooks !== 'object') { + return result; + } + + const hooksObj = hooks as Record; + + for (const originalId of Object.keys(hooksObj)) { + // Resolve Claude hook type name to our canonical HookType + const hookType = resolveClaudeHookType(originalId) ?? normalizeHookTypeId(originalId); + if (!hookType) { + continue; + } + + const hookArray = hooksObj[originalId]; + if (!Array.isArray(hookArray)) { + continue; + } + + 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); + } + } + } + + if (commands.length > 0) { + const existing = result.get(hookType); + if (existing) { + existing.hooks.push(...commands); + } else { + result.set(hookType, { hooks: commands, originalId }); + } + } + } + + return result; +} + +/** + * Resolves a Claude hook command to our IHookCommand format. + * Claude commands can be: { type: "command", command: "..." } or { command: "..." } + */ +function resolveClaudeCommand( + raw: Record, + 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; + } + + // Add type if missing for resolveHookCommand + const normalized = { ...raw, type: 'command' }; + return resolveHookCommand(normalized, workspaceRootUri, userHome); +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts new file mode 100644 index 00000000000..4da87267c90 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookCompatibility.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { basename, dirname } from '../../../../../base/common/path.js'; +import { HookType, IHookCommand, normalizeHookTypeId, resolveHookCommand } from './hookSchema.js'; +import { parseClaudeHooks } from './hookClaudeCompat.js'; + +/** + * Represents a hook source with its original and normalized properties. + * Used to display hooks from different formats in a unified view. + */ +export interface IResolvedHookEntry { + /** The normalized hook type (our canonical HookType enum) */ + readonly hookType: HookType; + /** The original hook type ID as it appears in the source file */ + readonly originalHookTypeId: string; + /** The source format this hook came from */ + readonly sourceFormat: HookSourceFormat; + /** The resolved hook command */ + readonly command: IHookCommand; + /** The index of this hook in its array (for editing) */ + readonly index: number; +} + +/** + * Supported hook file formats. + */ +export enum HookSourceFormat { + /** GitHub Copilot hooks.json format */ + Copilot = 'copilot', + /** Claude settings.json / settings.local.json format */ + Claude = 'claude', +} + +/** + * Determines the hook source format based on the file URI. + */ +export function getHookSourceFormat(fileUri: URI): HookSourceFormat { + const filename = basename(fileUri.path).toLowerCase(); + const dir = dirname(fileUri.path); + + // Claude format: .claude/settings.json or .claude/settings.local.json + if ((filename === 'settings.json' || filename === 'settings.local.json') && dir.endsWith('.claude')) { + return HookSourceFormat.Claude; + } + + // Copilot format: hooks.json (typically in .github/hooks/) + if (filename === 'hooks.json') { + return HookSourceFormat.Copilot; + } + + // Default to Copilot format + return HookSourceFormat.Copilot; +} + +/** + * Checks if a file is read-only based on its source format. + * Claude settings files should be read-only from our perspective since they have a different format. + */ +export function isReadOnlyHookSource(format: HookSourceFormat): boolean { + return format === HookSourceFormat.Claude; +} + +/** + * Parses hooks from a Copilot hooks.json file (our native format). + */ +export function parseCopilotHooks( + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): Map { + const result = new Map(); + + if (!json || typeof json !== 'object') { + return result; + } + + const root = json as Record; + + // Check version + if (root.version !== 1) { + return result; + } + + const hooks = root.hooks; + if (!hooks || typeof hooks !== 'object') { + return result; + } + + const hooksObj = hooks as Record; + + for (const originalId of Object.keys(hooksObj)) { + const hookType = normalizeHookTypeId(originalId); + if (!hookType) { + continue; + } + + const hookArray = hooksObj[originalId]; + if (!Array.isArray(hookArray)) { + continue; + } + + const commands: IHookCommand[] = []; + + for (const item of hookArray) { + const resolved = resolveHookCommand(item as Record, workspaceRootUri, userHome); + if (resolved) { + commands.push(resolved); + } + } + + if (commands.length > 0) { + result.set(hookType, { hooks: commands, originalId }); + } + } + + return result; +} + +/** + * Parses hooks from any supported format, auto-detecting the format from the file URI. + */ +export function parseHooksFromFile( + fileUri: URI, + json: unknown, + workspaceRootUri: URI | undefined, + userHome: string +): { format: HookSourceFormat; hooks: Map } { + const format = getHookSourceFormat(fileUri); + + let hooks: Map; + + switch (format) { + case HookSourceFormat.Claude: + hooks = parseClaudeHooks(json, workspaceRootUri, userHome); + break; + case HookSourceFormat.Copilot: + default: + hooks = parseCopilotHooks(json, workspaceRootUri, userHome); + break; + } + + return { format, hooks }; +} + +/** + * Gets a human-readable label for a hook source format. + */ +export function getHookSourceFormatLabel(format: HookSourceFormat): string { + switch (format) { + case HookSourceFormat.Claude: + return 'Claude'; + case HookSourceFormat.Copilot: + return 'GitHub Copilot'; + } +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts new file mode 100644 index 00000000000..5d5623a113d --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts @@ -0,0 +1,370 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IJSONSchema } from '../../../../../base/common/jsonSchema.js'; +import * as nls from '../../../../../nls.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { joinPath } from '../../../../../base/common/resources.js'; +import { isAbsolute } from '../../../../../base/common/path.js'; +import { untildify } from '../../../../../base/common/labels.js'; + +/** + * Enum of available hook types that can be configured in hooks.json + */ +export enum HookType { + SessionStart = 'sessionStart', + UserPromptSubmitted = 'userPromptSubmitted', + PreToolUse = 'preToolUse', + PostToolUse = 'postToolUse', + PostToolUseFailure = 'postToolUseFailure', + SubagentStart = 'subagentStart', + SubagentStop = 'subagentStop', + Stop = 'stop', +} + +/** + * String literal type derived from HookType enum values. + */ +export type HookTypeValue = `${HookType}`; + +/** + * Metadata for hook types including localized labels and descriptions + */ +export const HOOK_TYPES = [ + { + id: HookType.SessionStart, + label: nls.localize('hookType.sessionStart.label', "Session Start"), + description: nls.localize('hookType.sessionStart.description', "Executed when a new agent session begins or when resuming an existing session.") + }, + { + id: HookType.UserPromptSubmitted, + label: nls.localize('hookType.userPromptSubmitted.label', "User Prompt Submitted"), + description: nls.localize('hookType.userPromptSubmitted.description', "Executed when the user submits a prompt to the agent.") + }, + { + id: HookType.PreToolUse, + label: nls.localize('hookType.preToolUse.label', "Pre-Tool Use"), + description: nls.localize('hookType.preToolUse.description', "Executed before the agent uses any tool (such as bash, edit, view).") + }, + { + id: HookType.PostToolUse, + label: nls.localize('hookType.postToolUse.label', "Post-Tool Use"), + description: nls.localize('hookType.postToolUse.description', "Executed after a tool completes execution successfully.") + }, + { + id: HookType.PostToolUseFailure, + label: nls.localize('hookType.postToolUseFailure.label', "Post-Tool Use Failure"), + description: nls.localize('hookType.postToolUseFailure.description', "Executed after a tool completes execution with a failure.") + }, + { + id: HookType.SubagentStart, + label: nls.localize('hookType.subagentStart.label', "Subagent Start"), + description: nls.localize('hookType.subagentStart.description', "Executed when a subagent is started.") + }, + { + id: HookType.SubagentStop, + label: nls.localize('hookType.subagentStop.label', "Subagent Stop"), + description: nls.localize('hookType.subagentStop.description', "Executed when a subagent stops.") + }, + { + id: HookType.Stop, + label: nls.localize('hookType.stop.label', "Stop"), + description: nls.localize('hookType.stop.description', "Executed when the agent stops.") + } +] as const; + +/** + * A single hook command configuration. + */ +export interface IHookCommand { + readonly type: 'command'; + /** Cross-platform command to execute. */ + readonly command?: string; + /** Bash-specific command. */ + readonly bash?: string; + /** PowerShell-specific command. */ + readonly powershell?: string; + /** Resolved working directory URI. */ + readonly cwd?: URI; + readonly env?: Record; + readonly timeoutSec?: number; +} + +/** + * Collected hooks for a chat request, organized by hook type. + * This is passed to the extension host so it knows what hooks are available. + */ +export interface IChatRequestHooks { + readonly [HookType.SessionStart]?: readonly IHookCommand[]; + readonly [HookType.UserPromptSubmitted]?: readonly IHookCommand[]; + readonly [HookType.PreToolUse]?: readonly IHookCommand[]; + readonly [HookType.PostToolUse]?: readonly IHookCommand[]; + readonly [HookType.PostToolUseFailure]?: readonly IHookCommand[]; + readonly [HookType.SubagentStart]?: readonly IHookCommand[]; + readonly [HookType.SubagentStop]?: readonly IHookCommand[]; + readonly [HookType.Stop]?: readonly IHookCommand[]; +} + +/** + * JSON Schema for GitHub Copilot hook configuration files. + * Hooks enable executing custom shell commands at strategic points in an agent's workflow. + */ +const hookCommandSchema: IJSONSchema = { + type: 'object', + additionalProperties: false, + required: ['type'], + anyOf: [ + { required: ['command'] }, + { required: ['bash'] }, + { required: ['powershell'] } + ], + errorMessage: nls.localize('hook.commandRequired', 'At least one of "command", "bash", or "powershell" must be specified.'), + properties: { + type: { + type: 'string', + enum: ['command'], + description: nls.localize('hook.type', 'Must be "command".') + }, + command: { + type: 'string', + description: nls.localize('hook.command', 'The command to execute. This is the recommended way to specify commands and works cross-platform.') + }, + bash: { + type: 'string', + description: nls.localize('hook.bash', 'Path to a bash script or an inline bash command. Use for Unix-specific commands when cross-platform "command" is not sufficient.') + }, + powershell: { + type: 'string', + description: nls.localize('hook.powershell', 'Path to a PowerShell script or an inline PowerShell command. Use for Windows-specific commands when cross-platform "command" is not sufficient.') + }, + cwd: { + type: 'string', + description: nls.localize('hook.cwd', 'Working directory for the script (relative to repository root).') + }, + env: { + type: 'object', + additionalProperties: { type: 'string' }, + description: nls.localize('hook.env', 'Additional environment variables that are merged with the existing environment.') + }, + timeoutSec: { + type: 'number', + default: 30, + description: nls.localize('hook.timeoutSec', 'Maximum execution time in seconds (default: 30).') + } + } +}; + +const hookArraySchema: IJSONSchema = { + type: 'array', + items: hookCommandSchema +}; + +export const hookFileSchema: IJSONSchema = { + $schema: 'http://json-schema.org/draft-07/schema#', + type: 'object', + description: nls.localize('hookFile.description', 'GitHub Copilot hook configuration file. Hooks enable executing custom shell commands at strategic points in an agent\'s workflow.'), + additionalProperties: false, + required: ['version', 'hooks'], + properties: { + version: { + type: 'number', + enum: [1], + description: nls.localize('hookFile.version', 'Schema version. Must be 1.') + }, + hooks: { + type: 'object', + description: nls.localize('hookFile.hooks', 'Hook definitions organized by type.'), + additionalProperties: false, + properties: { + sessionStart: { + ...hookArraySchema, + description: nls.localize('hookFile.sessionStart', 'Executed when a new agent session begins or when resuming an existing session. Use to initialize environments, log session starts, validate project state, or set up temporary resources.') + }, + userPromptSubmitted: { + ...hookArraySchema, + description: nls.localize('hookFile.userPromptSubmitted', 'Executed when the user submits a prompt to the agent. Use to log user requests for auditing and usage analysis.') + }, + preToolUse: { + ...hookArraySchema, + description: nls.localize('hookFile.preToolUse', 'Executed before the agent uses any tool (such as bash, edit, view). This is the most powerful hook as it can approve or deny tool executions. Use to block dangerous commands, enforce security policies, require approval for sensitive operations, or log tool usage.') + }, + postToolUse: { + ...hookArraySchema, + description: nls.localize('hookFile.postToolUse', 'Executed after a tool completes execution successfully. Use to log execution results, track usage statistics, generate audit trails, or monitor performance.') + }, + postToolUseFailure: { + ...hookArraySchema, + description: nls.localize('hookFile.postToolUseFailure', 'Executed after a tool completes execution with a failure. Use to log errors, send failure alerts, or trigger recovery actions.') + }, + subagentStart: { + ...hookArraySchema, + description: nls.localize('hookFile.subagentStart', 'Executed when a subagent is started. Use to log subagent spawning, track nested agent usage, or initialize subagent-specific resources.') + }, + subagentStop: { + ...hookArraySchema, + description: nls.localize('hookFile.subagentStop', 'Executed when a subagent stops. Use to log subagent completion, cleanup subagent resources, or aggregate subagent results.') + }, + stop: { + ...hookArraySchema, + description: nls.localize('hookFile.stop', 'Executed when the agent session stops. Use to cleanup resources, generate final reports, or send completion notifications.') + } + } + } + }, + defaultSnippets: [ + { + label: nls.localize('hookFile.snippet.basic', 'Basic hook configuration'), + description: nls.localize('hookFile.snippet.basic.description', 'A basic hook configuration with common hooks'), + body: { + version: 1, + hooks: { + sessionStart: [ + { + type: 'command', + command: '${1:echo "Session started"}' + } + ], + preToolUse: [ + { + type: 'command', + command: '${2:./scripts/validate.sh}', + timeoutSec: 15 + } + ] + } + } + } + ] +}; + +/** + * URI for the hook schema registration. + */ +export const HOOK_SCHEMA_URI = 'vscode://schemas/hooks'; + +/** + * Glob pattern for hook files. + */ +export const HOOK_FILE_GLOB = 'hooks/hooks.json'; + +/** + * Normalizes a raw hook type identifier to the canonical HookType enum value. + * Supports alternative casing and naming conventions from different tools: + * - Claude Code: PreToolUse, PostToolUse, SessionStart, Stop, SubagentStart, SubagentStop, UserPromptSubmit + * - GitHub Copilot: sessionStart, userPromptSubmitted, preToolUse, postToolUse, etc. + * + * @see https://docs.anthropic.com/en/docs/claude-code/hooks + * @see https://docs.github.com/en/copilot/concepts/agents/coding-agent/about-hooks#types-of-hooks + */ +export function normalizeHookTypeId(rawHookTypeId: string): HookType | undefined { + // Check if it's already a canonical HookType value + if (Object.values(HookType).includes(rawHookTypeId as HookType)) { + return rawHookTypeId as HookType; + } + + // Handle alternative names from Claude Code + switch (rawHookTypeId) { + case 'SessionStart': + return HookType.SessionStart; + case 'UserPromptSubmit': + return HookType.UserPromptSubmitted; + case 'PreToolUse': + return HookType.PreToolUse; + case 'PostToolUse': + return HookType.PostToolUse; + case 'PostToolUseFailure': + return HookType.PostToolUseFailure; + case 'SubagentStart': + return HookType.SubagentStart; + case 'SubagentStop': + return HookType.SubagentStop; + case 'Stop': + return HookType.Stop; + default: + return undefined; + } +} + +/** + * Normalizes a raw hook command object, validating structure. + * This is an internal helper - use resolveHookCommand for the full resolution. + */ +function normalizeHookCommand(raw: Record): { command?: string; bash?: string; powershell?: string; cwd?: string; env?: Record; timeoutSec?: number } | undefined { + if (raw.type !== 'command') { + return undefined; + } + + const hasCommand = typeof raw.command === 'string' && raw.command.length > 0; + const hasBash = typeof raw.bash === 'string' && raw.bash.length > 0; + const hasPowerShell = typeof raw.powershell === 'string' && raw.powershell.length > 0; + + return { + ...(hasCommand && { command: raw.command as string }), + ...(hasBash && { bash: raw.bash as string }), + ...(hasPowerShell && { powershell: raw.powershell as string }), + ...(typeof raw.cwd === 'string' && { cwd: raw.cwd }), + ...(typeof raw.env === 'object' && raw.env !== null && { env: raw.env as Record }), + ...(typeof raw.timeoutSec === 'number' && { timeoutSec: raw.timeoutSec }), + }; +} + +/** + * Formats a hook command for display. + * If `command` is present, returns just that value. + * Otherwise, joins "bash: " and "powershell: " with " | ". + */ +export function formatHookCommandLabel(hook: IHookCommand): string { + if (hook.command) { + return hook.command; + } + + const parts: string[] = []; + if (hook.bash) { + parts.push(`bash: ${hook.bash}`); + } + if (hook.powershell) { + parts.push(`powershell: ${hook.powershell}`); + } + return parts.join(' | '); +} + +/** + * Resolves a raw hook command object to the canonical IHookCommand format. + * Normalizes the command and resolves the cwd path relative to the workspace root. + * @param raw The raw hook command object from JSON + * @param workspaceRootUri The workspace root URI to resolve relative cwd paths against + * @param userHome The user's home directory path for tilde expansion + */ +export function resolveHookCommand(raw: Record, workspaceRootUri: URI | undefined, userHome: string): IHookCommand | undefined { + const normalized = normalizeHookCommand(raw); + if (!normalized) { + return undefined; + } + + let cwdUri: URI | undefined; + if (normalized.cwd) { + // Expand tilde to user home directory + const expandedCwd = untildify(normalized.cwd, userHome); + if (isAbsolute(expandedCwd)) { + // Use absolute path directly + cwdUri = URI.file(expandedCwd); + } else if (workspaceRootUri) { + // Resolve relative to workspace root + cwdUri = joinPath(workspaceRootUri, expandedCwd); + } + } else { + cwdUri = workspaceRootUri; + } + + return { + type: 'command', + ...(normalized.command && { command: normalized.command }), + ...(normalized.bash && { bash: normalized.bash }), + ...(normalized.powershell && { powershell: normalized.powershell }), + ...(cwdUri && { cwd: cwdUri }), + ...(normalized.env && { env: normalized.env }), + ...(normalized.timeoutSec !== undefined && { timeoutSec: normalized.timeoutSec }), + }; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index 66faf44b6c8..07b7cfd336e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -136,8 +136,9 @@ export class PromptHoverProvider implements HoverProvider { return this.createHover(baseMessage + '\n\n' + localize('promptHeader.agent.model.githubCopilot', 'Note: This attribute is not used when target is github-copilot.'), node.range); } const modelHoverContent = (modelName: string): Hover | undefined => { - const meta = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); - if (meta) { + const result = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); + if (result) { + const meta = result.metadata; const lines: string[] = []; lines.push(baseMessage + '\n'); lines.push(localize('modelName', '- Name: {0}', meta.name)); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index d582ca6b76b..19550c51daf 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -334,9 +334,9 @@ export class PromptValidator { } private findModelByName(modelName: string): ILanguageModelChatMetadata | undefined { - const metadata = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); - if (metadata && metadata.isUserSelectable !== false) { - return metadata; + const metadataAndId = this.languageModelsService.lookupLanguageModelByQualifiedName(modelName); + if (metadataAndId && metadataAndId.metadata.isUserSelectable !== false) { + return metadataAndId.metadata; } return undefined; } @@ -621,18 +621,20 @@ export class PromptValidator { } } -const allAttributeNames = { +const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata], + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer]; -const recommendedAttributeNames = { +const recommendedAttributeNames: Record = { [PromptsType.prompt]: allAttributeNames[PromptsType.prompt].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.instructions]: allAttributeNames[PromptsType.instructions].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)), [PromptsType.skill]: allAttributeNames[PromptsType.skill].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, isGitHubTarget: boolean): string[] { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts index 7da38b26d22..4d588cc1cfd 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptTypes.ts @@ -12,6 +12,8 @@ export const PROMPT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-prompt-snipp export const INSTRUCTIONS_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-instructions'; export const AGENT_DOCUMENTATION_URL = 'https://aka.ms/vscode-ghcp-custom-chat-modes'; // todo export const SKILL_DOCUMENTATION_URL = 'https://aka.ms/vscode-agent-skills'; +// TODO: update link when available +export const HOOK_DOCUMENTATION_URL = 'https://aka.ms/vscode-chat-hooks'; /** * Language ID for the reusable prompt syntax. @@ -51,6 +53,9 @@ export function getLanguageIdForPromptsType(type: PromptsType): string { return AGENT_LANGUAGE_ID; case PromptsType.skill: return SKILL_LANGUAGE_ID; + case PromptsType.hook: + // Hooks use JSON syntax with schema validation + return 'json'; default: throw new Error(`Unknown prompt type: ${type}`); } @@ -66,6 +71,7 @@ export function getPromptsTypeForLanguageId(languageId: string): PromptsType | u return PromptsType.agent; case SKILL_LANGUAGE_ID: return PromptsType.skill; + // Note: hook uses 'json' language ID which is shared, so we don't map it here default: return undefined; } @@ -79,7 +85,8 @@ export enum PromptsType { instructions = 'instructions', prompt = 'prompt', agent = 'agent', - skill = 'skill' + skill = 'skill', + hook = 'hook' } export function isValidPromptType(type: string): type is PromptsType { return Object.values(PromptsType).includes(type as PromptsType); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 64623874735..fdb4790ac6f 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -14,6 +14,7 @@ import { IChatModeInstructions, IVariableReference } from '../../chatModes.js'; import { PromptsType } from '../promptTypes.js'; import { IHandOff, ParsedPromptFile } from '../promptFileParser.js'; import { ResourceSet } from '../../../../../../base/common/map.js'; +import { IChatRequestHooks } from '../hookSchema.js'; import { IResolvedPromptSourceFolder } from '../config/promptFileLocations.js'; /** @@ -396,4 +397,10 @@ export interface IPromptsService extends IDisposable { * Used for diagnostics and config-info displays. */ getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise; + + /** + * Gets all hooks collected from hooks.json files. + * The result is cached and invalidated when hook files change. + */ + getHooks(token: CancellationToken): Promise; } 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 35e3fee0fe4..d37fa5f5fd3 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -34,6 +34,10 @@ import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../p import { IAgentInstructions, IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; +import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; +import { parseHooksFromFile } from '../hookCompatibility.js'; +import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IPathService } from '../../../../../services/path/common/pathService.js'; /** * Error thrown when a skill file is missing the required name attribute. @@ -87,6 +91,11 @@ export class PromptsService extends Disposable implements IPromptsService { */ private readonly cachedSlashCommands: CachedPromise; + /** + * Cached hooks. Invalidated when hook files change. + */ + private readonly cachedHooks: CachedPromise; + /** * Cache for parsed prompt files keyed by URI. * The number in the returned tuple is textModel.getVersionId(), which is an internal VS Code counter that increments every time the text model's content changes. @@ -114,6 +123,7 @@ export class PromptsService extends Disposable implements IPromptsService { [PromptsType.instructions]: new ResourceMap>(), [PromptsType.agent]: new ResourceMap>(), [PromptsType.skill]: new ResourceMap>(), + [PromptsType.hook]: new ResourceMap>(), }; constructor( @@ -128,6 +138,8 @@ export class PromptsService extends Disposable implements IPromptsService { @IStorageService private readonly storageService: IStorageService, @IExtensionService private readonly extensionService: IExtensionService, @ITelemetryService private readonly telemetryService: ITelemetryService, + @IWorkspaceContextService private readonly workspaceService: IWorkspaceContextService, + @IPathService private readonly pathService: IPathService, ) { super(); @@ -146,6 +158,14 @@ export class PromptsService extends Disposable implements IPromptsService { (token) => this.computePromptSlashCommands(token), () => Event.any(this.getFileLocatorEvent(PromptsType.prompt), Event.filter(modelChangeEvent, e => e.promptType === PromptsType.prompt)) )); + + this.cachedHooks = this._register(new CachedPromise( + (token) => this.computeHooks(token), + () => this.getFileLocatorEvent(PromptsType.hook) + )); + + // Hack: Subscribe to activate caching (CachedPromise only caches when onDidChange has listeners) + this._register(this.cachedHooks.onDidChange(() => { })); } private getFileLocatorEvent(type: PromptsType): Event { @@ -341,11 +361,15 @@ export class PromptsService extends Disposable implements IPromptsService { .map(result => result.value); const activationEvent = this.getProviderActivationEvent(type); + if (!activationEvent) { + // No provider activation event for this type (e.g., hooks) + return contributedFiles; + } const providerFiles = await this.listFromProviders(type, activationEvent, token); return [...contributedFiles, ...providerFiles]; } - private getProviderActivationEvent(type: PromptsType): string { + private getProviderActivationEvent(type: PromptsType): string | undefined { switch (type) { case PromptsType.agent: return CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT; @@ -355,6 +379,8 @@ export class PromptsService extends Disposable implements IPromptsService { return PROMPT_FILE_PROVIDER_ACTIVATION_EVENT; case PromptsType.skill: return SKILL_PROVIDER_ACTIVATION_EVENT; + case PromptsType.hook: + return undefined; // hooks don't have extension providers } } @@ -366,6 +392,13 @@ export class PromptsService extends Disposable implements IPromptsService { for (const uri of folders) { result.push({ uri, storage: PromptsStorage.local, type }); } + } else if (type === PromptsType.hook) { + // For hooks, return the Copilot hooks folder for creating new hooks + // (Claude paths are read-only and not included here) + const hooksFolders = await this.fileLocator.getHookSourceFolders(); + for (const uri of hooksFolders) { + result.push({ uri, storage: PromptsStorage.local, type }); + } } else { for (const uri of await this.fileLocator.getConfigBasedSourceFolders(type)) { result.push({ uri, storage: PromptsStorage.local, type }); @@ -848,6 +881,81 @@ export class PromptsService extends Disposable implements IPromptsService { return result; } + public getHooks(token: CancellationToken): Promise { + return this.cachedHooks.get(token); + } + + private async computeHooks(token: CancellationToken): Promise { + const hookFiles = await this.listPromptFiles(PromptsType.hook, token); + + if (hookFiles.length === 0) { + this.logger.trace('[PromptsService] No hook files found.'); + return undefined; + } + + this.logger.trace(`[PromptsService] Found ${hookFiles.length} hook file(s).`); + + // Get user home for tilde expansion + const userHomeUri = await this.pathService.userHome(); + const userHome = userHomeUri.scheme === Schemas.file ? userHomeUri.fsPath : userHomeUri.path; + + // Get workspace root for resolving relative cwd paths + const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; + const workspaceRootUri = workspaceFolder?.uri; + + const collectedHooks: Record = { + [HookType.SessionStart]: [], + [HookType.UserPromptSubmitted]: [], + [HookType.PreToolUse]: [], + [HookType.PostToolUse]: [], + [HookType.PostToolUseFailure]: [], + [HookType.SubagentStart]: [], + [HookType.SubagentStop]: [], + [HookType.Stop]: [], + }; + + for (const hookFile of hookFiles) { + try { + const content = await this.fileService.readFile(hookFile.uri); + const json = JSON.parse(content.value.toString()); + + // Use format-aware parsing that handles Copilot, Claude, and Cursor formats + const { format, hooks } = parseHooksFromFile(hookFile.uri, json, workspaceRootUri, userHome); + + for (const [hookType, { hooks: commands }] of hooks) { + for (const command of commands) { + collectedHooks[hookType].push(command); + this.logger.trace(`[PromptsService] Collected ${hookType} hook from ${hookFile.uri} (format: ${format})`); + } + } + } catch (error) { + this.logger.warn(`[PromptsService] Failed to parse hook file: ${hookFile.uri}`, error); + } + } + + // Check if any hooks were collected + const hasHooks = Object.values(collectedHooks).some(arr => arr.length > 0); + if (!hasHooks) { + this.logger.trace('[PromptsService] No valid hooks collected.'); + return undefined; + } + + // Build the result, only including hook types that have entries + const result: IChatRequestHooks = { + ...(collectedHooks[HookType.SessionStart].length > 0 && { sessionStart: collectedHooks[HookType.SessionStart] }), + ...(collectedHooks[HookType.UserPromptSubmitted].length > 0 && { userPromptSubmitted: collectedHooks[HookType.UserPromptSubmitted] }), + ...(collectedHooks[HookType.PreToolUse].length > 0 && { preToolUse: collectedHooks[HookType.PreToolUse] }), + ...(collectedHooks[HookType.PostToolUse].length > 0 && { postToolUse: collectedHooks[HookType.PostToolUse] }), + ...(collectedHooks[HookType.PostToolUseFailure].length > 0 && { postToolUseFailure: collectedHooks[HookType.PostToolUseFailure] }), + ...(collectedHooks[HookType.SubagentStart].length > 0 && { subagentStart: collectedHooks[HookType.SubagentStart] }), + ...(collectedHooks[HookType.SubagentStop].length > 0 && { subagentStop: collectedHooks[HookType.SubagentStop] }), + ...(collectedHooks[HookType.Stop].length > 0 && { stop: collectedHooks[HookType.Stop] }), + }; + + this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); + return result; + } + public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise { const files: IPromptFileDiscoveryResult[] = []; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 2d81297751b..731c55576a6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -12,7 +12,7 @@ import { getPromptFileLocationsConfigKey, isTildePath, PromptsConfig } from '../ import { basename, dirname, isEqualOrParent, joinPath } from '../../../../../../base/common/resources.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource } from '../config/promptFileLocations.js'; +import { COPILOT_CUSTOM_INSTRUCTIONS_FILENAME, AGENTS_SOURCE_FOLDER, getPromptFileExtension, getPromptFileType, LEGACY_MODE_FILE_EXTENSION, getCleanPromptName, AGENT_FILE_EXTENSION, getPromptFileDefaultLocations, SKILL_FILENAME, IPromptSourceFolder, DEFAULT_AGENT_SOURCE_FOLDERS, DEFAULT_HOOK_FILE_PATHS, IResolvedPromptFile, IResolvedPromptSourceFolder, PromptFileSource, HOOKS_SOURCE_FOLDER } from '../config/promptFileLocations.js'; import { PromptsType } from '../promptTypes.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { Schemas } from '../../../../../../base/common/network.js'; @@ -116,12 +116,19 @@ export class PromptFilesLocator { const defaultFolders = getPromptFileDefaultLocations(type); for (const sourceFolder of defaultFolders) { + let folderPath: URI; if (sourceFolder.storage === PromptsStorage.local) { for (const workspaceFolder of folders) { - result.push(joinPath(workspaceFolder.uri, sourceFolder.path)); + folderPath = joinPath(workspaceFolder.uri, sourceFolder.path); + // For hooks, the paths are file paths, so get the parent directory + result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); } } else if (sourceFolder.storage === PromptsStorage.user) { - result.push(joinPath(userHome, sourceFolder.path)); + // For tilde paths, strip the ~/ prefix before joining with userHome + const relativePath = isTildePath(sourceFolder.path) ? sourceFolder.path.substring(2) : sourceFolder.path; + folderPath = joinPath(userHome, relativePath); + // For hooks, the paths are file paths, so get the parent directory + result.push(type === PromptsType.hook ? dirname(folderPath) : folderPath); } } @@ -193,6 +200,15 @@ export class PromptFilesLocator { return this.toAbsoluteLocations(PromptsType.agent, DEFAULT_AGENT_SOURCE_FOLDERS, userHome).map(l => l.uri); } + /** + * Gets the hook source folders for creating new hooks. + * Returns only the Copilot hooks folder (.github/hooks) since Claude paths are read-only. + */ + public async getHookSourceFolders(): Promise { + const { folders } = this.workspaceService.getWorkspace(); + return folders.map(folder => joinPath(folder.uri, HOOKS_SOURCE_FOLDER)); + } + /** * Get all possible unambiguous prompt file source folders based on * the current workspace folder structure. @@ -328,9 +344,18 @@ export class PromptFilesLocator { const configuredLocations = PromptsConfig.promptSourceFolders(this.configService, type); if (type === PromptsType.agent) { configuredLocations.push(...DEFAULT_AGENT_SOURCE_FOLDERS); + } else if (type === PromptsType.hook) { + configuredLocations.push(...DEFAULT_HOOK_FILE_PATHS); } const absoluteLocations = this.toAbsoluteLocations(type, configuredLocations, undefined); + + // For hooks, the paths are file paths (e.g., '.github/hooks/hooks.json'), not folder paths. + // We need to watch the parent directories of these files. + if (type === PromptsType.hook) { + return absoluteLocations.map((location) => ({ parent: dirname(location.uri) })); + } + return absoluteLocations.map((location) => firstNonGlobParentAndPattern(location.uri)); } diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index 58cc00882f6..a71ce6ba3ed 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -17,7 +17,6 @@ import { IInstantiationService } from '../../../../../../platform/instantiation/ import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IChatAgentRequest, IChatAgentService } from '../../participants/chatAgents.js'; import { ChatModel, IChatRequestModeInstructions } from '../../model/chatModel.js'; -import { ChatMode, IChatMode, IChatModeService } from '../../chatModes.js'; import { IChatProgress, IChatService } from '../../chatService/chatService.js'; import { ChatRequestVariableSet } from '../../attachments/chatVariableEntries.js'; import { ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../../constants.js'; @@ -39,6 +38,7 @@ import { import { ComputeAutomaticInstructions } from '../../promptSyntax/computeAutomaticInstructions.js'; import { ManageTodoListToolToolId } from './manageTodoListTool.js'; import { createToolSimpleTextResult } from './toolHelpers.js'; +import { ICustomAgent, IPromptsService } from '../../promptSyntax/service/promptsService.js'; const BaseModelDescription = `Launch a new agent to handle complex, multi-step tasks autonomously. This tool is good at researching complex questions, searching for code, and executing multi-step tasks. When you are searching for a keyword or file and are not confident that you will find the right match in the first few tries, use this agent to perform the search for you. @@ -63,12 +63,12 @@ export class RunSubagentTool extends Disposable implements IToolImpl { constructor( @IChatAgentService private readonly chatAgentService: IChatAgentService, @IChatService private readonly chatService: IChatService, - @IChatModeService private readonly chatModeService: IChatModeService, @ILanguageModelToolsService private readonly languageModelToolsService: ILanguageModelToolsService, @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, @ILogService private readonly logService: ILogService, @ILanguageModelToolsService private readonly toolsService: ILanguageModelToolsService, @IConfigurationService private readonly configurationService: IConfigurationService, + @IPromptsService private readonly promptsService: IPromptsService, @IInstantiationService private readonly instantiationService: IInstantiationService, ) { super(); @@ -142,32 +142,30 @@ export class RunSubagentTool extends Disposable implements IToolImpl { let modeModelId = invocation.modelId; let modeTools = invocation.userSelectedTools; let modeInstructions: IChatRequestModeInstructions | undefined; - let mode: IChatMode | undefined; + let subagent: ICustomAgent | undefined; - if (args.agentName) { - mode = this.chatModeService.findModeByName(args.agentName); - if (mode) { + const subAgentName = args.agentName; + if (subAgentName) { + subagent = await this.getSubAgentByName(subAgentName); + if (subagent) { // Use mode-specific model if available - const modeModelQualifiedNames = mode.model?.get(); + const modeModelQualifiedNames = subagent.model; if (modeModelQualifiedNames) { // Find the actual model identifier from the qualified name(s) - for (const qualifiedName of modeModelQualifiedNames) { + outer: for (const qualifiedName of modeModelQualifiedNames) { const lmByQualifiedName = this.languageModelsService.lookupLanguageModelByQualifiedName(qualifiedName); - for (const fullId of this.languageModelsService.getLanguageModelIds()) { - const lmById = this.languageModelsService.lookupLanguageModel(fullId); - if (lmById && lmById?.id === lmByQualifiedName?.id) { - modeModelId = fullId; - break; - } + if (lmByQualifiedName?.identifier) { + modeModelId = lmByQualifiedName.identifier; + break outer; } } } // Use mode-specific tools if available - const modeCustomTools = mode.customTools?.get(); + const modeCustomTools = subagent.tools; if (modeCustomTools) { // Convert the mode's custom tools (array of qualified names) to UserSelectedTools format - const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, mode.target?.get(), undefined); + const enablementMap = this.languageModelToolsService.toToolAndToolSetEnablementMap(modeCustomTools, subagent.target, undefined); // Convert enablement map to UserSelectedTools (Record) modeTools = {}; for (const [tool, enabled] of enablementMap) { @@ -177,15 +175,15 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } - const instructions = mode.modeInstructions?.get(); + const instructions = subagent.agentInstructions; modeInstructions = instructions && { - name: mode.name.get(), + name: subAgentName, content: instructions.content, toolReferences: this.toolsService.toToolReferences(instructions.toolReferences), metadata: instructions.metadata, }; } else { - throw new Error(`Requested agent '${args.agentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); + throw new Error(`Requested agent '${subAgentName}' not found. Try again with the correct agent name, or omit the agentName to use the current agent.`); } } @@ -229,7 +227,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } const variableSet = new ChatRequestVariableSet(); - const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, mode ?? ChatMode.Agent, modeTools, undefined); // agents can not call subagents + const computer = this.instantiationService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, modeTools, undefined); // agents can not call subagents await computer.collect(variableSet, token); // Build the agent request @@ -241,7 +239,7 @@ export class RunSubagentTool extends Disposable implements IToolImpl { variables: { variables: variableSet.asArray() }, location: ChatAgentLocation.Chat, subAgentInvocationId: invocation.callId, - subAgentName: mode?.name.get(), + subAgentName: subAgentName, userSelectedModelId: modeModelId, userSelectedTools: modeTools, modeInstructions, @@ -300,17 +298,22 @@ export class RunSubagentTool extends Disposable implements IToolImpl { } } + private async getSubAgentByName(name: string): Promise { + const agents = await this.promptsService.getCustomAgents(CancellationToken.None); + return agents.find(agent => agent.name === name); + } + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const args = context.parameters as IRunSubagentToolInputParams; - const mode = args.agentName ? this.chatModeService.findModeByName(args.agentName) : undefined; + const subagent = args.agentName ? await this.getSubAgentByName(args.agentName) : undefined; return { invocationMessage: args.description, toolSpecificData: { kind: 'subagent', description: args.description, - agentName: mode?.name.get(), + agentName: subagent?.name, prompt: args.prompt, }, }; diff --git a/src/vs/workbench/contrib/chat/common/widget/annotations.ts b/src/vs/workbench/contrib/chat/common/widget/annotations.ts index c22c60ca430..df8564c6192 100644 --- a/src/vs/workbench/contrib/chat/common/widget/annotations.ts +++ b/src/vs/workbench/contrib/chat/common/widget/annotations.ts @@ -2,7 +2,6 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { findLastIdx } from '../../../../../base/common/arraysFind.js'; import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { basename } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; @@ -18,7 +17,7 @@ export function annotateSpecialMarkdownContent(response: Iterable p.kind !== 'textEditGroup' && p.kind !== 'undoStop'); + const previousItemIndex = result.findLastIndex(p => p.kind !== 'textEditGroup' && p.kind !== 'undoStop'); const previousItem = result[previousItemIndex]; if (item.kind === 'inlineReference') { let label: string | undefined = item.name; diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts index ab75922685a..064114c7882 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentSessionViewModel.test.ts @@ -750,6 +750,7 @@ suite('AgentSessions', () => { suite('AgentSessionsFilter', () => { const disposables = new DisposableStore(); + const storageKey = 'agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu'; let mockChatSessionsService: MockChatSessionsService; let instantiationService: TestInstantiationService; @@ -788,7 +789,7 @@ suite('AgentSessions', () => { { filterMenuId: MenuId.ViewTitle } )); - // Default: archived sessions should NOT be excluded (archived: false by default) + // Default: archived sessions should NOT be excluded unless grouped by capped const archivedSession = createSession({ isArchived: () => true }); @@ -827,7 +828,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // After excluding type-1, session1 should be filtered but not session2 assert.strictEqual(filter.exclude(session1), true); @@ -851,14 +852,14 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(session1), true); assert.strictEqual(filter.exclude(session2), true); assert.strictEqual(filter.exclude(session3), false); }); - test('should filter not out archived sessions', () => { + test('should not exclude archived sessions when not capped', () => { const storageService = instantiationService.get(IStorageService); const filter = disposables.add(instantiationService.createInstance( AgentSessionsFilter, @@ -875,7 +876,7 @@ suite('AgentSessions', () => { isArchived: () => false }); - // By default, archived sessions should NOT be filtered (archived: false in default excludes) + // By default, archived sessions should NOT be filtered when not capped assert.strictEqual(filter.exclude(archivedSession), false); assert.strictEqual(filter.exclude(activeSession), false); @@ -885,9 +886,9 @@ suite('AgentSessions', () => { states: [], archived: true }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); - // After excluding archived, only archived session should be filtered + // Archived exclusion only applies when grouped by capped assert.strictEqual(filter.exclude(archivedSession), false); assert.strictEqual(filter.exclude(activeSession), false); }); @@ -925,7 +926,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Failed], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // After excluding failed status, only failedSession should be filtered assert.strictEqual(filter.exclude(failedSession), true); @@ -950,7 +951,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Failed, ChatSessionStatus.InProgress], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(failedSession), true); assert.strictEqual(filter.exclude(completedSession), false); @@ -982,7 +983,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Failed], archived: true }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // session1 should be excluded for multiple reasons assert.strictEqual(filter.exclude(session1), true); @@ -1008,7 +1009,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(changeEventFired, true); }); @@ -1031,7 +1032,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // Should now be excluded assert.strictEqual(filter.exclude(session), true); @@ -1096,7 +1097,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // Nothing should be excluded assert.strictEqual(filter.exclude(session), false); @@ -1117,7 +1118,7 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(session), false); }); @@ -1144,19 +1145,19 @@ suite('AgentSessions', () => { states: [], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // filter1 should exclude the session assert.strictEqual(filter1.exclude(session), true); - // filter2 should not exclude the session (different storage key) - assert.strictEqual(filter2.exclude(session), false); + // filter2 should also exclude the session (shared storage key) + assert.strictEqual(filter2.exclude(session), true); }); test('should handle malformed storage data gracefully', () => { const storageService = instantiationService.get(IStorageService); // Store malformed JSON - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, 'invalid json', StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, 'invalid json', StorageScope.PROFILE, StorageTarget.USER); // Filter should still be created with default excludes const filter = disposables.add(instantiationService.createInstance( @@ -1188,7 +1189,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Completed], archived: true }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); // Should be excluded due to archived (checked first) assert.strictEqual(filter.exclude(session), true); @@ -1211,7 +1212,7 @@ suite('AgentSessions', () => { states: [ChatSessionStatus.Completed, ChatSessionStatus.InProgress, ChatSessionStatus.Failed], archived: false }; - storageService.store(`agentSessions.filterExcludes.${MenuId.ViewTitle.id.toLowerCase()}`, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); + storageService.store(storageKey, JSON.stringify(excludes), StorageScope.PROFILE, StorageTarget.USER); assert.strictEqual(filter.exclude(completedSession), true); assert.strictEqual(filter.exclude(inProgressSession), true); @@ -1393,7 +1394,7 @@ suite('AgentSessions', () => { instantiationService.stub(IChatSessionsService, mockChatSessionsService); instantiationService.stub(ILifecycleService, disposables.add(new TestLifecycleService())); const storageService = instantiationService.get(IStorageService); - storageService.store('agentSessions.readDateBaseline', 1, StorageScope.WORKSPACE, StorageTarget.MACHINE); + storageService.store('agentSessions.readDateBaseline2', 1, StorageScope.WORKSPACE, StorageTarget.MACHINE); }); teardown(() => { diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts index 3fbe3886f43..40547235409 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/localAgentSessionsProvider.test.ts @@ -17,7 +17,7 @@ import { workbenchInstantiationService } from '../../../../../test/browser/workb import { LocalAgentsSessionsProvider } from '../../../browser/agentSessions/localAgentSessionsProvider.js'; import { ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { IChatModel, IChatRequestModel, IChatResponseModel } from '../../../common/model/chatModel.js'; -import { IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatDetail, IChatService, IChatSessionStartOptions, ResponseModelState } from '../../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatSessionItem, IChatSessionsService, localChatSessionType } from '../../../common/chatSessionsService.js'; import { LocalChatSessionUri } from '../../../common/model/chatUri.js'; import { ChatAgentLocation } from '../../../common/constants.js'; @@ -62,6 +62,10 @@ class MockChatService implements IChatService { } + processPendingRequests(sessionResource: URI): void { + + } + setLiveSessionItems(items: IChatDetail[]): void { this.liveSessionItems = items; } @@ -144,6 +148,12 @@ class MockChatService implements IChatService { cancelCurrentRequestForSession(_sessionResource: URI): void { } + setYieldRequested(_sessionResource: URI): void { } + + removePendingRequest(_sessionResource: URI, _requestId: string): void { } + + setPendingRequests(_sessionResource: URI, _requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { } + addCompleteRequest(): void { } async getLocalSessionHistory(): Promise { @@ -801,35 +811,5 @@ suite('LocalAgentsSessionsProvider', () => { assert.strictEqual(changeEventCount, 0, 'onDidChangeChatSessionItems should NOT fire after model is removed'); }); }); - - test('should fire onDidChange when session items change for local type', async () => { - return runWithFakedTimers({}, async () => { - const provider = createProvider(); - - let changeEventFired = false; - disposables.add(provider.onDidChange(() => { - changeEventFired = true; - })); - - mockChatSessionsService.notifySessionItemsChanged(localChatSessionType); - - assert.strictEqual(changeEventFired, true); - }); - }); - - test('should not fire onDidChange when session items change for other types', async () => { - return runWithFakedTimers({}, async () => { - const provider = createProvider(); - - let changeEventFired = false; - disposables.add(provider.onDidChange(() => { - changeEventFired = true; - })); - - mockChatSessionsService.notifySessionItemsChanged('other-type'); - - assert.strictEqual(changeEventFired, false); - }); - }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts index 50d5b38401e..3b884414dec 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatEditing/chatEditingService.test.ts @@ -44,8 +44,11 @@ import { ChatTransferService, IChatTransferService } from '../../../common/model import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { IHooksExecutionService } from '../../../common/hooksExecutionService.js'; import { NullLanguageModelsService } from '../../common/languageModels.js'; import { MockChatVariablesService } from '../../common/mockChatVariables.js'; +import { MockPromptsService } from '../../common/promptSyntax/service/mockPromptsService.js'; function getAgentData(id: string): IChatAgentData { return { @@ -83,7 +86,11 @@ suite('ChatEditingService', function () { collection.set(IEditorWorkerService, new SyncDescriptor(TestWorkerService)); collection.set(IChatService, new SyncDescriptor(ChatService)); collection.set(IMcpService, new TestMcpService()); + collection.set(IPromptsService, new MockPromptsService()); collection.set(ILanguageModelsService, new SyncDescriptor(NullLanguageModelsService)); + collection.set(IHooksExecutionService, new class extends mock() { + override registerHooks() { return Disposable.None; } + }); collection.set(IMultiDiffSourceResolverService, new class extends mock() { override registerResolver(_resolver: IMultiDiffSourceResolver): IDisposable { return Disposable.None; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts index 322f8893f18..4ad52ff646c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatManagement/chatModelsViewModel.test.ts @@ -82,10 +82,10 @@ class MockLanguageModelsService implements ILanguageModelsService { return this.models.get(identifier); } - lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadata | undefined { - for (const metadata of this.models.values()) { + lookupLanguageModelByQualifiedName(referenceName: string): ILanguageModelChatMetadataAndIdentifier | undefined { + for (const [identifier, metadata] of this.models.entries()) { if (ILanguageModelChatMetadata.matchesQualifiedName(referenceName, metadata)) { - return metadata; + return { metadata, identifier }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts new file mode 100644 index 00000000000..03257775f1e --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/hookUtils.test.ts @@ -0,0 +1,344 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { findHookCommandSelection } from '../../../browser/promptSyntax/hookUtils.js'; +import { ITextEditorSelection } from '../../../../../../platform/editor/common/editor.js'; + +/** + * Helper to extract the selected text from content using a selection range. + */ +function getSelectedText(content: string, selection: ITextEditorSelection): string { + const lines = content.split('\n'); + if (selection.startLineNumber === selection.endLineNumber) { + return lines[selection.startLineNumber - 1].substring(selection.startColumn - 1, selection.endColumn! - 1); + } + // Multi-line selection + const result: string[] = []; + result.push(lines[selection.startLineNumber - 1].substring(selection.startColumn - 1)); + for (let i = selection.startLineNumber; i < selection.endLineNumber! - 1; i++) { + result.push(lines[i]); + } + result.push(lines[selection.endLineNumber! - 1].substring(0, selection.endColumn! - 1)); + return result.join('\n'); +} + +suite('hookUtils', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('findHookCommandSelection', () => { + + suite('simple format', () => { + const simpleFormat = `{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "echo first" + }, + { + "type": "command", + "command": "echo second" + } + ], + "userPromptSubmitted": [ + { + "type": "command", + "command": "echo foo > test.derp" + } + ] + } +}`; + + test('finds first command in sessionStart', () => { + const result = findHookCommandSelection(simpleFormat, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo first'); + assert.deepStrictEqual(result, { + startLineNumber: 7, + startColumn: 17, + endLineNumber: 7, + endColumn: 27 + }); + }); + + test('finds second command in sessionStart', () => { + const result = findHookCommandSelection(simpleFormat, 'sessionStart', 1, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo second'); + assert.deepStrictEqual(result, { + startLineNumber: 11, + startColumn: 17, + endLineNumber: 11, + endColumn: 28 + }); + }); + + test('finds command in userPromptSubmitted', () => { + const result = findHookCommandSelection(simpleFormat, 'userPromptSubmitted', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(simpleFormat, result), 'echo foo > test.derp'); + assert.deepStrictEqual(result, { + startLineNumber: 17, + startColumn: 17, + endLineNumber: 17, + endColumn: 37 + }); + }); + + test('returns undefined for out of bounds index', () => { + const result = findHookCommandSelection(simpleFormat, 'sessionStart', 5, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for non-existent hook type', () => { + const result = findHookCommandSelection(simpleFormat, 'nonExistent', 0, 'command'); + assert.strictEqual(result, undefined); + }); + }); + + suite('nested matcher format', () => { + const nestedFormat = `{ + "forceLoginMethod": "console", + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "", + "hooks": [ + { + "type": "command", + "command": "echo 'foobarbaz5' > ~/foobarbaz.txt" + } + ] + } + ] + } +}`; + + test('finds command inside nested hooks', () => { + const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(nestedFormat, result), 'echo \'foobarbaz5\' > ~/foobarbaz.txt'); + assert.deepStrictEqual(result, { + startLineNumber: 10, + startColumn: 19, + endLineNumber: 10, + endColumn: 54 + }); + }); + + test('returns undefined for non-existent field name', () => { + const result = findHookCommandSelection(nestedFormat, 'UserPromptSubmit', 0, 'bash'); + assert.strictEqual(result, undefined); + }); + }); + + suite('mixed format with multiple nested hooks', () => { + const mixedFormat = `{ + "hooks": { + "preToolUse": [ + { + "matcher": "edit_file", + "hooks": [ + { + "type": "command", + "command": "first nested" + }, + { + "type": "command", + "command": "second nested" + } + ] + }, + { + "type": "command", + "command": "simple after nested" + } + ] + } +}`; + + test('finds first command in first nested hooks array', () => { + const result = findHookCommandSelection(mixedFormat, 'preToolUse', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'first nested'); + assert.deepStrictEqual(result, { + startLineNumber: 9, + startColumn: 19, + endLineNumber: 9, + endColumn: 31 + }); + }); + + test('finds second command in first nested hooks array', () => { + const result = findHookCommandSelection(mixedFormat, 'preToolUse', 1, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'second nested'); + assert.deepStrictEqual(result, { + startLineNumber: 13, + startColumn: 19, + endLineNumber: 13, + endColumn: 32 + }); + }); + + test('finds simple command after nested structure', () => { + const result = findHookCommandSelection(mixedFormat, 'preToolUse', 2, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(mixedFormat, result), 'simple after nested'); + assert.deepStrictEqual(result, { + startLineNumber: 19, + startColumn: 17, + endLineNumber: 19, + endColumn: 36 + }); + }); + }); + + suite('bash and powershell fields', () => { + const platformSpecificFormat = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "echo hello from bash", + "powershell": "Write-Host hello" + } + ] + } +}`; + + test('finds bash field', () => { + const result = findHookCommandSelection(platformSpecificFormat, 'sessionStart', 0, 'bash'); + assert.ok(result); + assert.strictEqual(getSelectedText(platformSpecificFormat, result), 'echo hello from bash'); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 14, + endLineNumber: 6, + endColumn: 34 + }); + }); + + test('finds powershell field', () => { + const result = findHookCommandSelection(platformSpecificFormat, 'sessionStart', 0, 'powershell'); + assert.ok(result); + assert.strictEqual(getSelectedText(platformSpecificFormat, result), 'Write-Host hello'); + assert.deepStrictEqual(result, { + startLineNumber: 7, + startColumn: 20, + endLineNumber: 7, + endColumn: 36 + }); + }); + }); + + suite('edge cases', () => { + test('returns undefined for empty content', () => { + const result = findHookCommandSelection('', 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined for invalid JSON', () => { + const result = findHookCommandSelection('{ invalid json }', 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hooks key is missing', () => { + const content = '{ "version": 1 }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hook type array is empty', () => { + const content = '{ "hooks": { "sessionStart": [] } }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('returns undefined when hook item is not an object', () => { + const content = '{ "hooks": { "sessionStart": ["not an object"] } }'; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.strictEqual(result, undefined); + }); + + test('handles empty command string', () => { + const content = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "" + } + ] + } +}`; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), ''); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 17, + endLineNumber: 6, + endColumn: 17 + }); + }); + + test('handles multiline command value', () => { + // JSON strings can contain escaped newlines + const content = `{ + "hooks": { + "sessionStart": [ + { + "type": "command", + "command": "line1\\nline2" + } + ] + } +}`; + const result = findHookCommandSelection(content, 'sessionStart', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(content, result), 'line1\\nline2'); + assert.deepStrictEqual(result, { + startLineNumber: 6, + startColumn: 17, + endLineNumber: 6, + endColumn: 29 + }); + }); + }); + + suite('nested matcher with empty hooks array', () => { + const emptyNestedHooks = `{ + "hooks": { + "UserPromptSubmit": [ + { + "matcher": "some-pattern", + "hooks": [] + }, + { + "type": "command", + "command": "after empty nested" + } + ] + } +}`; + + test('skips empty nested hooks and finds subsequent command', () => { + const result = findHookCommandSelection(emptyNestedHooks, 'UserPromptSubmit', 0, 'command'); + assert.ok(result); + assert.strictEqual(getSelectedText(emptyNestedHooks, result), 'after empty nested'); + assert.deepStrictEqual(result, { + startLineNumber: 10, + startColumn: 17, + endLineNumber: 10, + endColumn: 35 + }); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index 4a8d29fc2b7..ebff561a2c0 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -62,7 +62,7 @@ suite('PromptHoverProvider', () => { lookupLanguageModelByQualifiedName(qualifiedName: string) { for (const metadata of testModels) { if (ILanguageModelChatMetadata.matchesQualifiedName(qualifiedName, metadata)) { - return metadata; + return { metadata, identifier: metadata.id }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 45e77e43d2c..e156499b347 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -123,7 +123,7 @@ suite('PromptValidator', () => { lookupLanguageModelByQualifiedName(qualifiedName: string) { for (const metadata of testModels) { if (ILanguageModelChatMetadata.matchesQualifiedName(qualifiedName, metadata)) { - return metadata; + return { metadata, identifier: metadata.id }; } } return undefined; diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts index 6844fd2cee0..4473ad266aa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatSubagentContentPart.test.ts @@ -22,7 +22,6 @@ import { IMarkdownString } from '../../../../../../../base/common/htmlContent.js import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; -import { ChatTreeItem } from '../../../../browser/chat.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { CollapsibleListPool } from '../../../../browser/widget/chatContentParts/chatReferencesContentPart.js'; @@ -52,7 +51,7 @@ suite('ChatSubagentContentPart', () => { }; return { - element: mockElement as ChatTreeItem, + element: mockElement as IChatResponseViewModel, elementIndex: 0, container: mainWindow.document.createElement('div'), content: [], diff --git a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts index cd8d46a2abc..81ff8e020e9 100644 --- a/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/widget/chatContentParts/chatThinkingContentPart.test.ts @@ -25,7 +25,6 @@ import { ThinkingDisplayMode } from '../../../../common/constants.js'; import { CodeBlockModelCollection } from '../../../../common/widget/codeBlockModelCollection.js'; import { EditorPool, DiffEditorPool } from '../../../../browser/widget/chatContentParts/chatContentCodePools.js'; import { IHoverService } from '../../../../../../../platform/hover/browser/hover.js'; -import { ChatTreeItem } from '../../../../browser/chat.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; import { URI } from '../../../../../../../base/common/uri.js'; @@ -49,7 +48,7 @@ suite('ChatThinkingContentPart', () => { }; return { - element: mockElement as ChatTreeItem, + element: mockElement as IChatResponseViewModel, elementIndex: 0, container: mainWindow.document.createElement('div'), content: [], diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 4b1c94e37e3..23f59fb2ba8 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -37,13 +37,15 @@ import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; import { IChatEditingService, IChatEditingSession } from '../../../common/editing/chatEditingService.js'; import { ChatModel, IChatModel, ISerializableChatData } from '../../../common/model/chatModel.js'; -import { IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; +import { ChatSendResult, IChatFollowup, IChatModelReference, IChatService } from '../../../common/chatService/chatService.js'; import { ChatService } from '../../../common/chatService/chatServiceImpl.js'; import { ChatSlashCommandService, IChatSlashCommandService } from '../../../common/participants/chatSlashCommands.js'; import { IChatVariablesService } from '../../../common/attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../../../common/constants.js'; import { MockChatService } from './mockChatService.js'; import { MockChatVariablesService } from '../mockChatVariables.js'; +import { IPromptsService } from '../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from '../promptSyntax/service/mockPromptsService.js'; const chatAgentWithUsedContextId = 'ChatProviderWithUsedContext'; const chatAgentWithUsedContext: IChatAgent = { @@ -156,6 +158,7 @@ suite('ChatService', () => { [IChatVariablesService, new MockChatVariablesService()], [IWorkbenchAssignmentService, new NullWorkbenchAssignmentService()], [IMcpService, new TestMcpService()], + [IPromptsService, new MockPromptsService()], ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); instantiationService.stub(ILogService, new NullLogService()); @@ -268,8 +271,8 @@ suite('ChatService', () => { const modelRef = testDisposables.add(startSessionModel(testService)); const model = modelRef.object; const response = await testService.sendRequest(model.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); - await response.responseCompletePromise; + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; await assertSnapshot(toSnapshotExportData(model)); }); @@ -294,22 +297,22 @@ suite('ChatService', () => { // Send a request to default agent const response = await testService.sendRequest(model.sessionResource, `test request`, { agentId: 'defaultAgent' }); - assert(response); - await response.responseCompletePromise; + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 1); assert.strictEqual(model.getRequests()[0].response?.result?.metadata?.historyLength, 0); // Send a request to agent2- it can't see the default agent's message const response2 = await testService.sendRequest(model.sessionResource, `test request`, { agentId: 'agent2' }); - assert(response2); - await response2.responseCompletePromise; + ChatSendResult.assertSent(response2); + await response2.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 2); assert.strictEqual(model.getRequests()[1].response?.result?.metadata?.historyLength, 0); // Send a request to defaultAgent - the default agent can see agent2's message const response3 = await testService.sendRequest(model.sessionResource, `test request`, { agentId: 'defaultAgent' }); - assert(response3); - await response3.responseCompletePromise; + ChatSendResult.assertSent(response3); + await response3.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 3); assert.strictEqual(model.getRequests()[2].response?.result?.metadata?.historyLength, 2); }); @@ -326,13 +329,13 @@ suite('ChatService', () => { await assertSnapshot(toSnapshotExportData(model)); const response = await testService.sendRequest(model.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); - await response.responseCompletePromise; + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 1); const response2 = await testService.sendRequest(model.sessionResource, `test request 2`); - assert(response2); - await response2.responseCompletePromise; + ChatSendResult.assertSent(response2); + await response2.data.responseCompletePromise; assert.strictEqual(model.getRequests().length, 2); await assertSnapshot(toSnapshotExportData(model)); @@ -351,9 +354,9 @@ suite('ChatService', () => { assert.strictEqual(chatModel1.getRequests().length, 0); const response = await testService.sendRequest(chatModel1.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); + ChatSendResult.assertSent(response); - await response.responseCompletePromise; + await response.data.responseCompletePromise; serializedChatData = JSON.parse(JSON.stringify(chatModel1)); } @@ -382,9 +385,9 @@ suite('ChatService', () => { assert.strictEqual(chatModel1.getRequests().length, 0); const response = await testService.sendRequest(chatModel1.sessionResource, `@${chatAgentWithUsedContextId} test request`); - assert(response); + ChatSendResult.assertSent(response); - await response.responseCompletePromise; + await response.data.responseCompletePromise; serializedChatData = JSON.parse(JSON.stringify(chatModel1)); } diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 5f104554cab..68c2d724b00 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -10,7 +10,7 @@ import { IObservable, observableValue } from '../../../../../../base/common/obse import { URI } from '../../../../../../base/common/uri.js'; import { IChatModel, IChatRequestModel, IChatRequestVariableData, ISerializableChatData } from '../../../common/model/chatModel.js'; import { IParsedChatRequest } from '../../../common/requestParser/chatParserTypes.js'; -import { IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestData, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, ChatSendResult, IChatCompleteResponse, IChatDetail, IChatModelReference, IChatProgress, IChatProviderInfo, IChatSendRequestOptions, IChatService, IChatSessionContext, IChatSessionStartOptions, IChatUserActionEvent } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; export class MockChatService implements IChatService { @@ -67,11 +67,14 @@ export class MockChatService implements IChatService { } appendProgress(request: IChatRequestModel, progress: IChatProgress): void { + } + processPendingRequests(sessionResource: URI): void { + } /** * Returns whether the request was accepted. */ - sendRequest(sessionResource: URI, message: string): Promise { + sendRequest(sessionResource: URI, message: string): Promise { throw new Error('Method not implemented.'); } resendRequest(request: IChatRequestModel, options?: IChatSendRequestOptions | undefined): Promise { @@ -86,6 +89,15 @@ export class MockChatService implements IChatService { cancelCurrentRequestForSession(sessionResource: URI): void { throw new Error('Method not implemented.'); } + setYieldRequested(sessionResource: URI): void { + throw new Error('Method not implemented.'); + } + removePendingRequest(sessionResource: URI, requestId: string): void { + throw new Error('Method not implemented.'); + } + setPendingRequests(sessionResource: URI, requests: readonly { requestId: string; kind: ChatRequestQueueKind }[]): void { + throw new Error('Method not implemented.'); + } addCompleteRequest(sessionResource: URI, message: IParsedChatRequest | string, variableData: IChatRequestVariableData | undefined, attempt: number | undefined, response: IChatCompleteResponse): void { throw new Error('Method not implemented.'); } diff --git a/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts new file mode 100644 index 00000000000..35e0dc6bc92 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/hooksExecutionService.test.ts @@ -0,0 +1,220 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { HookResultKind, HooksExecutionService, IHookResult, IHooksExecutionProxy } from '../../common/hooksExecutionService.js'; +import { HookType, IHookCommand } from '../../common/promptSyntax/hookSchema.js'; +import { IOutputChannel, IOutputService } from '../../../../services/output/common/output.js'; + +function cmd(command: string): IHookCommand { + return { type: 'command', command, cwd: URI.file('/') }; +} + +function createMockOutputService(): IOutputService { + const mockChannel: Partial = { + append: () => { }, + }; + return { + _serviceBrand: undefined, + getChannel: () => mockChannel as IOutputChannel, + } as unknown as IOutputService; +} + +suite('HooksExecutionService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let service: HooksExecutionService; + const sessionUri = URI.file('/test/session'); + + setup(() => { + service = new HooksExecutionService(new NullLogService(), createMockOutputService()); + }); + + suite('registerHooks', () => { + test('registers hooks for a session', () => { + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + assert.strictEqual(service.getHooksForSession(sessionUri), hooks); + }); + + test('returns disposable that unregisters hooks', () => { + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + const disposable = service.registerHooks(sessionUri, hooks); + + assert.strictEqual(service.getHooksForSession(sessionUri), hooks); + + disposable.dispose(); + + assert.strictEqual(service.getHooksForSession(sessionUri), undefined); + }); + + test('different sessions have independent hooks', () => { + const session1 = URI.file('/test/session1'); + const session2 = URI.file('/test/session2'); + const hooks1 = { [HookType.PreToolUse]: [cmd('echo 1')] }; + const hooks2 = { [HookType.PostToolUse]: [cmd('echo 2')] }; + + store.add(service.registerHooks(session1, hooks1)); + store.add(service.registerHooks(session2, hooks2)); + + assert.strictEqual(service.getHooksForSession(session1), hooks1); + assert.strictEqual(service.getHooksForSession(session2), hooks2); + }); + }); + + suite('getHooksForSession', () => { + test('returns undefined for unregistered session', () => { + assert.strictEqual(service.getHooksForSession(sessionUri), undefined); + }); + }); + + suite('executeHook', () => { + test('returns empty array when no proxy set', async () => { + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + assert.deepStrictEqual(results, []); + }); + + test('returns empty array when no hooks registered for session', async () => { + const proxy = createMockProxy(); + service.setProxy(proxy); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + assert.deepStrictEqual(results, []); + }); + + test('returns empty array when no hooks of requested type', async () => { + const proxy = createMockProxy(); + service.setProxy(proxy); + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PostToolUse, sessionUri); + assert.deepStrictEqual(results, []); + }); + + test('executes hook commands via proxy and returns results', async () => { + const proxyResults: IHookResult[] = []; + const proxy = createMockProxy((cmd) => { + const result: IHookResult = { kind: HookResultKind.Success, result: `executed: ${cmd.command}` }; + proxyResults.push(result); + return result; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri, { input: 'test-input' }); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, HookResultKind.Success); + assert.strictEqual(results[0].result, 'executed: echo test'); + }); + + test('executes multiple hook commands in order', async () => { + const executedCommands: string[] = []; + const proxy = createMockProxy((cmd) => { + executedCommands.push(cmd.command ?? ''); + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { + [HookType.PreToolUse]: [cmd('cmd1'), cmd('cmd2'), cmd('cmd3')] + }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(results.length, 3); + assert.deepStrictEqual(executedCommands, ['cmd1', 'cmd2', 'cmd3']); + }); + + test('wraps proxy errors in HookResultKind.Error', async () => { + const proxy = createMockProxy(() => { + throw new Error('proxy failed'); + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('fail')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const results = await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].kind, HookResultKind.Error); + assert.strictEqual(results[0].result, 'proxy failed'); + }); + + test('passes cancellation token to proxy', async () => { + let receivedToken: CancellationToken | undefined; + const proxy = createMockProxy((_cmd, _input, token) => { + receivedToken = token; + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const cts = store.add(new CancellationTokenSource()); + await service.executeHook(HookType.PreToolUse, sessionUri, { token: cts.token }); + + assert.strictEqual(receivedToken, cts.token); + }); + + test('uses CancellationToken.None when no token provided', async () => { + let receivedToken: CancellationToken | undefined; + const proxy = createMockProxy((_cmd, _input, token) => { + receivedToken = token; + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + await service.executeHook(HookType.PreToolUse, sessionUri); + + assert.strictEqual(receivedToken, CancellationToken.None); + }); + + test('passes input to proxy', async () => { + let receivedInput: unknown; + const proxy = createMockProxy((_cmd, input) => { + receivedInput = input; + return { kind: HookResultKind.Success, result: 'ok' }; + }); + service.setProxy(proxy); + + const hooks = { [HookType.PreToolUse]: [cmd('echo test')] }; + store.add(service.registerHooks(sessionUri, hooks)); + + const testInput = { foo: 'bar', nested: { value: 123 } }; + await service.executeHook(HookType.PreToolUse, sessionUri, { input: testInput }); + + assert.deepStrictEqual(receivedInput, testInput); + }); + }); + + function createMockProxy(handler?: (cmd: IHookCommand, input: unknown, token: CancellationToken) => IHookResult): IHooksExecutionProxy { + return { + runHookCommand: async (hookCommand, input, token) => { + if (handler) { + return handler(hookCommand, input, token); + } + return { kind: HookResultKind.Success, result: 'mock result' }; + } + }; + } +}); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 80e7de23580..c08c536df20 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -19,10 +19,10 @@ export class MockChatSessionsService implements IChatSessionsService { private readonly _onDidChangeSessionOptions = new Emitter(); readonly onDidChangeSessionOptions = this._onDidChangeSessionOptions.event; - private readonly _onDidChangeItemsProviders = new Emitter(); + private readonly _onDidChangeItemsProviders = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeItemsProviders = this._onDidChangeItemsProviders.event; - private readonly _onDidChangeSessionItems = new Emitter(); + private readonly _onDidChangeSessionItems = new Emitter<{ readonly chatSessionType: string }>(); readonly onDidChangeSessionItems = this._onDidChangeSessionItems.event; private readonly _onDidChangeAvailability = new Emitter(); @@ -54,7 +54,7 @@ export class MockChatSessionsService implements IChatSessionsService { } fireDidChangeSessionItems(chatSessionType: string): void { - this._onDidChangeSessionItems.fire(chatSessionType); + this._onDidChangeSessionItems.fire({ chatSessionType }); } fireDidChangeAvailability(): void { @@ -86,8 +86,8 @@ export class MockChatSessionsService implements IChatSessionsService { this.contributions = contributions; } - async activateChatSessionItemProvider(chatSessionType: string): Promise { - return this.sessionItemProviders.get(chatSessionType); + async activateChatSessionItemProvider(chatSessionType: string): Promise { + // Noop, nothing to activate } getIconForSessionType(chatSessionType: string): ThemeIcon | URI | undefined { @@ -167,10 +167,6 @@ export class MockChatSessionsService implements IChatSessionsService { await this._onRequestNotifyExtension.fireAsync({ sessionResource, updates }, CancellationToken.None); } - notifySessionItemsChanged(chatSessionType: string): void { - this._onDidChangeSessionItems.fire(chatSessionType); - } - getSessionOption(sessionResource: URI, optionId: string): string | undefined { return this.sessionOptions.get(sessionResource)?.get(optionId); } diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index ee07a7c00d4..934d86152e6 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -25,9 +25,9 @@ import { TestExtensionService, TestStorageService } from '../../../../../test/co import { CellUri } from '../../../../notebook/common/notebookCommon.js'; import { IChatRequestImplicitVariableEntry, IChatRequestStringVariableEntry, IChatRequestFileEntry, StringChatContextValue } from '../../../common/attachments/chatVariableEntries.js'; import { ChatAgentService, IChatAgentService } from '../../../common/participants/chatAgents.js'; -import { ChatModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; +import { ChatModel, ChatRequestModel, IExportableChatData, ISerializableChatData1, ISerializableChatData2, ISerializableChatData3, isExportableSessionData, isSerializableSessionData, normalizeSerializableChatData, Response } from '../../../common/model/chatModel.js'; import { ChatRequestTextPart } from '../../../common/requestParser/chatParserTypes.js'; -import { IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; +import { ChatRequestQueueKind, IChatService, IChatToolInvocation } from '../../../common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { MockChatService } from '../chatService/mockChatService.js'; @@ -738,3 +738,222 @@ suite('ChatResponseModel', () => { } }); }); + +suite('ChatModel - Pending Requests', () => { + const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + + function createModel(): ChatModel { + return testDisposables.add(instantiationService.createInstance( + ChatModel, + undefined, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + } + + function addRequestToModel(model: ChatModel, text: string): ChatRequestModel { + return model.addRequest( + { text, parts: [new ChatRequestTextPart(new OffsetRange(0, text.length), new Range(1, text.length, 1, text.length), text)] }, + { variables: [] }, + 0 + ); + } + + setup(async () => { + instantiationService = testDisposables.add(new TestInstantiationService()); + instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IExtensionService, new TestExtensionService()); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + instantiationService.stub(IChatAgentService, testDisposables.add(instantiationService.createInstance(ChatAgentService))); + instantiationService.stub(IConfigurationService, new TestConfigurationService()); + instantiationService.stub(IChatService, new MockChatService()); + }); + + test('addPendingRequest - queued messages are added at the end', () => { + const model = createModel(); + const request1 = addRequestToModel(model, 'first'); + const request2 = addRequestToModel(model, 'second'); + + model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {}); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 2); + assert.strictEqual(pending[0].request.id, request1.id); + assert.strictEqual(pending[1].request.id, request2.id); + }); + + test('addPendingRequest - steering messages are inserted before queued messages', () => { + const model = createModel(); + const queued = addRequestToModel(model, 'queued'); + const steering = addRequestToModel(model, 'steering'); + + model.addPendingRequest(queued, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(steering, ChatRequestQueueKind.Steering, {}); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 2); + assert.strictEqual(pending[0].request.id, steering.id); + assert.strictEqual(pending[0].kind, ChatRequestQueueKind.Steering); + assert.strictEqual(pending[1].request.id, queued.id); + assert.strictEqual(pending[1].kind, ChatRequestQueueKind.Queued); + }); + + test('addPendingRequest - multiple steering messages maintain order', () => { + const model = createModel(); + const [steering1, steering2, queued] = ['s1', 's2', 'q'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(queued, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(steering1, ChatRequestQueueKind.Steering, {}); + model.addPendingRequest(steering2, ChatRequestQueueKind.Steering, {}); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 3); + assert.strictEqual(pending[0].request.id, steering1.id); + assert.strictEqual(pending[1].request.id, steering2.id); + assert.strictEqual(pending[2].request.id, queued.id); + }); + + test('addPendingRequest - fires onDidChangePendingRequests event', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + + let eventFired = false; + testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; })); + + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + assert.strictEqual(eventFired, true); + }); + + test('removePendingRequest - removes specified request', () => { + const model = createModel(); + const [request1, request2] = ['r1', 'r2'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {}); + + model.removePendingRequest(request1.id); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 1); + assert.strictEqual(pending[0].request.id, request2.id); + }); + + test('removePendingRequest - no-op for non-existent request', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + let eventCount = 0; + testDisposables.add(model.onDidChangePendingRequests(() => { eventCount++; })); + + model.removePendingRequest('non-existent-id'); + + assert.strictEqual(model.getPendingRequests().length, 1); + assert.strictEqual(eventCount, 0); + }); + + test('dequeuePendingRequest - returns and removes first request', () => { + const model = createModel(); + const [request1, request2] = ['r1', 'r2'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(request1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(request2, ChatRequestQueueKind.Queued, {}); + + const dequeued = model.dequeuePendingRequest(); + + assert.strictEqual(dequeued?.request.id, request1.id); + assert.strictEqual(model.getPendingRequests().length, 1); + assert.strictEqual(model.getPendingRequests()[0].request.id, request2.id); + }); + + test('dequeuePendingRequest - returns undefined when empty', () => { + const model = createModel(); + assert.strictEqual(model.dequeuePendingRequest(), undefined); + }); + + test('dequeuePendingRequest - fires event when request dequeued', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + let eventFired = false; + testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; })); + + model.dequeuePendingRequest(); + + assert.strictEqual(eventFired, true); + }); + + test('clearPendingRequests - removes all pending requests', () => { + const model = createModel(); + ['r1', 'r2', 'r3'].forEach(t => { + model.addPendingRequest(addRequestToModel(model, t), ChatRequestQueueKind.Queued, {}); + }); + + model.clearPendingRequests(); + + assert.strictEqual(model.getPendingRequests().length, 0); + }); + + test('clearPendingRequests - no event when already empty', () => { + const model = createModel(); + + let eventFired = false; + testDisposables.add(model.onDidChangePendingRequests(() => { eventFired = true; })); + + model.clearPendingRequests(); + + assert.strictEqual(eventFired, false); + }); + + test('setPendingRequests - reorders existing pending requests', () => { + const model = createModel(); + const [r1, r2, r3] = ['r1', 'r2', 'r3'].map(t => addRequestToModel(model, t)); + + model.addPendingRequest(r1, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(r2, ChatRequestQueueKind.Queued, {}); + model.addPendingRequest(r3, ChatRequestQueueKind.Steering, {}); + + // Reverse the order + model.setPendingRequests([ + { requestId: r2.id, kind: ChatRequestQueueKind.Queued }, + { requestId: r1.id, kind: ChatRequestQueueKind.Steering }, // Change kind + ]); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 2); + assert.strictEqual(pending[0].request.id, r2.id); + assert.strictEqual(pending[1].request.id, r1.id); + assert.strictEqual(pending[1].kind, ChatRequestQueueKind.Steering); + }); + + test('setPendingRequests - ignores non-existent request IDs', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + model.addPendingRequest(request, ChatRequestQueueKind.Queued, {}); + + model.setPendingRequests([ + { requestId: 'non-existent', kind: ChatRequestQueueKind.Queued }, + { requestId: request.id, kind: ChatRequestQueueKind.Queued }, + ]); + + const pending = model.getPendingRequests(); + assert.strictEqual(pending.length, 1); + assert.strictEqual(pending[0].request.id, request.id); + }); + + test('pending requests preserve send options', () => { + const model = createModel(); + const request = addRequestToModel(model, 'test'); + const sendOptions = { agentId: 'test-agent', attempt: 3 }; + + const pending = model.addPendingRequest(request, ChatRequestQueueKind.Queued, sendOptions); + + assert.strictEqual(pending.sendOptions.agentId, 'test-agent'); + assert.strictEqual(pending.sendOptions.attempt, 3); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts index 026c88b2fa5..f21450267d4 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/mockChatModel.ts @@ -3,12 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from '../../../../../../base/common/event.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { IObservable, observableValue } from '../../../../../../base/common/observable.js'; import { URI } from '../../../../../../base/common/uri.js'; import { IChatEditingSession } from '../../../common/editing/chatEditingService.js'; -import { IChatChangeEvent, IChatModel, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; +import { IChatChangeEvent, IChatModel, IChatPendingRequest, IChatRequestModel, IChatRequestNeedsInputInfo, IExportableChatData, IExportableRepoData, IInputModel, ISerializableChatData } from '../../../common/model/chatModel.js'; import { ChatAgentLocation } from '../../../common/constants.js'; import { IChatSessionTiming } from '../../../common/chatService/chatService.js'; @@ -61,6 +61,8 @@ export class MockChatModel extends Disposable implements IChatModel { getRequests(): IChatRequestModel[] { return []; } setCheckpoint(requestId: string | undefined): void { } setRepoData(data: IExportableRepoData | undefined): void { this.repoData = data; } + readonly onDidChangePendingRequests: Event = this._register(new Emitter()).event; + getPendingRequests(): readonly IChatPendingRequest[] { return []; } toExport(): IExportableChatData { return { initialLocation: this.initialLocation, 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 2038fe08f25..fafc1500aa2 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 @@ -39,10 +39,10 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../platf import { IPathService } from '../../../../../services/path/common/pathService.js'; import { IFileQuery, ISearchService } from '../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../services/extensions/common/extensions.js'; -import { ChatMode } from '../../../common/chatModes.js'; import { ILanguageModelToolsService } from '../../../common/tools/languageModelToolsService.js'; import { basename } from '../../../../../../base/common/resources.js'; import { match } from '../../../../../../base/common/glob.js'; +import { ChatModeKind } from '../../../common/constants.js'; suite('ComputeAutomaticInstructions', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -218,7 +218,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); { - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -235,7 +235,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, false); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -252,7 +252,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, false); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, true); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -269,7 +269,7 @@ suite('ComputeAutomaticInstructions', () => { testConfigService.setUserConfiguration(PromptsConfig.INCLUDE_APPLYING_INSTRUCTIONS, true); testConfigService.setUserConfiguration(PromptsConfig.USE_COPILOT_INSTRUCTION_FILES, true); testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_MD, false); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -319,7 +319,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -354,7 +354,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Edit, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Edit, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -389,7 +389,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -431,7 +431,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -467,7 +467,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/component.tsx'))); @@ -503,7 +503,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file1.ts'))); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file2.ts'))); @@ -537,7 +537,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -581,7 +581,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); const referencedUri = URI.joinPath(rootFolderUri, '.github/instructions/referenced.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -617,7 +617,7 @@ suite('ComputeAutomaticInstructions', () => { const mainUri = URI.joinPath(rootFolderUri, '.github/instructions/main.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -666,7 +666,7 @@ suite('ComputeAutomaticInstructions', () => { const level2Uri = URI.joinPath(rootFolderUri, '.github/instructions/level2.instructions.md'); const level3Uri = URI.joinPath(rootFolderUri, '.github/instructions/level3.instructions.md'); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -726,7 +726,7 @@ suite('ComputeAutomaticInstructions', () => { } as unknown as ITelemetryService; instaService.stub(ITelemetryService, mockTelemetryService); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -784,7 +784,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -874,7 +874,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_runSubagent': true }, // Enable runSubagent tool ['*'] // Enable all subagents ); @@ -936,7 +936,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -986,7 +986,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, undefined, // No tools available undefined ); @@ -1022,7 +1022,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -1075,7 +1075,7 @@ suite('ComputeAutomaticInstructions', () => { const contextComputer = instaService.createInstance( ComputeAutomaticInstructions, - ChatMode.Agent, + ChatModeKind.Agent, { 'vscode_readFile': true }, // Enable readFile tool undefined ); @@ -1108,7 +1108,7 @@ suite('ComputeAutomaticInstructions', () => { workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); await contextComputer.collect(variables, CancellationToken.None); @@ -1140,7 +1140,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); @@ -1163,7 +1163,7 @@ suite('ComputeAutomaticInstructions', () => { }, ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const variables = new ChatRequestVariableSet(); variables.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'src/file.ts'))); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts index 0a3dc060d7e..a81e59d0ec6 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/promptFileLocations.test.ts @@ -82,6 +82,51 @@ suite('promptFileLocations', function () { const uri = URI.file('/workspace/.github/skills/test/Skill.md'); assert.strictEqual(getPromptFileType(uri), PromptsType.skill); }); + + test('hooks.json should be recognized as hook', () => { + const uri = URI.file('/workspace/.github/hooks/hooks.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('HOOKS.JSON (uppercase) should be recognized as hook', () => { + const uri = URI.file('/workspace/.github/hooks/HOOKS.JSON'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('hooks.json in any folder should be recognized as hook', () => { + const uri = URI.file('/workspace/some/other/path/hooks.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('settings.json in .claude folder should be recognized as hook', () => { + const uri = URI.file('/workspace/.claude/settings.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('settings.local.json in .claude folder should be recognized as hook', () => { + const uri = URI.file('/workspace/.claude/settings.local.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('SETTINGS.JSON (uppercase) in .claude folder should be recognized as hook', () => { + const uri = URI.file('/workspace/.claude/SETTINGS.JSON'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); + + test('settings.json outside .claude folder should NOT be recognized as hook', () => { + const uri = URI.file('/workspace/.github/settings.json'); + assert.strictEqual(getPromptFileType(uri), undefined); + }); + + test('settings.local.json outside .claude folder should NOT be recognized as hook', () => { + const uri = URI.file('/workspace/some/path/settings.local.json'); + assert.strictEqual(getPromptFileType(uri), undefined); + }); + + test('settings.json in ~/.claude folder should be recognized as hook', () => { + const uri = URI.file('/Users/user/.claude/settings.json'); + assert.strictEqual(getPromptFileType(uri), PromptsType.hook); + }); }); suite('getCleanPromptName', () => { @@ -162,5 +207,21 @@ suite('promptFileLocations', function () { test('regular .md files should return false', () => { assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/SKILL2.md')), false); }); + + test('hooks.json should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.github/hooks/hooks.json')), true); + }); + + test('settings.json in .claude folder should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.json')), true); + }); + + test('settings.local.json in .claude folder should return true', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/.claude/settings.local.json')), true); + }); + + test('settings.json outside .claude folder should return false', () => { + assert.strictEqual(isPromptOrInstructionsFile(URI.file('/workspace/settings.json')), false); + }); }); }); 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 new file mode 100644 index 00000000000..778b57732ff --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookClaudeCompat.test.ts @@ -0,0 +1,362 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { HookType } from '../../../common/promptSyntax/hookSchema.js'; +import { parseClaudeHooks, resolveClaudeHookType, getClaudeHookTypeName } from '../../../common/promptSyntax/hookClaudeCompat.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +suite('HookClaudeCompat', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('resolveClaudeHookType', () => { + test('resolves PreToolUse', () => { + assert.strictEqual(resolveClaudeHookType('PreToolUse'), HookType.PreToolUse); + }); + + test('resolves UserPromptSubmit', () => { + assert.strictEqual(resolveClaudeHookType('UserPromptSubmit'), HookType.UserPromptSubmitted); + }); + + test('returns undefined for unknown type', () => { + assert.strictEqual(resolveClaudeHookType('UnknownHook'), undefined); + }); + + test('returns undefined for camelCase (not Claude format)', () => { + assert.strictEqual(resolveClaudeHookType('preToolUse'), undefined); + }); + }); + + suite('getClaudeHookTypeName', () => { + test('gets PreToolUse for HookType.PreToolUse', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.PreToolUse), 'PreToolUse'); + }); + + test('gets UserPromptSubmit for HookType.UserPromptSubmitted', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.UserPromptSubmitted), 'UserPromptSubmit'); + }); + + test('returns undefined for HookType.PostToolUseFailure (not supported)', () => { + assert.strictEqual(getClaudeHookTypeName(HookType.PostToolUseFailure), undefined); + }); + }); + + suite('parseClaudeHooks', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + suite('basic parsing', () => { + test('parses simple hook with command', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "pre-tool"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.originalId, 'PreToolUse'); + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "pre-tool"'); + }); + + test('parses multiple hook types', () => { + const json = { + hooks: { + SessionStart: [{ type: 'command', command: 'echo "start"' }], + Stop: [{ type: 'command', command: 'echo "stop"' }] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 2); + assert.ok(result.has(HookType.SessionStart)); + assert.ok(result.has(HookType.Stop)); + }); + + test('parses multiple commands for same hook type', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "first"'); + assert.strictEqual(entry.hooks[1].command, 'echo "second"'); + }); + }); + + suite('nested hooks with matchers', () => { + test('parses nested hooks with matcher', () => { + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "bash hook"' } + ] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "bash hook"'); + }); + + test('parses multiple nested hooks within one matcher', () => { + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [ + { type: 'command', command: 'echo "first"' }, + { type: 'command', command: 'echo "second"' } + ] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + }); + + test('parses multiple matchers for same hook type', () => { + const json = { + hooks: { + PreToolUse: [ + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo "bash"' }] + }, + { + matcher: 'Write', + hooks: [{ type: 'command', command: 'echo "write"' }] + } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 2); + assert.strictEqual(entry.hooks[0].command, 'echo "bash"'); + assert.strictEqual(entry.hooks[1].command, 'echo "write"'); + }); + + test('parses mix of direct and nested hooks', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "direct"' }, + { + matcher: 'Bash', + hooks: [{ type: 'command', command: 'echo "nested"' }] + } + ] + } + }; + + const result = parseClaudeHooks(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"'); + }); + }); + + suite('command without type field', () => { + test('parses command without explicit type field', () => { + const json = { + hooks: { + PreToolUse: [ + { command: 'echo "no type"' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'echo "no type"'); + }); + }); + + suite('hook type name mapping', () => { + test('maps UserPromptSubmit to UserPromptSubmitted', () => { + const json = { + hooks: { + UserPromptSubmit: [{ type: 'command', command: 'echo "submitted"' }] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.ok(result.has(HookType.UserPromptSubmitted)); + assert.strictEqual(result.get(HookType.UserPromptSubmitted)!.originalId, 'UserPromptSubmit'); + }); + }); + + suite('invalid inputs', () => { + test('returns empty map for null json', () => { + const result = parseClaudeHooks(null, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for undefined json', () => { + const result = parseClaudeHooks(undefined, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for non-object json', () => { + const result = parseClaudeHooks('string', workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for missing hooks property', () => { + const result = parseClaudeHooks({}, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('returns empty map for non-object hooks property', () => { + const result = parseClaudeHooks({ hooks: 'invalid' }, workspaceRoot, userHome); + assert.strictEqual(result.size, 0); + }); + + test('skips unknown hook types', () => { + const json = { + hooks: { + UnknownType: [{ type: 'command', command: 'echo "test"' }], + PreToolUse: [{ type: 'command', command: 'echo "known"' }] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 1); + assert.ok(result.has(HookType.PreToolUse)); + }); + + test('skips non-array hook entries', () => { + const json = { + hooks: { + PreToolUse: { type: 'command', command: 'echo "not array"' } + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + assert.strictEqual(result.size, 0); + }); + + test('skips invalid command entries', () => { + const json = { + hooks: { + PreToolUse: [ + 'invalid string', + null, + { type: 'command', command: 'valid' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'valid'); + }); + + test('skips commands with wrong type', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'script', command: 'invalid type' }, + { type: 'command', command: 'valid' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks.length, 1); + assert.strictEqual(entry.hooks[0].command, 'valid'); + }); + }); + + suite('cwd and env resolution', () => { + test('resolves cwd relative to workspace', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', cwd: 'src' } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.deepStrictEqual(entry.hooks[0].cwd, URI.file('/workspace/src')); + }); + + test('preserves env variables', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', env: { NODE_ENV: 'production' } } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.deepStrictEqual(entry.hooks[0].env, { NODE_ENV: 'production' }); + }); + + test('preserves timeoutSec', () => { + const json = { + hooks: { + PreToolUse: [ + { type: 'command', command: 'echo "test"', timeoutSec: 60 } + ] + } + }; + + const result = parseClaudeHooks(json, workspaceRoot, userHome); + + const entry = result.get(HookType.PreToolUse)!; + assert.strictEqual(entry.hooks[0].timeoutSec, 60); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts new file mode 100644 index 00000000000..f9557563e11 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/hookSchema.test.ts @@ -0,0 +1,331 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { HookType, normalizeHookTypeId, resolveHookCommand } from '../../../common/promptSyntax/hookSchema.js'; +import { URI } from '../../../../../../base/common/uri.js'; + +suite('HookSchema', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('normalizeHookTypeId', () => { + + suite('Claude Code hook types (PascalCase)', () => { + // @see https://code.claude.com/docs/en/hooks#hook-lifecycle + + test('SessionStart -> sessionStart', () => { + assert.strictEqual(normalizeHookTypeId('SessionStart'), HookType.SessionStart); + }); + + test('UserPromptSubmit -> userPromptSubmitted', () => { + assert.strictEqual(normalizeHookTypeId('UserPromptSubmit'), HookType.UserPromptSubmitted); + }); + + test('PreToolUse -> preToolUse', () => { + assert.strictEqual(normalizeHookTypeId('PreToolUse'), HookType.PreToolUse); + }); + + test('PostToolUse -> postToolUse', () => { + assert.strictEqual(normalizeHookTypeId('PostToolUse'), HookType.PostToolUse); + }); + + test('PostToolUseFailure -> postToolUseFailure', () => { + assert.strictEqual(normalizeHookTypeId('PostToolUseFailure'), HookType.PostToolUseFailure); + }); + + test('SubagentStart -> subagentStart', () => { + assert.strictEqual(normalizeHookTypeId('SubagentStart'), HookType.SubagentStart); + }); + + test('SubagentStop -> subagentStop', () => { + assert.strictEqual(normalizeHookTypeId('SubagentStop'), HookType.SubagentStop); + }); + + test('Stop -> stop', () => { + assert.strictEqual(normalizeHookTypeId('Stop'), HookType.Stop); + }); + }); + + suite('unknown hook types', () => { + test('unknown type returns undefined', () => { + assert.strictEqual(normalizeHookTypeId('unknownHook'), undefined); + }); + + test('empty string returns undefined', () => { + assert.strictEqual(normalizeHookTypeId(''), undefined); + }); + + test('typo returns undefined', () => { + assert.strictEqual(normalizeHookTypeId('sessionstart'), undefined); + assert.strictEqual(normalizeHookTypeId('SESSIONSTART'), undefined); + }); + }); + }); + + suite('resolveHookCommand', () => { + const workspaceRoot = URI.file('/workspace'); + const userHome = '/home/user'; + + suite('command property', () => { + test('resolves basic command', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + + test('resolves command with all optional properties', () => { + const result = resolveHookCommand({ + type: 'command', + command: './scripts/validate.sh', + cwd: 'src', + env: { NODE_ENV: 'test' }, + timeoutSec: 60 + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: './scripts/validate.sh', + cwd: URI.file('/workspace/src'), + env: { NODE_ENV: 'test' }, + timeoutSec: 60 + }); + }); + + test('empty command returns object without command', () => { + const result = resolveHookCommand({ + type: 'command', + command: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: workspaceRoot + }); + }); + }); + + suite('bash shorthand', () => { + test('preserves bash property', () => { + const result = resolveHookCommand({ + type: 'command', + bash: 'echo "hello world"' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + bash: 'echo "hello world"', + cwd: workspaceRoot + }); + }); + + test('bash with cwd and env', () => { + const result = resolveHookCommand({ + type: 'command', + bash: './test.sh', + cwd: 'scripts', + env: { DEBUG: '1' } + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + bash: './test.sh', + cwd: URI.file('/workspace/scripts'), + env: { DEBUG: '1' } + }); + }); + + test('empty bash returns object without bash', () => { + const result = resolveHookCommand({ + type: 'command', + bash: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: workspaceRoot + }); + }); + }); + + suite('powershell shorthand', () => { + test('preserves powershell property', () => { + const result = resolveHookCommand({ + type: 'command', + powershell: 'Write-Host "hello"' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + powershell: 'Write-Host "hello"', + cwd: workspaceRoot + }); + }); + + test('powershell with timeoutSec', () => { + const result = resolveHookCommand({ + type: 'command', + powershell: 'Get-Process', + timeoutSec: 30 + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + powershell: 'Get-Process', + cwd: workspaceRoot, + timeoutSec: 30 + }); + }); + + test('empty powershell returns object without powershell', () => { + const result = resolveHookCommand({ + type: 'command', + powershell: '' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: workspaceRoot + }); + }); + }); + + suite('multiple properties specified', () => { + test('preserves both command and bash', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'direct-command', + bash: 'bash-script.sh' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'direct-command', + bash: 'bash-script.sh', + cwd: workspaceRoot + }); + }); + + test('preserves both command and powershell', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'direct-command', + powershell: 'ps-script.ps1' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'direct-command', + powershell: 'ps-script.ps1', + cwd: workspaceRoot + }); + }); + + test('preserves both bash and powershell when no command', () => { + const result = resolveHookCommand({ + type: 'command', + bash: 'bash-script.sh', + powershell: 'ps-script.ps1' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + bash: 'bash-script.sh', + powershell: 'ps-script.ps1', + cwd: workspaceRoot + }); + }); + }); + + suite('cwd resolution', () => { + test('cwd is not resolved when no workspace root', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + cwd: 'src' + }, undefined, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello' + }); + }); + + test('cwd is resolved relative to workspace root', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + cwd: 'nested/path' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: URI.file('/workspace/nested/path') + }); + }); + }); + + suite('invalid inputs', () => { + test('wrong type returns undefined', () => { + const result = resolveHookCommand({ + type: 'script', + command: 'echo hello' + }, workspaceRoot, userHome); + assert.strictEqual(result, undefined); + }); + + test('missing type returns undefined', () => { + const result = resolveHookCommand({ + command: 'echo hello' + }, workspaceRoot, userHome); + assert.strictEqual(result, undefined); + }); + + test('no command/bash/powershell returns object with just type and cwd', () => { + const result = resolveHookCommand({ + type: 'command', + cwd: '/workspace' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + cwd: URI.file('/workspace') + }); + }); + + test('ignores non-string cwd', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + cwd: 123 + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + + test('ignores non-object env', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + env: 'invalid' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + + test('ignores non-number timeoutSec', () => { + const result = resolveHookCommand({ + type: 'command', + command: 'echo hello', + timeoutSec: '30' + }, workspaceRoot, userHome); + assert.deepStrictEqual(result, { + type: 'command', + command: 'echo hello', + cwd: workspaceRoot + }); + }); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index 1fb0c86c423..d307385e6a8 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -66,5 +66,7 @@ export class MockPromptsService implements IPromptsService { findAgentSkills(token: CancellationToken): Promise { throw new Error('Method not implemented.'); } // eslint-disable-next-line @typescript-eslint/no-explicit-any getPromptDiscoveryInfo(_type: any, _token: CancellationToken): Promise { throw new Error('Method not implemented.'); } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getHooks(_token: CancellationToken): Promise { throw new Error('Method not implemented.'); } dispose(): void { } } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 58bce6f2a91..306efc1e4da 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -47,7 +47,7 @@ import { InMemoryStorageService, IStorageService } from '../../../../../../../pl import { IPathService } from '../../../../../../services/path/common/pathService.js'; import { IFileMatch, IFileQuery, ISearchService } from '../../../../../../services/search/common/search.js'; import { IExtensionService } from '../../../../../../services/extensions/common/extensions.js'; -import { ChatMode } from '../../../../common/chatModes.js'; +import { ChatModeKind } from '../../../../common/constants.js'; suite('PromptsService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -464,7 +464,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -635,7 +635,7 @@ suite('PromptsService', () => { ]); const instructionFiles = await service.listPromptFiles(PromptsType.instructions, CancellationToken.None); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const context = { files: new ResourceSet([ URI.joinPath(rootFolderUri, 'folder1/main.tsx'), @@ -709,7 +709,7 @@ suite('PromptsService', () => { ]); - const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatMode.Agent, undefined, undefined); + const contextComputer = instaService.createInstance(ComputeAutomaticInstructions, ChatModeKind.Agent, undefined, undefined); const context = new ChatRequestVariableSet(); context.add(toFileVariableEntry(URI.joinPath(rootFolderUri, 'README.md'))); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index f67bcc11c8e..25d7088d46e 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -12,10 +12,11 @@ import { TestConfigurationService } from '../../../../../../../platform/configur import { RunSubagentTool } from '../../../../common/tools/builtinTools/runSubagentTool.js'; import { MockLanguageModelToolsService } from '../mockLanguageModelToolsService.js'; import { IChatAgentService } from '../../../../common/participants/chatAgents.js'; -import { IChatModeService } from '../../../../common/chatModes.js'; import { IChatService } from '../../../../common/chatService/chatService.js'; import { ILanguageModelsService } from '../../../../common/languageModels.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; +import { ICustomAgent, PromptsStorage } from '../../../../common/promptSyntax/service/promptsService.js'; +import { MockPromptsService } from '../../promptSyntax/service/mockPromptsService.js'; suite('RunSubagentTool', () => { const testDisposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -45,28 +46,27 @@ suite('RunSubagentTool', () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const configService = new TestConfigurationService(); - const mockChatModeService = { - findModeByName: (name: string) => { - if (name === 'CustomAgent') { - return { - name: { - get: () => 'CustomAgent' - } - }; - } - return undefined; - } - } as unknown as IChatModeService; + const promptsService = new MockPromptsService(); + const customMode: ICustomAgent = { + uri: URI.parse('file:///test/custom-agent.md'), + name: 'CustomAgent', + description: 'A test custom agent', + tools: ['tool1', 'tool2'], + agentInstructions: { content: 'Custom agent body', toolReferences: [] }, + source: { storage: PromptsStorage.local }, + visibility: { userInvokable: true, agentInvokable: true } + }; + promptsService.setCustomModes([customMode]); const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, - mockChatModeService, mockToolsService, {} as ILanguageModelsService, new NullLogService(), mockToolsService, configService, + promptsService, {} as IInstantiationService, )); @@ -97,16 +97,17 @@ suite('RunSubagentTool', () => { test('returns basic tool data', () => { const mockToolsService = testDisposables.add(new MockLanguageModelToolsService()); const configService = new TestConfigurationService(); + const promptsService = new MockPromptsService(); const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, - {} as IChatModeService, mockToolsService, {} as ILanguageModelsService, new NullLogService(), mockToolsService, configService, + promptsService, {} as IInstantiationService, )); @@ -124,16 +125,17 @@ suite('RunSubagentTool', () => { const configService = new TestConfigurationService({ 'chat.customAgentInSubagent.enabled': true, }); + const promptsService = new MockPromptsService(); const tool = testDisposables.add(new RunSubagentTool( {} as IChatAgentService, {} as IChatService, - {} as IChatModeService, mockToolsService, {} as ILanguageModelsService, new NullLogService(), mockToolsService, configService, + promptsService, {} as IInstantiationService, )); diff --git a/src/vs/workbench/contrib/debug/common/debugModel.ts b/src/vs/workbench/contrib/debug/common/debugModel.ts index 8a468452695..9c943348a97 100644 --- a/src/vs/workbench/contrib/debug/common/debugModel.ts +++ b/src/vs/workbench/contrib/debug/common/debugModel.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { distinct } from '../../../../base/common/arrays.js'; -import { findLastIdx } from '../../../../base/common/arraysFind.js'; import { DeferredPromise, RunOnceScheduler } from '../../../../base/common/async.js'; import { VSBuffer, decodeBase64, encodeBase64 } from '../../../../base/common/buffer.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; @@ -1542,7 +1541,7 @@ export class DebugModel extends Disposable implements IDebugModel { let index = -1; if (session.parentSession) { // Make sure that child sessions are placed after the parent session - index = findLastIdx(this.sessions, s => s.parentSession === session.parentSession || s === session.parentSession); + index = this.sessions.findLastIndex(s => s.parentSession === session.parentSession || s === session.parentSession); } if (index >= 0) { this.sessions.splice(index + 1, 0, session); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts index 8574e038520..23ea0f7354a 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionsWorkbenchService.ts @@ -2684,9 +2684,11 @@ export class ExtensionsWorkbenchService extends Disposable implements IExtension } const extensionsToUninstall: UninstallExtensionInfo[] = [{ extension: extension.local }]; - for (const packExtension of this.getAllPackedExtensions(extension, this.local)) { - if (packExtension.local && !extensionsToUninstall.some(e => areSameExtensions(e.extension.identifier, packExtension.identifier))) { - extensionsToUninstall.push({ extension: packExtension.local }); + if (!areSameExtensions(extension.identifier, { id: this.productService.defaultChatAgent.extensionId })) { + for (const packExtension of this.getAllPackedExtensions(extension, this.local)) { + if (packExtension.local && !extensionsToUninstall.some(e => areSameExtensions(e.extension.identifier, packExtension.identifier))) { + extensionsToUninstall.push({ extension: packExtension.local }); + } } } diff --git a/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts b/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts index 1ef6e0ab1f1..4c84c8a6cf8 100644 --- a/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts +++ b/src/vs/workbench/contrib/extensions/browser/unsupportedExtensionsMigrationContribution.ts @@ -21,10 +21,10 @@ export class UnsupportedExtensionsMigrationContrib implements IWorkbenchContribu ) { // Unsupported extensions are not migrated for local extension management server, because it is done in shared process if (extensionManagementServerService.remoteExtensionManagementServer) { - migrateUnsupportedExtensions(extensionManagementServerService.remoteExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); + migrateUnsupportedExtensions(undefined, extensionManagementServerService.remoteExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); } if (extensionManagementServerService.webExtensionManagementServer) { - migrateUnsupportedExtensions(extensionManagementServerService.webExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); + migrateUnsupportedExtensions(undefined, extensionManagementServerService.webExtensionManagementServer.extensionManagementService, extensionGalleryService, extensionStorageService, extensionEnablementService, logService); } } diff --git a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts index ade5b69a2d5..a144b6b1c95 100644 --- a/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts +++ b/src/vs/workbench/contrib/extensions/test/electron-browser/extensionsWorkbenchService.test.ts @@ -38,7 +38,7 @@ import { ExtensionKind } from '../../../../../platform/environment/common/enviro import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js'; import { RemoteAgentService } from '../../../../services/remote/electron-browser/remoteAgentService.js'; import { ISharedProcessService } from '../../../../../platform/ipc/electron-browser/services.js'; -import { TestContextService } from '../../../../test/common/workbenchTestServices.js'; +import { TestContextService, TestProductService } from '../../../../test/common/workbenchTestServices.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { TestLifecycleService } from '../../../../test/browser/workbenchTestServices.js'; @@ -82,7 +82,7 @@ suite('ExtensionsWorkbenchServiceTest', () => { instantiationService.stub(ILogService, NullLogService); instantiationService.stub(IFileService, disposableStore.add(new FileService(new NullLogService()))); instantiationService.stub(IProgressService, ProgressService); - instantiationService.stub(IProductService, {}); + instantiationService.stub(IProductService, TestProductService); instantiationService.stub(IExtensionGalleryService, ExtensionGalleryService); instantiationService.stub(IURLService, NativeURLService); diff --git a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts index 3b26d58d13a..8b06a57a016 100644 --- a/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts +++ b/src/vs/workbench/contrib/notebook/browser/diff/notebookDiffEditor.ts @@ -5,7 +5,6 @@ import * as nls from '../../../../../nls.js'; import * as DOM from '../../../../../base/browser/dom.js'; -import { findLastIdx } from '../../../../../base/common/arraysFind.js'; import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IThemeService, registerThemingParticipant } from '../../../../../platform/theme/common/themeService.js'; @@ -774,7 +773,7 @@ export class NotebookTextDiffEditor extends EditorPane implements INotebookTextD this._list.reveal(prevChangeIndex); } else { // go to the last one - const index = findLastIdx(currentViewModels, vm => vm.type !== 'unchanged' && vm.type !== 'unchangedMetadata' && vm.type !== 'placeholder'); + const index = currentViewModels.findLastIndex(vm => vm.type !== 'unchanged' && vm.type !== 'unchangedMetadata' && vm.type !== 'placeholder'); if (index >= 0) { this._list.setFocus([index]); this._list.reveal(index); diff --git a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts index db818b43764..11ba4d1bbae 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsEditor2.ts @@ -1597,7 +1597,7 @@ export class SettingsEditor2 extends EditorPane { resolvedSettingsRoot.children!.push(await createTocTreeForExtensionSettings(this.extensionService, extensionSettingsGroups, filter)); - resolvedSettingsRoot.children!.unshift(getCommonlyUsedData(groups, toggleData?.commonlyUsed)); + resolvedSettingsRoot.children!.unshift(getCommonlyUsedData(groups)); if (toggleData && setAdditionalGroups) { // Add the additional groups to the model to help with searching. @@ -1671,7 +1671,7 @@ export class SettingsEditor2 extends EditorPane { try { this.settingsTree.reveal(newElement, 0); } catch (e) { - // Ignore the error + // Ignore the error } } } diff --git a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts index a2e37c2a0c0..e23cf45c101 100644 --- a/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts +++ b/src/vs/workbench/contrib/preferences/browser/settingsLayout.ts @@ -27,14 +27,15 @@ export interface ITOCEntry { hide?: boolean; } -const defaultCommonlyUsedSettings: string[] = [ +const COMMONLY_USED_SETTINGS: readonly string[] = [ 'editor.fontSize', 'editor.formatOnSave', 'files.autoSave', + 'GitHub.copilot-chat.manageExtension', 'editor.defaultFormatter', 'editor.fontFamily', - 'chat.agent.maxRequests', 'editor.wordWrap', + 'chat.agent.maxRequests', 'files.exclude', 'workbench.colorTheme', 'editor.tabSize', @@ -42,7 +43,7 @@ const defaultCommonlyUsedSettings: string[] = [ 'editor.formatOnPaste' ]; -export function getCommonlyUsedData(settingGroups: ISettingsGroup[], commonlyUsed: string[] = defaultCommonlyUsedSettings): ITOCEntry { +export function getCommonlyUsedData(settingGroups: ISettingsGroup[]): ITOCEntry { const allSettings = new Map(); for (const group of settingGroups) { for (const section of group.sections) { @@ -52,7 +53,7 @@ export function getCommonlyUsedData(settingGroups: ISettingsGroup[], commonlyUse } } const settings: ISetting[] = []; - for (const id of commonlyUsed) { + for (const id of COMMONLY_USED_SETTINGS) { const setting = allSettings.get(id); if (setting) { settings.push(setting); diff --git a/src/vs/workbench/contrib/preferences/common/preferences.ts b/src/vs/workbench/contrib/preferences/common/preferences.ts index 2aa5e003d16..1e6fcb82027 100644 --- a/src/vs/workbench/contrib/preferences/common/preferences.ts +++ b/src/vs/workbench/contrib/preferences/common/preferences.ts @@ -129,7 +129,6 @@ export enum WorkbenchSettingsEditorSettings { export type ExtensionToggleData = { settingsEditorRecommendedExtensions: IStringDictionary; recommendedExtensionsGalleryInfo: IStringDictionary; - commonlyUsed: string[]; }; let cachedExtensionToggleData: ExtensionToggleData | undefined; @@ -155,7 +154,7 @@ export async function getExperimentalExtensionToggleData( return cachedExtensionToggleData; } - if (productService.extensionRecommendations && productService.commonlyUsedSettings) { + if (productService.extensionRecommendations) { const settingsEditorRecommendedExtensions: IStringDictionary = {}; Object.keys(productService.extensionRecommendations).forEach(extensionId => { const extensionInfo = productService.extensionRecommendations![extensionId]; @@ -188,8 +187,7 @@ export async function getExperimentalExtensionToggleData( cachedExtensionToggleData = { settingsEditorRecommendedExtensions, - recommendedExtensionsGalleryInfo, - commonlyUsed: productService.commonlyUsedSettings + recommendedExtensionsGalleryInfo }; return cachedExtensionToggleData; } diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh index 87e3a63fe0a..ff974695f69 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-bash.sh @@ -108,6 +108,14 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python bash activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi fi + # Remove any leftover Python activation env vars. + for var in "${!VSCODE_PYTHON_@}"; do + case "$var" in + VSCODE_PYTHON_*_ACTIVATE) + unset "$var" + ;; + esac + done fi __vsc_get_trap() { diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh index 5389bd95b12..4869a391ebc 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration-rc.zsh @@ -79,6 +79,8 @@ if [ -z "${VSCODE_PYTHON_AUTOACTIVATE_GUARD:-}" ]; then builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python zsh activation failed with exit code %d \x1b[0m' "$__vsc_activation_status" fi fi + # Remove any leftover Python activation env vars. + unset -m 'VSCODE_PYTHON_*_ACTIVATE' fi # Report prompt type diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish index 0e0b6798c16..6cff7487a71 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.fish @@ -95,6 +95,10 @@ if not set -q VSCODE_PYTHON_AUTOACTIVATE_GUARD builtin printf '\x1b[0m\x1b[7m * \x1b[0;103m VS Code Python fish activation failed with exit code %d \x1b[0m \n' "$__vsc_activation_status" end end + # Remove any leftover Python activation env vars. + for var in (set -n | string match -r '^VSCODE_PYTHON_.*_ACTIVATE$') + set -eg $var + end end # Handle the shell integration nonce diff --git a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 index e89f6ec24c4..a16eaa85342 100644 --- a/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 +++ b/src/vs/workbench/contrib/terminal/common/scripts/shellIntegration.ps1 @@ -78,7 +78,6 @@ if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD = '1' if ($env:VSCODE_PYTHON_PWSH_ACTIVATE -and $env:TERM_PROGRAM -eq 'vscode') { $activateScript = $env:VSCODE_PYTHON_PWSH_ACTIVATE - Remove-Item Env:VSCODE_PYTHON_PWSH_ACTIVATE try { Invoke-Expression $activateScript @@ -89,6 +88,8 @@ if (-not $env:VSCODE_PYTHON_AUTOACTIVATE_GUARD) { Write-Host "`e[0m`e[7m * `e[0;103m VS Code Python powershell activation failed with exit code $($activationError.Exception.Message) `e[0m" } } + # Remove any leftover Python activation env vars. + Get-ChildItem Env:VSCODE_PYTHON_*_ACTIVATE | Remove-Item -ErrorAction SilentlyContinue } function Global:__VSCode-Escape-Value([string]$value) { diff --git a/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css new file mode 100644 index 00000000000..b80d8e50ea7 --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/media/updateStatusBarEntry.css @@ -0,0 +1,148 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.update-status-tooltip { + display: flex; + flex-direction: column; + padding: 4px 0; + min-width: 300px; + max-width: 400px; +} + +/* Header with title and gear icon */ +.update-status-tooltip .header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.update-status-tooltip .header .title { + font-weight: 600; + font-size: var(--vscode-bodyFontSize); + color: var(--vscode-foreground); + margin-bottom: 0; +} + +.update-status-tooltip .header .monaco-action-bar { + margin-left: auto; +} + +/* Product info section with logo */ +.update-status-tooltip .product-info { + display: flex; + gap: 12px; + margin-bottom: 16px; +} + +.update-status-tooltip .product-logo { + width: 48px; + height: 48px; + border-radius: var(--vscode-cornerRadius-large); + padding: 5px; + flex-shrink: 0; + background-image: url('../../../../browser/media/code-icon.svg'); + background-size: contain; + background-position: center; + background-repeat: no-repeat; +} + +.update-status-tooltip .product-details { + display: flex; + flex-direction: column; + justify-content: center; +} + +.update-status-tooltip .product-name { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 4px; +} + +.update-status-tooltip .product-version, +.update-status-tooltip .product-release-date { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +.update-status-tooltip .release-notes-link { + color: var(--vscode-textLink-foreground); + text-decoration: none; + font-size: var(--vscode-bodyFontSize-small); + cursor: pointer; +} + +.update-status-tooltip .release-notes-link:hover { + color: var(--vscode-textLink-activeForeground); + text-decoration: underline; +} + +/* What's Included section */ +.update-status-tooltip .whats-included .section-title { + font-weight: 600; + color: var(--vscode-foreground); + margin-bottom: 8px; +} + +.update-status-tooltip .whats-included ul { + margin: 0; + padding-left: 16px; + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +.update-status-tooltip .whats-included li { + margin-bottom: 2px; +} + +/* Progress bar */ +.update-status-tooltip .progress-container { + margin-bottom: 8px; +} + +.update-status-tooltip .progress-bar { + width: 100%; + height: 4px; + background-color: color-mix(in srgb, var(--vscode-progressBar-background) 30%, transparent); + border-radius: var(--vscode-cornerRadius-small); + overflow: hidden; +} + +.update-status-tooltip .progress-bar .progress-fill { + height: 100%; + background-color: var(--vscode-progressBar-background); + border-radius: var(--vscode-cornerRadius-small); + transition: width 0.3s ease; +} + +.update-status-tooltip .progress-text { + display: flex; + justify-content: space-between; + margin-top: 4px; + font-size: var(--vscode-bodyFontSize-small); + color: var(--vscode-descriptionForeground); +} + +.update-status-tooltip .progress-details { + color: var(--vscode-descriptionForeground); + margin-bottom: 4px; +} + +.update-status-tooltip .speed-info, +.update-status-tooltip .time-remaining { + color: var(--vscode-descriptionForeground); + font-size: var(--vscode-bodyFontSize-small); +} + +/* Action button */ +.update-status-tooltip .action-button-container { + display: flex; + justify-content: flex-end; + margin-top: 8px; +} + +.update-status-tooltip .action-button-container .monaco-button { + padding: 4px 14px; +} diff --git a/src/vs/workbench/contrib/update/browser/update.contribution.ts b/src/vs/workbench/contrib/update/browser/update.contribution.ts index dbc489922b2..d030ea35576 100644 --- a/src/vs/workbench/contrib/update/browser/update.contribution.ts +++ b/src/vs/workbench/contrib/update/browser/update.contribution.ts @@ -10,6 +10,7 @@ import { IWorkbenchContributionsRegistry, Extensions as WorkbenchExtensions } fr import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { MenuId, registerAction2, Action2 } from '../../../../platform/actions/common/actions.js'; import { ProductContribution, UpdateContribution, CONTEXT_UPDATE_STATE, SwitchProductQualityContribution, RELEASE_NOTES_URL, showReleaseNotesInEditor, DOWNLOAD_URL, DefaultAccountUpdateContribution } from './update.js'; +import { UpdateStatusBarEntryContribution } from './updateStatusBarEntry.js'; import { LifecyclePhase } from '../../../services/lifecycle/common/lifecycle.js'; import product from '../../../../platform/product/common/product.js'; import { IUpdateService, StateType } from '../../../../platform/update/common/update.js'; @@ -30,10 +31,11 @@ workbench.registerWorkbenchContribution(ProductContribution, LifecyclePhase.Rest workbench.registerWorkbenchContribution(UpdateContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(SwitchProductQualityContribution, LifecyclePhase.Restored); workbench.registerWorkbenchContribution(DefaultAccountUpdateContribution, LifecyclePhase.Eventually); +workbench.registerWorkbenchContribution(UpdateStatusBarEntryContribution, LifecyclePhase.Restored); // Release notes -export class ShowCurrentReleaseNotesAction extends Action2 { +export class ShowReleaseNotesAction extends Action2 { constructor() { super({ @@ -54,13 +56,14 @@ export class ShowCurrentReleaseNotesAction extends Action2 { }); } - async run(accessor: ServicesAccessor): Promise { + async run(accessor: ServicesAccessor, version?: string): Promise { const instantiationService = accessor.get(IInstantiationService); const productService = accessor.get(IProductService); const openerService = accessor.get(IOpenerService); + const targetVersion = version ?? productService.version; try { - await showReleaseNotesInEditor(instantiationService, productService.version, false); + await showReleaseNotesInEditor(instantiationService, targetVersion, false); } catch (err) { if (productService.releaseNotesUrl) { await openerService.open(URI.parse(productService.releaseNotesUrl)); @@ -97,7 +100,7 @@ export class ShowCurrentReleaseNotesFromCurrentFileAction extends Action2 { } } -registerAction2(ShowCurrentReleaseNotesAction); +registerAction2(ShowReleaseNotesAction); registerAction2(ShowCurrentReleaseNotesFromCurrentFileAction); // Update diff --git a/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts new file mode 100644 index 00000000000..eab82efe7fc --- /dev/null +++ b/src/vs/workbench/contrib/update/browser/updateStatusBarEntry.ts @@ -0,0 +1,549 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { toAction } from '../../../../base/common/actions.js'; +import { CancellationToken } from '../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { isWeb } from '../../../../base/common/platform.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import * as nls from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IHoverService, nativeHoverDelegate } from '../../../../platform/hover/browser/hover.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { Downloading, IUpdate, IUpdateService, Overwriting, StateType, State as UpdateState } from '../../../../platform/update/common/update.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { IStatusbarEntry, IStatusbarEntryAccessor, IStatusbarService, ShowTooltipCommand, StatusbarAlignment, TooltipContent } from '../../../services/statusbar/browser/statusbar.js'; +import './media/updateStatusBarEntry.css'; + +/** + * Displays update status and actions in the status bar. + */ +export class UpdateStatusBarEntryContribution extends Disposable implements IWorkbenchContribution { + private static readonly NAME = nls.localize('updateStatus', "Update Status"); + private readonly statusBarEntryAccessor = this._register(new MutableDisposable()); + private lastStateType: StateType | undefined; + + constructor( + @IUpdateService private readonly updateService: IUpdateService, + @IStatusbarService private readonly statusbarService: IStatusbarService, + @IProductService private readonly productService: IProductService, + @ICommandService private readonly commandService: ICommandService, + @IHoverService private readonly hoverService: IHoverService, + @IConfigurationService private readonly configurationService: IConfigurationService + ) { + super(); + + if (isWeb) { + return; // Electron only + } + + this._register(this.updateService.onStateChange(state => this.onUpdateStateChange(state))); + this._register(this.configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('update.statusBar')) { + this.onUpdateStateChange(this.updateService.state); + } + })); + this.onUpdateStateChange(this.updateService.state); + } + + private onUpdateStateChange(state: UpdateState) { + if (this.lastStateType !== state.type) { + this.statusBarEntryAccessor.clear(); + this.lastStateType = state.type; + } + + const statusBarMode = this.configurationService.getValue('update.statusBar'); + + if (statusBarMode === 'hidden') { + this.statusBarEntryAccessor.clear(); + return; + } + + const actionRequiredStates = [ + StateType.AvailableForDownload, + StateType.Downloaded, + StateType.Ready + ]; + + // In 'actionable' mode, only show for states that require user action + if (statusBarMode === 'actionable' && !actionRequiredStates.includes(state.type)) { + this.statusBarEntryAccessor.clear(); + return; + } + + switch (state.type) { + case StateType.Uninitialized: + case StateType.Idle: + case StateType.Disabled: + this.statusBarEntryAccessor.clear(); + break; + + case StateType.CheckingForUpdates: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.checkingForUpdates', "$(sync~spin) Checking for updates..."), + ariaLabel: nls.localize('updateStatus.checkingForUpdatesAria', "Checking for updates"), + tooltip: this.getCheckingTooltip(), + command: ShowTooltipCommand + }); + break; + + case StateType.AvailableForDownload: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.updateAvailableStatus', "$(cloud-download) Update is available. Click here to download."), + ariaLabel: nls.localize('updateStatus.updateAvailableAria', "Update available. Click here to download."), + tooltip: this.getAvailableTooltip(state.update), + command: 'update.downloadNow' + }); + break; + + case StateType.Downloading: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: this.getDownloadingText(state), + ariaLabel: nls.localize('updateStatus.downloadingUpdateAria', "Downloading update"), + tooltip: this.getDownloadingTooltip(state), + command: ShowTooltipCommand + }); + break; + + case StateType.Downloaded: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.updateReadyStatus', "$(package) Downloaded update. Click here to install."), + ariaLabel: nls.localize('updateStatus.updateReadyAria', "Downloaded update. Click here to install."), + tooltip: this.getReadyToInstallTooltip(state.update), + command: 'update.install' + }); + break; + + case StateType.Updating: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.installingUpdateStatus', "$(sync~spin) Installing update..."), + ariaLabel: nls.localize('updateStatus.installingUpdateAria', "Installing update"), + tooltip: this.getUpdatingTooltip(state.update), + command: ShowTooltipCommand + }); + break; + + case StateType.Ready: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.restartToUpdateStatus', "$(debug-restart) Update is ready. Click here to restart."), + ariaLabel: nls.localize('updateStatus.restartToUpdateAria', "Update is ready. Click here to restart."), + tooltip: this.getRestartToUpdateTooltip(state.update), + command: 'update.restart' + }); + break; + + case StateType.Overwriting: + this.updateStatusBarEntry({ + name: UpdateStatusBarEntryContribution.NAME, + text: nls.localize('updateStatus.downloadingNewerUpdateStatus', "$(sync~spin) Downloading update..."), + ariaLabel: nls.localize('updateStatus.downloadingNewerUpdateAria', "Downloading a newer update"), + tooltip: this.getOverwritingTooltip(state), + command: ShowTooltipCommand + }); + break; + } + } + + private updateStatusBarEntry(entry: IStatusbarEntry) { + if (this.statusBarEntryAccessor.value) { + this.statusBarEntryAccessor.value.update(entry); + } else { + this.statusBarEntryAccessor.value = this.statusbarService.addEntry( + entry, + 'status.update', + StatusbarAlignment.LEFT, + -Number.MAX_VALUE + ); + } + } + + private getCheckingTooltip(): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.checkingForUpdatesTitle', "Checking for Updates"), store); + this.appendProductInfo(container); + + const waitMessage = dom.append(container, dom.$('.progress-details')); + waitMessage.textContent = nls.localize('updateStatus.checkingPleaseWait', "Checking for updates, please wait..."); + + return container; + } + }; + } + + private getAvailableTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.updateAvailableTitle', "Update Available"), store); + this.appendProductInfo(container, update); + this.appendWhatsIncluded(container); + + this.appendActionButton(container, nls.localize('updateStatus.downloadButton', "Download"), store, () => { + this.runCommandAndClose('update.downloadNow'); + }); + + return container; + } + }; + } + + private getDownloadingText({ downloadedBytes, totalBytes }: Downloading): string { + if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { + return nls.localize('updateStatus.downloadUpdateProgressStatus', "$(sync~spin) Downloading update: {0} / {1} • {2}%", + formatBytes(downloadedBytes), + formatBytes(totalBytes), + Math.round((downloadedBytes / totalBytes) * 100)); + } else { + return nls.localize('updateStatus.downloadUpdateStatus', "$(sync~spin) Downloading update..."); + } + } + + private getDownloadingTooltip(state: Downloading): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.downloadingUpdateTitle', "Downloading Update"), store); + this.appendProductInfo(container, state.update); + + const { downloadedBytes, totalBytes } = state; + if (downloadedBytes !== undefined && totalBytes !== undefined && totalBytes > 0) { + const percentage = Math.round((downloadedBytes / totalBytes) * 100); + + const progressContainer = dom.append(container, dom.$('.progress-container')); + const progressBar = dom.append(progressContainer, dom.$('.progress-bar')); + const progressFill = dom.append(progressBar, dom.$('.progress-fill')); + progressFill.style.width = `${percentage}%`; + + const progressText = dom.append(progressContainer, dom.$('.progress-text')); + const percentageSpan = dom.append(progressText, dom.$('span')); + percentageSpan.textContent = `${percentage}%`; + + const sizeSpan = dom.append(progressText, dom.$('span')); + sizeSpan.textContent = `${formatBytes(downloadedBytes)} / ${formatBytes(totalBytes)}`; + + const speed = computeDownloadSpeed(state); + if (speed !== undefined && speed > 0) { + const speedInfo = dom.append(container, dom.$('.speed-info')); + speedInfo.textContent = nls.localize('updateStatus.downloadSpeed', '{0}/s', formatBytes(speed)); + } + + const timeRemaining = computeDownloadTimeRemaining(state); + if (timeRemaining !== undefined && timeRemaining > 0) { + const timeRemainingNode = dom.append(container, dom.$('.time-remaining')); + timeRemainingNode.textContent = `~${formatTimeRemaining(timeRemaining)} ${nls.localize('updateStatus.timeRemaining', "remaining")}`; + } + } else { + const waitMessage = dom.append(container, dom.$('.progress-details')); + waitMessage.textContent = nls.localize('updateStatus.downloadingPleaseWait', "Downloading, please wait..."); + } + + return container; + } + }; + } + + private getReadyToInstallTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.updateReadyTitle', "Update is Ready to Install"), store); + this.appendProductInfo(container, update); + this.appendWhatsIncluded(container); + + this.appendActionButton(container, nls.localize('updateStatus.installButton', "Install"), store, () => { + this.runCommandAndClose('update.install'); + }); + + return container; + } + }; + } + + private getRestartToUpdateTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.updateInstalledTitle', "Update Installed"), store); + this.appendProductInfo(container, update); + this.appendWhatsIncluded(container); + + this.appendActionButton(container, nls.localize('updateStatus.restartButton', "Restart"), store, () => { + this.runCommandAndClose('update.restart'); + }); + + return container; + } + }; + } + + private getUpdatingTooltip(update: IUpdate): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.installingUpdateTitle', "Installing Update"), store); + this.appendProductInfo(container, update); + + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.installingPleaseWait', "Installing update, please wait..."); + + return container; + } + }; + } + + private getOverwritingTooltip(state: Overwriting): TooltipContent { + return { + element: (token: CancellationToken) => { + const store = this.createTooltipDisposableStore(token); + const container = dom.$('.update-status-tooltip'); + + this.appendHeader(container, nls.localize('updateStatus.downloadingNewerUpdateTitle', "Downloading Newer Update"), store); + this.appendProductInfo(container, state.update); + + const message = dom.append(container, dom.$('.progress-details')); + message.textContent = nls.localize('updateStatus.downloadingNewerPleaseWait', "A newer update was released. Downloading, please wait..."); + + return container; + } + }; + } + + private createTooltipDisposableStore(token: CancellationToken): DisposableStore { + const store = new DisposableStore(); + store.add(token.onCancellationRequested(() => store.dispose())); + return store; + } + + private runCommandAndClose(command: string, ...args: unknown[]): void { + this.commandService.executeCommand(command, ...args); + this.hoverService.hideHover(true); + } + + private appendHeader(container: HTMLElement, title: string, store: DisposableStore) { + const header = dom.append(container, dom.$('.header')); + const text = dom.append(header, dom.$('.title')); + text.textContent = title; + + const actionBar = store.add(new ActionBar(header, { hoverDelegate: nativeHoverDelegate })); + actionBar.push([toAction({ + id: 'update.openSettings', + label: nls.localize('updateStatus.settingsTooltip', "Update Settings"), + class: ThemeIcon.asClassName(Codicon.gear), + run: () => this.runCommandAndClose('workbench.action.openSettings', '@id:update*'), + })], { icon: true, label: false }); + } + + private appendProductInfo(container: HTMLElement, update?: IUpdate) { + const productInfo = dom.append(container, dom.$('.product-info')); + + const logoContainer = dom.append(productInfo, dom.$('.product-logo')); + logoContainer.setAttribute('role', 'img'); + logoContainer.setAttribute('aria-label', this.productService.nameLong); + + const details = dom.append(productInfo, dom.$('.product-details')); + + const productName = dom.append(details, dom.$('.product-name')); + productName.textContent = this.productService.nameLong; + + const productVersion = this.productService.version; + if (productVersion) { + const currentVersion = dom.append(details, dom.$('.product-version')); + currentVersion.textContent = nls.localize('updateStatus.currentVersionLabel', "Current Version: {0}", productVersion); + } + + const version = update?.productVersion; + if (version) { + const latestVersion = dom.append(details, dom.$('.product-version')); + latestVersion.textContent = nls.localize('updateStatus.latestVersionLabel', "Latest Version: {0}", version); + } + + const releaseDate = update?.timestamp ?? tryParseDate(this.productService.date); + if (releaseDate) { + const releaseDateNode = dom.append(details, dom.$('.product-release-date')); + releaseDateNode.textContent = nls.localize('updateStatus.releasedLabel', "Released {0}", formatDate(releaseDate)); + } + + const releaseNotesVersion = version ?? productVersion; + if (releaseNotesVersion) { + const link = dom.append(details, dom.$('a.release-notes-link')) as HTMLAnchorElement; + link.textContent = nls.localize('updateStatus.releaseNotesLink', "Release Notes"); + link.href = '#'; + link.addEventListener('click', (e) => { + e.preventDefault(); + this.runCommandAndClose('update.showCurrentReleaseNotes', releaseNotesVersion); + }); + } + } + + private appendWhatsIncluded(container: HTMLElement): void { + const whatsIncluded = dom.append(container, dom.$('.whats-included')); + + const sectionTitle = dom.append(whatsIncluded, dom.$('.section-title')); + sectionTitle.textContent = nls.localize('updateStatus.whatsIncludedTitle', "What's Included"); + + const list = dom.append(whatsIncluded, dom.$('ul')); + + const items = [ + nls.localize('updateStatus.featureItem', "New features and functionality"), + nls.localize('updateStatus.bugFixesItem', "Bug fixes and improvements"), + nls.localize('updateStatus.securityItem', "Security fixes and enhancements") + ]; + + for (const item of items) { + const li = dom.append(list, dom.$('li')); + li.textContent = item; + } + } + + private appendActionButton(container: HTMLElement, label: string, store: DisposableStore, onClick: () => void): void { + const buttonContainer = dom.append(container, dom.$('.action-button-container')); + const button = store.add(new Button(buttonContainer, { ...defaultButtonStyles, secondary: true, hoverDelegate: nativeHoverDelegate })); + button.label = label; + store.add(button.onDidClick(onClick)); + } +} + +/** + * Tries to parse a date string and returns the timestamp or undefined if parsing fails. + */ +export function tryParseDate(date: string | undefined): number | undefined { + try { + return date !== undefined ? Date.parse(date) : undefined; + } catch { + return undefined; + } +} + +/** + * Formats a timestamp as a localized date string. + */ +export function formatDate(timestamp: number): string { + return new Date(timestamp).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }); +} + +/** + * Computes an estimate of remaining download time in seconds. + */ +export function computeDownloadTimeRemaining(state: Downloading): number | undefined { + const { downloadedBytes, totalBytes, startTime } = state; + if (downloadedBytes === undefined || totalBytes === undefined || startTime === undefined) { + return undefined; + } + + const elapsedMs = Date.now() - startTime; + if (downloadedBytes <= 0 || totalBytes <= 0 || elapsedMs <= 0) { + return undefined; + } + + const remainingBytes = totalBytes - downloadedBytes; + if (remainingBytes <= 0) { + return 0; + } + + const bytesPerMs = downloadedBytes / elapsedMs; + if (bytesPerMs <= 0) { + return undefined; + } + + const remainingMs = remainingBytes / bytesPerMs; + return Math.ceil(remainingMs / 1000); +} + +/** + * Formats the time remaining as a human-readable string. + */ +export function formatTimeRemaining(seconds: number): string { + const hours = seconds / 3600; + if (hours >= 1) { + const formattedHours = formatDecimal(hours); + return formattedHours === '1' + ? nls.localize('timeRemainingHour', "{0} hour", formattedHours) + : nls.localize('timeRemainingHours', "{0} hours", formattedHours); + } + + const minutes = Math.floor(seconds / 60); + if (minutes >= 1) { + return nls.localize('timeRemainingMinutes', "{0} min", minutes); + } + + return nls.localize('timeRemainingSeconds', "{0}s", seconds); +} + +/** + * Formats a byte count as a human-readable string. + */ +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return nls.localize('bytes', "{0} B", bytes); + } + + const kb = bytes / 1024; + if (kb < 1024) { + return nls.localize('kilobytes', "{0} KB", formatDecimal(kb)); + } + + const mb = kb / 1024; + if (mb < 1024) { + return nls.localize('megabytes', "{0} MB", formatDecimal(mb)); + } + + const gb = mb / 1024; + return nls.localize('gigabytes', "{0} GB", formatDecimal(gb)); +} + +/** + * Formats a number to 1 decimal place, omitting ".0" for whole numbers. + */ +function formatDecimal(value: number): string { + const rounded = Math.round(value * 10) / 10; + return rounded % 1 === 0 ? rounded.toString() : rounded.toFixed(1); +} + +/** + * Computes the current download speed in bytes per second. + */ +export function computeDownloadSpeed(state: Downloading): number | undefined { + const { downloadedBytes, startTime } = state; + if (downloadedBytes === undefined || startTime === undefined) { + return undefined; + } + + const elapsedMs = Date.now() - startTime; + if (elapsedMs <= 0 || downloadedBytes <= 0) { + return undefined; + } + + return (downloadedBytes / elapsedMs) * 1000; +} diff --git a/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.ts new file mode 100644 index 00000000000..16f10c93674 --- /dev/null +++ b/src/vs/workbench/contrib/update/test/browser/updateStatusBarEntry.test.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 assert from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { Downloading, StateType } from '../../../../../platform/update/common/update.js'; +import { computeDownloadSpeed, computeDownloadTimeRemaining, formatBytes, formatTimeRemaining } from '../../browser/updateStatusBarEntry.js'; + +suite('UpdateStatusBarEntry', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + function createDownloadingState(downloadedBytes?: number, totalBytes?: number, startTime?: number): Downloading { + return { type: StateType.Downloading, explicit: true, overwrite: false, downloadedBytes, totalBytes, startTime }; + } + + suite('computeDownloadTimeRemaining', () => { + test('returns undefined for invalid or incomplete input', () => { + const now = Date.now(); + + // Missing parameters + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState()), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, undefined, now)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(undefined, 1000, now)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, undefined)), undefined); + + // Zero or negative values + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(0, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 0, now - 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, now + 1000)), undefined); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(-100, 1000, now - 1000)), undefined); + }); + + test('returns 0 when download is complete or over-downloaded', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(1000, 1000, now - 1000)), 0); + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(1500, 1000, now - 1000)), 0); + }); + + test('computes correct time remaining', () => { + const now = Date.now(); + + // Simple case: Downloaded 500 bytes of 1000 in 1000ms => 1s remaining + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(500, 1000, now - 1000)), 1); + + // 10 seconds remaining: Downloaded 100MB of 200MB in 10s + const downloadedBytes = 100 * 1024 * 1024; + const totalBytes = 200 * 1024 * 1024; + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(downloadedBytes, totalBytes, now - 10000)), 10); + + // Rounds up: 900 of 1000 bytes in 900ms => 100ms remaining => rounds to 1s + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(900, 1000, now - 900)), 1); + + // Realistic scenario: 50MB of 100MB in 50s => 50s remaining + const downloaded50MB = 50 * 1024 * 1024; + const total100MB = 100 * 1024 * 1024; + assert.strictEqual(computeDownloadTimeRemaining(createDownloadingState(downloaded50MB, total100MB, now - 50000)), 50); + }); + }); + + suite('formatTimeRemaining', () => { + test('formats seconds for values less than 1 minute', () => { + assert.strictEqual(formatTimeRemaining(1), '1s'); + assert.strictEqual(formatTimeRemaining(30), '30s'); + assert.strictEqual(formatTimeRemaining(59), '59s'); + }); + + test('formats minutes for values between 1 minute and 1 hour', () => { + assert.strictEqual(formatTimeRemaining(60), '1 min'); + assert.strictEqual(formatTimeRemaining(120), '2 min'); + assert.strictEqual(formatTimeRemaining(90), '1 min'); // Floors to 1 min + assert.strictEqual(formatTimeRemaining(3599), '59 min'); + }); + + test('formats fractional hours for values >= 1 hour', () => { + assert.strictEqual(formatTimeRemaining(3600), '1 hour'); + assert.strictEqual(formatTimeRemaining(5400), '1.5 hours'); // 1.5 hours + assert.strictEqual(formatTimeRemaining(7200), '2 hours'); + assert.strictEqual(formatTimeRemaining(9000), '2.5 hours'); // 2.5 hours + assert.strictEqual(formatTimeRemaining(3960), '1.1 hours'); // 1 hour 6 min = 1.1 hours + }); + }); + + suite('formatBytes', () => { + test('formats bytes for values less than 1 KB', () => { + assert.strictEqual(formatBytes(0), '0 B'); + assert.strictEqual(formatBytes(1), '1 B'); + assert.strictEqual(formatBytes(512), '512 B'); + assert.strictEqual(formatBytes(1023), '1023 B'); + }); + + test('formats kilobytes for values between 1 KB and 1 MB', () => { + assert.strictEqual(formatBytes(1024), '1 KB'); + assert.strictEqual(formatBytes(1536), '1.5 KB'); // 1.5 KB + assert.strictEqual(formatBytes(2048), '2 KB'); + assert.strictEqual(formatBytes(1024 * 100), '100 KB'); + assert.strictEqual(formatBytes(1024 * 1023), '1023 KB'); + }); + + test('formats megabytes for values between 1 MB and 1 GB', () => { + assert.strictEqual(formatBytes(1024 * 1024), '1 MB'); + assert.strictEqual(formatBytes(1024 * 1024 * 1.5), '1.5 MB'); + assert.strictEqual(formatBytes(1024 * 1024 * 100), '100 MB'); + assert.strictEqual(formatBytes(1024 * 1024 * 512), '512 MB'); + }); + + test('formats gigabytes for values >= 1 GB', () => { + assert.strictEqual(formatBytes(1024 * 1024 * 1024), '1 GB'); + assert.strictEqual(formatBytes(1024 * 1024 * 1024 * 1.5), '1.5 GB'); + assert.strictEqual(formatBytes(1024 * 1024 * 1024 * 10), '10 GB'); + }); + + test('rounds to one decimal place correctly', () => { + assert.strictEqual(formatBytes(1126), '1.1 KB'); + assert.strictEqual(formatBytes(1075), '1 KB'); + assert.strictEqual(formatBytes(1024 * 1024 * 25.35), '25.4 MB'); + }); + }); + + suite('computeDownloadSpeed', () => { + test('returns undefined for invalid or incomplete input', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(undefined, 1000, now - 1000)), undefined); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(500, 1000, undefined)), undefined); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(undefined, undefined, undefined)), undefined); + }); + + test('returns undefined for zero or negative elapsed time', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(500, 1000, now + 1000)), undefined); + }); + + test('returns undefined for zero downloaded bytes', () => { + const now = Date.now(); + assert.strictEqual(computeDownloadSpeed(createDownloadingState(0, 1000, now - 1000)), undefined); + }); + + test('computes correct download speed in bytes per second', () => { + const now = Date.now(); + + // 1000 bytes in 1 second = 1000 B/s + const speed1 = computeDownloadSpeed(createDownloadingState(1000, 2000, now - 1000)); + assert.ok(speed1 !== undefined); + assert.ok(Math.abs(speed1 - 1000) < 50); // Allow small timing variance + + // 10 MB in 10 seconds = 1 MB/s = 1048576 B/s + const tenMB = 10 * 1024 * 1024; + const speed2 = computeDownloadSpeed(createDownloadingState(tenMB, tenMB * 2, now - 10000)); + assert.ok(speed2 !== undefined); + const expectedSpeed = 1024 * 1024; // 1 MB/s + assert.ok(Math.abs(speed2 - expectedSpeed) < expectedSpeed * 0.01); // Within 1% + }); + }); +}); diff --git a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts index 2069029b1d5..262a050262d 100644 --- a/src/vs/workbench/contrib/webview/browser/resourceLoading.ts +++ b/src/vs/workbench/contrib/webview/browser/resourceLoading.ts @@ -7,10 +7,10 @@ import { VSBufferReadableStream } from '../../../../base/common/buffer.js'; import { CancellationToken } from '../../../../base/common/cancellation.js'; import { isUNC } from '../../../../base/common/extpath.js'; import { Schemas } from '../../../../base/common/network.js'; -import { normalize, sep } from '../../../../base/common/path.js'; import { URI } from '../../../../base/common/uri.js'; import { FileOperationError, FileOperationResult, IFileService, IWriteFileOptions } from '../../../../platform/files/common/files.js'; import { ILogService } from '../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { getWebviewContentMimeType } from '../../../../platform/webview/common/mimeTypes.js'; export namespace WebviewResourceResponse { @@ -48,11 +48,12 @@ export async function loadLocalResource( ifNoneMatch: string | undefined; roots: ReadonlyArray; }, + uriIdentityService: IUriIdentityService, fileService: IFileService, logService: ILogService, token: CancellationToken, ): Promise { - const resourceToLoad = getResourceToLoad(requestUri, options.roots); + const resourceToLoad = getResourceToLoad(requestUri, options.roots, uriIdentityService); logService.trace(`Webview.loadLocalResource - trying to load resource. requestUri=${requestUri}, resourceToLoad=${resourceToLoad}`); @@ -84,12 +85,14 @@ export async function loadLocalResource( } } -function getResourceToLoad( +export function getResourceToLoad( requestUri: URI, roots: ReadonlyArray, + uriIdentityService: IUriIdentityService, ): URI | undefined { + const requestUriNoQueryString = requestUri.with({ query: '' }); for (const root of roots) { - if (containsResource(root, requestUri)) { + if (containsResource(root, requestUriNoQueryString, uriIdentityService)) { return normalizeResourcePath(requestUri); } } @@ -97,20 +100,30 @@ function getResourceToLoad( return undefined; } -function containsResource(root: URI, resource: URI): boolean { - if (root.scheme !== resource.scheme) { +function containsResource(root: URI, resource: URI, uriIdentityService: IUriIdentityService): boolean { + if (uriIdentityService.extUri.isEqual(root, resource, /* ignoreFragment */ true)) { return false; } - let resourceFsPath = normalize(resource.fsPath); - let rootPath = normalize(root.fsPath + (root.fsPath.endsWith(sep) ? '' : sep)); - - if (isUNC(root.fsPath) && isUNC(resource.fsPath)) { - rootPath = rootPath.toLowerCase(); - resourceFsPath = resourceFsPath.toLowerCase(); + // Compare unc paths case-insensitively + if (root.scheme === Schemas.file && isUNC(root.fsPath)) { + if (resource.scheme === Schemas.file && isUNC(resource.fsPath)) { + return uriIdentityService.extUri.isEqualOrParent( + resource.with({ + path: resource.path.toLowerCase(), + authority: resource.authority.toLowerCase() + }), + root.with({ + path: root.path.toLowerCase(), + authority: root.authority.toLowerCase() + }), + /* ignoreFragment */ true + ); + } + return false; } - return resourceFsPath.startsWith(rootPath); + return uriIdentityService.extUri.isEqualOrParent(resource, root, /* ignoreFragment */ true); } function normalizeResourcePath(resource: URI): URI { diff --git a/src/vs/workbench/contrib/webview/browser/webviewElement.ts b/src/vs/workbench/contrib/webview/browser/webviewElement.ts index 2824da9bb28..5d0d61f8113 100644 --- a/src/vs/workbench/contrib/webview/browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/browser/webviewElement.ts @@ -30,6 +30,7 @@ import { ILogService } from '../../../../platform/log/common/log.js'; import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { WebviewPortMappingManager } from '../../../../platform/webview/common/webviewPortMapping.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { decodeAuthority, webviewGenericCspSource, webviewRootResourceAuthority } from '../common/webview.js'; @@ -163,6 +164,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi @ITunnelService private readonly _tunnelService: ITunnelService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService private readonly _accessibilityService: IAccessibilityService, + @IUriIdentityService private readonly _uriIdentityService: IUriIdentityService, ) { super(); @@ -763,7 +765,7 @@ export class WebviewElement extends Disposable implements IWebviewElement, Webvi const result = await loadLocalResource(uri, { ifNoneMatch, roots: this._content.options.localResourceRoots || [], - }, this._fileService, this._logService, this._resourceLoadingCts.token); + }, this._uriIdentityService, this._fileService, this._logService, this._resourceLoadingCts.token); switch (result.type) { case WebviewResourceResponse.Type.Success: { diff --git a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts index 13d75633fb4..deaf633184c 100644 --- a/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts +++ b/src/vs/workbench/contrib/webview/electron-browser/webviewElement.ts @@ -19,6 +19,7 @@ import { INativeHostService } from '../../../../platform/native/common/native.js import { INotificationService } from '../../../../platform/notification/common/notification.js'; import { IRemoteAuthorityResolverService } from '../../../../platform/remote/common/remoteAuthorityResolver.js'; import { ITunnelService } from '../../../../platform/tunnel/common/tunnel.js'; +import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uriIdentity.js'; import { FindInFrameOptions, IWebviewManagerService } from '../../../../platform/webview/common/webviewManagerService.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { WebviewThemeDataProvider } from '../browser/themeing.js'; @@ -56,10 +57,11 @@ export class ElectronWebviewElement extends WebviewElement { @INativeHostService private readonly _nativeHostService: INativeHostService, @IInstantiationService instantiationService: IInstantiationService, @IAccessibilityService accessibilityService: IAccessibilityService, + @IUriIdentityService uriIdentityService: IUriIdentityService, ) { super(initInfo, webviewThemeDataProvider, configurationService, contextMenuService, notificationService, environmentService, - fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService); + fileService, logService, remoteAuthorityResolverService, tunnelService, instantiationService, accessibilityService, uriIdentityService); this._webviewKeyboardHandler = new WindowIgnoreMenuShortcutsManager(configurationService, mainProcessService, _nativeHostService); diff --git a/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts new file mode 100644 index 00000000000..2ae1d202ac7 --- /dev/null +++ b/src/vs/workbench/contrib/webview/test/browser/resourceLoading.test.ts @@ -0,0 +1,243 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { isWindows } from '../../../../../base/common/platform.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { FileService } from '../../../../../platform/files/common/fileService.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js'; +import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; +import { UriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentityService.js'; +import { getResourceToLoad } from '../../browser/resourceLoading.js'; + +suite('Webview Resource Loading - getResourceToLoad', () => { + const disposableStore = ensureNoDisposablesAreLeakedInTestSuite(); + + let uriIdentityService: IUriIdentityService; + + setup(() => { + const instantiationService = disposableStore.add(new TestInstantiationService()); + instantiationService.stub(ILogService, NullLogService); + const fileService = disposableStore.add(new FileService(instantiationService.get(ILogService))); + uriIdentityService = instantiationService.stub(IUriIdentityService, disposableStore.add(new UriIdentityService(fileService))); + }); + + test('Returns resource when file is under root', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Returns resource when file is in nested directory', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/subdir/nested/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is outside root', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when file is root', () => { + const root = URI.file('/home/user/project'); + const result = getResourceToLoad(root, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when file is sibling of root directory', () => { + const root = URI.file('/home/user/project'); + { + const resource = URI.file('/home/user/projectOther/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + } + { + const resource = URI.file('/home/user/project.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + } + }); + + test('Returns resource when root ends with /', () => { + const root = URI.file('/home/user/project/'); + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails for sibling when root ends with / ', () => { + const root = URI.file('/home/user/project/'); + const resource = URI.file('/home/user/projectOther/file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + (!isWindows /* UNC is windows only */ ? suite.skip : suite)('UNC paths', () => { + test('Returns resource when file is under UNC root', () => { + const root = URI.file('\\\\server\\share\\folder'); + const resource = URI.file('\\\\server\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Returns resource with case-insensitive comparison for UNC paths', () => { + const root = URI.file('\\\\SERVER\\SHARE\\folder'); + const resource = URI.file('\\\\server\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is outside UNC root', () => { + const root = URI.file('\\\\server\\share\\folder'); + const resource = URI.file('\\\\server\\share\\other\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Fails when UNC server differs', () => { + const root = URI.file('\\\\server1\\share\\folder'); + const resource = URI.file('\\\\server2\\share\\folder\\file.txt'); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); + + suite('Different authorities', () => { + test('Returns resource when authorities match', () => { + const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+myserver', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.ok(result); + }); + + test('Fails when authorities differ', () => { + const root = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server1', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: 'ssh-remote+server2', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('handles empty authority', () => { + const root = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'test-scheme', authority: '', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + }); + + suite('Different schemes', () => { + test('Fails when schemes differ', () => { + const root = URI.from({ scheme: 'file', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'http', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Returns resource when schemes match', () => { + const root = URI.from({ scheme: 'custom-scheme', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'custom-scheme', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('normalizes vscode-remote scheme', () => { + const root = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project' }); + const resource = URI.from({ scheme: 'vscode-remote', authority: 'test', path: '/home/user/project/file.txt' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + + assert.ok(result); + assert.strictEqual(result.scheme, 'vscode-remote'); + assert.strictEqual(result.authority, 'test'); + assert.strictEqual(result.path, '/vscode-resource'); + const query = JSON.parse(result.query); + assert.strictEqual(query.requestResourcePath, '/home/user/project/file.txt'); + }); + }); + + suite('Fragment and query strings', () => { + test('preserves fragment in returned URI', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.fragment, 'section1'); + }); + + test('preserves query in returned URI', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.query, 'version=2'); + }); + + test('preserves both fragment and query', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/project/file.txt').with({ fragment: 'section1', query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result?.fragment, 'section1'); + assert.strictEqual(result?.query, 'version=2'); + }); + + test('still validates path containment with query params', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt').with({ query: 'version=2' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('still validates path containment with fragment', () => { + const root = URI.file('/home/user/project'); + const resource = URI.file('/home/user/other/file.txt').with({ fragment: 'section1' }); + const result = getResourceToLoad(resource, [root], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); + + suite('Multiple roots', () => { + test('Returns resource when file is under one of multiple roots', () => { + const roots = [ + URI.file('/home/user/project1'), + URI.file('/home/user/project2'), + URI.file('/home/user/project3') + ]; + const resource = URI.file('/home/user/project2/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('Fails when file is not under any root', () => { + const roots = [ + URI.file('/home/user/project1'), + URI.file('/home/user/project2') + ]; + const resource = URI.file('/home/user/other/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + assert.strictEqual(result, undefined); + }); + + test('Returns resource matching first valid root', () => { + const roots = [ + URI.file('/home/user/project'), + URI.file('/home/user/project/subdir') + ]; + const resource = URI.file('/home/user/project/subdir/file.txt'); + const result = getResourceToLoad(resource, roots, uriIdentityService); + // Should match first root in the list + assert.strictEqual(result?.toString(), resource.toString()); + }); + + test('handles empty roots array', () => { + const resource = URI.file('/home/user/project/file.txt'); + const result = getResourceToLoad(resource, [], uriIdentityService); + assert.strictEqual(result, undefined); + }); + }); +}); diff --git a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts index 0a90315b88a..30789ae673c 100644 --- a/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts +++ b/src/vs/workbench/contrib/welcomeAgentSessions/browser/agentSessionsWelcome.ts @@ -46,7 +46,7 @@ import { ChatViewId, IChatWidgetService, ISessionTypePickerDelegate, IWorkspaceP import { ChatSessionPosition, getResourceForNewChatSession } from '../../chat/browser/chatSessions/chatSessions.contribution.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; import { AgentSessionsControl, IAgentSessionsControlOptions } from '../../chat/browser/agentSessions/agentSessionsControl.js'; -import { IAgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsViewer.js'; +import { AgentSessionsFilter } from '../../chat/browser/agentSessions/agentSessionsFilter.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; import { IResolvedWalkthrough, IWalkthroughsService } from '../../welcomeGettingStarted/browser/gettingStartedService.js'; import { GettingStartedEditorOptions, GettingStartedInput } from '../../welcomeGettingStarted/browser/gettingStartedInput.js'; @@ -140,7 +140,7 @@ export class AgentSessionsWelcomePage extends EditorPane { // Telemetry tracking private _openedAt: number = 0; - private _closedBy: string = 'unknown'; + private _closedBy?: string; private _storedInput: AgentSessionsWelcomeInput | undefined; constructor( @@ -202,11 +202,29 @@ export class AgentSessionsWelcomePage extends EditorPane { override async setInput(input: AgentSessionsWelcomeInput, options: AgentSessionsWelcomeEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise { this._storedInput = input; + this._openedAt = Date.now(); await super.setInput(input, options, context, token); this._workspaceKind = input.workspaceKind ?? 'empty'; await this.buildContent(); } + override clearInput(): void { + // Send closed telemetry when the editor is closed + if (this._openedAt > 0) { + const visibleDurationMs = Date.now() - this._openedAt; + this.telemetryService.publicLog2( + 'agentSessionsWelcome.closed', + { + visibleDurationMs, + closedBy: this._closedBy ?? 'disposed' + } + ); + this._openedAt = 0; + this._closedBy = undefined; + } + super.clearInput(); + } + private async buildContent(): Promise { this.contentDisposables.clear(); this.sessionsControlDisposables.clear(); @@ -566,32 +584,20 @@ export class AgentSessionsWelcomePage extends EditorPane { // Hide the control initially until loading completes this.sessionsControlContainer.style.display = 'none'; - // Create a filter that limits results and excludes archived sessions - const onDidChangeEmitter = this.sessionsControlDisposables.add(new Emitter()); - const filter: IAgentSessionsFilter = { - onDidChange: onDidChangeEmitter.event, - limitResults: () => MAX_SESSIONS, - exclude: (session: IAgentSession) => session.isArchived(), - getExcludes: () => ({ - providers: [], - states: [], - archived: true, - read: false, - }), - }; - const options: IAgentSessionsControlOptions = { overrideStyles: getListStyles({ listBackground: editorBackground, }), - filter, + filter: this.sessionsControlDisposables.add(this.instantiationService.createInstance(AgentSessionsFilter, { + limitResults: () => MAX_SESSIONS, + })), getHoverPosition: () => HoverPosition.BELOW, trackActiveEditorSession: () => false, source: 'welcomeView', notifySessionOpened: () => { - this._closedBy = 'sessionClicked'; const isProjectionEnabled = this.configurationService.getValue(ChatConfiguration.AgentSessionProjectionEnabled); if (!isProjectionEnabled) { + this._closedBy = 'sessionClicked'; this.revealMaximizedChat(); } } @@ -909,22 +915,6 @@ export class AgentSessionsWelcomePage extends EditorPane { } } - override dispose(): void { - // Send closed telemetry before disposing - if (this._openedAt > 0) { - const visibleDurationMs = Date.now() - this._openedAt; - this.telemetryService.publicLog2( - 'agentSessionsWelcome.closed', - { - visibleDurationMs, - closedBy: this._closedBy - } - ); - } - - super.dispose(); - } - private async getRecentlyOpenedWorkspaces(onlyTrusted: boolean = false): Promise> { const workspaces = await this.workspacesService.getRecentlyOpened(); const trustInfoPromises = workspaces.workspaces.map(async ws => { diff --git a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts index 6a70526e4c7..03e466cec04 100644 --- a/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts +++ b/src/vs/workbench/contrib/welcomeGettingStarted/browser/gettingStarted.ts @@ -286,14 +286,14 @@ export class GettingStartedPage extends EditorPane { badgeelement.parentElement?.setAttribute('aria-checked', 'true'); badgeelement.classList.remove(...ThemeIcon.asClassNameArray(gettingStartedUncheckedCodicon)); badgeelement.classList.add('complete', ...ThemeIcon.asClassNameArray(gettingStartedCheckedCodicon)); - badgeelement.setAttribute('aria-label', localize('stepDone', "Checkbox for Step {0}: Completed", step.title)); + badgeelement.setAttribute('aria-label', localize('stepDone', "{0}: Completed", step.title)); } else { badgeelement.setAttribute('aria-checked', 'false'); badgeelement.parentElement?.setAttribute('aria-checked', 'false'); badgeelement.classList.remove('complete', ...ThemeIcon.asClassNameArray(gettingStartedCheckedCodicon)); badgeelement.classList.add(...ThemeIcon.asClassNameArray(gettingStartedUncheckedCodicon)); - badgeelement.setAttribute('aria-label', localize('stepNotDone', "Checkbox for Step {0}: Not completed", step.title)); + badgeelement.setAttribute('aria-label', localize('stepNotDone', "{0}: Not completed", step.title)); } }); if (step.done) { @@ -1559,8 +1559,8 @@ export class GettingStartedPage extends EditorPane { 'role': 'checkbox', 'aria-checked': step.done ? 'true' : 'false', 'aria-label': step.done - ? localize('stepDone', "Checkbox for Step {0}: Completed", step.title) - : localize('stepNotDone', "Checkbox for Step {0}: Not completed", step.title), + ? localize('stepDone', "{0}: Completed", step.title) + : localize('stepNotDone', "{0}: Not completed", step.title), }); const container = $('.step-description-container', { 'x-step-description-for': step.id }); diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index 4557cd63aac..069e1db04be 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -546,18 +546,18 @@ export class PreferencesService extends Disposable implements IPreferencesServic private getMostCommonlyUsedSettings(): string[] { return [ - 'files.autoSave', 'editor.fontSize', + 'editor.formatOnSave', + 'files.autoSave', + 'editor.defaultFormatter', 'editor.fontFamily', - 'editor.tabSize', - 'editor.renderWhitespace', - 'editor.cursorStyle', - 'editor.multiCursorModifier', - 'editor.insertSpaces', 'editor.wordWrap', + 'chat.agent.maxRequests', 'files.exclude', - 'files.associations', - 'workbench.editor.enablePreview' + 'workbench.colorTheme', + 'editor.tabSize', + 'editor.mouseWheelZoom', + 'editor.formatOnPaste' ]; } diff --git a/src/vs/workbench/services/search/common/textSearchManager.ts b/src/vs/workbench/services/search/common/textSearchManager.ts index 9c7ad25a879..24cc41e05b9 100644 --- a/src/vs/workbench/services/search/common/textSearchManager.ts +++ b/src/vs/workbench/services/search/common/textSearchManager.ts @@ -144,26 +144,28 @@ export class TextSearchManager { if (result.uri === undefined) { throw Error('Text search result URI is undefined. Please check provider implementation.'); } - const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri)!; - const hasSibling = folderQuery.folder.scheme === Schemas.file ? - hasSiblingPromiseFn(() => { - return this.fileUtils.readdir(resources.dirname(result.uri)); - }) : - undefined; + const folderQuery = folderMappings.findQueryFragmentAwareSubstr(result.uri); + if (folderQuery?.folder?.scheme) { + const hasSibling = folderQuery.folder.scheme === Schemas.file ? + hasSiblingPromiseFn(() => { + return this.fileUtils.readdir(resources.dirname(result.uri)); + }) : + undefined; - const relativePath = resources.relativePath(folderQuery.folder, result.uri); - if (relativePath) { - // This method is only async when the exclude contains sibling clauses - const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); - if (isThenable(included)) { - testingPs.push( - included.then(isIncluded => { - if (isIncluded) { - onResult(result, folderQuery.folderIdx); - } - })); - } else if (included) { - onResult(result, folderQuery.folderIdx); + const relativePath = resources.relativePath(folderQuery.folder, result.uri); + if (relativePath) { + // This method is only async when the exclude contains sibling clauses + const included = folderQuery.queryTester.includedInQuery(relativePath, path.basename(relativePath), hasSibling); + if (isThenable(included)) { + testingPs.push( + included.then(isIncluded => { + if (isIncluded) { + onResult(result, folderQuery.folderIdx); + } + })); + } else if (included) { + onResult(result, folderQuery.folderIdx); + } } } } diff --git a/src/vs/workbench/services/search/test/node/textSearchManager.test.ts b/src/vs/workbench/services/search/test/node/textSearchManager.test.ts index 190ec3005f3..fa034800545 100644 --- a/src/vs/workbench/services/search/test/node/textSearchManager.test.ts +++ b/src/vs/workbench/services/search/test/node/textSearchManager.test.ts @@ -9,7 +9,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Progress } from '../../../../../platform/progress/common/progress.js'; import { ITextQuery, QueryType } from '../../common/search.js'; -import { ProviderResult, TextSearchComplete2, TextSearchProviderOptions, TextSearchProvider2, TextSearchQuery2, TextSearchResult2 } from '../../common/searchExtTypes.js'; +import { ProviderResult, Range, TextSearchComplete2, TextSearchMatch2, TextSearchProviderOptions, TextSearchProvider2, TextSearchQuery2, TextSearchResult2 } from '../../common/searchExtTypes.js'; import { NativeTextSearchManager } from '../../node/textSearchManager.js'; suite('NativeTextSearchManager', () => { @@ -40,5 +40,52 @@ suite('NativeTextSearchManager', () => { assert.ok(correctEncoding); }); + test('handles result from unmatched folder gracefully via optional chaining', async () => { + let receivedResults = 0; + const provider: TextSearchProvider2 = { + provideTextSearchResults(query: TextSearchQuery2, options: TextSearchProviderOptions, progress: Progress, token: CancellationToken): ProviderResult { + const range = new Range(0, 0, 0, 5); + + // Report a result from a folder that IS in the query - should be received + progress.report(new TextSearchMatch2( + URI.file('/folder1/test.txt'), + [{ sourceRange: range, previewRange: range }], + 'test match' + )); + + // Report a result from a folder that is NOT in the query + // This exercises: folderQuery?.folder?.scheme where folderQuery is undefined + // The optional chaining should handle this gracefully without throwing + progress.report(new TextSearchMatch2( + URI.file('/unknown/folder/file.txt'), + [{ sourceRange: range, previewRange: range }], + 'unmatched result' + )); + + return null; + } + }; + + const query: ITextQuery = { + type: QueryType.Text, + contentPattern: { + pattern: 'a' + }, + folderQueries: [ + { folder: URI.file('/folder1') } + ] + }; + + const m = new NativeTextSearchManager(query, provider); + // This should not throw even though a result from an unmatched folder was reported + await m.search((results) => { + receivedResults += results.length; + }, CancellationToken.None); + + // Should only receive 1 result (the one from /folder1) + // The result from /unknown/folder should be silently ignored + assert.strictEqual(receivedResults, 1); + }); + ensureNoDisposablesAreLeakedInTestSuite(); }); diff --git a/src/vscode-dts/vscode.proposed.chatHooks.d.ts b/src/vscode-dts/vscode.proposed.chatHooks.d.ts new file mode 100644 index 00000000000..04b2fde38c6 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.chatHooks.d.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// version: 1 + +declare module 'vscode' { + + /** + * The type of hook to execute. + */ + export type ChatHookType = 'sessionStart' | 'userPromptSubmitted' | 'preToolUse' | 'postToolUse' | 'postToolUseFailure' | 'subagentStart' | 'subagentStop' | 'stop'; + + /** + * Options for executing a hook command. + */ + export interface ChatHookExecutionOptions { + /** + * Input data to pass to the hook via stdin (will be JSON-serialized). + */ + readonly input?: unknown; + /** + * The tool invocation token from the chat request context, + * used to associate the hook execution with the current chat session. + */ + readonly toolInvocationToken: ChatParticipantToolToken; + } + + /** + * The kind of result from a hook execution. + */ + export enum ChatHookResultKind { + /** + * Hook executed successfully (exit code 0). + */ + Success = 1, + /** + * Hook returned an error (any non-zero exit code). + */ + Error = 2 + } + + /** + * Result of executing a hook command. + */ + export interface ChatHookResult { + /** + * The kind of result. + */ + readonly kind: ChatHookResultKind; + /** + * The result from the hook. For success, this is stdout parsed as JSON. + * For errors, this is stderr. + */ + readonly result: string | object; + } + + export namespace chat { + /** + * Execute all hooks of the specified type for the current chat session. + * Hooks are configured in hooks.json files in the workspace. + * + * @param hookType The type of hook to execute. + * @param options Hook execution options including the input data. + * @param token Optional cancellation token. + * @returns A promise that resolves to an array of hook execution results. + */ + export function executeHook(hookType: ChatHookType, options: ChatHookExecutionOptions, token?: CancellationToken): Thenable; + } +} diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index f92b2e620a0..4b117fcb275 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -344,4 +344,17 @@ declare module 'vscode' { } // #endregion + + // #region Steering + + export interface ChatContext { + /** + * Set to `true` by the editor to request the language model gracefully + * stop after its next opportunity. When set, it's likely that the editor + * will immediately follow up with a new request in the same conversation. + */ + readonly yieldRequested: boolean; + } + + // #endregion } diff --git a/test/automation/src/electron.ts b/test/automation/src/electron.ts index 7475a8a8494..f6f1d78551e 100644 --- a/test/automation/src/electron.ts +++ b/test/automation/src/electron.ts @@ -121,13 +121,22 @@ function findFilePath(root: string, path: string): string { throw new Error(`Could not find ${path} in any subdirectory`); } +function parseVersion(version: string) { + const match = /^(\d+)\.(\d+)\.(\d+)/.exec(version); + if (!match) { + throw new Error(`Invalid version string: ${version}`); + } + const [, major, minor, patch] = match; + return { major: parseInt(major), minor: parseInt(minor), patch: parseInt(patch) }; +} + export function getDevElectronPath(): string { const buildPath = join(root, '.build'); const product = require(join(root, 'product.json')); switch (process.platform) { case 'darwin': - return join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', 'Electron'); + return join(buildPath, 'electron', `${product.nameLong}.app`, 'Contents', 'MacOS', `${product.nameShort}`); case 'linux': return join(buildPath, 'electron', `${product.applicationName}`); case 'win32': @@ -139,8 +148,20 @@ export function getDevElectronPath(): string { export function getBuildElectronPath(root: string): string { switch (process.platform) { - case 'darwin': - return join(root, 'Contents', 'MacOS', 'Electron'); + case 'darwin': { + const packageJson = require(join(root, 'Contents', 'Resources', 'app', 'package.json')); + const product = require(join(root, 'Contents', 'Resources', 'app', 'product.json')); + const { major, minor } = parseVersion(packageJson.version); + // For macOS builds using the legacy Electron binary name, versions up to and including + // 1.109.x ship the executable as "Electron". From later versions onward, the executable + // is renamed to match product.nameShort. This check preserves compatibility with older + // builds; update the cutoff here only if the binary naming scheme changes again. + if (major === 1 && minor <= 109) { + return join(root, 'Contents', 'MacOS', 'Electron'); + } else { + return join(root, 'Contents', 'MacOS', product.nameShort); + } + } case 'linux': { const product = require(join(root, 'resources', 'app', 'product.json')); return join(root, product.applicationName); diff --git a/test/mcp/package-lock.json b/test/mcp/package-lock.json index 75ce8f4867d..49e04398f6d 100644 --- a/test/mcp/package-lock.json +++ b/test/mcp/package-lock.json @@ -10,16 +10,11 @@ "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "1.25.2", - "@playwright/mcp": "^0.0.40", - "cors": "^2.8.5", - "express": "^5.2.1", "minimist": "^1.2.8", "ncp": "^2.0.0", "node-fetch": "^2.6.7" }, "devDependencies": { - "@types/cors": "^2.8.19", - "@types/express": "^5.0.3", "@types/ncp": "2.0.1", "@types/node": "22.x", "@types/node-fetch": "^2.5.10", @@ -77,92 +72,6 @@ } } }, - "node_modules/@playwright/mcp": { - "version": "0.0.40", - "resolved": "https://registry.npmjs.org/@playwright/mcp/-/mcp-0.0.40.tgz", - "integrity": "sha512-gkaE0enMiRLKU3UdVZP2vUn9/rkLT01susE4XY7K10Wpl9vgOXeDCoTNwA2z82D8S2MX31lHx+uveEU4nHF3yw==", - "license": "Apache-2.0", - "dependencies": { - "playwright": "1.56.0-alpha-1758750661000", - "playwright-core": "1.56.0-alpha-1758750661000" - }, - "bin": { - "mcp-server-playwright": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.6", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", - "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" - } - }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/cors": { - "version": "2.8.19", - "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", - "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/express": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", - "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/body-parser": "*", - "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "*" - } - }, - "node_modules/@types/express-serve-static-core": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.7.tgz", - "integrity": "sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*", - "@types/qs": "*", - "@types/range-parser": "*", - "@types/send": "*" - } - }, - "node_modules/@types/http-errors": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", - "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ncp": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/ncp/-/ncp-2.0.1.tgz", @@ -194,43 +103,6 @@ "form-data": "^4.0.4" } }, - "node_modules/@types/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/range-parser": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", - "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/send": { - "version": "0.17.5", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", - "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/mime": "^1", - "@types/node": "*" - } - }, - "node_modules/@types/serve-static": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", - "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/http-errors": "*", - "@types/node": "*", - "@types/send": "*" - } - }, "node_modules/accepts": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", @@ -727,20 +599,6 @@ "node": ">= 0.8" } }, - "node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -1236,36 +1094,6 @@ "node": ">=16.20.0" } }, - "node_modules/playwright": { - "version": "1.56.0-alpha-1758750661000", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.56.0-alpha-1758750661000.tgz", - "integrity": "sha512-15C/m7NPpAmBX2MFMrepCMj18ksBYvhbT90cvFjG2iBs2YPqO2U4f9OjcX207ITSmDAAJ8pWBlJutcZUYUERXg==", - "license": "Apache-2.0", - "dependencies": { - "playwright-core": "1.56.0-alpha-1758750661000" - }, - "bin": { - "playwright": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" - } - }, - "node_modules/playwright-core": { - "version": "1.56.0-alpha-1758750661000", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.56.0-alpha-1758750661000.tgz", - "integrity": "sha512-ivP4xjc6EHkUqF80pMFfDRijKLEvO64qC6DTgyYrbsyCo8gugkqwKm6lFWn4W47g4S8juoUwQhlRVjM2BJ+ruA==", - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/test/mcp/src/application.ts b/test/mcp/src/application.ts index 1eff6a914ad..e2749493ad4 100644 --- a/test/mcp/src/application.ts +++ b/test/mcp/src/application.ts @@ -196,7 +196,7 @@ async function ensureStableCode(): Promise { })); if (process.platform === 'darwin') { - // Visual Studio Code.app/Contents/MacOS/Electron + // Visual Studio Code.app/Contents/MacOS/Code stableCodePath = path.dirname(path.dirname(path.dirname(stableCodeExecutable))); } else { // VSCode/Code.exe (Windows) | VSCode/code (Linux) diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 1ee1b036fb8..95273f9f7fc 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -779,18 +779,22 @@ export class TestContext { switch (os.platform()) { case 'darwin': { let appName: string; + let binaryName: string; switch (this.options.quality) { case 'stable': appName = 'Visual Studio Code.app'; + binaryName = 'Code'; break; case 'insider': appName = 'Visual Studio Code - Insiders.app'; + binaryName = 'Code - Insiders'; break; case 'exploration': appName = 'Visual Studio Code - Exploration.app'; + binaryName = 'Code - Exploration'; break; } - filePath = path.join(dir, appName, 'Contents/MacOS/Electron'); + filePath = path.join(dir, appName, 'Contents/MacOS', binaryName); break; } case 'linux': { diff --git a/test/smoke/src/areas/accessibility/accessibility.test.ts b/test/smoke/src/areas/accessibility/accessibility.test.ts index 9472218a82f..7f912764a04 100644 --- a/test/smoke/src/areas/accessibility/accessibility.test.ts +++ b/test/smoke/src/areas/accessibility/accessibility.test.ts @@ -3,14 +3,14 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Application, Logger } from '../../../../automation'; +import { Application, Logger, Quality } from '../../../../automation'; import { installAllHandlers } from '../../utils'; -export function setup(logger: Logger, opts: { web?: boolean }) { - describe.skip('Accessibility', function () { +export function setup(logger: Logger, opts: { web?: boolean }, quality: Quality) { + describe('Accessibility', function () { // Increase timeout for accessibility scans - this.timeout(30 * 1000); + this.timeout(2 * 60 * 1000); // Retry tests to minimize flakiness this.retries(2); @@ -38,7 +38,9 @@ export function setup(logger: Logger, opts: { web?: boolean }) { // Monaco lists use aria-multiselectable on role="list" and aria-setsize/aria-posinset/aria-selected on role="dialog" rows // These violations appear intermittently when notification lists or other dynamic lists are visible // Note: patterns match against HTML string, not CSS selectors, so no leading dots - 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'] + 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'], + // Monaco lists may temporarily contain dialog children during extension activation errors + 'aria-required-children': ['monaco-list'] } }); }); @@ -69,7 +71,7 @@ export function setup(logger: Logger, opts: { web?: boolean }) { }); // Chat is not available in web mode - if (!opts.web) { + if (quality !== Quality.Dev && quality !== Quality.OSS && !opts.web) { describe('Chat', function () { it('chat panel has no accessibility violations', async function () { @@ -87,6 +89,83 @@ export function setup(logger: Logger, opts: { web?: boolean }) { } }); }); + + // Chat response test requires gallery service which is only available in non-Dev/OSS builds + it('chat response has no accessibility violations', async function () { + // Disable retries for this test - it modifies settings and retries cause issues + this.retries(0); + // Extend timeout for this test since AI responses can take a while + this.timeout(3 * 60 * 1000); + + // Enable anonymous chat access + await app.workbench.settingsEditor.addUserSetting('chat.allowAnonymousAccess', 'true'); + + // Open chat panel + await app.workbench.quickaccess.runCommand('workbench.action.chat.open'); + + // Wait for chat view to be visible + await app.workbench.chat.waitForChatView(); + + // Send a simple message + await app.workbench.chat.sendMessage('Create a simple hello.txt file with the text "Hello World"'); + + // Wait for the response to complete (1500 retries ~= 150 seconds at 100ms per retry) + await app.workbench.chat.waitForResponse(1500); + + // Run accessibility check on the chat panel with the response + await app.code.driver.assertNoAccessibilityViolations({ + selector: 'div[id="workbench.panel.chat"]', + excludeRules: { + // Links in chat welcome view show underline on hover/focus which axe-core static analysis cannot detect + 'link-in-text-block': ['command:workbench.action.chat.generateInstructions'], + // Monaco lists use aria-multiselectable on role="list" and aria-selected on role="listitem" + // These are used intentionally for selection semantics even though technically not spec-compliant + 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'], + // Some icon buttons have empty aria-label during rendering + 'aria-command-name': ['codicon-plus'], + // Todo list widget has clear button nested inside expander button for layout purposes + 'nested-interactive': ['todo-list-container'] + } + }); + }); + + it('chat terminal tool response has no accessibility violations', async function () { + // Disable retries for this test + this.retries(0); + // Extend timeout for this test since AI responses can take a while + this.timeout(3 * 60 * 1000); + + // Enable auto-approve for tools so terminal commands run automatically + await app.workbench.settingsEditor.addUserSetting('chat.tools.global.autoApprove', 'true'); + + // Open chat panel + await app.workbench.quickaccess.runCommand('workbench.action.chat.open'); + + // Wait for chat view to be visible + await app.workbench.chat.waitForChatView(); + + // Send a terminal command request + await app.workbench.chat.sendMessage('Run ls in the terminal'); + + // Wait for the response to complete (1500 retries ~= 150 seconds at 100ms per retry) + await app.workbench.chat.waitForResponse(1500); + + // Run accessibility check on the chat panel with the response + await app.code.driver.assertNoAccessibilityViolations({ + selector: 'div[id="workbench.panel.chat"]', + excludeRules: { + // Links in chat welcome view show underline on hover/focus which axe-core static analysis cannot detect + 'link-in-text-block': ['command:workbench.action.chat.generateInstructions'], + // Monaco lists use aria-multiselectable on role="list" and aria-selected on role="listitem" + // These are used intentionally for selection semantics even though technically not spec-compliant + 'aria-allowed-attr': ['monaco-list', 'monaco-list-row'], + // Some icon buttons have empty aria-label during rendering + 'aria-command-name': ['codicon-plus'], + // Todo list widget has clear button nested inside expander button for layout purposes + 'nested-interactive': ['todo-list-container'] + } + }); + }); }); } }); diff --git a/test/smoke/src/areas/chat/chatAnonymous.test.ts b/test/smoke/src/areas/chat/chatAnonymous.test.ts index 5993b99502d..520292a96d4 100644 --- a/test/smoke/src/areas/chat/chatAnonymous.test.ts +++ b/test/smoke/src/areas/chat/chatAnonymous.test.ts @@ -7,7 +7,7 @@ import { Application, Logger } from '../../../../automation'; import { installAllHandlers } from '../../utils'; export function setup(logger: Logger) { - describe('Chat Anonymous', () => { + describe.skip('Chat Anonymous', () => { // Shared before/after handling installAllHandlers(logger); diff --git a/test/smoke/src/main.ts b/test/smoke/src/main.ts index 2360381fd52..fc8b4f8800f 100644 --- a/test/smoke/src/main.ts +++ b/test/smoke/src/main.ts @@ -322,7 +322,7 @@ async function ensureStableCode(): Promise { }); if (process.platform === 'darwin') { - // Visual Studio Code.app/Contents/MacOS/Electron + // Visual Studio Code.app/Contents/MacOS/Code stableCodePath = path.dirname(path.dirname(path.dirname(stableCodeExecutable))); } else { // VSCode/Code.exe (Windows) | VSCode/code (Linux) @@ -408,5 +408,5 @@ describe(`VSCode Smoke Tests (${opts.web ? 'Web' : 'Electron'})`, () => { if (!opts.web && !opts.remote) { setupLaunchTests(logger); } if (!opts.web) { setupChatTests(logger); } if (!opts.web && quality === Quality.Insiders) { setupChatAnonymousTests(logger); } - setupAccessibilityTests(logger, opts); + setupAccessibilityTests(logger, opts, quality); });