Merge branch 'main' into joh/esbuild-the-things

This commit is contained in:
Johannes
2026-02-04 09:11:45 +01:00
188 changed files with 7468 additions and 937 deletions
@@ -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}`
);
}
}
+3 -3
View File
@@ -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"
-2
View File
@@ -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]": {
@@ -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)"
@@ -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)"
@@ -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)
@@ -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
+6
View File
@@ -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/**',
+2 -1
View File
@@ -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
]
}
+33 -23
View File
@@ -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<void> {
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
+8 -10
View File
@@ -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
+2
View File
@@ -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}`));
+1
View File
@@ -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',
+116
View File
@@ -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> | void): Promise<void> {
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<void>((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> | 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;
}
+1 -1
View File
@@ -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}');
+4 -4
View File
@@ -47,11 +47,11 @@ function _throttle<T>(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));
};
}
+5 -5
View File
@@ -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);
};
}
+4 -5
View File
@@ -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 });
};
}
+8 -5
View File
@@ -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;
}
+7 -4
View File
@@ -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');
};
}
+2 -1
View File
@@ -12,7 +12,8 @@
],
"typeRoots": [
"./node_modules/@types"
]
],
"skipLibCheck": true
},
"include": [
"src/**/*",
@@ -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,
@@ -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,
+2 -1
View File
@@ -15,6 +15,7 @@
"noImplicitOverride": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true
}
}
+4 -4
View File
@@ -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",
+2 -2
View File
@@ -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",
+4 -4
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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"
+8 -2
View File
@@ -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;
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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"
+1 -1
View File
@@ -11,7 +11,7 @@
"moduleResolution": "nodenext",
"removeComments": false,
"preserveConstEnums": true,
"target": "ES2022",
"target": "ES2024",
"sourceMap": false,
"declaration": true,
"skipLibCheck": true
+1 -1
View File
@@ -13,7 +13,7 @@
"forceConsistentCasingInFileNames": true,
"types": [],
"lib": [
"ES2022"
"ES2024"
],
},
"include": [
+2 -2
View File
@@ -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');
}
}
@@ -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<void> {
for (const profile of this.userDataProfilesService.profiles) {
await migrateUnsupportedExtensions(profile, this.extensionManagementService, this.extensionGalleryService, this.extensionStorageService, this.extensionEnablementService, this.logService);
}
}
}
+1 -1
View File
@@ -73,7 +73,7 @@ export async function main(argv: string[]): Promise<void> {
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' : ''}`);
@@ -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<boolean> {
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 {
@@ -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');
@@ -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<void>;
/**
* 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<string>;
/**
* Clear all storage data for the global browser session
*/
@@ -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);
}
}());
@@ -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<void> = 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<Electron.WebContents['getType']> } = {
...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<string> {
// 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
*/
@@ -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<IBrowserViewState> {
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<string> {
return this._getBrowserView(id).getSelectedText();
}
async clearStorage(id: string): Promise<void> {
return this._getBrowserView(id).clearStorage();
}
@@ -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) {
@@ -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 };
}
@@ -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[];
@@ -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<void> {
export async function migrateUnsupportedExtensions(profile: IUserDataProfile | undefined, extensionManagementService: IExtensionManagementService, galleryService: IExtensionGalleryService, extensionStorageService: IExtensionStorageService, extensionEnablementService: IGlobalExtensionEnablementService, logService: ILogService): Promise<void> {
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);
@@ -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',
},
@@ -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.")
]
}
}
});
+4 -4
View File
@@ -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 {
@@ -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;
}
@@ -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 {
@@ -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<void>(500);
const progressStream = transform<VSBuffer, VSBuffer>(
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));
@@ -96,6 +96,7 @@ import './mainThreadChatStatus.js';
import './mainThreadChatOutputRenderer.js';
import './mainThreadChatSessions.js';
import './mainThreadDataChannels.js';
import './mainThreadHooks.js';
export class ExtensionPoints implements IWorkbenchContribution {
@@ -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();
}
}
@@ -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<IHookResult> => {
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<IHookResult[]> {
const uri = URI.revive(sessionResource);
return this._hooksExecutionService.executeHook(hookType as HookTypeValue, uri, { input, token });
}
}
@@ -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<ProxyIdentifier<any>>(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<vscode.ChatHookResult[]> {
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,
};
};
}
@@ -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<IHookCommand>;
export interface ExtHostHooksShape {
$runHookCommand(hookCommand: IHookCommandDto, input: unknown, token: CancellationToken): Promise<IHookResult>;
}
export interface ExtHostMcpShape {
$substituteVariables(workspaceFolder: UriComponents | undefined, value: McpServerLaunch.Serialized): Promise<McpServerLaunch.Serialized>;
@@ -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<IHookResult[]>;
}
export interface ExtHostDataChannelsShape {
$onDidReceiveData(channelId: string, data: unknown): void;
}
@@ -3485,6 +3495,7 @@ export const MainContext = {
MainThreadChatStatus: createProxyIdentifier<MainThreadChatStatusShape>('MainThreadChatStatus'),
MainThreadAiSettingsSearch: createProxyIdentifier<MainThreadAiSettingsSearchShape>('MainThreadAiSettingsSearch'),
MainThreadDataChannels: createProxyIdentifier<MainThreadDataChannelsShape>('MainThreadDataChannels'),
MainThreadHooks: createProxyIdentifier<MainThreadHooksShape>('MainThreadHooks'),
MainThreadChatSessions: createProxyIdentifier<MainThreadChatSessionsShape>('MainThreadChatSessions'),
MainThreadChatOutputRenderer: createProxyIdentifier<MainThreadChatOutputRendererShape>('MainThreadChatOutputRenderer'),
MainThreadChatContext: createProxyIdentifier<MainThreadChatContextShape>('MainThreadChatContext'),
@@ -3562,6 +3573,7 @@ export const ExtHostContext = {
ExtHostTelemetry: createProxyIdentifier<ExtHostTelemetryShape>('ExtHostTelemetry'),
ExtHostLocalization: createProxyIdentifier<ExtHostLocalizationShape>('ExtHostLocalization'),
ExtHostMcp: createProxyIdentifier<ExtHostMcpShape>('ExtHostMcp'),
ExtHostHooks: createProxyIdentifier<ExtHostHooksShape>('ExtHostHooks'),
ExtHostDataChannels: createProxyIdentifier<ExtHostDataChannelsShape>('ExtHostDataChannels'),
ExtHostChatSessions: createProxyIdentifier<ExtHostChatSessionsShape>('ExtHostChatSessions'),
};
@@ -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<string | undefined> {
@@ -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);
}
}
@@ -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 {};
@@ -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>('IExtHostHooks');
export interface IChatHookExecutionOptions {
readonly input?: unknown;
readonly toolInvocationToken: unknown;
}
export interface IExtHostHooks extends ExtHostHooksShape {
executeHook(hookType: HookTypeValue, options: IChatHookExecutionOptions, token?: CancellationToken): Promise<vscode.ChatHookResult[]>;
}
@@ -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
};
}
}
@@ -3935,6 +3935,15 @@ export enum SettingsSearchResultKind {
//#endregion
//#region Chat Hooks
export enum ChatHookResultKind {
Success = 1,
Error = 2
}
//#endregion
//#region Speech
export enum SpeechToTextStatus {
@@ -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);
@@ -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<vscode.ChatHookResult[]> {
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<IHookResult> {
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<IHookResult> {
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);
});
});
}
}
@@ -424,6 +424,7 @@ async function lookupProxyAuthorization(
proxyAuthenticate: string | string[] | undefined,
state: { kerberosRequested?: boolean; basicAuthCacheUsed?: boolean; basicAuthAttempt?: number }
): Promise<string | undefined> {
proxyURL = proxyURL.replace(/\/+$/, '');
const cached = proxyAuthenticateCache[proxyURL];
if (proxyAuthenticate) {
proxyAuthenticateCache[proxyURL] = proxyAuthenticate;
@@ -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<Omit<IHookCommandDto, 'type' | 'command'>>): IHookCommandDto {
return {
type: 'command',
command,
...options,
};
}
function createMockExtHostRpcService(mainThreadProxy: MainThreadHooksShape): IExtHostRpcService {
return {
_serviceBrand: undefined,
getProxy<T>(): T {
return mainThreadProxy as unknown as T;
},
set<T, R extends T>(_identifier: unknown, instance: R): R {
return instance;
},
dispose(): void { },
assertRegistered(): void { },
drain(): Promise<void> { return Promise.resolve(); },
} as IExtHostRpcService;
}
suite.skip('ExtHostHooks', () => {
ensureNoDisposablesAreLeakedInTestSuite();
let hooksService: NodeExtHostHooks;
setup(() => {
const mockMainThreadProxy: MainThreadHooksShape = {
$executeHook: async (): Promise<IHookResult[]> => {
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'));
});
});
@@ -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 {
@@ -118,6 +118,7 @@ export interface IBrowserViewModel extends IDisposable {
focus(): Promise<void>;
findInPage(text: string, options?: IBrowserViewFindInPageOptions): Promise<void>;
stopFindInPage(keepSelection?: boolean): Promise<void>;
getSelectedText(): Promise<string>;
clearStorage(): Promise<void>;
}
@@ -336,6 +337,10 @@ export class BrowserViewModel extends Disposable implements IBrowserViewModel {
return this.browserViewService.stopFindInPage(this.id, keepSelection);
}
async getSelectedText(): Promise<string> {
return this.browserViewService.getSelectedText(this.id);
}
async clearStorage(): Promise<void> {
return this.browserViewService.clearStorage(this.id);
}
@@ -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<boolean>('browserCanGoBack', false, localize('browser.canGoBack', "Whether the browser can go back"));
export const CONTEXT_BROWSER_CAN_GO_FORWARD = new RawContextKey<boolean>('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<void> {
// 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);
}
@@ -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 }
@@ -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;
@@ -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);
@@ -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);
}
@@ -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',
@@ -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 }
});
}
@@ -22,7 +22,7 @@ export enum AgentSessionsGrouping {
export interface IAgentSessionsFilterOptions extends Partial<IAgentSessionsFilter> {
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<IAgentSessionsFilter> {
private readonly STORAGE_KEY: string;
private readonly STORAGE_KEY = `agentSessions.filterExcludes.agentsessionsviewerfiltersubmenu`;
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;
@@ -61,8 +61,6 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
) {
super();
this.STORAGE_KEY = `agentSessions.filterExcludes.${this.options.filterMenuId.id.toLowerCase()}`;
this.updateExcludes(false);
this.registerListeners();
@@ -116,14 +114,19 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
private updateFilterActions(): void {
this.actionDisposables.clear();
this.registerProviderActions(this.actionDisposables);
this.registerStateActions(this.actionDisposables);
this.registerArchivedActions(this.actionDisposables);
this.registerReadActions(this.actionDisposables);
this.registerResetAction(this.actionDisposables);
const menuId = this.options.filterMenuId;
if (!menuId) {
return;
}
this.registerProviderActions(this.actionDisposables, menuId);
this.registerStateActions(this.actionDisposables, menuId);
this.registerArchivedActions(this.actionDisposables, menuId);
this.registerReadActions(this.actionDisposables, menuId);
this.registerResetAction(this.actionDisposables, menuId);
}
private registerProviderActions(disposables: DisposableStore): void {
private registerProviderActions(disposables: DisposableStore, menuId: MenuId): void {
const providers: { id: string; label: string }[] = Object.values(AgentSessionProviders).map(provider => ({
id: provider,
label: getAgentSessionProviderName(provider)
@@ -143,10 +146,10 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
disposables.add(registerAction2(class extends Action2 {
constructor() {
super({
id: `agentSessions.filter.toggleExclude:${provider.id}.${that.options.filterMenuId.id.toLowerCase()}`,
id: `agentSessions.filter.toggleExclude:${provider.id}.${menuId.id.toLowerCase()}`,
title: provider.label,
menu: {
id: that.options.filterMenuId,
id: menuId,
group: '1_providers',
order: counter++,
},
@@ -165,7 +168,7 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
}
}
private registerStateActions(disposables: DisposableStore): void {
private registerStateActions(disposables: DisposableStore, menuId: MenuId): void {
const states: { id: AgentSessionStatus; label: string }[] = [
{ id: AgentSessionStatus.Completed, label: localize('agentSessionStatus.completed', "Completed") },
{ id: AgentSessionStatus.InProgress, label: localize('agentSessionStatus.inProgress', "In Progress") },
@@ -179,10 +182,10 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
disposables.add(registerAction2(class extends Action2 {
constructor() {
super({
id: `agentSessions.filter.toggleExcludeState:${state.id}.${that.options.filterMenuId.id.toLowerCase()}`,
id: `agentSessions.filter.toggleExcludeState:${state.id}.${menuId.id.toLowerCase()}`,
title: state.label,
menu: {
id: that.options.filterMenuId,
id: menuId,
group: '2_states',
order: counter++,
},
@@ -201,15 +204,15 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
}
}
private registerArchivedActions(disposables: DisposableStore): void {
private registerArchivedActions(disposables: DisposableStore, menuId: MenuId): void {
const that = this;
disposables.add(registerAction2(class extends Action2 {
constructor() {
super({
id: `agentSessions.filter.toggleExcludeArchived.${that.options.filterMenuId.id.toLowerCase()}`,
id: `agentSessions.filter.toggleExcludeArchived.${menuId.id.toLowerCase()}`,
title: localize('agentSessions.filter.archived', 'Archived'),
menu: {
id: that.options.filterMenuId,
id: menuId,
group: '3_props',
order: 1000,
},
@@ -222,15 +225,15 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
}));
}
private registerReadActions(disposables: DisposableStore): void {
private registerReadActions(disposables: DisposableStore, menuId: MenuId): void {
const that = this;
disposables.add(registerAction2(class extends Action2 {
constructor() {
super({
id: `agentSessions.filter.toggleExcludeRead.${that.options.filterMenuId.id.toLowerCase()}`,
id: `agentSessions.filter.toggleExcludeRead.${menuId.id.toLowerCase()}`,
title: localize('agentSessions.filter.read', 'Read'),
menu: {
id: that.options.filterMenuId,
id: menuId,
group: '3_props',
order: 0,
},
@@ -243,15 +246,15 @@ export class AgentSessionsFilter extends Disposable implements Required<IAgentSe
}));
}
private registerResetAction(disposables: DisposableStore): void {
private registerResetAction(disposables: DisposableStore, menuId: MenuId): void {
const that = this;
disposables.add(registerAction2(class extends Action2 {
constructor() {
super({
id: `agentSessions.filter.resetExcludes.${that.options.filterMenuId.id.toLowerCase()}`,
id: `agentSessions.filter.resetExcludes.${menuId.id.toLowerCase()}`,
title: localize('agentSessions.filter.reset', "Reset"),
menu: {
id: that.options.filterMenuId,
id: menuId,
group: '4_reset',
order: 0,
},
@@ -12,6 +12,7 @@ import { IMarkdownString } from '../../../../../base/common/htmlContent.js';
import { Disposable } from '../../../../../base/common/lifecycle.js';
import { ResourceMap } from '../../../../../base/common/map.js';
import { MarshalledId } from '../../../../../base/common/marshallingIds.js';
import { safeStringify } from '../../../../../base/common/objects.js';
import { ThemeIcon } from '../../../../../base/common/themables.js';
import { URI, UriComponents } from '../../../../../base/common/uri.js';
import { localize } from '../../../../../nls.js';
@@ -425,9 +426,9 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
private registerListeners(): void {
// Sessions changes
this._register(this.chatSessionsService.onDidChangeItemsProviders(({ chatSessionType: provider }) => 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
@@ -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<void> {
const disposables = new DisposableStore();
const picker = disposables.add(this.quickInputService.createQuickPick<ISessionPickItem>({ 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);
@@ -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<IPickerQuickAccessItem> {
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<IPickerQuickAccessItem | IQuickPickSeparator> = [];
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()) {
@@ -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() { }
});
@@ -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<typeof setTimeout> | 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<void> {
// 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);
}
@@ -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();
}
}));
@@ -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<boolean>(ChatContextKeys.enabled.key, this.contextKeyService);
const sendElementsEnabledObs = observableConfigValue<boolean>('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;
}
@@ -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<IJSONContributionRegistry>(JSONExtensions.JSONContribution);
jsonContributionRegistry.registerSchema(HOOK_SCHEMA_URI, hookFileSchema);
jsonContributionRegistry.registerSchemaAssociation(HOOK_SCHEMA_URI, HOOK_FILE_GLOB);
// Register configuration
const configurationRegistry = Registry.as<IConfigurationRegistry>(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);
@@ -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 {
@@ -259,11 +259,11 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
private readonly _alternativeIdMap: Map</* alternativeId */ string, /* primaryType */ string> = new Map();
private readonly _contextKeys = new Set<string>();
private readonly _onDidChangeItemsProviders = this._register(new Emitter<IChatSessionItemProvider>());
readonly onDidChangeItemsProviders: Event<IChatSessionItemProvider> = 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<string>());
readonly onDidChangeSessionItems: Event<string> = 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<void>());
readonly onDidChangeAvailability: Event<void> = 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<IChatSessionItemProvider | undefined> {
async activateChatSessionItemProvider(chatViewType: string): Promise<void> {
await this.doActivateChatSessionItemProvider(chatViewType);
}
private async doActivateChatSessionItemProvider(chatViewType: string): Promise<IChatSessionItemProvider | undefined> {
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
*/
@@ -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<void> {
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<HookType, IHookEntry[]>();
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);
}
@@ -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
};
}
@@ -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<string, unknown[]> };
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);
}
@@ -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');
}
@@ -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
};
}
}
@@ -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);
@@ -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);
}
@@ -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<IChatRendererContent>;
@@ -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);
@@ -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;
@@ -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);
}

Some files were not shown because too many files have changed in this diff Show More