From 7443cd17d5f73553b17b0d176b23ed597b02e5b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:09:43 +0000 Subject: [PATCH 001/221] Initial plan From aa9ec519b5ad2d38b58edb3bb9e8054c2617af50 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 18:23:47 +0000 Subject: [PATCH 002/221] Filter out sole wildcard * from allowTrustedDomains for sandbox - Added check in TerminalSandboxService to filter out '*' wildcard when building allowedDomains list for sandbox - Sandbox runtime doesn't allow sole wildcard '*', but wildcards like '*.github.com' are OK - Added comprehensive unit tests covering various scenarios including wildcards, combinations, and deduplication Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../common/terminalSandboxService.ts | 6 +- .../browser/terminalSandboxService.test.ts | 232 ++++++++++++++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index c10004a8417f5..9b1da127a3529 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -160,7 +160,11 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const allowedDomainsSet = new Set(networkSetting.allowedDomains ?? []); if (networkSetting.allowTrustedDomains) { for (const domain of this._trustedDomainService.trustedDomains) { - allowedDomainsSet.add(domain); + // Filter out sole wildcard '*' as sandbox runtime doesn't allow it + // Wildcards like '*.github.com' are OK + if (domain !== '*') { + allowedDomainsSet.add(domain); + } } } const allowedDomains = Array.from(allowedDomainsSet); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts new file mode 100644 index 0000000000000..76bcdfc82706b --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual, ok } from 'assert'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { workbenchInstantiationService } from '../../../../../test/browser/workbenchTestServices.js'; +import { TerminalSandboxService } from '../../common/terminalSandboxService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { IEnvironmentService } from '../../../../../../platform/environment/common/environment.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; +import { ITrustedDomainService } from '../../../../url/common/trustedDomainService.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentToolsConfiguration.js'; +import { Event, Emitter } from '../../../../../../base/common/event.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { VSBuffer } from '../../../../../../base/common/buffer.js'; + +suite('TerminalSandboxService - allowTrustedDomains', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let trustedDomainService: MockTrustedDomainService; + let fileService: MockFileService; + let createdFiles: Map; + + class MockTrustedDomainService implements ITrustedDomainService { + _serviceBrand: undefined; + private _onDidChangeTrustedDomains = new Emitter(); + readonly onDidChangeTrustedDomains: Event = this._onDidChangeTrustedDomains.event; + trustedDomains: string[] = []; + isValid(_resource: URI): boolean { + return true; + } + } + + class MockFileService { + async createFile(uri: URI, content: VSBuffer): Promise { + const contentString = content.toString(); + createdFiles.set(uri.path, contentString); + return {}; + } + } + + class MockRemoteAgentService { + async getEnvironment() { + return null; + } + } + + const mockEnvironmentService: IEnvironmentService & { tmpDir?: URI; execPath?: string } = { + tmpDir: URI.file('/tmp'), + execPath: '/usr/bin/node' + } as any; + + setup(() => { + createdFiles = new Map(); + instantiationService = workbenchInstantiationService({}, store); + configurationService = new TestConfigurationService(); + trustedDomainService = new MockTrustedDomainService(); + fileService = new MockFileService(); + + // Setup default configuration + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxEnabled, true); + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: false + }); + + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(IFileService, fileService); + instantiationService.stub(IEnvironmentService, mockEnvironmentService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); + instantiationService.stub(ITrustedDomainService, trustedDomainService); + }); + + test('should filter out sole wildcard (*) from trusted domains', async () => { + // Setup: Enable allowTrustedDomains and add * to trusted domains + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 0, 'Sole wildcard * should be filtered out'); + }); + + test('should allow wildcards with domains like *.github.com', async () => { + // Setup: Enable allowTrustedDomains and add *.github.com + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*.github.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 1, 'Wildcard domain should be included'); + strictEqual(config.network.allowedDomains[0], '*.github.com', 'Wildcard domain should match'); + }); + + test('should combine trusted domains with configured allowedDomains, filtering out *', async () => { + // Setup: Enable allowTrustedDomains with multiple domains including * + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['example.com'], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*', '*.github.com', 'microsoft.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 3, 'Should have 3 domains (excluding *)'); + ok(config.network.allowedDomains.includes('example.com'), 'Should include configured domain'); + ok(config.network.allowedDomains.includes('*.github.com'), 'Should include wildcard domain'); + ok(config.network.allowedDomains.includes('microsoft.com'), 'Should include microsoft.com'); + ok(!config.network.allowedDomains.includes('*'), 'Should not include sole wildcard'); + }); + + test('should not include trusted domains when allowTrustedDomains is false', async () => { + // Setup: Disable allowTrustedDomains + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['example.com'], + deniedDomains: [], + allowTrustedDomains: false + }); + trustedDomainService.trustedDomains = ['*', '*.github.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 1, 'Should only have configured domain'); + strictEqual(config.network.allowedDomains[0], 'example.com', 'Should only include example.com'); + }); + + test('should deduplicate domains when combining sources', async () => { + // Setup: Same domain in both sources + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['github.com', '*.github.com'], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*.github.com', 'github.com']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 2, 'Should have 2 unique domains'); + ok(config.network.allowedDomains.includes('github.com'), 'Should include github.com'); + ok(config.network.allowedDomains.includes('*.github.com'), 'Should include *.github.com'); + }); + + test('should handle empty trusted domains list', async () => { + // Setup: Empty trusted domains + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: ['example.com'], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = []; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 1, 'Should have only configured domain'); + strictEqual(config.network.allowedDomains[0], 'example.com', 'Should only include example.com'); + }); + + test('should handle only * in trusted domains', async () => { + // Setup: Only * in trusted domains (edge case) + configurationService.setUserConfiguration(TerminalChatAgentToolsSettingId.TerminalSandboxNetwork, { + allowedDomains: [], + deniedDomains: [], + allowTrustedDomains: true + }); + trustedDomainService.trustedDomains = ['*']; + + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + const configPath = await sandboxService.getSandboxConfigPath(); + + ok(configPath, 'Config path should be defined'); + const configContent = createdFiles.get(configPath); + ok(configContent, 'Config file should be created'); + + const config = JSON.parse(configContent); + strictEqual(config.network.allowedDomains.length, 0, 'Should have no domains (* filtered out)'); + }); +}); From 87173b4915155cbd725b0e19438beaac41a9019c Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:38:57 -0800 Subject: [PATCH 003/221] Remove `IToolInvocationContext.sessionId` For #274403 Confirmed that this is not used in copilot --- .../api/common/extHostLanguageModelTools.ts | 2 -- .../workbench/api/common/extHostTypeConverters.ts | 2 +- .../browser/tools/languageModelToolsService.ts | 3 +-- .../common/tools/languageModelToolsService.ts | 6 +----- .../tools/languageModelToolsService.test.ts | 2 -- .../common/mcpLanguageModelToolContribution.ts | 2 +- .../tools/runInTerminalConfirmationTool.ts | 13 ------------- .../browser/tools/runInTerminalTool.ts | 15 +++++++++------ .../test/browser/outputMonitor.test.ts | 2 +- .../vscode.proposed.chatParticipantPrivate.d.ts | 4 ---- 10 files changed, 14 insertions(+), 37 deletions(-) diff --git a/src/vs/workbench/api/common/extHostLanguageModelTools.ts b/src/vs/workbench/api/common/extHostLanguageModelTools.ts index 6095aca4be16a..f3d03c9d08a01 100644 --- a/src/vs/workbench/api/common/extHostLanguageModelTools.ts +++ b/src/vs/workbench/api/common/extHostLanguageModelTools.ts @@ -186,7 +186,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape if (isProposedApiEnabled(item.extension, 'chatParticipantPrivate')) { options.chatRequestId = dto.chatRequestId; options.chatInteractionId = dto.chatInteractionId; - options.chatSessionId = dto.context?.sessionId; options.chatSessionResource = URI.revive(dto.context?.sessionResource); options.subAgentInvocationId = dto.subAgentInvocationId; } @@ -289,7 +288,6 @@ export class ExtHostLanguageModelTools implements ExtHostLanguageModelToolsShape const options: vscode.LanguageModelToolInvocationPrepareOptions = { input: context.parameters, chatRequestId: context.chatRequestId, - chatSessionId: context.chatSessionId, chatSessionResource: context.chatSessionResource, chatInteractionId: context.chatInteractionId, forceConfirmationReason: context.forceConfirmationReason diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 3bddf605a3055..e4cf7e8f86097 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3428,7 +3428,7 @@ export namespace ChatAgentRequest { acceptedConfirmationData: request.acceptedConfirmationData, rejectedConfirmationData: request.rejectedConfirmationData, location2, - toolInvocationToken: Object.freeze({ sessionId, sessionResource: request.sessionResource }) as never, + toolInvocationToken: Object.freeze({ sessionResource: request.sessionResource }) as never, tools, model, editedFileEvents: request.editedFileEvents, diff --git a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts index 4bc871cae8938..0de4af3ef6383 100644 --- a/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/browser/tools/languageModelToolsService.ts @@ -659,7 +659,7 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo 'languageModelToolInvoked', { result, - chatSessionId: dto.context?.sessionId, + chatSessionId: dto.context?.sessionResource ? chatSessionResourceToId(dto.context.sessionResource) : undefined, toolId: tool.data.id, toolExtensionId: tool.data.source.type === 'extension' ? tool.data.source.extensionId.value : undefined, toolSourceKind: tool.data.source.type, @@ -753,7 +753,6 @@ export class LanguageModelToolsService extends Disposable implements ILanguageMo parameters: dto.parameters, toolCallId: dto.callId, chatRequestId: dto.chatRequestId, - chatSessionId: dto.context?.sessionId, chatSessionResource: dto.context?.sessionResource, chatInteractionId: dto.chatInteractionId, modelId: dto.modelId, diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 9153ccc9facfb..e7e2d7a6658a4 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -199,14 +199,12 @@ export interface IToolInvocation { } export interface IToolInvocationContext { - /** @deprecated Use {@link sessionResource} instead */ - readonly sessionId: string; readonly sessionResource: URI; } // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return typeof obj === 'object' && typeof obj.sessionId === 'string' && URI.isUri(obj.sessionResource); + return typeof obj === 'object' && URI.isUri(obj.sessionResource); } export interface IToolInvocationPreparationContext { @@ -214,8 +212,6 @@ export interface IToolInvocationPreparationContext { parameters: any; toolCallId: string; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource: URI | undefined; chatInteractionId?: string; modelId?: string; diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts index 3b53ed241bae1..3f34962a5af76 100644 --- a/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/tools/languageModelToolsService.test.ts @@ -77,7 +77,6 @@ function registerToolForTest(service: LanguageModelToolsService, store: any, id: tokenBudget: 100, parameters, context: context ? { - sessionId: context.sessionId, sessionResource: LocalChatSessionUri.forSession(context.sessionId), } : undefined, }), @@ -2902,7 +2901,6 @@ suite('LanguageModelToolsService', () => { tokenBudget: 100, parameters: { test: 1 }, context: { - sessionId, sessionResource: LocalChatSessionUri.forSession(sessionId), }, chatStreamToolCallId: 'stream-call-id', // This should correlate diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index 25e9080dd4c2d..f8a82ce9db3a1 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -251,7 +251,7 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionId: invocation.context?.sessionId }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts index 9432fe208d714..65a0a56506888 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalConfirmationTool.ts @@ -5,7 +5,6 @@ import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../../base/common/codicons.js'; -import { URI } from '../../../../../../base/common/uri.js'; import { localize } from '../../../../../../nls.js'; import { CountTokensCallback, IPreparedToolInvocation, IToolData, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { RunInTerminalTool } from './runInTerminalTool.js'; @@ -60,18 +59,6 @@ export const ConfirmTerminalCommandToolData: IToolData = { export class ConfirmTerminalCommandTool extends RunInTerminalTool { override async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { - // Safe-guard: If session is the chat provider specific id - // then convert it to the session id understood by chat service - try { - const sessionUri = context.chatSessionId ? URI.parse(context.chatSessionId) : undefined; - const sessionId = sessionUri ? this._chatService.getSession(sessionUri)?.sessionId : undefined; - if (sessionId) { - context.chatSessionId = sessionId; - } - } - catch { - // Ignore parse errors or session lookup failures; fallback to using the original chatSessionId. - } const preparedInvocation = await super.prepareToolInvocation(context, token); if (preparedInvocation) { preparedInvocation.presentation = ToolInvocationPresentation.HiddenAfterComplete; diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 8fad759ebe12e..e9b28acf901f6 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -429,7 +429,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, token: CancellationToken): Promise { const args = context.parameters as IRunInTerminalInputParams; - const chatSessionResource = context.chatSessionResource ?? (context.chatSessionId ? LocalChatSessionUri.forSession(context.chatSessionId) : undefined); + const chatSessionResource = context.chatSessionResource; let instance: ITerminalInstance | undefined; if (chatSessionResource) { const toolTerminal = this._sessionTerminalAssociations.get(chatSessionResource); @@ -658,6 +658,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (!toolSpecificData) { throw new Error('toolSpecificData must be provided for this tool'); } + if (!invocation.context) { + throw new Error('Invocation context must be provided for this tool'); + } + const commandId = toolSpecificData.terminalCommandId; if (toolSpecificData.alternativeRecommendation) { return { @@ -672,8 +676,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Invoking with options ${JSON.stringify(args)}`); let toolResultMessage: string | IMarkdownString | undefined; - const chatSessionResource = invocation.context?.sessionResource ?? LocalChatSessionUri.forSession(invocation.context?.sessionId ?? 'no-chat-session'); - const chatSessionId = chatSessionResourceToId(chatSessionResource); + const chatSessionResource = invocation.context.sessionResource; const command = toolSpecificData.commandLine.userEdited ?? toolSpecificData.commandLine.toolEdited ?? toolSpecificData.commandLine.original; const didUserEditCommand = ( toolSpecificData.commandLine.userEdited !== undefined && @@ -699,7 +702,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { const store = new DisposableStore(); // Unified terminal initialization - this._logService.debug(`RunInTerminalTool: Creating ${args.isBackground ? 'background' : 'foreground'} terminal. termId=${termId}, chatSessionId=${chatSessionId}`); + this._logService.debug(`RunInTerminalTool: Creating ${args.isBackground ? 'background' : 'foreground'} terminal. termId=${termId}, chatSessionResource=${chatSessionResource}`); const toolTerminal = await this._initTerminal(chatSessionResource, termId, terminalToolSessionId, args.isBackground, token); this._handleTerminalVisibility(toolTerminal, chatSessionResource); @@ -775,7 +778,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { // Create unified ActiveTerminalExecution (creates and owns the strategy) const execution = this._instantiationService.createInstance( ActiveTerminalExecution, - chatSessionId, + chatSessionResource, termId, toolTerminal, commandDetection!, @@ -1229,7 +1232,7 @@ class ActiveTerminalExecution extends Disposable implements IActiveTerminalExecu } constructor( - readonly sessionId: string, + readonly sessionResource: URI, readonly termId: string, toolTerminal: IToolTerminal, commandDetection: ICommandDetectionCapability, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts index e7eeceda2e794..cb4aa2c1dc081 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/outputMonitor.test.ts @@ -362,5 +362,5 @@ suite('OutputMonitor', () => { }); function createTestContext(id: string): IToolInvocationContext { - return { sessionId: id, sessionResource: LocalChatSessionUri.forSession(id) }; + return { sessionResource: LocalChatSessionUri.forSession(id) }; } diff --git a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts index ad37a1404ceea..b8423da500317 100644 --- a/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts +++ b/src/vscode-dts/vscode.proposed.chatParticipantPrivate.d.ts @@ -262,8 +262,6 @@ declare module 'vscode' { export interface LanguageModelToolInvocationOptions { chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: Uri; chatInteractionId?: string; terminalCommand?: string; @@ -289,8 +287,6 @@ declare module 'vscode' { */ input: T; chatRequestId?: string; - /** @deprecated Use {@link chatSessionResource} instead */ - chatSessionId?: string; chatSessionResource?: Uri; chatInteractionId?: string; /** From cb6f5653e7f26983acefcbb0e19bb41f7da102f8 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:55:11 -0800 Subject: [PATCH 004/221] Update src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/common/tools/languageModelToolsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index e7e2d7a6658a4..21615b6939771 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -204,7 +204,7 @@ export interface IToolInvocationContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return typeof obj === 'object' && URI.isUri(obj.sessionResource); + return obj !== null && typeof obj === 'object' && URI.isUri((obj as any).sessionResource); } export interface IToolInvocationPreparationContext { From 696342d4c6065170d8bf38d7ce297ac697e93607 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:00:00 -0800 Subject: [PATCH 005/221] Better fixes for mcp --- .../workbench/contrib/mcp/browser/mcpElicitationService.ts | 5 ++--- src/vs/workbench/contrib/mcp/common/mcpServer.ts | 5 +++-- src/vs/workbench/contrib/mcp/common/mcpTypes.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts index 2b1b21e5bb0e5..622dc36f19850 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpElicitationService.ts @@ -19,7 +19,6 @@ import { IQuickInputService, IQuickPick, IQuickPickItem } from '../../../../plat import { ChatElicitationRequestPart } from '../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; import { ChatModel } from '../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../chat/common/chatService/chatService.js'; -import { LocalChatSessionUri } from '../../chat/common/model/chatUri.js'; import { ElicitationKind, ElicitResult, IFormModeElicitResult, IMcpElicitationService, IMcpServer, IMcpToolCallContext, IUrlModeElicitResult, McpConnectionState, MpcResponseError } from '../common/mcpTypes.js'; import { mcpServerToSourceData } from '../common/mcpTypesUtils.js'; import { MCP } from '../common/modelContextProtocol.js'; @@ -85,7 +84,7 @@ export class McpElicitationService implements IMcpElicitationService { private async _elicitForm(server: IMcpServer, context: IMcpToolCallContext | undefined, elicitation: MCP.ElicitRequestFormParams | Pre20251125ElicitationParams, token: CancellationToken): Promise { const store = new DisposableStore(); const value = await new Promise(resolve => { - const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId)); + const chatModel = context?.chatSessionResource && this._chatService.getSession(context.chatSessionResource); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { @@ -152,7 +151,7 @@ export class McpElicitationService implements IMcpElicitationService { const store = new DisposableStore(); const value = await new Promise(resolve => { - const chatModel = context?.chatSessionId && this._chatService.getSession(LocalChatSessionUri.forSession(context.chatSessionId)); + const chatModel = context?.chatSessionResource && this._chatService.getSession(context.chatSessionResource); if (chatModel instanceof ChatModel) { const request = chatModel.getRequests().at(-1); if (request) { diff --git a/src/vs/workbench/contrib/mcp/common/mcpServer.ts b/src/vs/workbench/contrib/mcp/common/mcpServer.ts index 0c55918b43bf9..6452f5d62bd83 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpServer.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpServer.ts @@ -30,6 +30,7 @@ import { IEditorService } from '../../../services/editor/common/editorService.js import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; import { IExtensionService } from '../../../services/extensions/common/extensions.js'; import { IOutputService } from '../../../services/output/common/output.js'; +import { chatSessionResourceToId } from '../../chat/common/model/chatUri.js'; import { ToolProgress } from '../../chat/common/tools/languageModelToolsService.js'; import { mcpActivationEvent } from './mcpConfiguration.js'; import { McpDevModeServerAttache } from './mcpDevMode.js'; @@ -1069,8 +1070,8 @@ export class McpTool implements IMcpTool { } const meta: Record = { progressToken }; - if (context?.chatSessionId) { - meta['vscode.conversationId'] = context.chatSessionId; + if (context?.chatSessionResource) { + meta['vscode.conversationId'] = chatSessionResourceToId(context.chatSessionResource); } if (context?.chatRequestId) { meta['vscode.requestId'] = context.chatRequestId; diff --git a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts index 9a38b10c35fff..7d35fa80ac796 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpTypes.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpTypes.ts @@ -434,7 +434,7 @@ export const mcpPromptPrefix = (definition: McpDefinitionReference) => export interface IMcpPromptMessage extends MCP.PromptMessage { } export interface IMcpToolCallContext { - chatSessionId?: string; + chatSessionResource: URI | undefined; chatRequestId?: string; } From 7d7012efbebce9e111eb61decff3465c0c55b0ef Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:03:10 -0800 Subject: [PATCH 006/221] Add missing change --- .../contrib/mcp/common/mcpLanguageModelToolContribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts index f8a82ce9db3a1..f5f6687be947d 100644 --- a/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts +++ b/src/vs/workbench/contrib/mcp/common/mcpLanguageModelToolContribution.ts @@ -251,7 +251,7 @@ class McpToolImplementation implements IToolImpl { content: [] }; - const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId }, token); + const callResult = await this._tool.callWithProgress(invocation.parameters as Record, progress, { chatRequestId: invocation.chatRequestId, chatSessionResource: undefined }, token); const details: Mutable = { input: JSON.stringify(invocation.parameters, undefined, 2), output: [], From 77ed81673371b6ada041bfca6503b9a7374f1c2b Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:32:51 -0800 Subject: [PATCH 007/221] Fix lint --- .../contrib/chat/common/tools/languageModelToolsService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts index 21615b6939771..3316f148d3186 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsService.ts @@ -204,7 +204,7 @@ export interface IToolInvocationContext { // eslint-disable-next-line @typescript-eslint/no-explicit-any export function isToolInvocationContext(obj: any): obj is IToolInvocationContext { - return obj !== null && typeof obj === 'object' && URI.isUri((obj as any).sessionResource); + return obj !== null && typeof obj === 'object' && URI.isUri(obj.sessionResource); } export interface IToolInvocationPreparationContext { From fb50bb3031eff3570415f4e877eab12fce30b0e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Feb 2026 08:58:31 +0000 Subject: [PATCH 008/221] Fix ESLint warning: avoid any cast in test by using direct type assertion Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../test/browser/terminalSandboxService.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 76bcdfc82706b..d1be92c4880c1 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -53,11 +53,6 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { } } - const mockEnvironmentService: IEnvironmentService & { tmpDir?: URI; execPath?: string } = { - tmpDir: URI.file('/tmp'), - execPath: '/usr/bin/node' - } as any; - setup(() => { createdFiles = new Map(); instantiationService = workbenchInstantiationService({}, store); @@ -75,7 +70,10 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); - instantiationService.stub(IEnvironmentService, mockEnvironmentService); + instantiationService.stub(IEnvironmentService, { + tmpDir: URI.file('/tmp'), + execPath: '/usr/bin/node' + }); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IRemoteAgentService, new MockRemoteAgentService()); instantiationService.stub(ITrustedDomainService, trustedDomainService); From 533dedf0082b59e87bee7964bb6033bc9c94dc71 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Wed, 11 Feb 2026 11:05:52 -0600 Subject: [PATCH 009/221] Refactor chat status widget for no auth scenario --- .../contrib/chat/browser/chat.contribution.ts | 13 ++++--------- .../chat/browser/widget/input/chatStatusWidget.ts | 7 ++----- .../chat/common/chatService/chatServiceImpl.ts | 6 ++++++ .../services/chat/common/chatEntitlementService.ts | 11 +++++++++++ .../workbench/test/common/workbenchTestServices.ts | 2 ++ 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 0cc2595296b8f..e06da23ad20ca 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -653,15 +653,10 @@ configurationRegistry.registerConfiguration({ default: true, tags: ['experimental'], }, - ['chat.statusWidget.sku']: { - type: 'string', - enum: ['free', 'anonymous'], - enumDescriptions: [ - nls.localize('chat.statusWidget.sku.free', "Show status widget for free tier users."), - nls.localize('chat.statusWidget.sku.anonymous', "Show status widget for anonymous users.") - ], - description: nls.localize('chat.statusWidget.enabled.description', "Controls which user type should see the status widget in new chat sessions when quota is exceeded."), - default: undefined, + ['chat.statusWidget.anonymous']: { + type: 'boolean', + description: nls.localize('chat.statusWidget.anonymous.description', "Controls whether anonymous users see the status widget in new chat sessions when rate limited."), + default: false, tags: ['experimental', 'advanced'], experiment: { mode: 'auto' diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts index 03954d815ded7..87fa84c517cbf 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatStatusWidget.ts @@ -51,10 +51,7 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget const entitlement = this.chatEntitlementService.entitlement; const isAnonymous = this.chatEntitlementService.anonymous; - // Free tier is always enabled, anonymous is controlled by experiment via chat.statusWidget.sku - const enabledSku = this.configurationService.getValue('chat.statusWidget.sku'); - - if (isAnonymous && enabledSku === 'anonymous') { + if (isAnonymous && this.configurationService.getValue('chat.statusWidget.anonymous')) { this.createWidgetContent('anonymous'); } else if (entitlement === ChatEntitlement.Free) { this.createWidgetContent('free'); @@ -82,7 +79,7 @@ export class ChatStatusWidget extends Disposable implements IChatInputPartWidget this.actionButton.element.classList.add('chat-status-button'); if (enabledSku === 'anonymous') { - const message = localize('chat.anonymousRateLimited.message', "You've reached the limit for chat messages. Try Copilot Pro for free."); + const message = localize('chat.anonymousRateLimited.message', "You've reached the limit for chat messages. Sign in to use Copilot Free."); const buttonLabel = localize('chat.anonymousRateLimited.signIn', "Sign In"); this.messageElement.textContent = message; this.actionButton.label = buttonLabel; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index d78cefbe85f14..33519705f8251 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -28,6 +28,7 @@ import { Progress } from '../../../../../platform/progress/common/progress.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; import { IExtensionService } from '../../../../services/extensions/common/extensions.js'; +import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { InlineChatConfigKeys } from '../../../inlineChat/common/inlineChat.js'; import { IMcpService } from '../../../mcp/common/mcpTypes.js'; import { awaitStatsForSession } from '../chat.js'; @@ -157,6 +158,7 @@ export class ChatService extends Disposable implements IChatService { @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); @@ -1109,6 +1111,10 @@ export class ChatService extends Disposable implements IChatService { completeResponseCreated(); this.trace('sendRequest', `Provider returned response for session ${model.sessionResource}`); + if (rawResult.errorDetails?.isRateLimited) { + this.chatEntitlementService.markAnonymousRateLimited(); + } + shouldProcessPending = !rawResult.errorDetails && !token.isCancellationRequested; request.response?.complete(); if (agentOrCommandFollowups) { diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index adca06c9c5aa8..919d79dc291ee 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -164,6 +164,8 @@ export interface IChatEntitlementService { readonly anonymous: boolean; readonly anonymousObs: IObservable; + markAnonymousRateLimited(): void; + update(token: CancellationToken): Promise; } @@ -511,6 +513,15 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme //#endregion + markAnonymousRateLimited(): void { + if (!this.anonymous) { + return; + } + + this.chatQuotaExceededContextKey.set(true); + this._onDidChangeQuotaExceeded.fire(); + } + async update(token: CancellationToken): Promise { await this.requests?.value.forceResolveEntitlement(token); } diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index 25ebf2bcc5281..e625aa79e5bbd 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -806,6 +806,8 @@ export class TestChatEntitlementService implements IChatEntitlementService { onDidChangeAnonymous = Event.None; readonly anonymousObs = observableValue({}, false); + markAnonymousRateLimited(): void { } + readonly previewFeaturesDisabled = false; } From f7b524feda5bb0c27befdb325871ee7354066782 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Wed, 11 Feb 2026 13:57:26 -0600 Subject: [PATCH 010/221] Addressing errors --- .../contrib/chat/common/chatService/chatServiceImpl.ts | 1 - .../contrib/chat/test/common/chatService/chatService.test.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 0e894f0b4984e..28cda580f378f 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -156,7 +156,6 @@ export class ChatService extends Disposable implements IChatService { @IChatSessionsService private readonly chatSessionService: IChatSessionsService, @IMcpService private readonly mcpService: IMcpService, @IPromptsService private readonly promptsService: IPromptsService, - @IHooksExecutionService private readonly hooksExecutionService: IHooksExecutionService, @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, ) { super(); diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index 601e138974197..15a5add31964f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -33,7 +33,8 @@ import { IExtensionService, nullExtensionDescription } from '../../../../../serv import { ILifecycleService } from '../../../../../services/lifecycle/common/lifecycle.js'; import { IViewsService } from '../../../../../services/views/common/viewsService.js'; import { IWorkspaceEditingService } from '../../../../../services/workspaces/common/workspaceEditing.js'; -import { InMemoryTestFileService, mock, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; +import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; +import { InMemoryTestFileService, mock, TestChatEntitlementService, TestContextService, TestExtensionService, TestStorageService } from '../../../../../test/common/workbenchTestServices.js'; import { IMcpService } from '../../../../mcp/common/mcpTypes.js'; import { TestMcpService } from '../../../../mcp/test/common/testMcpService.js'; import { ChatAgentService, IChatAgent, IChatAgentData, IChatAgentImplementation, IChatAgentService } from '../../../common/participants/chatAgents.js'; @@ -163,6 +164,7 @@ suite('ChatService', () => { [IPromptsService, new MockPromptsService()], ))); instantiationService.stub(IStorageService, testDisposables.add(new TestStorageService())); + instantiationService.stub(IChatEntitlementService, new TestChatEntitlementService()); instantiationService.stub(ILogService, new NullLogService()); instantiationService.stub(IUserDataProfilesService, { defaultProfile: toUserDataProfile('default', 'Default', URI.file('/test/userdata'), URI.file('/test/cache')) }); instantiationService.stub(ITelemetryService, NullTelemetryService); From 657601215027277115e422f62f82790aca047adc Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 11 Feb 2026 13:17:28 -0800 Subject: [PATCH 011/221] Initial OSC 99 implementation --- src/vs/platform/terminal/common/terminal.ts | 1 + .../terminal/browser/terminalInstance.ts | 1 + .../contrib/terminal/common/terminal.ts | 1 + .../terminal/common/terminalConfiguration.ts | 5 + .../contrib/terminal/terminal.all.ts | 1 + .../terminal.oscNotifications.contribution.ts | 509 ++++++++++++++++++ 6 files changed, 518 insertions(+) create mode 100644 src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 4431f19b65917..1cbef39fcdf17 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -82,6 +82,7 @@ export const enum TerminalSettingId { ConfirmOnKill = 'terminal.integrated.confirmOnKill', EnableBell = 'terminal.integrated.enableBell', EnableVisualBell = 'terminal.integrated.enableVisualBell', + EnableNotifications = 'terminal.integrated.enableNotifications', CommandsToSkipShell = 'terminal.integrated.commandsToSkipShell', AllowChords = 'terminal.integrated.allowChords', AllowMnemonics = 'terminal.integrated.allowMnemonics', diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 3ec8c50c4fd7e..37068b6ecd2ec 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -119,6 +119,7 @@ interface IGridDimensions { rows: number; } + const shellIntegrationSupportedShellTypes: (PosixShellType | GeneralShellType | WindowsShellType)[] = [ PosixShellType.Bash, PosixShellType.Zsh, diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 19a1d971cdbd5..1572df0ad4ce8 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -171,6 +171,7 @@ export interface ITerminalConfiguration { confirmOnExit: ConfirmOnExit; confirmOnKill: ConfirmOnKill; enableBell: boolean; + enableNotifications: boolean; env: { linux: { [key: string]: string }; osx: { [key: string]: string }; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 87c3d392b20a7..4d451f9f460f9 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -413,6 +413,11 @@ const terminalConfiguration: IStringDictionary = { type: 'boolean', default: false }, + [TerminalSettingId.EnableNotifications]: { + description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), + type: 'boolean', + default: true + }, [TerminalSettingId.CommandsToSkipShell]: { markdownDescription: localize( 'terminal.integrated.commandsToSkipShell', diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 6f08c6293479b..361be62a7e9c3 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -24,6 +24,7 @@ import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contributi import '../terminalContrib/history/browser/terminal.history.contribution.js'; import '../terminalContrib/inlineHint/browser/terminal.initialHint.contribution.js'; import '../terminalContrib/links/browser/terminal.links.contribution.js'; +import '../terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.js'; import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js'; import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js'; import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js'; diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts new file mode 100644 index 0000000000000..e13af42d11b08 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts @@ -0,0 +1,509 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; +import { Action, IAction } from '../../../../../base/common/actions.js'; +import { disposableTimeout } from '../../../../../base/common/async.js'; +import { decodeBase64 } from '../../../../../base/common/buffer.js'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; +import { localize } from '../../../../../nls.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { INotificationService, NotificationPriority, Severity, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; +import { ITerminalLogService, TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; +import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js'; +import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; + +const enum Osc99PayloadType { + Title = 'title', + Body = 'body', + Buttons = 'buttons', + Close = 'close', + Query = '?', + Alive = 'alive' +} + +type Osc99Occasion = 'always' | 'unfocused' | 'invisible'; + +interface IOsc99NotificationState { + id: string | undefined; + title: string; + body: string; + buttonsPayload: string; + focusOnActivate: boolean; + reportOnActivate: boolean; + reportOnClose: boolean; + urgency: number | undefined; + autoCloseMs: number | undefined; + occasion: Osc99Occasion | undefined; +} + +interface IOsc99ActiveNotification { + id: string | undefined; + handle: INotificationHandle; + autoCloseDisposable: IDisposable | undefined; + reportOnActivate: boolean; + reportOnClose: boolean; + focusOnActivate: boolean; +} + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private _isVisible = false; + private readonly _osc99PendingNotifications = new Map(); + private _osc99PendingAnonymous: IOsc99NotificationState | undefined; + private readonly _osc99ActiveNotifications = new Map(); + + constructor( + private readonly _ctx: ITerminalContributionContext, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @INotificationService private readonly _notificationService: INotificationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, + ) { + super(); + this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + this._register(xterm.raw.parser.registerOscHandler(99, data => this._handleOsc99(data))); + } + + private _handleOsc99(data: string): boolean { + const { metadata, payload } = this._splitOsc99Data(data); + const metadataEntries = this._parseOsc99Metadata(metadata); + const payloadTypes = metadataEntries.get('p'); + const rawPayloadType = payloadTypes && payloadTypes.length > 0 ? payloadTypes[payloadTypes.length - 1] : undefined; + const payloadType = rawPayloadType && rawPayloadType.length > 0 ? rawPayloadType : Osc99PayloadType.Title; + const id = this._sanitizeOsc99Id(metadataEntries.get('i')?.[0]); + + if (!this._configurationService.getValue(TerminalSettingId.EnableNotifications)) { + return true; + } + + switch (payloadType) { + case Osc99PayloadType.Query: + this._sendOsc99QueryResponse(id); + return true; + case Osc99PayloadType.Alive: + this._sendOsc99AliveResponse(id); + return true; + case Osc99PayloadType.Close: + this._closeOsc99Notification(id); + return true; + } + + const state = this._getOrCreateOsc99State(id); + this._updateOsc99StateFromMetadata(state, metadataEntries); + + const isEncoded = metadataEntries.get('e')?.[0] === '1'; + const payloadText = this._decodeOsc99Payload(payload, isEncoded); + const isDone = metadataEntries.get('d')?.[0] !== '0'; + + switch (payloadType) { + case Osc99PayloadType.Title: + state.title += payloadText; + break; + case Osc99PayloadType.Body: + state.body += payloadText; + break; + case Osc99PayloadType.Buttons: + state.buttonsPayload += payloadText; + break; + default: + return true; + } + + if (!isDone) { + return true; + } + if (!this._shouldHonorOsc99Occasion(state.occasion)) { + this._clearOsc99PendingState(id); + return true; + } + + this._showOsc99Notification(state); + this._clearOsc99PendingState(id); + return true; + } + + private _splitOsc99Data(data: string): { metadata: string; payload: string } { + const separatorIndex = data.indexOf(';'); + if (separatorIndex === -1) { + return { metadata: data, payload: '' }; + } + return { + metadata: data.substring(0, separatorIndex), + payload: data.substring(separatorIndex + 1) + }; + } + + private _parseOsc99Metadata(metadata: string): Map { + const result = new Map(); + if (!metadata) { + return result; + } + for (const entry of metadata.split(':')) { + if (!entry) { + continue; + } + const separatorIndex = entry.indexOf('='); + if (separatorIndex === -1) { + continue; + } + const key = entry.substring(0, separatorIndex); + const value = entry.substring(separatorIndex + 1); + if (!key) { + continue; + } + let values = result.get(key); + if (!values) { + values = []; + result.set(key, values); + } + values.push(value); + } + return result; + } + + private _decodeOsc99Payload(payload: string, isEncoded: boolean): string { + if (!isEncoded) { + return payload; + } + try { + return decodeBase64(payload).toString(); + } catch { + this._logService.warn('Failed to decode OSC 99 payload'); + return ''; + } + } + + private _sanitizeOsc99Id(rawId: string | undefined): string | undefined { + if (!rawId) { + return undefined; + } + const sanitized = rawId.replace(/[^a-zA-Z0-9_\-+.]/g, ''); + return sanitized.length > 0 ? sanitized : undefined; + } + + private _getOrCreateOsc99State(id: string | undefined): IOsc99NotificationState { + if (!id) { + if (!this._osc99PendingAnonymous) { + this._osc99PendingAnonymous = this._createOsc99State(undefined); + } + return this._osc99PendingAnonymous; + } + let state = this._osc99PendingNotifications.get(id); + if (!state) { + state = this._createOsc99State(id); + this._osc99PendingNotifications.set(id, state); + } + return state; + } + + private _createOsc99State(id: string | undefined): IOsc99NotificationState { + return { + id, + title: '', + body: '', + buttonsPayload: '', + focusOnActivate: true, + reportOnActivate: false, + reportOnClose: false, + urgency: undefined, + autoCloseMs: undefined, + occasion: undefined + }; + } + + private _clearOsc99PendingState(id: string | undefined): void { + if (!id) { + this._osc99PendingAnonymous = undefined; + return; + } + this._osc99PendingNotifications.delete(id); + } + + private _updateOsc99StateFromMetadata(state: IOsc99NotificationState, metadataEntries: Map): void { + const actionValues = metadataEntries.get('a'); + const actionValue = actionValues && actionValues.length > 0 ? actionValues[actionValues.length - 1] : undefined; + if (actionValue !== undefined) { + const actions = this._parseOsc99Actions(actionValue); + state.focusOnActivate = actions.focusOnActivate; + state.reportOnActivate = actions.reportOnActivate; + } + const closeValues = metadataEntries.get('c'); + const closeValue = closeValues && closeValues.length > 0 ? closeValues[closeValues.length - 1] : undefined; + if (closeValue !== undefined) { + state.reportOnClose = closeValue === '1'; + } + const urgencyValues = metadataEntries.get('u'); + const urgencyValue = urgencyValues && urgencyValues.length > 0 ? urgencyValues[urgencyValues.length - 1] : undefined; + if (urgencyValue !== undefined) { + const urgency = Number.parseInt(urgencyValue, 10); + if (!Number.isNaN(urgency)) { + state.urgency = urgency; + } + } + const autoCloseValues = metadataEntries.get('w'); + const autoCloseValue = autoCloseValues && autoCloseValues.length > 0 ? autoCloseValues[autoCloseValues.length - 1] : undefined; + if (autoCloseValue !== undefined) { + const autoClose = Number.parseInt(autoCloseValue, 10); + if (!Number.isNaN(autoClose)) { + state.autoCloseMs = autoClose; + } + } + const occasionValues = metadataEntries.get('o'); + const occasionValue = occasionValues && occasionValues.length > 0 ? occasionValues[occasionValues.length - 1] : undefined; + if (occasionValue === 'always' || occasionValue === 'unfocused' || occasionValue === 'invisible') { + state.occasion = occasionValue; + } + } + + private _parseOsc99Actions(value: string): { focusOnActivate: boolean; reportOnActivate: boolean } { + let focusOnActivate = true; + let reportOnActivate = false; + for (const token of value.split(',')) { + switch (token) { + case 'focus': + focusOnActivate = true; + break; + case '-focus': + focusOnActivate = false; + break; + case 'report': + reportOnActivate = true; + break; + case '-report': + reportOnActivate = false; + break; + } + } + return { focusOnActivate, reportOnActivate }; + } + + private _shouldHonorOsc99Occasion(occasion: Osc99Occasion | undefined): boolean { + if (!occasion || occasion === 'always') { + return true; + } + const windowFocused = dom.getActiveWindow().document.hasFocus(); + switch (occasion) { + case 'unfocused': + return !windowFocused; + case 'invisible': + return !windowFocused && !this._isVisible; + default: + return true; + } + } + + private _showOsc99Notification(state: IOsc99NotificationState): void { + const message = this._getOsc99NotificationMessage(state); + if (!message) { + return; + } + + const severity = state.urgency === 2 ? Severity.Warning : Severity.Info; + const priority = this._getOsc99NotificationPriority(state.urgency); + const source = { + id: 'terminal', + label: localize('terminalNotificationSource', 'Terminal') + }; + const buttons = state.buttonsPayload.length > 0 ? state.buttonsPayload.split('\u2028') : []; + + const handleRef: { current: INotificationHandle | undefined } = { current: undefined }; + const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { + if (forceFocus || state.focusOnActivate) { + this._ctx.instance.focus(true); + } + if (state.reportOnActivate) { + this._sendOsc99ActivationReport(state.id, buttonIndex); + } + }; + + const primaryActions: IAction[] = []; + for (let i = 0; i < buttons.length; i++) { + const label = buttons[i]; + if (!label) { + continue; + } + primaryActions.push(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { + reportActivation(i + 1); + handleRef.current?.close(); + })); + } + primaryActions.push(new Action( + 'terminal.osc99.focus', + localize('terminalNotificationFocus', 'Focus Terminal'), + undefined, + true, + () => { + reportActivation(undefined, true); + handleRef.current?.close(); + } + )); + + const secondaryActions: IAction[] = []; + secondaryActions.push(new Action( + 'terminal.osc99.dismiss', + localize('terminalNotificationDismiss', 'Dismiss'), + undefined, + true, + () => handleRef.current?.close() + )); + secondaryActions.push(new Action( + 'terminal.osc99.disable', + localize('terminalNotificationDisable', 'Disable Terminal Notifications'), + undefined, + true, + async () => { + await this._configurationService.updateValue(TerminalSettingId.EnableNotifications, false); + handleRef.current?.close(); + } + )); + + const actions = { primary: primaryActions, secondary: secondaryActions }; + + if (state.id) { + const existing = this._osc99ActiveNotifications.get(state.id); + if (existing) { + existing.handle.updateMessage(message); + existing.handle.updateSeverity(severity); + existing.handle.updateActions(actions); + existing.focusOnActivate = state.focusOnActivate; + existing.reportOnActivate = state.reportOnActivate; + existing.reportOnClose = state.reportOnClose; + existing.autoCloseDisposable?.dispose(); + existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing.handle, state.autoCloseMs); + return; + } + } + + const handle = this._notificationService.notify({ + id: state.id ? `terminal.osc99.${state.id}` : undefined, + severity, + message, + source, + actions, + priority + }); + handleRef.current = handle; + + const active: IOsc99ActiveNotification = { + id: state.id, + handle, + autoCloseDisposable: undefined, + reportOnActivate: state.reportOnActivate, + reportOnClose: state.reportOnClose, + focusOnActivate: state.focusOnActivate + }; + active.autoCloseDisposable = this._scheduleOsc99AutoClose(handle, state.autoCloseMs); + this._register(handle.onDidClose(() => { + if (active.reportOnClose) { + this._sendOsc99CloseReport(active.id); + } + active.autoCloseDisposable?.dispose(); + if (active.id) { + this._osc99ActiveNotifications.delete(active.id); + } + })); + + if (active.id) { + this._osc99ActiveNotifications.set(active.id, active); + } + } + + private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined { + const title = state.title; + const body = state.body; + const hasTitle = title.trim().length > 0; + const hasBody = body.trim().length > 0; + if (hasTitle && hasBody) { + return `${title}\n${body}`; + } + if (hasTitle) { + return title; + } + if (hasBody) { + return body; + } + return undefined; + } + + private _getOsc99NotificationPriority(urgency: number | undefined): NotificationPriority | undefined { + switch (urgency) { + case 0: + return NotificationPriority.SILENT; + case 1: + return NotificationPriority.DEFAULT; + case 2: + return NotificationPriority.URGENT; + default: + return undefined; + } + } + + private _scheduleOsc99AutoClose(handle: INotificationHandle, autoCloseMs: number | undefined): IDisposable | undefined { + if (autoCloseMs === undefined || autoCloseMs <= 0) { + return undefined; + } + return disposableTimeout(() => handle.close(), autoCloseMs, this._store); + } + + private _closeOsc99Notification(id: string | undefined): void { + if (!id) { + return; + } + const active = this._osc99ActiveNotifications.get(id); + if (active) { + active.handle.close(); + } + this._osc99PendingNotifications.delete(id); + } + + private _sendOsc99QueryResponse(id: string | undefined): void { + const requestId = id ?? '0'; + this._sendOsc99Response([ + `i=${requestId}`, + 'p=?', + 'a=report,focus', + 'c=1', + 'o=always,unfocused,invisible', + 'p=title,body,buttons,close,alive,?', + 'u=0,1,2', + 'w=1' + ]); + } + + private _sendOsc99AliveResponse(id: string | undefined): void { + const requestId = id ?? '0'; + const aliveIds = Array.from(this._osc99ActiveNotifications.keys()).join(','); + this._sendOsc99Response([ + `i=${requestId}`, + 'p=alive' + ], aliveIds); + } + + private _sendOsc99ActivationReport(id: string | undefined, buttonIndex?: number): void { + const reportId = id ?? '0'; + this._sendOsc99Response([`i=${reportId}`], buttonIndex !== undefined ? String(buttonIndex) : ''); + } + + private _sendOsc99CloseReport(id: string | undefined): void { + const reportId = id ?? '0'; + this._sendOsc99Response([`i=${reportId}`, 'p=close']); + } + + private _sendOsc99Response(metadataParts: string[], payload: string = ''): void { + const metadata = metadataParts.join(':'); + void this._ctx.processManager.write(`\x1b]99;${metadata};${payload}\x1b\\`); + } +} + +registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution); + +export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null { + return instance.getContribution(TerminalOscNotificationsContribution.ID); +} From 928687e78e63f068490e3b389d34b2a67dae9d86 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 11 Feb 2026 14:10:19 -0800 Subject: [PATCH 012/221] Test refactor --- .../terminal.oscNotifications.contribution.ts | 75 ++++-- .../browser/terminalOscNotifications.test.ts | 231 ++++++++++++++++++ 2 files changed, 284 insertions(+), 22 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts index e13af42d11b08..018308530a48a 100644 --- a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts @@ -11,7 +11,7 @@ import * as dom from '../../../../../base/browser/dom.js'; import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { INotificationService, NotificationPriority, Severity, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; +import { INotificationService, NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; import { ITerminalLogService, TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js'; import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; @@ -49,29 +49,29 @@ interface IOsc99ActiveNotification { focusOnActivate: boolean; } -class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { - static readonly ID = 'terminal.oscNotifications'; +export interface IOsc99NotificationHost { + isEnabled(): boolean; + isWindowFocused(): boolean; + isTerminalVisible(): boolean; + focusTerminal(): void; + notify(notification: INotification): INotificationHandle; + updateEnableNotifications(value: boolean): Promise; + logWarn(message: string): void; + writeToProcess(data: string): void; +} - private _isVisible = false; +export class Osc99NotificationHandler extends Disposable { private readonly _osc99PendingNotifications = new Map(); private _osc99PendingAnonymous: IOsc99NotificationState | undefined; private readonly _osc99ActiveNotifications = new Map(); constructor( - private readonly _ctx: ITerminalContributionContext, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @INotificationService private readonly _notificationService: INotificationService, - @ITerminalLogService private readonly _logService: ITerminalLogService, + private readonly _host: IOsc99NotificationHost ) { super(); - this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); - } - - xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { - this._register(xterm.raw.parser.registerOscHandler(99, data => this._handleOsc99(data))); } - private _handleOsc99(data: string): boolean { + handleSequence(data: string): boolean { const { metadata, payload } = this._splitOsc99Data(data); const metadataEntries = this._parseOsc99Metadata(metadata); const payloadTypes = metadataEntries.get('p'); @@ -79,7 +79,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin const payloadType = rawPayloadType && rawPayloadType.length > 0 ? rawPayloadType : Osc99PayloadType.Title; const id = this._sanitizeOsc99Id(metadataEntries.get('i')?.[0]); - if (!this._configurationService.getValue(TerminalSettingId.EnableNotifications)) { + if (!this._host.isEnabled()) { return true; } @@ -175,7 +175,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin try { return decodeBase64(payload).toString(); } catch { - this._logService.warn('Failed to decode OSC 99 payload'); + this._host.logWarn('Failed to decode OSC 99 payload'); return ''; } } @@ -288,12 +288,12 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin if (!occasion || occasion === 'always') { return true; } - const windowFocused = dom.getActiveWindow().document.hasFocus(); + const windowFocused = this._host.isWindowFocused(); switch (occasion) { case 'unfocused': return !windowFocused; case 'invisible': - return !windowFocused && !this._isVisible; + return !windowFocused && !this._host.isTerminalVisible(); default: return true; } @@ -316,7 +316,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin const handleRef: { current: INotificationHandle | undefined } = { current: undefined }; const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { if (forceFocus || state.focusOnActivate) { - this._ctx.instance.focus(true); + this._host.focusTerminal(); } if (state.reportOnActivate) { this._sendOsc99ActivationReport(state.id, buttonIndex); @@ -359,7 +359,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin undefined, true, async () => { - await this._configurationService.updateValue(TerminalSettingId.EnableNotifications, false); + await this._host.updateEnableNotifications(false); handleRef.current?.close(); } )); @@ -381,7 +381,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin } } - const handle = this._notificationService.notify({ + const handle = this._host.notify({ id: state.id ? `terminal.osc99.${state.id}` : undefined, severity, message, @@ -498,7 +498,38 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin private _sendOsc99Response(metadataParts: string[], payload: string = ''): void { const metadata = metadataParts.join(':'); - void this._ctx.processManager.write(`\x1b]99;${metadata};${payload}\x1b\\`); + this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`); + } +} + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private _isVisible = false; + private readonly _handler: Osc99NotificationHandler; + + constructor( + private readonly _ctx: ITerminalContributionContext, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @INotificationService private readonly _notificationService: INotificationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, + ) { + super(); + this._handler = this._register(new Osc99NotificationHandler({ + isEnabled: () => this._configurationService.getValue(TerminalSettingId.EnableNotifications), + isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), + isTerminalVisible: () => this._isVisible, + focusTerminal: () => this._ctx.instance.focus(true), + notify: notification => this._notificationService.notify(notification), + updateEnableNotifications: value => this._configurationService.updateValue(TerminalSettingId.EnableNotifications, value), + logWarn: message => this._logService.warn(message), + writeToProcess: data => { void this._ctx.processManager.write(data); } + })); + this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data))); } } diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts b/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts new file mode 100644 index 0000000000000..4020fae121771 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts @@ -0,0 +1,231 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { strictEqual } from 'assert'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { NotificationPriority, Severity, type INotification, type INotificationActions, type INotificationHandle, type INotificationProgress, type NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; +import { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.oscNotifications.contribution.js'; + +class TestNotificationProgress implements INotificationProgress { + infinite(): void { } + total(_value: number): void { } + worked(_value: number): void { } + done(): void { } +} + +class TestNotificationHandle implements INotificationHandle { + private readonly _onDidClose = new Emitter(); + readonly onDidClose = this._onDidClose.event; + readonly onDidChangeVisibility = Event.None; + readonly progress = new TestNotificationProgress(); + closed = false; + message: NotificationMessage; + severity: Severity; + actions?: INotificationActions; + priority?: NotificationPriority; + source?: string | { id: string; label: string }; + + constructor(notification: INotification) { + this.message = notification.message; + this.severity = notification.severity; + this.actions = notification.actions; + this.priority = notification.priority; + this.source = notification.source; + } + + updateSeverity(severity: Severity): void { + this.severity = severity; + } + + updateMessage(message: NotificationMessage): void { + this.message = message; + } + + updateActions(actions?: INotificationActions): void { + this.actions = actions; + } + + close(): void { + this.closed = true; + this._onDidClose.fire(); + } +} + +class TestOsc99Host implements IOsc99NotificationHost { + enabled = true; + windowFocused = false; + terminalVisible = false; + writes: string[] = []; + notifications: TestNotificationHandle[] = []; + focusCalls = 0; + updatedEnableNotifications: boolean[] = []; + logMessages: string[] = []; + + isEnabled(): boolean { + return this.enabled; + } + + isWindowFocused(): boolean { + return this.windowFocused; + } + + isTerminalVisible(): boolean { + return this.terminalVisible; + } + + focusTerminal(): void { + this.focusCalls++; + } + + notify(notification: INotification): INotificationHandle { + const handle = new TestNotificationHandle(notification); + this.notifications.push(handle); + return handle; + } + + async updateEnableNotifications(value: boolean): Promise { + this.enabled = value; + this.updatedEnableNotifications.push(value); + } + + logWarn(message: string): void { + this.logMessages.push(message); + } + + writeToProcess(data: string): void { + this.writes.push(data); + } +} + +suite('Terminal OSC 99 notifications', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + function createHandler(host: TestOsc99Host): Osc99NotificationHandler { + return store.add(new Osc99NotificationHandler(host)); + } + + test('ignores notifications when disabled', () => { + const host = new TestOsc99Host(); + host.enabled = false; + const handler = createHandler(host); + + handler.handleSequence(';Hello'); + strictEqual(host.notifications.length, 0); + strictEqual(host.writes.length, 0); + }); + + test('creates notification for title and body and updates', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=1:p=title;Hello'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello'); + + handler.handleSequence('i=1:p=body;World'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello\nWorld'); + }); + + test('decodes base64 payloads', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('e=1:p=title;SGVsbG8='); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello'); + }); + + test('defers display until done', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=chunk:d=0:p=title;Hello '); + strictEqual(host.notifications.length, 0); + + handler.handleSequence('i=chunk:d=1:p=title;World'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].message, 'Hello World'); + }); + + test('reports activation on button click', async () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=btn:a=report:p=title;Hi'); + handler.handleSequence('i=btn:p=buttons;Yes'); + + const actions = host.notifications[0].actions; + if (!actions?.primary || actions.primary.length === 0) { + throw new Error('Expected primary actions'); + } + await actions.primary[0].run(); + strictEqual(host.writes[0], '\x1b]99;i=btn;1\x1b\\'); + }); + + test('sends close report when requested', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=close:c=1:p=title;Bye'); + strictEqual(host.notifications.length, 1); + host.notifications[0].close(); + strictEqual(host.writes[0], '\x1b]99;i=close:p=close;\x1b\\'); + }); + + test('responds to query and alive', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=a:p=title;A'); + handler.handleSequence('i=b:p=title;B'); + handler.handleSequence('i=q:p=?;'); + handler.handleSequence('i=q:p=alive;'); + + strictEqual(host.writes[0], '\x1b]99;i=q:p=?:a=report,focus:c=1:o=always,unfocused,invisible:p=title,body,buttons,close,alive,?:u=0,1,2:w=1;\x1b\\'); + strictEqual(host.writes[1], '\x1b]99;i=q:p=alive;a,b\x1b\\'); + }); + + test('honors occasion for visibility and focus', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + host.windowFocused = true; + host.terminalVisible = true; + handler.handleSequence('o=unfocused:p=title;Hidden'); + strictEqual(host.notifications.length, 0); + + host.windowFocused = false; + host.terminalVisible = true; + handler.handleSequence('o=invisible:p=title;Hidden'); + strictEqual(host.notifications.length, 0); + + host.terminalVisible = false; + handler.handleSequence('o=invisible:p=title;Shown'); + strictEqual(host.notifications.length, 1); + }); + + test('closes notifications via close payload', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('i=closeme:p=title;Close'); + strictEqual(host.notifications.length, 1); + strictEqual(host.notifications[0].closed, false); + + handler.handleSequence('i=closeme:p=close;'); + strictEqual(host.notifications[0].closed, true); + }); + + test('maps urgency to severity and priority', () => { + const host = new TestOsc99Host(); + const handler = createHandler(host); + + handler.handleSequence('u=2:p=title;Urgent'); + strictEqual(host.notifications[0].severity, Severity.Warning); + strictEqual(host.notifications[0].priority, NotificationPriority.URGENT); + }); +}); From e091fec668662af34a87c0c9b64e2e155ba5bf2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 12 Feb 2026 09:43:31 +0000 Subject: [PATCH 013/221] Fix test failure: add _serviceBrand to environment service stub The TerminalSandboxService tests were failing because the mock environment service stub was missing the required _serviceBrand property. This property is mandatory for all service interfaces in VS Code's DI system. Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../chatAgentTools/test/browser/terminalSandboxService.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index d1be92c4880c1..3b5f156b3c031 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -71,6 +71,7 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IFileService, fileService); instantiationService.stub(IEnvironmentService, { + _serviceBrand: undefined, tmpDir: URI.file('/tmp'), execPath: '/usr/bin/node' }); From 315525c23b240f3c7f3c9f07f1a995cec6b40d29 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 12 Feb 2026 14:17:45 +0000 Subject: [PATCH 014/221] refactor(theme): update button border color and clean up unused shadows in styles --- extensions/theme-2026/themes/2026-light.json | 2 +- extensions/theme-2026/themes/styles.css | 21 +------------------- 2 files changed, 2 insertions(+), 21 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 3ed64c055bf87..e7093770bea1f 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -20,7 +20,7 @@ "button.background": "#0069CC", "button.foreground": "#FFFFFF", "button.hoverBackground": "#0063C1", - "button.border": "#F2F3F4FF", + "button.border": "#EEEEF1", "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", "button.secondaryHoverBackground": "#F3F3F3", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 35fe38ae75a50..f97a5350b8095 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -7,28 +7,22 @@ --radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; - /* --radius-lg: 12px; */ - --shadow-xs: 0 0 2px rgba(0, 0, 0, 0.06); --shadow-sm: 0 0 4px rgba(0, 0, 0, 0.08); --shadow-md: 0 0 6px rgba(0, 0, 0, 0.08); --shadow-lg: 0 0 12px rgba(0, 0, 0, 0.14); --shadow-xl: 0 0 20px rgba(0, 0, 0, 0.15); - --shadow-2xl: 0 0 20px rgba(0, 0, 0, 0.18); --shadow-hover: 0 0 8px rgba(0, 0, 0, 0.12); --shadow-sm-strong: 0 0 4px rgba(0, 0, 0, 0.18); --shadow-button-active: inset 0 1px 2px rgba(0, 0, 0, 0.1); - --shadow-inset-white: inset 0 0 4px rgba(255, 255, 255, 0.1); --shadow-active-tab: 0 8px 12px rgba(0, 0, 0, 0.02); - --backdrop-blur-sm: blur(12px); --backdrop-blur-md: blur(20px) saturate(180%); --backdrop-blur-lg: blur(40px) saturate(180%); } /* Dark theme: add brightness reduction for contrast-safe luminosity blending over bright backgrounds */ .monaco-workbench.vs-dark { - --backdrop-blur-sm: blur(12px) brightness(0.55); --backdrop-blur-md: blur(20px) saturate(180%) brightness(0.55); --backdrop-blur-lg: blur(40px) saturate(180%) brightness(0.55); } @@ -131,7 +125,6 @@ .monaco-workbench .part.editor > .content .editor-group-container > .title .tabs-container > .tab.active { box-shadow: inset var(--shadow-active-tab); - /* background: var(--vs) */ position: relative; z-index: 5; border-radius: 0; @@ -339,7 +332,7 @@ /* Dialog */ .monaco-workbench .monaco-dialog-box { - box-shadow: var(--shadow-2xl); + box-shadow: var(--shadow-xl); border-radius: var(--radius-lg); backdrop-filter: var(--backdrop-blur-lg); -webkit-backdrop-filter: var(--backdrop-blur-lg); @@ -467,13 +460,6 @@ } /* Buttons */ -.monaco-workbench .monaco-button { - box-shadow: var(--shadow-xs); -} - -.monaco-workbench .monaco-button:hover { - box-shadow: var(--shadow-sm); -} .monaco-workbench .monaco-button:active { box-shadow: var(--shadow-button-active); @@ -508,11 +494,6 @@ border-radius: var(--radius-lg); } -/* Terminal */ -.monaco-workbench.vs .pane-body.integrated-terminal { - box-shadow: var(--shadow-inset-white); -} - /* SCM */ .monaco-workbench .scm-view .scm-provider { box-shadow: var(--shadow-sm); From 78e3e7713a579e7a2d9d3732d4c1985e1c5031d3 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 12 Feb 2026 14:36:13 +0000 Subject: [PATCH 015/221] style(theme): update button hover backgrounds and enhance quick input widget transparency --- extensions/theme-2026/themes/2026-light.json | 14 +++++++------- extensions/theme-2026/themes/styles.css | 4 ++++ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index e7093770bea1f..91b7c39954f12 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -23,7 +23,7 @@ "button.border": "#EEEEF1", "button.secondaryBackground": "#EDEDED", "button.secondaryForeground": "#202020", - "button.secondaryHoverBackground": "#F3F3F3", + "button.secondaryHoverBackground": "#EEEEEE", "checkbox.background": "#EDEDED", "checkbox.border": "#D8D8D8", "checkbox.foreground": "#202020", @@ -64,7 +64,7 @@ "list.activeSelectionForeground": "#202020", "list.inactiveSelectionBackground": "#E0E0E0", "list.inactiveSelectionForeground": "#202020", - "list.hoverBackground": "#F3F3F3", + "list.hoverBackground": "#EEEEEE", "list.hoverForeground": "#202020", "list.dropBackground": "#0069CC15", "list.focusBackground": "#0069CC1A", @@ -106,7 +106,7 @@ "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", "commandCenter.background": "#FAFAFD", - "commandCenter.activeBackground": "#F3F3F3", + "commandCenter.activeBackground": "#EEEEEE", "commandCenter.border": "#D8D8D8", "editor.background": "#FFFFFF", "editor.foreground": "#202020", @@ -127,7 +127,7 @@ "editorLink.activeForeground": "#0069CC", "editorWhitespace.foreground": "#66666640", "editorIndentGuide.background": "#F7F7F740", - "editorIndentGuide.activeBackground": "#F3F3F3", + "editorIndentGuide.activeBackground": "#EEEEEE", "editorRuler.foreground": "#F7F7F7", "editorCodeLens.foreground": "#666666", "editorBracketMatch.background": "#0069CC40", @@ -179,8 +179,8 @@ "statusBar.debuggingForeground": "#FFFFFF", "statusBar.noFolderBackground": "#F0F0F3", "statusBar.noFolderForeground": "#666666", - "statusBarItem.activeBackground": "#F3F3F3", - "statusBarItem.hoverBackground": "#F3F3F3", + "statusBarItem.activeBackground": "#EEEEEE", + "statusBarItem.hoverBackground": "#EEEEEE", "statusBarItem.focusBorder": "#0069CCFF", "statusBarItem.prominentBackground": "#0069CCDD", "statusBarItem.prominentForeground": "#FFFFFF", @@ -193,7 +193,7 @@ "tab.lastPinnedBorder": "#F2F3F4FF", "tab.activeBorder": "#FAFAFD", "tab.activeBorderTop": "#000000", - "tab.hoverBackground": "#F3F3F3", + "tab.hoverBackground": "#EEEEEE", "tab.hoverForeground": "#202020", "tab.unfocusedActiveBackground": "#FAFAFD", "tab.unfocusedActiveForeground": "#666666", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index f97a5350b8095..0deacd89b93f6 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -196,6 +196,10 @@ border-radius: var(--radius-lg); } +.monaco-workbench .quick-input-widget .monaco-list-rows { + background: transparent !important; +} + .monaco-workbench .quick-input-widget .monaco-inputbox { box-shadow: none !important; background: transparent !important; From 0791e86ff16974e17a2c70cbf3ce6ca483c4533f Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 12 Feb 2026 14:48:44 +0000 Subject: [PATCH 016/221] style(theme): update menu separator background and refine quick input widget styles --- extensions/theme-2026/themes/2026-light.json | 4 ++-- extensions/theme-2026/themes/styles.css | 6 +----- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 91b7c39954f12..36c310315e65e 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -101,7 +101,7 @@ "menu.foreground": "#202020", "menu.selectionBackground": "#0069CC1A", "menu.selectionForeground": "#202020", - "menu.separatorBackground": "#F7F7F7", + "menu.separatorBackground": "#EEEEF1", "menu.border": "#F2F3F4FF", "commandCenter.foreground": "#202020", "commandCenter.activeForeground": "#202020", @@ -224,7 +224,7 @@ "extensionButton.prominentBackground": "#0069CC", "extensionButton.prominentForeground": "#FFFFFF", "extensionButton.prominentHoverBackground": "#0064CC", - "pickerGroup.border": "#F2F3F4FF", + "pickerGroup.border": "#EEEEF1", "pickerGroup.foreground": "#202020", "quickInput.background": "#F0F0F3", "quickInput.foreground": "#202020", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 0deacd89b93f6..a63e85a5b35f4 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -187,7 +187,7 @@ .monaco-workbench .quick-input-widget .quick-input-action, .monaco-workbench .quick-input-widget .quick-input-message, .monaco-workbench .quick-input-widget .monaco-list, -.monaco-workbench .quick-input-widget .monaco-list-row { +.monaco-workbench .quick-input-widget .monaco-list-row:not(:has(.quick-input-list-separator-border)) { border-color: transparent !important; outline: none !important; } @@ -658,10 +658,6 @@ background: transparent; } -.monaco-workbench .quick-input-list .quick-input-list-entry.quick-input-list-separator-border { - border-top-width: 0; -} - /* Quick Input List - use descriptionForeground color for descriptions */ .monaco-workbench .quick-input-list .monaco-icon-label .label-description { opacity: 1; From e23d2e944544517d96fb1d0935e69dd14ad341d4 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:02:22 -0800 Subject: [PATCH 017/221] Refactor notifications terminalcontrib --- src/vs/platform/terminal/common/terminal.ts | 1 - .../contrib/terminal/browser/terminal.ts | 1 + .../terminal/browser/terminalInstance.ts | 4 +- .../contrib/terminal/common/terminal.ts | 1 - .../terminal/common/terminalConfiguration.ts | 5 -- .../contrib/terminal/terminal.all.ts | 2 +- .../terminal/terminalContribExports.ts | 2 + .../terminal.notifications.contribution.ts | 51 +++++++++++++ .../terminal.notifications.handler.ts} | 68 ++++------------- .../terminalNotificationsConfiguration.ts | 20 +++++ .../browser/terminalNotifications.test.ts} | 74 +++++++++---------- 11 files changed, 131 insertions(+), 98 deletions(-) create mode 100644 src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts rename src/vs/workbench/contrib/terminalContrib/{oscNotifications/browser/terminal.oscNotifications.contribution.ts => notifications/browser/terminal.notifications.handler.ts} (83%) create mode 100644 src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts rename src/vs/workbench/contrib/terminalContrib/{oscNotifications/test/browser/terminalOscNotifications.test.ts => notifications/test/browser/terminalNotifications.test.ts} (84%) diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index 1cbef39fcdf17..4431f19b65917 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -82,7 +82,6 @@ export const enum TerminalSettingId { ConfirmOnKill = 'terminal.integrated.confirmOnKill', EnableBell = 'terminal.integrated.enableBell', EnableVisualBell = 'terminal.integrated.enableVisualBell', - EnableNotifications = 'terminal.integrated.enableNotifications', CommandsToSkipShell = 'terminal.integrated.commandsToSkipShell', AllowChords = 'terminal.integrated.allowChords', AllowMnemonics = 'terminal.integrated.allowMnemonics', diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index c4bcfd5b70bd3..b13ee6aded3f4 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -834,6 +834,7 @@ export interface ITerminalInstance extends IBaseTerminalInstance { readonly fixedCols?: number; readonly fixedRows?: number; readonly domElement: HTMLElement; + readonly isVisible: boolean; readonly icon?: TerminalIcon; readonly color?: string; readonly reconnectionProperties?: IReconnectionProperties; diff --git a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts index 37068b6ecd2ec..42c8195da39ec 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalInstance.ts @@ -151,7 +151,6 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { private _latestXtermParseData: number = 0; private _isExiting: boolean; private _hadFocusOnExit: boolean; - private _isVisible: boolean; private _exitCode: number | undefined; private _exitReason: TerminalExitReason | undefined; private _skipTerminalCommands: string[]; @@ -219,6 +218,9 @@ export class TerminalInstance extends Disposable implements ITerminalInstance { this._shellLaunchConfig.waitOnExit = value; } + private _isVisible: boolean; + get isVisible(): boolean { return this._isVisible; } + private _targetRef: ImmortalReference = new ImmortalReference(undefined); get targetRef(): IReference { return this._targetRef; } diff --git a/src/vs/workbench/contrib/terminal/common/terminal.ts b/src/vs/workbench/contrib/terminal/common/terminal.ts index 1572df0ad4ce8..19a1d971cdbd5 100644 --- a/src/vs/workbench/contrib/terminal/common/terminal.ts +++ b/src/vs/workbench/contrib/terminal/common/terminal.ts @@ -171,7 +171,6 @@ export interface ITerminalConfiguration { confirmOnExit: ConfirmOnExit; confirmOnKill: ConfirmOnKill; enableBell: boolean; - enableNotifications: boolean; env: { linux: { [key: string]: string }; osx: { [key: string]: string }; diff --git a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts index 4d451f9f460f9..87c3d392b20a7 100644 --- a/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts +++ b/src/vs/workbench/contrib/terminal/common/terminalConfiguration.ts @@ -413,11 +413,6 @@ const terminalConfiguration: IStringDictionary = { type: 'boolean', default: false }, - [TerminalSettingId.EnableNotifications]: { - description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), - type: 'boolean', - default: true - }, [TerminalSettingId.CommandsToSkipShell]: { markdownDescription: localize( 'terminal.integrated.commandsToSkipShell', diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index 361be62a7e9c3..a61db29612f3d 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -24,7 +24,7 @@ import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contributi import '../terminalContrib/history/browser/terminal.history.contribution.js'; import '../terminalContrib/inlineHint/browser/terminal.initialHint.contribution.js'; import '../terminalContrib/links/browser/terminal.links.contribution.js'; -import '../terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.js'; +import '../terminalContrib/notifications/browser/terminal.notifications.contribution.js'; import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js'; import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js'; import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js'; diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index 5692332ebc9eb..f6918c387cad0 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -14,6 +14,7 @@ import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGui import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js'; import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js'; import { defaultTerminalHistoryCommandsToSkipShell, terminalHistoryConfiguration } from '../terminalContrib/history/common/terminal.history.js'; +import { terminalOscNotificationsConfiguration } from '../terminalContrib/notifications/common/terminalNotificationsConfiguration.js'; import { TerminalStickyScrollSettingId, terminalStickyScrollConfiguration } from '../terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.js'; import { defaultTerminalSuggestCommandsToSkipShell } from '../terminalContrib/suggest/common/terminal.suggest.js'; import { TerminalSuggestSettingId, terminalSuggestConfiguration } from '../terminalContrib/suggest/common/terminalSuggestConfiguration.js'; @@ -65,6 +66,7 @@ export const terminalContribConfiguration: IConfigurationNode['properties'] = { ...terminalInitialHintConfiguration, ...terminalCommandGuideConfiguration, ...terminalHistoryConfiguration, + ...terminalOscNotificationsConfiguration, ...terminalStickyScrollConfiguration, ...terminalSuggestConfiguration, ...terminalTypeAheadConfiguration, diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts new file mode 100644 index 0000000000000..1ffe20afaf724 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts @@ -0,0 +1,51 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; +import * as dom from '../../../../../base/browser/dom.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; +import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js'; +import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; +import { TerminalOscNotificationsSettingId } from '../common/terminalNotificationsConfiguration.js'; +import { Osc99NotificationHandler } from './terminal.notifications.handler.js'; + + +class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { + static readonly ID = 'terminal.oscNotifications'; + + private readonly _handler: Osc99NotificationHandler; + + constructor( + private readonly _ctx: ITerminalContributionContext, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @INotificationService private readonly _notificationService: INotificationService, + @ITerminalLogService private readonly _logService: ITerminalLogService, + ) { + super(); + this._handler = this._register(new Osc99NotificationHandler({ + isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications), + isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), + isTerminalVisible: () => this._ctx.instance.isVisible, + focusTerminal: () => this._ctx.instance.focus(true), + notify: notification => this._notificationService.notify(notification), + updateEnableNotifications: value => this._configurationService.updateValue(TerminalOscNotificationsSettingId.EnableNotifications, value), + logWarn: message => this._logService.warn(message), + writeToProcess: data => { void this._ctx.processManager.write(data); } + })); + } + + xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { + this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data))); + } +} + +registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution); + +export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null { + return instance.getContribution(TerminalOscNotificationsContribution.ID); +} diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts similarity index 83% rename from src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts rename to src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts index 018308530a48a..06a0ad0ab9367 100644 --- a/src/vs/workbench/contrib/terminalContrib/oscNotifications/browser/terminal.oscNotifications.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts @@ -3,18 +3,12 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; import { Action, IAction } from '../../../../../base/common/actions.js'; import { disposableTimeout } from '../../../../../base/common/async.js'; import { decodeBase64 } from '../../../../../base/common/buffer.js'; -import * as dom from '../../../../../base/browser/dom.js'; -import { Disposable, type IDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableStore, type IDisposable } from '../../../../../base/common/lifecycle.js'; import { localize } from '../../../../../nls.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; -import { INotificationService, NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; -import { ITerminalLogService, TerminalSettingId } from '../../../../../platform/terminal/common/terminal.js'; -import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js'; -import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; +import { NotificationPriority, Severity, type INotification, type INotificationHandle } from '../../../../../platform/notification/common/notification.js'; const enum Osc99PayloadType { Title = 'title', @@ -43,6 +37,7 @@ interface IOsc99NotificationState { interface IOsc99ActiveNotification { id: string | undefined; handle: INotificationHandle; + actionStore: DisposableStore; autoCloseDisposable: IDisposable | undefined; reportOnActivate: boolean; reportOnClose: boolean; @@ -312,6 +307,7 @@ export class Osc99NotificationHandler extends Disposable { label: localize('terminalNotificationSource', 'Terminal') }; const buttons = state.buttonsPayload.length > 0 ? state.buttonsPayload.split('\u2028') : []; + const actionStore = this._register(new DisposableStore()); const handleRef: { current: INotificationHandle | undefined } = { current: undefined }; const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { @@ -329,12 +325,13 @@ export class Osc99NotificationHandler extends Disposable { if (!label) { continue; } - primaryActions.push(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { + const action = actionStore.add(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { reportActivation(i + 1); handleRef.current?.close(); })); + primaryActions.push(action); } - primaryActions.push(new Action( + primaryActions.push(actionStore.add(new Action( 'terminal.osc99.focus', localize('terminalNotificationFocus', 'Focus Terminal'), undefined, @@ -343,17 +340,17 @@ export class Osc99NotificationHandler extends Disposable { reportActivation(undefined, true); handleRef.current?.close(); } - )); + ))); const secondaryActions: IAction[] = []; - secondaryActions.push(new Action( + secondaryActions.push(actionStore.add(new Action( 'terminal.osc99.dismiss', localize('terminalNotificationDismiss', 'Dismiss'), undefined, true, () => handleRef.current?.close() - )); - secondaryActions.push(new Action( + ))); + secondaryActions.push(actionStore.add(new Action( 'terminal.osc99.disable', localize('terminalNotificationDisable', 'Disable Terminal Notifications'), undefined, @@ -362,7 +359,7 @@ export class Osc99NotificationHandler extends Disposable { await this._host.updateEnableNotifications(false); handleRef.current?.close(); } - )); + ))); const actions = { primary: primaryActions, secondary: secondaryActions }; @@ -372,6 +369,8 @@ export class Osc99NotificationHandler extends Disposable { existing.handle.updateMessage(message); existing.handle.updateSeverity(severity); existing.handle.updateActions(actions); + existing.actionStore.dispose(); + existing.actionStore = actionStore; existing.focusOnActivate = state.focusOnActivate; existing.reportOnActivate = state.reportOnActivate; existing.reportOnClose = state.reportOnClose; @@ -394,6 +393,7 @@ export class Osc99NotificationHandler extends Disposable { const active: IOsc99ActiveNotification = { id: state.id, handle, + actionStore, autoCloseDisposable: undefined, reportOnActivate: state.reportOnActivate, reportOnClose: state.reportOnClose, @@ -404,6 +404,7 @@ export class Osc99NotificationHandler extends Disposable { if (active.reportOnClose) { this._sendOsc99CloseReport(active.id); } + active.actionStore.dispose(); active.autoCloseDisposable?.dispose(); if (active.id) { this._osc99ActiveNotifications.delete(active.id); @@ -501,40 +502,3 @@ export class Osc99NotificationHandler extends Disposable { this._host.writeToProcess(`\x1b]99;${metadata};${payload}\x1b\\`); } } - -class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { - static readonly ID = 'terminal.oscNotifications'; - - private _isVisible = false; - private readonly _handler: Osc99NotificationHandler; - - constructor( - private readonly _ctx: ITerminalContributionContext, - @IConfigurationService private readonly _configurationService: IConfigurationService, - @INotificationService private readonly _notificationService: INotificationService, - @ITerminalLogService private readonly _logService: ITerminalLogService, - ) { - super(); - this._handler = this._register(new Osc99NotificationHandler({ - isEnabled: () => this._configurationService.getValue(TerminalSettingId.EnableNotifications), - isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), - isTerminalVisible: () => this._isVisible, - focusTerminal: () => this._ctx.instance.focus(true), - notify: notification => this._notificationService.notify(notification), - updateEnableNotifications: value => this._configurationService.updateValue(TerminalSettingId.EnableNotifications, value), - logWarn: message => this._logService.warn(message), - writeToProcess: data => { void this._ctx.processManager.write(data); } - })); - this._register(this._ctx.instance.onDidChangeVisibility(visible => this._isVisible = visible)); - } - - xtermReady(xterm: IXtermTerminal & { raw: RawXtermTerminal }): void { - this._register(xterm.raw.parser.registerOscHandler(99, data => this._handler.handleSequence(data))); - } -} - -registerTerminalContribution(TerminalOscNotificationsContribution.ID, TerminalOscNotificationsContribution); - -export function getTerminalOscNotifications(instance: ITerminalInstance): TerminalOscNotificationsContribution | null { - return instance.getContribution(TerminalOscNotificationsContribution.ID); -} diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts new file mode 100644 index 0000000000000..2ad950e8d7b45 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts @@ -0,0 +1,20 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { IStringDictionary } from '../../../../../base/common/collections.js'; +import { localize } from '../../../../../nls.js'; +import type { IConfigurationPropertySchema } from '../../../../../platform/configuration/common/configurationRegistry.js'; + +export const enum TerminalOscNotificationsSettingId { + EnableNotifications = 'terminal.integrated.enableNotifications', +} + +export const terminalOscNotificationsConfiguration: IStringDictionary = { + [TerminalOscNotificationsSettingId.EnableNotifications]: { + description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), + type: 'boolean', + default: true + }, +}; diff --git a/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts b/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts similarity index 84% rename from src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts rename to src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts index 4020fae121771..27a210c334629 100644 --- a/src/vs/workbench/contrib/terminalContrib/oscNotifications/test/browser/terminalOscNotifications.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts @@ -7,7 +7,7 @@ import { strictEqual } from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { NotificationPriority, Severity, type INotification, type INotificationActions, type INotificationHandle, type INotificationProgress, type NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; -import { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.oscNotifications.contribution.js'; +import { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.notifications.handler.js'; class TestNotificationProgress implements INotificationProgress { infinite(): void { } @@ -45,13 +45,33 @@ class TestNotificationHandle implements INotificationHandle { } updateActions(actions?: INotificationActions): void { + this._disposeActions(this.actions); this.actions = actions; } close(): void { + if (this.closed) { + return; + } this.closed = true; + this._disposeActions(this.actions); this._onDidClose.fire(); } + + private _disposeActions(actions: INotificationActions | undefined): void { + for (const action of actions?.primary ?? []) { + const disposable = action as { dispose?: () => void }; + if (typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + for (const action of actions?.secondary ?? []) { + const disposable = action as { dispose?: () => void }; + if (typeof disposable.dispose === 'function') { + disposable.dispose(); + } + } + } } class TestOsc99Host implements IOsc99NotificationHost { @@ -103,14 +123,22 @@ class TestOsc99Host implements IOsc99NotificationHost { suite('Terminal OSC 99 notifications', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); - function createHandler(host: TestOsc99Host): Osc99NotificationHandler { - return store.add(new Osc99NotificationHandler(host)); - } + let host: TestOsc99Host; + let handler: Osc99NotificationHandler; + + setup(() => { + host = new TestOsc99Host(); + handler = store.add(new Osc99NotificationHandler(host)); + }); + + teardown(() => { + for (const notification of host.notifications) { + notification.close(); + } + }); test('ignores notifications when disabled', () => { - const host = new TestOsc99Host(); host.enabled = false; - const handler = createHandler(host); handler.handleSequence(';Hello'); strictEqual(host.notifications.length, 0); @@ -118,12 +146,8 @@ suite('Terminal OSC 99 notifications', () => { }); test('creates notification for title and body and updates', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - - handler.handleSequence('i=1:p=title;Hello'); - strictEqual(host.notifications.length, 1); - strictEqual(host.notifications[0].message, 'Hello'); + handler.handleSequence('i=1:d=0:p=title;Hello'); + strictEqual(host.notifications.length, 0); handler.handleSequence('i=1:p=body;World'); strictEqual(host.notifications.length, 1); @@ -131,18 +155,12 @@ suite('Terminal OSC 99 notifications', () => { }); test('decodes base64 payloads', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('e=1:p=title;SGVsbG8='); strictEqual(host.notifications.length, 1); strictEqual(host.notifications[0].message, 'Hello'); }); test('defers display until done', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=chunk:d=0:p=title;Hello '); strictEqual(host.notifications.length, 0); @@ -152,10 +170,7 @@ suite('Terminal OSC 99 notifications', () => { }); test('reports activation on button click', async () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - - handler.handleSequence('i=btn:a=report:p=title;Hi'); + handler.handleSequence('i=btn:d=0:a=report:p=title;Hi'); handler.handleSequence('i=btn:p=buttons;Yes'); const actions = host.notifications[0].actions; @@ -167,9 +182,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('sends close report when requested', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=close:c=1:p=title;Bye'); strictEqual(host.notifications.length, 1); host.notifications[0].close(); @@ -177,9 +189,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('responds to query and alive', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=a:p=title;A'); handler.handleSequence('i=b:p=title;B'); handler.handleSequence('i=q:p=?;'); @@ -190,9 +199,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('honors occasion for visibility and focus', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - host.windowFocused = true; host.terminalVisible = true; handler.handleSequence('o=unfocused:p=title;Hidden'); @@ -209,9 +215,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('closes notifications via close payload', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('i=closeme:p=title;Close'); strictEqual(host.notifications.length, 1); strictEqual(host.notifications[0].closed, false); @@ -221,9 +224,6 @@ suite('Terminal OSC 99 notifications', () => { }); test('maps urgency to severity and priority', () => { - const host = new TestOsc99Host(); - const handler = createHandler(host); - handler.handleSequence('u=2:p=title;Urgent'); strictEqual(host.notifications[0].severity, Severity.Warning); strictEqual(host.notifications[0].priority, NotificationPriority.URGENT); From f211ce87b4a2543ab9197cea9be44264f222f758 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 12 Feb 2026 15:19:16 +0000 Subject: [PATCH 018/221] style(theme): adjust backdrop filter for peek view widget to enhance visual clarity --- extensions/theme-2026/themes/styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index a63e85a5b35f4..411370d768ef0 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -351,8 +351,8 @@ .monaco-workbench .monaco-editor .peekview-widget { box-shadow: var(--shadow-hover); background: color-mix(in srgb, var(--vscode-peekViewEditor-background) 60%, transparent) !important; - backdrop-filter: var(--backdrop-blur-sm); - -webkit-backdrop-filter: var(--backdrop-blur-sm); + backdrop-filter: var(--backdrop-blur-md); + -webkit-backdrop-filter: var(--backdrop-blur-md); } .monaco-workbench.vs-dark .monaco-editor .peekview-widget { From 61ce2a354bc9c9e676d3317e6073273230ee73bc Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Thu, 12 Feb 2026 15:19:50 +0000 Subject: [PATCH 019/221] style(theme): adjust background property for quick input widget rows --- extensions/theme-2026/themes/styles.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 411370d768ef0..5b7a95c2e0041 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -197,7 +197,7 @@ } .monaco-workbench .quick-input-widget .monaco-list-rows { - background: transparent !important; + background: transparent !important; } .monaco-workbench .quick-input-widget .monaco-inputbox { From 2e81391ad362a5b26212d99e25e632a7700024ff Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 12 Feb 2026 12:31:13 -0800 Subject: [PATCH 020/221] Adopt esbuild instead of webpack for a few more extensions Adopting for configuration-editing, emmet, grunt, jake, and npm --- .../configuration-editing/.vscodeignore | 5 +- .../configuration-editing/esbuild.browser.mts | 35 +++++++++++ extensions/configuration-editing/esbuild.mts | 18 ++++++ .../extension-browser.webpack.config.js | 23 ------- extensions/configuration-editing/package.json | 8 ++- .../tsconfig.browser.json | 10 +++ extensions/emmet/.vscodeignore | 5 +- extensions/emmet/esbuild.browser.mts | 21 +++++++ .../esbuild.mts} | 29 ++++----- extensions/emmet/package.json | 8 ++- extensions/emmet/tsconfig.browser.json | 10 +++ extensions/esbuild-extension-common.mts | 63 ++++++++++++------- extensions/grunt/.vscodeignore | 4 +- .../esbuild.mts} | 23 +++---- extensions/gulp/.vscodeignore | 4 +- .../esbuild.mts} | 23 ++++--- extensions/gulp/extension.webpack.config.js | 16 ----- extensions/jake/.vscodeignore | 4 +- .../esbuild.mts} | 22 ++++--- extensions/jake/extension.webpack.config.js | 16 ----- .../esbuild.browser.mts | 11 +--- .../markdown-language-features/esbuild.mts | 1 + extensions/markdown-math/esbuild.browser.mts | 9 +-- extensions/markdown-math/esbuild.mts | 1 + extensions/media-preview/esbuild.browser.mts | 9 +-- extensions/media-preview/esbuild.mts | 1 + .../mermaid-chat-features/esbuild.browser.mts | 9 +-- extensions/mermaid-chat-features/esbuild.mts | 1 + extensions/npm/.vscodeignore | 5 +- extensions/npm/esbuild.browser.mts | 25 ++++++++ .../esbuild.mts} | 25 ++++---- extensions/npm/extension.webpack.config.js | 20 ------ extensions/npm/package.json | 8 ++- .../src/features/packageJSONContribution.ts | 5 +- extensions/npm/tsconfig.browser.json | 10 +++ 35 files changed, 275 insertions(+), 212 deletions(-) create mode 100644 extensions/configuration-editing/esbuild.browser.mts create mode 100644 extensions/configuration-editing/esbuild.mts delete mode 100644 extensions/configuration-editing/extension-browser.webpack.config.js create mode 100644 extensions/configuration-editing/tsconfig.browser.json create mode 100644 extensions/emmet/esbuild.browser.mts rename extensions/{npm/extension-browser.webpack.config.js => emmet/esbuild.mts} (50%) create mode 100644 extensions/emmet/tsconfig.browser.json rename extensions/{emmet/extension-browser.webpack.config.js => grunt/esbuild.mts} (52%) rename extensions/{emmet/extension.webpack.config.js => gulp/esbuild.mts} (52%) delete mode 100644 extensions/gulp/extension.webpack.config.js rename extensions/{grunt/extension.webpack.config.js => jake/esbuild.mts} (52%) delete mode 100644 extensions/jake/extension.webpack.config.js create mode 100644 extensions/npm/esbuild.browser.mts rename extensions/{configuration-editing/extension.webpack.config.js => npm/esbuild.mts} (51%) delete mode 100644 extensions/npm/extension.webpack.config.js create mode 100644 extensions/npm/tsconfig.browser.json diff --git a/extensions/configuration-editing/.vscodeignore b/extensions/configuration-editing/.vscodeignore index 679a6d6859f0a..7c246a3d95f20 100644 --- a/extensions/configuration-editing/.vscodeignore +++ b/extensions/configuration-editing/.vscodeignore @@ -1,9 +1,8 @@ test/** src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild* package-lock.json build/** schemas/devContainer.codespaces.schema.json diff --git a/extensions/configuration-editing/esbuild.browser.mts b/extensions/configuration-editing/esbuild.browser.mts new file mode 100644 index 0000000000000..347aadc7c9229 --- /dev/null +++ b/extensions/configuration-editing/esbuild.browser.mts @@ -0,0 +1,35 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import type { Plugin } from 'esbuild'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +/** + * Plugin to redirect `./node/net` imports to `./browser/net` for the browser build. + */ +const browserNetPlugin: Plugin = { + name: 'browser-net-redirect', + setup(build) { + build.onResolve({ filter: /\/node\/net$/ }, args => { + return { path: path.join(path.dirname(args.resolveDir), 'src', 'browser', 'net.ts') }; + }); + }, +}; + +run({ + platform: 'browser', + entryPoints: { + 'configurationEditingMain': path.join(srcDir, 'configurationEditingMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + plugins: [browserNetPlugin], + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/configuration-editing/esbuild.mts b/extensions/configuration-editing/esbuild.mts new file mode 100644 index 0000000000000..a0d03289442b4 --- /dev/null +++ b/extensions/configuration-editing/esbuild.mts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'configurationEditingMain': path.join(srcDir, 'configurationEditingMain.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/configuration-editing/extension-browser.webpack.config.js b/extensions/configuration-editing/extension-browser.webpack.config.js deleted file mode 100644 index 1136c92520837..0000000000000 --- a/extensions/configuration-editing/extension-browser.webpack.config.js +++ /dev/null @@ -1,23 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; - -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/configurationEditingMain.ts' - }, - output: { - filename: 'configurationEditingMain.js' - }, - resolve: { - alias: { - './node/net': path.resolve(import.meta.dirname, 'src', 'browser', 'net'), - } - } -}); - diff --git a/extensions/configuration-editing/package.json b/extensions/configuration-editing/package.json index 84af30cb45b01..60569fb201fbe 100644 --- a/extensions/configuration-editing/package.json +++ b/extensions/configuration-editing/package.json @@ -22,7 +22,13 @@ "browser": "./dist/browser/configurationEditingMain", "scripts": { "compile": "gulp compile-extension:configuration-editing", - "watch": "gulp watch-extension:configuration-editing" + "watch": "gulp watch-extension:configuration-editing", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "dependencies": { "@octokit/rest": "^21.1.1", diff --git a/extensions/configuration-editing/tsconfig.browser.json b/extensions/configuration-editing/tsconfig.browser.json new file mode 100644 index 0000000000000..f609992ab5893 --- /dev/null +++ b/extensions/configuration-editing/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/configurationEditingMain.ts" + ] +} diff --git a/extensions/emmet/.vscodeignore b/extensions/emmet/.vscodeignore index ccf478fb97f7b..c63ff6f6805d2 100644 --- a/extensions/emmet/.vscodeignore +++ b/extensions/emmet/.vscodeignore @@ -2,9 +2,8 @@ test/** test-workspace/** src/** out/** -tsconfig.json -extension.webpack.config.js -extension-browser.webpack.config.js +tsconfig*.json +esbuild* CONTRIBUTING.md cgmanifest.json package-lock.json diff --git a/extensions/emmet/esbuild.browser.mts b/extensions/emmet/esbuild.browser.mts new file mode 100644 index 0000000000000..5436791dde64d --- /dev/null +++ b/extensions/emmet/esbuild.browser.mts @@ -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 * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'emmetBrowserMain': path.join(srcDir, 'browser', 'emmetBrowserMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + }, +}, process.argv); diff --git a/extensions/npm/extension-browser.webpack.config.js b/extensions/emmet/esbuild.mts similarity index 50% rename from extensions/npm/extension-browser.webpack.config.js rename to extensions/emmet/esbuild.mts index 6ec242a87a221..1bc372fa8c6d0 100644 --- a/extensions/npm/extension-browser.webpack.config.js +++ b/extensions/emmet/esbuild.mts @@ -2,22 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -const config = withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/npmBrowserMain.ts' - }, - output: { - filename: 'npmBrowserMain.js' - }, - resolve: { - fallback: { - 'child_process': false - } - } -}); +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'node'); -export default config; +run({ + platform: 'node', + entryPoints: { + 'emmetNodeMain': path.join(srcDir, 'node', 'emmetNodeMain.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/emmet/package.json b/extensions/emmet/package.json index 5b5d3748f87f6..c4e020a8d161e 100644 --- a/extensions/emmet/package.json +++ b/extensions/emmet/package.json @@ -474,8 +474,14 @@ } }, "scripts": { - "watch": "gulp watch-extension:emmet", "compile": "gulp compile-extension:emmet", + "watch": "gulp watch-extension:emmet", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch", "deps": "npm install @vscode/emmet-helper" }, "devDependencies": { diff --git a/extensions/emmet/tsconfig.browser.json b/extensions/emmet/tsconfig.browser.json new file mode 100644 index 0000000000000..781e4ba631c54 --- /dev/null +++ b/extensions/emmet/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/browser/emmetBrowserMain.ts" + ] +} diff --git a/extensions/esbuild-extension-common.mts b/extensions/esbuild-extension-common.mts index 513656ae89fb3..e429554a639e0 100644 --- a/extensions/esbuild-extension-common.mts +++ b/extensions/esbuild-extension-common.mts @@ -16,17 +16,7 @@ type BuildOptions = Partial & { * Build the source code once using esbuild. */ async function build(options: BuildOptions, didBuild?: (outDir: string) => unknown): Promise { - await esbuild.build({ - bundle: true, - minify: true, - sourcemap: false, - format: 'cjs', - platform: 'node', - target: ['es2024'], - external: ['vscode'], - ...options, - }); - + await esbuild.build(options); await didBuild?.(options.outdir); } @@ -42,10 +32,44 @@ async function tryBuild(options: BuildOptions, didBuild?: (outDir: string) => un } interface RunConfig { - srcDir: string; - outdir: string; - entryPoints: string[] | Record | { in: string; out: string }[]; - additionalOptions?: Partial; + readonly platform: 'node' | 'browser'; + readonly srcDir: string; + readonly outdir: string; + readonly entryPoints: string[] | Record | { in: string; out: string }[]; + readonly additionalOptions?: Partial; +} + +function resolveOptions(config: RunConfig, outdir: string): BuildOptions { + const options: BuildOptions = { + platform: config.platform, + bundle: true, + minify: true, + sourcemap: true, + target: ['es2024'], + external: ['vscode'], + entryPoints: config.entryPoints, + outdir, + logOverride: { + 'import-is-undefined': 'error', + }, + ...(config.additionalOptions || {}), + }; + + if (config.platform === 'node') { + options.format = 'cjs'; + } else if (config.platform === 'browser') { + options.format = 'iife'; + options.alias = { + 'path': 'path-browserify', + }; + options.define = { + 'process.platform': JSON.stringify('web'), + 'process.env': JSON.stringify({}), + 'process.env.BROWSER_ENV': JSON.stringify('true'), + }; + } + + return options; } export async function run(config: RunConfig, args: string[], didBuild?: (outDir: string) => unknown): Promise { @@ -57,14 +81,7 @@ export async function run(config: RunConfig, args: string[], didBuild?: (outDir: outdir = path.join(outputRoot, outputDirName); } - const resolvedOptions: BuildOptions = { - entryPoints: config.entryPoints, - outdir, - logOverride: { - 'import-is-undefined': 'error', - }, - ...(config.additionalOptions || {}), - }; + const resolvedOptions = resolveOptions(config, outdir); const isWatch = args.indexOf('--watch') >= 0; if (isWatch) { diff --git a/extensions/grunt/.vscodeignore b/extensions/grunt/.vscodeignore index 698898bf9dfb1..e6cd7f0ed82e6 100644 --- a/extensions/grunt/.vscodeignore +++ b/extensions/grunt/.vscodeignore @@ -1,6 +1,6 @@ test/** src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/emmet/extension-browser.webpack.config.js b/extensions/grunt/esbuild.mts similarity index 52% rename from extensions/emmet/extension-browser.webpack.config.js rename to extensions/grunt/esbuild.mts index ce7ea8d197b90..9be4332b6d832 100644 --- a/extensions/emmet/extension-browser.webpack.config.js +++ b/extensions/grunt/esbuild.mts @@ -2,16 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import { browser as withBrowserDefaults } from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withBrowserDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/browser/emmetBrowserMain.ts' - }, - output: { - filename: 'emmetBrowserMain.js' - } -}); +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), + }, + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/gulp/.vscodeignore b/extensions/gulp/.vscodeignore index 360fcfd1c990b..58e98fc51822d 100644 --- a/extensions/gulp/.vscodeignore +++ b/extensions/gulp/.vscodeignore @@ -1,5 +1,5 @@ src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/emmet/extension.webpack.config.js b/extensions/gulp/esbuild.mts similarity index 52% rename from extensions/emmet/extension.webpack.config.js rename to extensions/gulp/esbuild.mts index 2c6094112e17b..9be4332b6d832 100644 --- a/extensions/emmet/extension.webpack.config.js +++ b/extensions/gulp/esbuild.mts @@ -2,18 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import path from 'path'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -import withDefaults from '../shared.webpack.config.mjs'; +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/node/emmetNodeMain.ts', +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), }, - output: { - path: path.join(import.meta.dirname, 'dist', 'node'), - filename: 'emmetNodeMain.js' - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/gulp/extension.webpack.config.js b/extensions/gulp/extension.webpack.config.js deleted file mode 100644 index 1e221c2fa8500..0000000000000 --- a/extensions/gulp/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - }, - resolve: { - mainFields: ['module', 'main'] - } -}); diff --git a/extensions/jake/.vscodeignore b/extensions/jake/.vscodeignore index 360fcfd1c990b..58e98fc51822d 100644 --- a/extensions/jake/.vscodeignore +++ b/extensions/jake/.vscodeignore @@ -1,5 +1,5 @@ src/** -tsconfig.json +tsconfig*.json out/** -extension.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/grunt/extension.webpack.config.js b/extensions/jake/esbuild.mts similarity index 52% rename from extensions/grunt/extension.webpack.config.js rename to extensions/jake/esbuild.mts index 1e221c2fa8500..9be4332b6d832 100644 --- a/extensions/grunt/extension.webpack.config.js +++ b/extensions/jake/esbuild.mts @@ -2,15 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'main': path.join(srcDir, 'main.ts'), }, - resolve: { - mainFields: ['module', 'main'] - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/jake/extension.webpack.config.js b/extensions/jake/extension.webpack.config.js deleted file mode 100644 index 1e221c2fa8500..0000000000000 --- a/extensions/jake/extension.webpack.config.js +++ /dev/null @@ -1,16 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - main: './src/main.ts', - }, - resolve: { - mainFields: ['module', 'main'] - } -}); diff --git a/extensions/markdown-language-features/esbuild.browser.mts b/extensions/markdown-language-features/esbuild.browser.mts index ddf0c5a99dc4e..ece1769bcdc25 100644 --- a/extensions/markdown-language-features/esbuild.browser.mts +++ b/extensions/markdown-language-features/esbuild.browser.mts @@ -19,22 +19,13 @@ async function copyServerWorkerMain(outDir: string): Promise { } run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.browser.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - alias: { - 'path': 'path-browserify', - }, - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv, copyServerWorkerMain); diff --git a/extensions/markdown-language-features/esbuild.mts b/extensions/markdown-language-features/esbuild.mts index a1cf6eb5fa8de..2a7eda8c18328 100644 --- a/extensions/markdown-language-features/esbuild.mts +++ b/extensions/markdown-language-features/esbuild.mts @@ -19,6 +19,7 @@ async function copyServerWorkerMain(outDir: string): Promise { } run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/markdown-math/esbuild.browser.mts b/extensions/markdown-math/esbuild.browser.mts index e3fa7792d056d..9ea4d5f68401e 100644 --- a/extensions/markdown-math/esbuild.browser.mts +++ b/extensions/markdown-math/esbuild.browser.mts @@ -9,18 +9,13 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); diff --git a/extensions/markdown-math/esbuild.mts b/extensions/markdown-math/esbuild.mts index 5fafb57ab75a8..2b75ca703da06 100644 --- a/extensions/markdown-math/esbuild.mts +++ b/extensions/markdown-math/esbuild.mts @@ -9,6 +9,7 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/media-preview/esbuild.browser.mts b/extensions/media-preview/esbuild.browser.mts index e3fa7792d056d..9ea4d5f68401e 100644 --- a/extensions/media-preview/esbuild.browser.mts +++ b/extensions/media-preview/esbuild.browser.mts @@ -9,18 +9,13 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); diff --git a/extensions/media-preview/esbuild.mts b/extensions/media-preview/esbuild.mts index 5fafb57ab75a8..2b75ca703da06 100644 --- a/extensions/media-preview/esbuild.mts +++ b/extensions/media-preview/esbuild.mts @@ -9,6 +9,7 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/mermaid-chat-features/esbuild.browser.mts b/extensions/mermaid-chat-features/esbuild.browser.mts index e3fa7792d056d..9ea4d5f68401e 100644 --- a/extensions/mermaid-chat-features/esbuild.browser.mts +++ b/extensions/mermaid-chat-features/esbuild.browser.mts @@ -9,18 +9,13 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist', 'browser'); run({ + platform: 'browser', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, srcDir, outdir: outDir, additionalOptions: { - platform: 'browser', - format: 'cjs', - define: { - 'process.platform': JSON.stringify('web'), - 'process.env': JSON.stringify({}), - 'process.env.BROWSER_ENV': JSON.stringify('true'), - }, + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), }, }, process.argv); diff --git a/extensions/mermaid-chat-features/esbuild.mts b/extensions/mermaid-chat-features/esbuild.mts index 5fafb57ab75a8..2b75ca703da06 100644 --- a/extensions/mermaid-chat-features/esbuild.mts +++ b/extensions/mermaid-chat-features/esbuild.mts @@ -9,6 +9,7 @@ const srcDir = path.join(import.meta.dirname, 'src'); const outDir = path.join(import.meta.dirname, 'dist'); run({ + platform: 'node', entryPoints: { 'extension': path.join(srcDir, 'extension.ts'), }, diff --git a/extensions/npm/.vscodeignore b/extensions/npm/.vscodeignore index f05a79416be02..7e9dd51ede2aa 100644 --- a/extensions/npm/.vscodeignore +++ b/extensions/npm/.vscodeignore @@ -1,7 +1,6 @@ src/** out/** -tsconfig.json +tsconfig*.json .vscode/** -extension.webpack.config.js -extension-browser.webpack.config.js +esbuild* package-lock.json diff --git a/extensions/npm/esbuild.browser.mts b/extensions/npm/esbuild.browser.mts new file mode 100644 index 0000000000000..852700edd07a5 --- /dev/null +++ b/extensions/npm/esbuild.browser.mts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; + +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist', 'browser'); + +run({ + platform: 'browser', + entryPoints: { + 'npmBrowserMain': path.join(srcDir, 'npmBrowserMain.ts'), + }, + srcDir, + outdir: outDir, + additionalOptions: { + tsconfig: path.join(import.meta.dirname, 'tsconfig.browser.json'), + external: [ + 'vscode', + 'child_process', + ] + }, +}, process.argv); diff --git a/extensions/configuration-editing/extension.webpack.config.js b/extensions/npm/esbuild.mts similarity index 51% rename from extensions/configuration-editing/extension.webpack.config.js rename to extensions/npm/esbuild.mts index 519fc2e359f44..be92f45b26cc7 100644 --- a/extensions/configuration-editing/extension.webpack.config.js +++ b/extensions/npm/esbuild.mts @@ -2,18 +2,17 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; +import * as path from 'node:path'; +import { run } from '../esbuild-extension-common.mts'; -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/configurationEditingMain.ts', - }, - output: { - filename: 'configurationEditingMain.js' +const srcDir = path.join(import.meta.dirname, 'src'); +const outDir = path.join(import.meta.dirname, 'dist'); + +run({ + platform: 'node', + entryPoints: { + 'npmMain': path.join(srcDir, 'npmMain.ts'), }, - resolve: { - mainFields: ['module', 'main'] - } -}); + srcDir, + outdir: outDir, +}, process.argv); diff --git a/extensions/npm/extension.webpack.config.js b/extensions/npm/extension.webpack.config.js deleted file mode 100644 index 0dcad6f8b143f..0000000000000 --- a/extensions/npm/extension.webpack.config.js +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -// @ts-check -import withDefaults from '../shared.webpack.config.mjs'; - -export default withDefaults({ - context: import.meta.dirname, - entry: { - extension: './src/npmMain.ts', - }, - output: { - filename: 'npmMain.js', - }, - resolve: { - mainFields: ['module', 'main'], - extensions: ['.ts', '.js'] // support ts-files and js-files - } -}); diff --git a/extensions/npm/package.json b/extensions/npm/package.json index 9a67c3a83ecfa..bba6a23b8ac99 100644 --- a/extensions/npm/package.json +++ b/extensions/npm/package.json @@ -18,7 +18,13 @@ ], "scripts": { "compile": "npx gulp compile-extension:npm", - "watch": "npx gulp watch-extension:npm" + "watch": "npx gulp watch-extension:npm", + "compile-web": "npm-run-all2 -lp bundle-web typecheck-web", + "bundle-web": "node ./esbuild.browser.mts", + "typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit", + "watch-web": "npm-run-all2 -lp watch-bundle-web watch-typecheck-web", + "watch-bundle-web": "node ./esbuild.browser.mts --watch", + "watch-typecheck-web": "tsgo --project ./tsconfig.browser.json --noEmit --watch" }, "dependencies": { "find-up": "^5.0.0", diff --git a/extensions/npm/src/features/packageJSONContribution.ts b/extensions/npm/src/features/packageJSONContribution.ts index 999f39664f1c2..d90a6b189f533 100644 --- a/extensions/npm/src/features/packageJSONContribution.ts +++ b/extensions/npm/src/features/packageJSONContribution.ts @@ -8,7 +8,7 @@ import { IJSONContribution, ISuggestionsCollector } from './jsonContributions'; import { XHRRequest } from 'request-light'; import { Location } from 'jsonc-parser'; -import * as cp from 'child_process'; +import type * as cp from 'child_process'; import { dirname } from 'path'; import { fromNow } from './date'; @@ -283,7 +283,8 @@ export class PackageJSONContribution implements IJSONContribution { return info; } - private npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { + private async npmView(npmCommandPath: string, pack: string, resource: Uri | undefined): Promise { + const cp = await import('child_process'); return new Promise((resolve, _reject) => { const args = ['view', '--json', '--', pack, 'description', 'dist-tags.latest', 'homepage', 'version', 'time']; const cwd = resource && resource.scheme === 'file' ? dirname(resource.fsPath) : undefined; diff --git a/extensions/npm/tsconfig.browser.json b/extensions/npm/tsconfig.browser.json new file mode 100644 index 0000000000000..4e9b89120f863 --- /dev/null +++ b/extensions/npm/tsconfig.browser.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": {}, + "exclude": [ + "./src/test/**" + ], + "files": [ + "./src/npmBrowserMain.ts" + ] +} From 97384371fadaf8e26499071ce0db9ee4a7d05b6f Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:14:56 -0800 Subject: [PATCH 021/221] Fixing error reporting and resolution of jsonc --- build/lib/tsgo.ts | 45 +++++++------------------ extensions/esbuild-extension-common.mts | 1 + 2 files changed, 13 insertions(+), 33 deletions(-) diff --git a/build/lib/tsgo.ts b/build/lib/tsgo.ts index 421f4c1cc1b17..7f00c1816b56f 100644 --- a/build/lib/tsgo.ts +++ b/build/lib/tsgo.ts @@ -14,8 +14,8 @@ const npx = process.platform === 'win32' ? 'npx.cmd' : 'npx'; const ansiRegex = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; export function spawnTsgo(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): Promise { - function reporter(stdError: string) { - const matches = (stdError || '').match(/^error \w+: (.+)?/g); + function runReporter(stdError: string) { + const matches = (stdError || '').match(/error \w+: (.+)?/g); fancyLog(`Finished ${ansiColors.green(config.taskName)} ${projectPath} with ${matches ? matches.length : 0} errors.`); for (const match of matches || []) { fancyLog.error(match); @@ -34,29 +34,14 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi 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)) { - return; - } - if (/Compilation complete/i.test(trimmed)) { - return; - } - - 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); - } + const lines = data.toString() + .split(/\r?\n/) + .map(line => line.replace(ansiRegex, '').trim()) + .filter(line => line.length > 0) + .filter(line => !/Starting compilation|File change detected|Compilation complete/i.test(line)); + + runReporter(lines.join('\n')); }; child.stdout?.on('data', handleData); @@ -64,11 +49,6 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi return new Promise((resolve, reject) => { child.on('exit', code => { - if (buffer.trim()) { - handleLine(buffer); - buffer = ''; - } - if (code === 0) { Promise.resolve(onComplete?.()).then(() => resolve(), reject); } else { @@ -84,12 +64,11 @@ export function spawnTsgo(projectPath: string, config: { taskName: string; noEmi export function createTsgoStream(projectPath: string, config: { taskName: string; noEmit?: boolean }, onComplete?: () => Promise | void): NodeJS.ReadWriteStream { const stream = es.through(); + spawnTsgo(projectPath, config, onComplete).then(() => { stream.emit('end'); - }).catch(() => { - // 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'); + }).catch(err => { + stream.emit('error', err); }); return stream; diff --git a/extensions/esbuild-extension-common.mts b/extensions/esbuild-extension-common.mts index e429554a639e0..84eb4d02ec734 100644 --- a/extensions/esbuild-extension-common.mts +++ b/extensions/esbuild-extension-common.mts @@ -47,6 +47,7 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { sourcemap: true, target: ['es2024'], external: ['vscode'], + mainFields: ['module', 'main'], entryPoints: config.entryPoints, outdir, logOverride: { From 313730f8367531641728c12373fccc166534f693 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:15:08 -0800 Subject: [PATCH 022/221] Use `browser` if it exists --- extensions/esbuild-extension-common.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/esbuild-extension-common.mts b/extensions/esbuild-extension-common.mts index 84eb4d02ec734..b5ce1aceb0c78 100644 --- a/extensions/esbuild-extension-common.mts +++ b/extensions/esbuild-extension-common.mts @@ -47,7 +47,6 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { sourcemap: true, target: ['es2024'], external: ['vscode'], - mainFields: ['module', 'main'], entryPoints: config.entryPoints, outdir, logOverride: { @@ -58,8 +57,10 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { if (config.platform === 'node') { options.format = 'cjs'; + options.mainFields = ['module', 'main']; } else if (config.platform === 'browser') { options.format = 'iife'; + options.mainFields = ['browser', 'module', 'main']; options.alias = { 'path': 'path-browserify', }; From 104123aaee7dc537b3f725c93112e96c714589f7 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 12 Feb 2026 16:56:59 -0800 Subject: [PATCH 023/221] Merge pull request #295063 from microsoft/dev/dmitriv/flaky-download-fix Add retries for target download logic in sanity tests --- test/sanity/src/context.ts | 44 +++++++++++++++++++++++++++----------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/test/sanity/src/context.ts b/test/sanity/src/context.ts index 95273f9f7fc18..8ee3cd5bb0d76 100644 --- a/test/sanity/src/context.ts +++ b/test/sanity/src/context.ts @@ -298,21 +298,39 @@ export class TestContext { const { url, sha256hash } = await this.fetchMetadata(target); const filePath = path.join(this.createTempDir(), path.basename(url)); - this.log(`Downloading ${url} to ${filePath}`); - const { body } = await this.fetchNoErrors(url); - - const stream = fs.createWriteStream(filePath); - await new Promise((resolve, reject) => { - body.on('error', reject); - stream.on('error', reject); - stream.on('finish', resolve); - body.pipe(stream); - }); + const maxRetries = 5; + let lastError: Error | undefined; - this.log(`Downloaded ${url} to ${filePath}`); - this.validateSha256Hash(filePath, sha256hash); + for (let attempt = 0; attempt < maxRetries; attempt++) { + if (attempt > 0) { + const delay = Math.pow(2, attempt - 1) * 1000; + this.log(`Retrying download (attempt ${attempt + 1}/${maxRetries}) after ${delay}ms`); + await new Promise(resolve => setTimeout(resolve, delay)); + } - return filePath; + try { + this.log(`Downloading ${url} to ${filePath}`); + const { body } = await this.fetchNoErrors(url); + + const stream = fs.createWriteStream(filePath); + await new Promise((resolve, reject) => { + body.on('error', reject); + stream.on('error', reject); + stream.on('finish', resolve); + body.pipe(stream); + }); + + this.log(`Downloaded ${url} to ${filePath}`); + this.validateSha256Hash(filePath, sha256hash); + + return filePath; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + this.log(`Download attempt ${attempt + 1} failed: ${lastError.message}`); + } + } + + this.error(`Failed to download ${url} after ${maxRetries} attempts: ${lastError?.message}`); } /** From 1531635f6adb34162e60803ecd9f7ce5a32e6ee7 Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Thu, 12 Feb 2026 17:39:07 -0800 Subject: [PATCH 024/221] Merge pull request #295059 from microsoft/dev/dmitriv/fetch-prevent-ad-redirects Prevent tracking redirects in fetch tool --- .../electron-main/webPageLoader.ts | 14 ++- .../test/electron-main/webPageLoader.test.ts | 96 ++++++++++++++++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts b/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts index c176090f9d84a..14157f0fd1a28 100644 --- a/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts +++ b/src/vs/platform/webContentExtractor/electron-main/webPageLoader.ts @@ -84,8 +84,8 @@ export class WebPageLoader extends Disposable { .once('did-start-loading', this.onStartLoading.bind(this)) .once('did-finish-load', this.onFinishLoad.bind(this)) .once('did-fail-load', this.onFailLoad.bind(this)) - .once('will-navigate', this.onRedirect.bind(this)) - .once('will-redirect', this.onRedirect.bind(this)) + .on('will-navigate', this.onRedirect.bind(this)) + .on('will-redirect', this.onRedirect.bind(this)) .on('select-client-certificate', (event) => event.preventDefault()); this._window.webContents.session.webRequest.onBeforeSendHeaders( @@ -262,6 +262,9 @@ export class WebPageLoader extends Disposable { if (statusCode === -3) { this.trace(`Ignoring ERR_ABORTED (-3) as it may be caused by CSP or other measures`); void this._queue.queue(() => this.extractContent()); + } else if (statusCode === -27) { + this.trace(`Ignoring ERR_BLOCKED_BY_CLIENT (-27) as it may be caused by ad-blockers or similar extensions`); + void this._queue.queue(() => this.extractContent()); } else { void this._queue.queue(() => this.extractContent({ status: 'error', statusCode, error })); } @@ -289,6 +292,13 @@ export class WebPageLoader extends Disposable { return; } + // Ignore script-initiated navigation (ads/trackers etc) + if (this._didFinishLoad) { + this.trace(`Blocking post-load navigation to ${url} (likely ad/tracker script)`); + event.preventDefault(); + return; + } + // Otherwise, prevent redirect and report it event.preventDefault(); this._onResult({ status: 'redirect', toURI }); diff --git a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts index 6c86c592226e8..5de4f73f0d310 100644 --- a/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts +++ b/src/vs/platform/webContentExtractor/test/electron-main/webPageLoader.test.ts @@ -19,6 +19,7 @@ interface MockElectronEvent { class MockWebContents { private readonly _listeners = new Map void)[]>(); + private readonly _onceListeners = new Set<(...args: unknown[]) => void>(); public readonly debugger: MockDebugger; public loadURL = sinon.stub().resolves(); public getTitle = sinon.stub().returns('Test Page Title'); @@ -41,6 +42,7 @@ class MockWebContents { this._listeners.set(event, []); } this._listeners.get(event)!.push(listener); + this._onceListeners.add(listener); return this; } @@ -57,7 +59,16 @@ class MockWebContents { for (const listener of listeners) { listener(...args); } - this._listeners.delete(event); + // Remove once listeners, keep on listeners + const remaining = listeners.filter(l => !this._onceListeners.has(l)); + for (const listener of listeners) { + this._onceListeners.delete(listener); + } + if (remaining.length > 0) { + this._listeners.set(event, remaining); + } else { + this._listeners.delete(event); + } } beginFrameSubscription(_onlyDirty: boolean, callback: () => void): void { @@ -232,6 +243,27 @@ suite('WebPageLoader', () => { } })); + test('ERR_BLOCKED_BY_CLIENT is ignored and content extraction continues', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = URI.parse('https://example.com/page'); + + const loader = createWebPageLoader(uri); + setupDebuggerMock(); + + const loadPromise = loader.load(); + + // Simulate ERR_BLOCKED_BY_CLIENT (-27) which should be ignored + const mockEvent: MockElectronEvent = {}; + window.webContents.emit('did-fail-load', mockEvent, -27, 'ERR_BLOCKED_BY_CLIENT'); + + const result = await loadPromise; + + // ERR_BLOCKED_BY_CLIENT should not cause an error status, content should be extracted + assert.strictEqual(result.status, 'ok'); + if (result.status === 'ok') { + assert.ok(result.result.includes('Test content from page')); + } + })); + //#endregion //#region Redirect Tests @@ -394,6 +426,68 @@ suite('WebPageLoader', () => { assert.strictEqual(result.status, 'ok'); })); + test('post-load navigation to different domain is blocked silently and content is extracted', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = URI.parse('https://example.com/page'); + const adRedirectUrl = 'https://eus.rubiconproject.com/usync.html?p=12776'; + + const loader = createWebPageLoader(uri, { followRedirects: false }); + setupDebuggerMock(); + + const loadPromise = loader.load(); + + // Simulate successful page load + window.webContents.emit('did-start-loading'); + window.webContents.emit('did-finish-load'); + + // Simulate ad/tracker script redirecting after page load + const mockEvent: MockElectronEvent = { + preventDefault: sinon.stub() + }; + window.webContents.emit('will-navigate', mockEvent, adRedirectUrl); + + const result = await loadPromise; + + // Navigation should be prevented + assert.ok((mockEvent.preventDefault!).called); + // But result should be ok (content extracted), NOT redirect + assert.strictEqual(result.status, 'ok'); + assert.ok(result.result.includes('Test content from page')); + })); + + test('initial same-domain navigation is allowed but later cross-domain navigation is blocked', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const uri = URI.parse('https://example.com/page'); + const sameDomainUrl = 'https://example.com/otherpage'; + const crossDomainUrl = 'https://eus.rubiconproject.com/usync.html?p=12776'; + + const loader = createWebPageLoader(uri, { followRedirects: false }); + setupDebuggerMock(); + + const loadPromise = loader.load(); + + // First navigation: same-authority, should be allowed + const initialEvent: MockElectronEvent = { + preventDefault: sinon.stub() + }; + window.webContents.emit('will-navigate', initialEvent, sameDomainUrl); + assert.ok(!(initialEvent.preventDefault!).called); + + // Simulate successful page load + window.webContents.emit('did-start-loading'); + window.webContents.emit('did-finish-load'); + + // Second navigation: cross-domain after load, should be blocked + const crossDomainEvent: MockElectronEvent = { + preventDefault: sinon.stub() + }; + window.webContents.emit('will-navigate', crossDomainEvent, crossDomainUrl); + + const result = await loadPromise; + + assert.ok((crossDomainEvent.preventDefault!).called); + assert.strictEqual(result.status, 'ok'); + assert.ok(result.result.includes('Test content from page')); + })); + test('redirect to non-trusted domain is blocked', async () => { const uri = URI.parse('https://example.com/page'); const redirectUrl = 'https://untrusted-domain.com/redirected'; From 92d3b378a08369596fb5ef9993727a6b34c1c2ae Mon Sep 17 00:00:00 2001 From: Paul Date: Thu, 12 Feb 2026 18:09:06 -0800 Subject: [PATCH 025/221] Rename `user-invokable` to `user-invocable` (#295058) * rename * tests * fixes * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix test * Update src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../chatCustomizationDiagnosticsAction.ts | 10 +- .../input/editor/chatInputCompletions.ts | 8 +- .../contrib/chat/common/chatModes.ts | 4 +- .../computeAutomaticInstructions.ts | 8 +- .../promptHeaderAutocompletion.ts | 2 +- .../languageProviders/promptHovers.ts | 2 +- .../languageProviders/promptValidator.ts | 32 +++-- .../common/promptSyntax/promptFileParser.ts | 6 +- .../promptSyntax/service/promptsService.ts | 16 +-- .../service/promptsServiceImpl.ts | 16 +-- .../promptHeaderAutocompletion.test.ts | 12 +- .../languageProviders/promptHovers.test.ts | 8 +- .../languageProviders/promptValidator.test.ts | 73 ++++++----- .../chat/test/common/chatModeService.test.ts | 12 +- .../computeAutomaticInstructions.test.ts | 8 +- .../service/promptFileParser.test.ts | 44 +++++++ .../service/promptsService.test.ts | 116 +++++++++--------- .../builtinTools/runSubagentTool.test.ts | 4 +- 18 files changed, 226 insertions(+), 155 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index bb72913ae98e3..fe17d4b9cd535 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -87,8 +87,8 @@ export interface IFileStatusInfo { overwrittenBy?: string; /** Extension ID if this file comes from an extension */ extensionId?: string; - /** If true, hidden from / menu (user-invokable: false) */ - userInvokable?: boolean; + /** If false, hidden from / menu (user-invocable: false) */ + userInvocable?: boolean; /** If true, won't be auto-loaded by agent (disable-model-invocation: true) */ disableModelInvocation?: boolean; } @@ -466,7 +466,7 @@ function convertDiscoveryResultToFileStatus(result: IPromptFileDiscoveryResult): name: result.name, storage: result.storage, extensionId: result.extensionId, - userInvokable: result.userInvokable, + userInvocable: result.userInvocable, disableModelInvocation: result.disableModelInvocation }; } @@ -789,8 +789,8 @@ function getSkillFlags(file: IFileStatusInfo, type: PromptsType): string { flags.push(`${ICON_MANUAL} *${nls.localize('status.skill.manualOnly', 'manual only')}*`); } - // userInvokable: false means hidden from / menu - if (file.userInvokable === false) { + // userInvocable: false means hidden from / menu + if (file.userInvocable === false) { flags.push(`${ICON_HIDDEN} *${nls.localize('status.skill.hiddenFromMenu', 'hidden from menu')}*`); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index fdadd6673579c..65afc29143b0f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -201,14 +201,14 @@ class SlashCommandCompletions extends Disposable { return null; } - // Filter out commands that are not user-invokable (hidden from / menu) - const userInvokableCommands = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); - if (userInvokableCommands.length === 0) { + // Filter out commands that are not user-invocable (hidden from / menu) + const userInvocableCommands = promptCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); + if (userInvocableCommands.length === 0) { return null; } return { - suggestions: userInvokableCommands.map((c, i): CompletionItem => { + suggestions: userInvocableCommands.map((c, i): CompletionItem => { const label = `/${c.name}`; const description = c.description; return { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index f4d5712527aca..a099229a2702a 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -122,7 +122,7 @@ export class ChatModeService extends Disposable implements IChatModeService { agentInstructions: cachedMode.modeInstructions ?? { content: cachedMode.body ?? '', toolReferences: [] }, handOffs: cachedMode.handOffs, target: cachedMode.target ?? Target.Undefined, - visibility: cachedMode.visibility ?? { userInvokable: true, agentInvokable: cachedMode.infer !== false }, + visibility: cachedMode.visibility ?? { userInvocable: true, agentInvocable: cachedMode.infer !== false }, agents: cachedMode.agents, source: reviveChatModeSource(cachedMode.source) ?? { storage: PromptsStorage.local } }; @@ -154,7 +154,7 @@ export class ChatModeService extends Disposable implements IChatModeService { const seenUris = new Set(); for (const customMode of customModes) { - if (!customMode.visibility.userInvokable) { + if (!customMode.visibility.userInvocable) { continue; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts index 2ca88074c2675..50c007fc1168d 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/computeAutomaticInstructions.ts @@ -349,8 +349,8 @@ export class ComputeAutomaticInstructions { const agentSkills = await this._promptsService.findAgentSkills(token); // Filter out skills with disableModelInvocation=true (they can only be triggered manually via /name) - const modelInvokableSkills = agentSkills?.filter(skill => !skill.disableModelInvocation); - if (modelInvokableSkills && modelInvokableSkills.length > 0) { + const modelInvocableSkills = agentSkills?.filter(skill => !skill.disableModelInvocation); + if (modelInvocableSkills && modelInvocableSkills.length > 0) { const useSkillAdherencePrompt = this._configurationService.getValue(PromptsConfig.USE_SKILL_ADHERENCE_PROMPT); entries.push(''); if (useSkillAdherencePrompt) { @@ -372,7 +372,7 @@ export class ComputeAutomaticInstructions { entries.push('Each skill comes with a description of the topic and a file path that contains the detailed instructions.'); entries.push(`When a user asks you to perform a task that falls within the domain of a skill, use the ${readTool.variable} tool to acquire the full instructions from the file URI.`); } - for (const skill of modelInvokableSkills) { + for (const skill of modelInvocableSkills) { entries.push(''); entries.push(`${skill.name}`); if (skill.description) { @@ -387,7 +387,7 @@ export class ComputeAutomaticInstructions { if (runSubagentTool && this._configurationService.getValue(ChatConfiguration.SubagentToolCustomAgents)) { const canUseAgent = (() => { if (!this._enabledSubagents || this._enabledSubagents.includes('*')) { - return (agent: ICustomAgent) => agent.visibility.agentInvokable; + return (agent: ICustomAgent) => agent.visibility.agentInvocable; } else { const subagents = this._enabledSubagents; return (agent: ICustomAgent) => subagents.includes(agent.name); diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 9035e6bf40400..3ca82e90d52ca 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -299,7 +299,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return [{ name: '["*"]' }]; } break; - case PromptHeaderAttributes.userInvokable: + case PromptHeaderAttributes.userInvocable: if (promptType === PromptsType.agent || promptType === PromptsType.skill) { return [{ name: 'true' }, { name: 'false' }]; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index c1df4ed4ab0c6..662ce1962657e 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -87,7 +87,7 @@ export class PromptHoverProvider implements HoverProvider { case PromptHeaderAttributes.handOffs: return this.getHandsOffHover(attribute, position, target); case PromptHeaderAttributes.infer: - return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invokable` and `disable-model-invocation` instead.'), attribute.range); + return this.createHover(description + '\n\n' + localize('promptHeader.attribute.infer.hover', 'Deprecated: Use `user-invocable` and `disable-model-invocation` instead.'), attribute.range); default: return this.createHover(description, attribute.range); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 802e88203dd31..55559b5b04fea 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -186,6 +186,7 @@ export class PromptValidator { case PromptsType.agent: { this.validateTarget(attributes, report); this.validateInfer(attributes, report); + this.validateUserInvocable(attributes, report); this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); this.validateTools(attributes, ChatModeKind.Agent, target, report); @@ -200,6 +201,7 @@ export class PromptValidator { } case PromptsType.skill: + this.validateUserInvocable(attributes, report); this.validateUserInvokable(attributes, report); this.validateDisableModelInvocation(attributes, report); break; @@ -604,7 +606,7 @@ export class PromptValidator { if (!attribute) { return; } - report(toMarker(localize('promptValidator.inferDeprecated', "The 'infer' attribute is deprecated in favour of 'user-invokable' and 'disable-model-invocation'."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.inferDeprecated', "The 'infer' attribute is deprecated in favour of 'user-invocable' and 'disable-model-invocation'."), attribute.value.range, MarkerSeverity.Error)); } private validateTarget(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { @@ -627,15 +629,23 @@ export class PromptValidator { } } - private validateUserInvokable(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { - const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.userInvokable); + private validateUserInvocable(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.userInvocable); if (!attribute) { return; } if (attribute.value.type !== 'boolean') { - report(toMarker(localize('promptValidator.userInvokableMustBeBoolean', "The 'user-invokable' attribute must be a boolean."), attribute.value.range, MarkerSeverity.Error)); + report(toMarker(localize('promptValidator.userInvocableMustBeBoolean', "The 'user-invocable' attribute must be a boolean."), attribute.value.range, MarkerSeverity.Error)); + return; + } + } + + private validateUserInvokable(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { + const attribute = attributes.find(attr => attr.key === PromptHeaderAttributes.userInvokable); + if (!attribute) { return; } + report(toMarker(localize('promptValidator.userInvokableDeprecated', "The 'user-invokable' attribute is deprecated. Use 'user-invocable' instead."), attribute.range, MarkerSeverity.Warning)); } private validateDisableModelInvocation(attributes: IHeaderAttribute[], report: (markers: IMarkerData) => void): undefined { @@ -690,8 +700,8 @@ export class PromptValidator { const allAttributeNames: Record = { [PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint], [PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent], - [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], - [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], + [PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer, PromptHeaderAttributes.agents, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], + [PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.userInvocable, PromptHeaderAttributes.userInvokable, PromptHeaderAttributes.disableModelInvocation], [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter }; const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer]; @@ -718,7 +728,7 @@ export function getValidAttributeNames(promptType: PromptsType, includeNonRecomm } export function isNonRecommendedAttribute(attributeName: string): boolean { - return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer; + return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer || attributeName === PromptHeaderAttributes.userInvokable; } export function getAttributeDescription(attributeName: string, promptType: PromptsType, target: Target): string | undefined { @@ -749,8 +759,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp return localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'); case PromptHeaderAttributes.argumentHint: return localize('promptHeader.skill.argumentHint', 'Hint shown during autocomplete to indicate expected arguments. Example: [issue-number] or [filename] [format]'); - case PromptHeaderAttributes.userInvokable: - return localize('promptHeader.skill.userInvokable', 'Set to false to hide from the / menu. Use for background knowledge users should not invoke directly. Default: true.'); + case PromptHeaderAttributes.userInvocable: + return localize('promptHeader.skill.userInvocable', 'Set to false to hide from the / menu. Use for background knowledge users should not invoke directly. Default: true.'); case PromptHeaderAttributes.disableModelInvocation: return localize('promptHeader.skill.disableModelInvocation', 'Set to true to prevent the agent from automatically loading this skill. Use for workflows you want to trigger manually with /name. Default: false.'); } @@ -775,8 +785,8 @@ export function getAttributeDescription(attributeName: string, promptType: Promp return localize('promptHeader.agent.infer', 'Controls visibility of the agent.'); case PromptHeaderAttributes.agents: return localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); - case PromptHeaderAttributes.userInvokable: - return localize('promptHeader.agent.userInvokable', 'Whether the agent can be selected and invoked by users in the UI.'); + case PromptHeaderAttributes.userInvocable: + return localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.'); case PromptHeaderAttributes.disableModelInvocation: return localize('promptHeader.agent.disableModelInvocation', 'If true, prevents the agent from being invoked as a subagent.'); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 778cf3a82454b..bb0bc23424442 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -82,6 +82,7 @@ export namespace PromptHeaderAttributes { export const metadata = 'metadata'; export const agents = 'agents'; export const userInvokable = 'user-invokable'; + export const userInvocable = 'user-invocable'; export const disableModelInvocation = 'disable-model-invocation'; } @@ -327,8 +328,9 @@ export class PromptHeader { return this.getStringArrayAttribute(PromptHeaderAttributes.agents); } - public get userInvokable(): boolean | undefined { - return this.getBooleanAttribute(PromptHeaderAttributes.userInvokable); + public get userInvocable(): boolean | undefined { + // TODO: user-invokable is deprecated, remove later and only keep user-invocable + return this.getBooleanAttribute(PromptHeaderAttributes.userInvocable) ?? this.getBooleanAttribute(PromptHeaderAttributes.userInvokable); } public get disableModelInvocation(): boolean | undefined { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index f59d436d4fd2b..8ee90218f2b49 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -125,16 +125,16 @@ export type IAgentSource = { * - 'hidden': neither in picker nor usable as subagent */ export type ICustomAgentVisibility = { - readonly userInvokable: boolean; - readonly agentInvokable: boolean; + readonly userInvocable: boolean; + readonly agentInvocable: boolean; }; export function isCustomAgentVisibility(obj: unknown): obj is ICustomAgentVisibility { if (typeof obj !== 'object' || obj === null) { return false; } - const v = obj as { userInvokable?: unknown; agentInvokable?: unknown }; - return typeof v.userInvokable === 'boolean' && typeof v.agentInvokable === 'boolean'; + const v = obj as { userInvocable?: unknown; agentInvocable?: unknown }; + return typeof v.userInvocable === 'boolean' && typeof v.agentInvocable === 'boolean'; } export enum Target { @@ -181,7 +181,7 @@ export interface ICustomAgent { readonly target: Target; /** - * What visibility the agent has (user invokable, subagent invokable). + * What visibility the agent has (user invocable, subagent invocable). */ readonly visibility: ICustomAgentVisibility; @@ -235,7 +235,7 @@ export interface IAgentSkill { * If false, the skill is hidden from the / menu. * Use for background knowledge users shouldn't invoke directly. */ - readonly userInvokable: boolean; + readonly userInvocable: boolean; } /** @@ -291,8 +291,8 @@ export interface IPromptFileDiscoveryResult { readonly duplicateOf?: URI; /** Extension ID if from extension */ readonly extensionId?: string; - /** If true, the skill is hidden from the / menu (user-invokable: false) */ - readonly userInvokable?: boolean; + /** Whether the skill is user-invocable in the / menu (set user-invocable: false to hide it) */ + readonly userInvocable?: boolean; /** If true, the skill won't be automatically loaded by the agent (disable-model-invocation: true) */ readonly disableModelInvocation?: boolean; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 257cb5fdc57b6..25ba3b58bd7fa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -579,11 +579,11 @@ export class PromptsService extends Disposable implements IPromptsService { const source: IAgentSource = IAgentSource.fromPromptPath(promptPath); if (!ast.header) { - return { uri, name, agentInstructions, source, target, visibility: { userInvokable: true, agentInvokable: true } }; + return { uri, name, agentInstructions, source, target, visibility: { userInvocable: true, agentInvocable: true } }; } const visibility = { - userInvokable: ast.header.userInvokable !== false, - agentInvokable: ast.header.infer === true || ast.header.disableModelInvocation !== true, + userInvocable: ast.header.userInvocable !== false, + agentInvocable: ast.header.infer === true || ast.header.disableModelInvocation !== true, } satisfies ICustomAgentVisibility; let model = ast.header.model; @@ -916,7 +916,7 @@ export class PromptsService extends Disposable implements IPromptsService { name: file.name, description: sanitizedDescription, disableModelInvocation: file.disableModelInvocation ?? false, - userInvokable: file.userInvokable ?? true + userInvocable: file.userInvocable ?? true }); } } @@ -1124,10 +1124,10 @@ export class PromptsService extends Disposable implements IPromptsService { * Returns the discovery results and a map of skill counts by source type for telemetry. */ private async computeSkillDiscoveryInfo(token: CancellationToken): Promise<{ - files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvokable?: boolean })[]; + files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvocable?: boolean })[]; skillsBySource: Map; }> { - const files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvokable?: boolean })[] = []; + const files: (IPromptFileDiscoveryResult & { description?: string; source?: PromptFileSource; disableModelInvocation?: boolean; userInvocable?: boolean })[] = []; const skillsBySource = new Map(); const seenNames = new Set(); const nameToUri = new Map(); @@ -1206,8 +1206,8 @@ export class PromptsService extends Disposable implements IPromptsService { seenNames.add(sanitizedName); nameToUri.set(sanitizedName, uri); const disableModelInvocation = parsedFile.header?.disableModelInvocation === true; - const userInvokable = parsedFile.header?.userInvokable !== false; - files.push({ uri, storage, status: 'loaded', name: sanitizedName, description, extensionId, source, disableModelInvocation, userInvokable }); + const userInvocable = parsedFile.header?.userInvocable !== false; + files.push({ uri, storage, status: 'loaded', name: sanitizedName, description, extensionId, source, disableModelInvocation, userInvocable }); // Track skill type skillsBySource.set(source, (skillsBySource.get(source) || 0) + 1); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts index 36a6634e5642e..db6a9d11f99ae 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHeaderAutocompletion.test.ts @@ -76,7 +76,7 @@ suite('PromptHeaderAutocompletion', () => { uri: URI.parse('myFs://.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; const parser = new PromptFileParser(); @@ -145,7 +145,7 @@ suite('PromptHeaderAutocompletion', () => { { label: 'name', result: 'name: $0' }, { label: 'target', result: 'target: ${0:vscode}' }, { label: 'tools', result: 'tools: ${0:[]}' }, - { label: 'user-invokable', result: 'user-invokable: ${0:true}' }, + { label: 'user-invocable', result: 'user-invocable: ${0:true}' }, ].sort(sortByLabel)); }); @@ -332,18 +332,18 @@ suite('PromptHeaderAutocompletion', () => { ].sort(sortByLabel)); }); - test('complete user-invokable attribute value', async () => { + test('complete user-invocable attribute value', async () => { const content = [ '---', 'description: "Test"', - 'user-invokable: |', + 'user-invocable: |', '---', ].join('\n'); const actual = await getCompletions(content, PromptsType.agent); assert.deepStrictEqual(actual.sort(sortByLabel), [ - { label: 'false', result: 'user-invokable: false' }, - { label: 'true', result: 'user-invokable: true' }, + { label: 'false', result: 'user-invocable: false' }, + { label: 'true', result: 'user-invocable: true' }, ].sort(sortByLabel)); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts index 476b8011ad05a..f8042fe069e53 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptHovers.test.ts @@ -79,7 +79,7 @@ suite('PromptHoverProvider', () => { agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -323,7 +323,7 @@ suite('PromptHoverProvider', () => { '---', ].join('\n'); const hover = await getHover(content, 4, 1, PromptsType.agent); - assert.strictEqual(hover, 'Controls visibility of the agent.\n\nDeprecated: Use `user-invokable` and `disable-model-invocation` instead.'); + assert.strictEqual(hover, 'Controls visibility of the agent.\n\nDeprecated: Use `user-invocable` and `disable-model-invocation` instead.'); }); test('hover on agents attribute shows description', async () => { @@ -338,12 +338,12 @@ suite('PromptHoverProvider', () => { assert.strictEqual(hover, 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'); }); - test('hover on user-invokable attribute shows description', async () => { + test('hover on user-invocable attribute shows description', async () => { const content = [ '---', 'name: "Test Agent"', 'description: "Test agent"', - 'user-invokable: true', + 'user-invocable: true', '---', ].join('\n'); const hover = await getHover(content, 4, 1, PromptsType.agent); diff --git a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts index 071f767b5d142..5fe0c45d88f71 100644 --- a/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/promptSyntax/languageProviders/promptValidator.test.ts @@ -136,7 +136,7 @@ suite('PromptValidator', () => { agentInstructions: { content: 'Beast mode instructions', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }); instaService.stub(IChatModeService, new MockChatModeService({ builtin: [ChatMode.Agent, ChatMode.Ask, ChatMode.Edit], custom: [customChatMode] })); @@ -156,7 +156,7 @@ suite('PromptValidator', () => { agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); instaService.stub(IPromptsService, promptsService); @@ -511,7 +511,7 @@ suite('PromptValidator', () => { assert.deepStrictEqual( markers.map(m => ({ severity: m.severity, message: m.message })), [ - { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, handoffs, model, name, target, tools, user-invokable.` }, + { severity: MarkerSeverity.Warning, message: `Attribute 'applyTo' is not supported in VS Code agent files. Supported: agents, argument-hint, description, disable-model-invocation, handoffs, model, name, target, tools, user-invocable.` }, ] ); }); @@ -846,7 +846,7 @@ suite('PromptValidator', () => { }); test('infer attribute validation', async () => { - const deprecationMessage = `The 'infer' attribute is deprecated in favour of 'user-invokable' and 'disable-model-invocation'.`; + const deprecationMessage = `The 'infer' attribute is deprecated in favour of 'user-invocable' and 'disable-model-invocation'.`; // Valid infer: true (maps to 'all') - shows deprecation warning { @@ -1085,68 +1085,83 @@ suite('PromptValidator', () => { assert.deepStrictEqual(markers, [], 'Empty array should not require agent tool'); }); - test('user-invokable attribute validation', async () => { - // Valid user-invokable: true + test('user-invocable attribute validation', async () => { + // Valid user-invocable: true { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: true', + 'user-invocable: true', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); - assert.deepStrictEqual(markers, [], 'Valid user-invokable: true should not produce errors'); + assert.deepStrictEqual(markers, [], 'Valid user-invocable: true should not produce errors'); } - // Valid user-invokable: false + // Valid user-invocable: false { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); - assert.deepStrictEqual(markers, [], 'Valid user-invokable: false should not produce errors'); + assert.deepStrictEqual(markers, [], 'Valid user-invocable: false should not produce errors'); } - // Invalid user-invokable: string value + // Invalid user-invocable: string value { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: "yes"', + 'user-invocable: "yes"', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); } - // Invalid user-invokable: number value + // Invalid user-invocable: number value { const content = [ '---', 'name: "TestAgent"', 'description: "Test agent"', - 'user-invokable: 1', + 'user-invocable: 1', '---', 'Body', ].join('\n'); const markers = await validate(content, PromptsType.agent); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); } }); + test('deprecated user-invokable attribute shows warning', async () => { + const content = [ + '---', + 'name: "TestAgent"', + 'description: "Test agent"', + 'user-invokable: true', + '---', + 'Body', + ].join('\n'); + const markers = await validate(content, PromptsType.agent); + assert.strictEqual(markers.length, 1); + assert.strictEqual(markers[0].severity, MarkerSeverity.Warning); + assert.strictEqual(markers[0].message, `The 'user-invokable' attribute is deprecated. Use 'user-invocable' instead.`); + }); + test('disable-model-invocation attribute validation', async () => { // Valid disable-model-invocation: true { @@ -1674,47 +1689,47 @@ suite('PromptValidator', () => { assert.ok(markers.every(m => m.message.includes('Supported: '))); }); - test('skill with user-invokable: false is valid', async () => { + test('skill with user-invocable: false is valid', async () => { const content = [ '---', 'name: my-skill', 'description: Background knowledge skill', - 'user-invokable: false', + 'user-invocable: false', '---', 'This skill provides background context.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'user-invokable: false should be valid for skills'); + assert.deepStrictEqual(markers, [], 'user-invocable: false should be valid for skills'); }); - test('skill with user-invokable: true is valid', async () => { + test('skill with user-invocable: true is valid', async () => { const content = [ '---', 'name: my-skill', 'description: User-accessible skill', - 'user-invokable: true', + 'user-invocable: true', '---', 'This skill can be invoked by users.' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); - assert.deepStrictEqual(markers, [], 'user-invokable: true should be valid for skills'); + assert.deepStrictEqual(markers, [], 'user-invocable: true should be valid for skills'); }); - test('skill with invalid user-invokable value shows error', async () => { + test('skill with invalid user-invocable value shows error', async () => { // String value instead of boolean { const content = [ '---', 'name: my-skill', 'description: Test Skill', - 'user-invokable: "false"', + 'user-invocable: "false"', '---', 'Body' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); } // Number value instead of boolean @@ -1723,14 +1738,14 @@ suite('PromptValidator', () => { '---', 'name: my-skill', 'description: Test Skill', - 'user-invokable: 0', + 'user-invocable: 0', '---', 'Body' ].join('\n'); const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md')); assert.strictEqual(markers.length, 1); assert.strictEqual(markers[0].severity, MarkerSeverity.Error); - assert.strictEqual(markers[0].message, `The 'user-invokable' attribute must be a boolean.`); + assert.strictEqual(markers[0].message, `The 'user-invocable' attribute must be a boolean.`); } }); @@ -1842,7 +1857,7 @@ suite('PromptValidator', () => { '---', 'name: my-skill', 'description: Complex visibility skill', - 'user-invokable: false', + 'user-invocable: false', 'disable-model-invocation: true', 'argument-hint: "[optional-arg]"', '---', diff --git a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts index 75f22e0c69f2b..f7745a0b6fa9f 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatModeService.test.ts @@ -120,7 +120,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -158,7 +158,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Custom mode body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -178,7 +178,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Findable mode body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -204,7 +204,7 @@ suite('ChatModeService', () => { model: ['gpt-4'], source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([initialMode]); @@ -249,7 +249,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Mode 1 body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; const mode2: ICustomAgent = { @@ -260,7 +260,7 @@ suite('ChatModeService', () => { agentInstructions: { content: 'Mode 2 body', toolReferences: [] }, source: workspaceSource, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; // Add both modes diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts index 5733ddff8275a..3cb6fe2ad58fe 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/computeAutomaticInstructions.test.ts @@ -1150,7 +1150,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 1\'', - 'user-invokable: true', + 'user-invocable: true', 'disable-model-invocation: false', '---', 'Test agent content', @@ -1161,7 +1161,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 2\'', - 'user-invokable: true', + 'user-invocable: true', 'disable-model-invocation: true', '---', 'Test agent content', @@ -1172,7 +1172,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 3\'', - 'user-invokable: false', + 'user-invocable: false', 'disable-model-invocation: false', '---', 'Test agent content', @@ -1183,7 +1183,7 @@ suite('ComputeAutomaticInstructions', () => { contents: [ '---', 'description: \'Test agent 4\'', - 'user-invokable: false', + 'user-invocable: false', 'disable-model-invocation: true', '---', 'Test agent content', diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts index 156e28227d776..d4a3d7d430a27 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptFileParser.test.ts @@ -407,4 +407,48 @@ suite('PromptFileParser', () => { }); + test('userInvocable getter falls back to deprecated user-invokable', async () => { + const uri = URI.parse('file:///test/test.agent.md'); + + // user-invocable (new spelling) takes precedence + const content1 = [ + '---', + 'description: "Test"', + 'user-invocable: true', + '---', + ].join('\n'); + const result1 = new PromptFileParser().parse(uri, content1); + assert.strictEqual(result1.header?.userInvocable, true); + + // deprecated user-invokable still works as fallback + const content2 = [ + '---', + 'description: "Test"', + 'user-invokable: false', + '---', + ].join('\n'); + const result2 = new PromptFileParser().parse(uri, content2); + assert.strictEqual(result2.header?.userInvocable, false); + + // user-invocable takes precedence over deprecated user-invokable + const content3 = [ + '---', + 'description: "Test"', + 'user-invocable: true', + 'user-invokable: false', + '---', + ].join('\n'); + const result3 = new PromptFileParser().parse(uri, content3); + assert.strictEqual(result3.header?.userInvocable, true); + + // neither set returns undefined + const content4 = [ + '---', + 'description: "Test"', + '---', + ].join('\n'); + const result4 = new PromptFileParser().parse(uri, content4); + assert.strictEqual(result4.header?.userInvocable, undefined); + }); + }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index f4e54cac7f152..dbcc9c0b2facd 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -769,7 +769,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } @@ -825,7 +825,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local }, @@ -843,7 +843,7 @@ suite('PromptsService', () => { uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } } ]; @@ -900,7 +900,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent1.agent.md'), source: { storage: PromptsStorage.local } @@ -918,7 +918,7 @@ suite('PromptsService', () => { model: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/agent2.agent.md'), source: { storage: PromptsStorage.local } @@ -988,7 +988,7 @@ suite('PromptsService', () => { handOffs: undefined, model: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/github-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1006,7 +1006,7 @@ suite('PromptsService', () => { handOffs: undefined, argumentHint: undefined, tools: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/vscode-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1024,7 +1024,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/generic-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1101,7 +1101,7 @@ suite('PromptsService', () => { }, handOffs: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/copilot-agent.agent.md'), source: { storage: PromptsStorage.local } @@ -1121,7 +1121,7 @@ suite('PromptsService', () => { }, handOffs: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent.md'), source: { storage: PromptsStorage.local } @@ -1140,7 +1140,7 @@ suite('PromptsService', () => { }, handOffs: undefined, argumentHint: undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.claude/agents/claude-agent2.md'), source: { storage: PromptsStorage.local } @@ -1195,7 +1195,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, agents: undefined, uri: URI.joinPath(rootFolderUri, '.github/agents/demonstrate.md'), source: { storage: PromptsStorage.local } @@ -1266,7 +1266,7 @@ suite('PromptsService', () => { model: undefined, argumentHint: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/restricted-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1284,7 +1284,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/no-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -1302,7 +1302,7 @@ suite('PromptsService', () => { argumentHint: undefined, tools: undefined, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true }, + visibility: { userInvocable: true, agentInvocable: true }, uri: URI.joinPath(rootFolderUri, '.github/agents/full-access-agent.agent.md'), source: { storage: PromptsStorage.local } }, @@ -3101,22 +3101,22 @@ suite('PromptsService', () => { }); }); - suite('getPromptSlashCommands - userInvokable filtering', () => { + suite('getPromptSlashCommands - userInvocable filtering', () => { teardown(() => { sinon.restore(); }); - test('should return correct userInvokable value for skills with user-invokable: false', async () => { + test('should return correct userInvocable value for skills with user-invocable: false', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'user-invokable-false'; + const rootFolderName = 'user-invocable-false'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a skill with user-invokable: false (should be hidden from / menu) + // Create a skill with user-invocable: false (should be hidden from / menu) await mockFiles(fileService, [ { path: `${rootFolder}/.github/skills/hidden-skill/SKILL.md`, @@ -3124,7 +3124,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-skill"', 'description: "A skill hidden from the / menu"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden skill content', ], @@ -3135,27 +3135,27 @@ suite('PromptsService', () => { const hiddenSkillCommand = slashCommands.find(cmd => cmd.name === 'hidden-skill'); assert.ok(hiddenSkillCommand, 'Should find hidden skill in slash commands'); - assert.strictEqual(hiddenSkillCommand.parsedPromptFile?.header?.userInvokable, false, - 'Should have userInvokable=false in parsed header'); + assert.strictEqual(hiddenSkillCommand.parsedPromptFile?.header?.userInvocable, false, + 'Should have userInvocable=false in parsed header'); // Verify the filtering logic would correctly exclude this skill - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const hiddenSkillInFiltered = filteredCommands.find(cmd => cmd.name === 'hidden-skill'); assert.strictEqual(hiddenSkillInFiltered, undefined, - 'Hidden skill should be filtered out when applying userInvokable filter'); + 'Hidden skill should be filtered out when applying userInvocable filter'); }); - test('should return correct userInvokable value for skills with user-invokable: true', async () => { + test('should return correct userInvocable value for skills with user-invocable: true', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'user-invokable-true'; + const rootFolderName = 'user-invocable-true'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a skill with explicit user-invokable: true + // Create a skill with explicit user-invocable: true await mockFiles(fileService, [ { path: `${rootFolder}/.github/skills/visible-skill/SKILL.md`, @@ -3163,7 +3163,7 @@ suite('PromptsService', () => { '---', 'name: "visible-skill"', 'description: "A skill visible in the / menu"', - 'user-invokable: true', + 'user-invocable: true', '---', 'Visible skill content', ], @@ -3174,34 +3174,34 @@ suite('PromptsService', () => { const visibleSkillCommand = slashCommands.find(cmd => cmd.name === 'visible-skill'); assert.ok(visibleSkillCommand, 'Should find visible skill in slash commands'); - assert.strictEqual(visibleSkillCommand.parsedPromptFile?.header?.userInvokable, true, - 'Should have userInvokable=true in parsed header'); + assert.strictEqual(visibleSkillCommand.parsedPromptFile?.header?.userInvocable, true, + 'Should have userInvocable=true in parsed header'); // Verify the filtering logic would correctly include this skill - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const visibleSkillInFiltered = filteredCommands.find(cmd => cmd.name === 'visible-skill'); assert.ok(visibleSkillInFiltered, - 'Visible skill should be included when applying userInvokable filter'); + 'Visible skill should be included when applying userInvocable filter'); }); - test('should default to true for skills without user-invokable attribute', async () => { + test('should default to true for skills without user-invocable attribute', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'user-invokable-undefined'; + const rootFolderName = 'user-invocable-undefined'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a skill without user-invokable attribute (should default to true) + // Create a skill without user-invocable attribute (should default to true) await mockFiles(fileService, [ { path: `${rootFolder}/.github/skills/default-skill/SKILL.md`, contents: [ '---', 'name: "default-skill"', - 'description: "A skill without explicit user-invokable"', + 'description: "A skill without explicit user-invocable"', '---', 'Default skill content', ], @@ -3212,24 +3212,24 @@ suite('PromptsService', () => { const defaultSkillCommand = slashCommands.find(cmd => cmd.name === 'default-skill'); assert.ok(defaultSkillCommand, 'Should find default skill in slash commands'); - assert.strictEqual(defaultSkillCommand.parsedPromptFile?.header?.userInvokable, undefined, - 'Should have userInvokable=undefined when attribute is not specified'); + assert.strictEqual(defaultSkillCommand.parsedPromptFile?.header?.userInvocable, undefined, + 'Should have userInvocable=undefined when attribute is not specified'); // Verify the filtering logic would correctly include this skill (undefined !== false is true) - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const defaultSkillInFiltered = filteredCommands.find(cmd => cmd.name === 'default-skill'); assert.ok(defaultSkillInFiltered, - 'Skill without user-invokable attribute should be included when applying userInvokable filter'); + 'Skill without user-invocable attribute should be included when applying userInvocable filter'); }); - test('should handle prompts with user-invokable: false', async () => { - const rootFolderName = 'prompt-user-invokable-false'; + test('should handle prompts with user-invocable: false', async () => { + const rootFolderName = 'prompt-user-invocable-false'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a prompt with user-invokable: false + // Create a prompt with user-invocable: false await mockFiles(fileService, [ { path: `${rootFolder}/.github/prompts/hidden-prompt.prompt.md`, @@ -3237,7 +3237,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-prompt"', 'description: "A prompt hidden from the / menu"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden prompt content', ], @@ -3248,27 +3248,27 @@ suite('PromptsService', () => { const hiddenPromptCommand = slashCommands.find(cmd => cmd.name === 'hidden-prompt'); assert.ok(hiddenPromptCommand, 'Should find hidden prompt in slash commands'); - assert.strictEqual(hiddenPromptCommand.parsedPromptFile?.header?.userInvokable, false, - 'Should have userInvokable=false in parsed header'); + assert.strictEqual(hiddenPromptCommand.parsedPromptFile?.header?.userInvocable, false, + 'Should have userInvocable=false in parsed header'); // Verify the filtering logic would correctly exclude this prompt - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const hiddenPromptInFiltered = filteredCommands.find(cmd => cmd.name === 'hidden-prompt'); assert.strictEqual(hiddenPromptInFiltered, undefined, - 'Hidden prompt should be filtered out when applying userInvokable filter'); + 'Hidden prompt should be filtered out when applying userInvocable filter'); }); - test('should correctly filter mixed user-invokable values', async () => { + test('should correctly filter mixed user-invocable values', async () => { testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - const rootFolderName = 'mixed-user-invokable'; + const rootFolderName = 'mixed-user-invocable'; const rootFolder = `/${rootFolderName}`; const rootFolderUri = URI.file(rootFolder); workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - // Create a mix of skills and prompts with different user-invokable values + // Create a mix of skills and prompts with different user-invocable values await mockFiles(fileService, [ { path: `${rootFolder}/.github/prompts/visible-prompt.prompt.md`, @@ -3286,7 +3286,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-prompt"', 'description: "A hidden prompt"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden prompt content', ], @@ -3297,7 +3297,7 @@ suite('PromptsService', () => { '---', 'name: "visible-skill"', 'description: "A visible skill"', - 'user-invokable: true', + 'user-invocable: true', '---', 'Visible skill content', ], @@ -3308,7 +3308,7 @@ suite('PromptsService', () => { '---', 'name: "hidden-skill"', 'description: "A hidden skill"', - 'user-invokable: false', + 'user-invocable: false', '---', 'Hidden skill content', ], @@ -3321,7 +3321,7 @@ suite('PromptsService', () => { assert.strictEqual(slashCommands.length, 4, 'Should find all 4 commands'); // Apply the same filtering logic as chatInputCompletions.ts - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); assert.strictEqual(filteredCommands.length, 2, 'Should have 2 commands after filtering'); assert.ok(filteredCommands.find(c => c.name === 'visible-prompt'), 'visible-prompt should be included'); @@ -3361,13 +3361,13 @@ suite('PromptsService', () => { 'Should have undefined header'); // Verify the filtering logic handles missing header correctly - // parsedPromptFile?.header?.userInvokable !== false + // parsedPromptFile?.header?.userInvocable !== false // When header is undefined: undefined !== false is true, so skill is included - const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvokable !== false); + const filteredCommands = slashCommands.filter(c => c.parsedPromptFile?.header?.userInvocable !== false); const noHeaderSkillInFiltered = filteredCommands.find(cmd => cmd.promptPath.uri.path.includes('no-header-skill')); assert.ok(noHeaderSkillInFiltered, - 'Skill without header should be included when applying userInvokable filter (defaults to true)'); + 'Skill without header should be included when applying userInvocable filter (defaults to true)'); }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts index 32809f2ebdd94..df306a3ef3ad0 100644 --- a/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/tools/builtinTools/runSubagentTool.test.ts @@ -56,7 +56,7 @@ suite('RunSubagentTool', () => { agentInstructions: { content: 'Custom agent body', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; promptsService.setCustomModes([customMode]); @@ -276,7 +276,7 @@ suite('RunSubagentTool', () => { agentInstructions: { content: 'test', toolReferences: [] }, source: { storage: PromptsStorage.local }, target: Target.Undefined, - visibility: { userInvokable: true, agentInvokable: true } + visibility: { userInvocable: true, agentInvocable: true } }; } From 16e49a8b88457e6bd43b048933707e77bdad65ff Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 13 Feb 2026 13:40:49 +1100 Subject: [PATCH 026/221] Support prompt file slash commands in background agents (#295070) * Support prompt file slash commands in backgrround agents * Update tests * Update comments --- .../chatSessions/chatSessions.contribution.ts | 4 + .../contrib/chat/browser/widget/chatWidget.ts | 2 + .../input/editor/chatInputCompletions.ts | 2 +- .../chat/common/participants/chatAgents.ts | 1 + .../common/requestParser/chatRequestParser.ts | 9 +- ...rity_with_supportsPromptAttachments.0.snap | 85 +++++++++++++++++ ...agent_and_supportsPromptAttachments.0.snap | 82 ++++++++++++++++ ...nt_but_no_supportsPromptAttachments.0.snap | 54 +++++++++++ ...agent_and_supportsPromptAttachments.0.snap | 82 ++++++++++++++++ .../requestParser/chatRequestParser.test.ts | 93 +++++++++++++++++++ 10 files changed, 410 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap create mode 100644 src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap create mode 100644 src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap create mode 100644 src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 8a6372af31d1a..a5da0877ddac1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -168,6 +168,10 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint = { supportsProblemAttachments: true, supportsSymbolAttachments: true, supportsTerminalAttachments: true, + supportsPromptAttachments: true, }; const DISCLAIMER = localize('chatDisclaimer', "AI responses may be inaccurate."); @@ -315,6 +316,7 @@ export class ChatWidget extends Disposable implements IChatWidget { .parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, + attachmentCapabilities: this.attachmentCapabilities, forcedAgent: this._lockedAgent?.id ? this.chatAgentService.getAgent(this._lockedAgent.id) : undefined }); this._onDidChangeParsedInput.fire(); diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts index 65afc29143b0f..4188fb18fd0be 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/editor/chatInputCompletions.ts @@ -197,7 +197,7 @@ class SlashCommandCompletions extends Disposable { return null; } - if (widget.lockedAgentId) { + if (widget.lockedAgentId && !widget.attachmentCapabilities.supportsPromptAttachments) { return null; } diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 94097d98c96e5..55f5e4c319cc8 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -50,6 +50,7 @@ export interface IChatAgentAttachmentCapabilities { supportsProblemAttachments?: boolean; supportsSymbolAttachments?: boolean; supportsTerminalAttachments?: boolean; + supportsPromptAttachments?: boolean; } export interface IChatAgentData { diff --git a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts index fa07524a29e1a..9994bcffbcb8a 100644 --- a/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts +++ b/src/vs/workbench/contrib/chat/common/requestParser/chatRequestParser.ts @@ -9,7 +9,7 @@ import { Range } from '../../../../../editor/common/core/range.js'; import { OffsetRange } from '../../../../../editor/common/core/ranges/offsetRange.js'; import { IChatVariablesService, IDynamicVariable } from '../attachments/chatVariables.js'; import { ChatAgentLocation, ChatModeKind } from '../constants.js'; -import { IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; +import { IChatAgentAttachmentCapabilities, IChatAgentData, IChatAgentService } from '../participants/chatAgents.js'; import { IChatSlashCommandService } from '../participants/chatSlashCommands.js'; import { IPromptsService } from '../promptSyntax/service/promptsService.js'; import { IToolData, IToolSet, isToolSet } from '../tools/languageModelToolsService.js'; @@ -25,6 +25,7 @@ export interface IChatParserContext { mode?: ChatModeKind; /** Parse as this agent, even when it does not appear in the query text */ forcedAgent?: IChatAgentData; + attachmentCapabilities?: IChatAgentAttachmentCapabilities; } export class ChatRequestParser { @@ -215,7 +216,9 @@ export class ChatRequestParser { // Valid agent subcommand return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand); } - } else { + } + + if (!usedAgent || context?.attachmentCapabilities?.supportsPromptAttachments) { const slashCommands = this.slashCommandService.getCommands(location, context?.mode ?? ChatModeKind.Ask); const slashCommand = slashCommands.find(c => c.command === command); if (slashCommand) { @@ -231,7 +234,7 @@ export class ChatRequestParser { } } - // if there's no agent, asume it is a prompt slash command + // if there's no agent or attachments are supported, asume it is a prompt slash command const isPromptCommand = this.promptsService.isValidSlashCommandName(command); if (isPromptCommand) { return new ChatRequestSlashPromptPart(slashRange, slashEditorRange, command); diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..932d8aaea2dc3 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_agent_subcommand_still_takes_priority_with_supportsPromptAttachments.0.snap @@ -0,0 +1,85 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 18 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 19 + }, + command: { + name: "subCommand", + description: "" + }, + kind: "subcommand" + }, + { + range: { + start: 18, + endExclusive: 31 + }, + editorRange: { + startLineNumber: 1, + startColumn: 19, + endLineNumber: 1, + endColumn: 32 + }, + text: " do something", + kind: "text" + } + ], + text: "@agent /subCommand do something" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..2ae3f526313cb --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_and_supportsPromptAttachments.0.snap @@ -0,0 +1,82 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 16 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 17 + }, + name: "myPrompt", + kind: "prompt" + }, + { + range: { + start: 16, + endExclusive: 29 + }, + editorRange: { + startLineNumber: 1, + startColumn: 17, + endLineNumber: 1, + endColumn: 30 + }, + text: " do something", + kind: "text" + } + ], + text: "@agent /myPrompt do something" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..736584ac2f41f --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_prompt_slash_command_with_agent_but_no_supportsPromptAttachments.0.snap @@ -0,0 +1,54 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 29 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 30 + }, + text: " /myPrompt do something", + kind: "text" + } + ], + text: "@agent /myPrompt do something" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap new file mode 100644 index 0000000000000..b7e770cf3f7c9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/__snapshots__/ChatRequestParser_slash_command_with_agent_and_supportsPromptAttachments.0.snap @@ -0,0 +1,82 @@ +{ + parts: [ + { + range: { + start: 0, + endExclusive: 6 + }, + editorRange: { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 7 + }, + agent: { + id: "agent", + name: "agent", + extensionId: { + value: "nullExtensionDescription", + _lower: "nullextensiondescription" + }, + extensionVersion: undefined, + publisherDisplayName: "", + extensionDisplayName: "", + extensionPublisherId: "", + locations: [ "panel" ], + modes: [ "ask" ], + metadata: { }, + slashCommands: [ + { + name: "subCommand", + description: "" + } + ], + disambiguation: [ ] + }, + kind: "agent" + }, + { + range: { + start: 6, + endExclusive: 7 + }, + editorRange: { + startLineNumber: 1, + startColumn: 7, + endLineNumber: 1, + endColumn: 8 + }, + text: " ", + kind: "text" + }, + { + range: { + start: 7, + endExclusive: 11 + }, + editorRange: { + startLineNumber: 1, + startColumn: 8, + endLineNumber: 1, + endColumn: 12 + }, + slashCommand: { command: "fix" }, + kind: "slash" + }, + { + range: { + start: 11, + endExclusive: 16 + }, + editorRange: { + startLineNumber: 1, + startColumn: 12, + endLineNumber: 1, + endColumn: 17 + }, + text: " this", + kind: "text" + } + ], + text: "@agent /fix this" +} \ No newline at end of file diff --git a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts index 1ec95eed15f7d..96e754d368887 100644 --- a/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/requestParser/chatRequestParser.test.ts @@ -347,4 +347,97 @@ suite('ChatRequestParser', () => { const result = parser.parseChatRequest(testSessionUri, '@agent Please \ndo /subCommand with #selection\nand #debugConsole'); await assertSnapshot(result); }); + + test('prompt slash command with agent and supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { + return !!command.match(/^[\w_\-\.]+$/); + }); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /myPrompt do something', undefined, { + attachmentCapabilities: { supportsPromptAttachments: true } + }); + await assertSnapshot(result); + }); + + test('prompt slash command with agent but no supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { + return !!command.match(/^[\w_\-\.]+$/); + }); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /myPrompt do something', undefined, { + attachmentCapabilities: { supportsPromptAttachments: false } + }); + await assertSnapshot(result); + }); + + test('agent subcommand still takes priority with supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + const promptSlashCommandService = mockObject()({}); + promptSlashCommandService.isValidSlashCommandName.callsFake((command: string) => { + return !!command.match(/^[\w_\-\.]+$/); + }); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IPromptsService, promptSlashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /subCommand do something', undefined, { + attachmentCapabilities: { supportsPromptAttachments: true } + }); + await assertSnapshot(result); + }); + + test('slash command with agent and supportsPromptAttachments', async () => { + const agentsService = mockObject()({}); + agentsService.getAgentsByName.returns([getAgentWithSlashCommands([{ name: 'subCommand', description: '' }])]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatAgentService, agentsService as any); + + const slashCommandService = mockObject()({}); + slashCommandService.getCommands.returns([{ command: 'fix' }]); + // eslint-disable-next-line local/code-no-any-casts + instantiationService.stub(IChatSlashCommandService, slashCommandService as any); + + parser = instantiationService.createInstance(ChatRequestParser); + const result = parser.parseChatRequest(testSessionUri, '@agent /fix this', undefined, { + attachmentCapabilities: { supportsPromptAttachments: true } + }); + await assertSnapshot(result); + }); }); From 2830eafa6c547d8b8f42bda6f3fa2c536475ce63 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Thu, 12 Feb 2026 21:11:57 -0800 Subject: [PATCH 027/221] Add /models and /tools slash commands --- .../chat/browser/actions/chatToolActions.ts | 2 +- .../contrib/chat/browser/chat.contribution.ts | 156 +-------------- .../contrib/chat/browser/chatSlashCommands.ts | 180 ++++++++++++++++++ 3 files changed, 188 insertions(+), 150 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts index 7f0aa2b785212..af3b2c394e33f 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolActions.ts @@ -114,7 +114,7 @@ class SkipToolConfirmation extends ToolConfirmationAction { } } -class ConfigureToolsAction extends Action2 { +export class ConfigureToolsAction extends Action2 { public static ID = 'workbench.action.chat.configureTools'; constructor() { diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 035ee4ffe0801..a974828d7dda3 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -3,9 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { timeout } from '../../../../base/common/async.js'; import { Event } from '../../../../base/common/event.js'; -import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableMap } from '../../../../base/common/lifecycle.js'; import { Schemas } from '../../../../base/common/network.js'; import { isMacintosh } from '../../../../base/common/platform.js'; @@ -14,7 +12,6 @@ import { registerEditorFeature } from '../../../../editor/common/editorFeatures. import * as nls from '../../../../nls.js'; import { AccessibleViewRegistry } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js'; import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; import { Extensions as ConfigurationExtensions, ConfigurationScope, IConfigurationNode, IConfigurationRegistry } from '../../../../platform/configuration/common/configurationRegistry.js'; import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; @@ -46,7 +43,7 @@ import { ChatTodoListService, IChatTodoListService } from '../common/tools/chatT import { ChatTransferService, IChatTransferService } from '../common/model/chatTransferService.js'; import { IChatVariablesService } from '../common/attachments/chatVariables.js'; import { ChatWidgetHistoryService, IChatWidgetHistoryService } from '../common/widget/chatWidgetHistoryService.js'; -import { AgentsControlClickBehavior, ChatAgentLocation, ChatConfiguration, ChatModeKind } from '../common/constants.js'; +import { AgentsControlClickBehavior, ChatConfiguration } from '../common/constants.js'; import { ILanguageModelIgnoredFilesService, LanguageModelIgnoredFilesService } from '../common/ignoredFiles.js'; import { ILanguageModelsService, LanguageModelsService } from '../common/languageModels.js'; import { ILanguageModelStatsService, LanguageModelStatsService } from '../common/languageModelStats.js'; @@ -66,13 +63,13 @@ import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js' import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; -import { ACTION_ID_NEW_CHAT, ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; +import { ModeOpenChatGlobalAction, registerChatActions } from './actions/chatActions.js'; import { CodeBlockActionRendering, registerChatCodeBlockActions, registerChatCodeCompareBlockActions } from './actions/chatCodeblockActions.js'; import { ChatContextContributions } from './actions/chatContext.js'; import { registerChatContextActions } from './actions/chatContextActions.js'; import { registerChatCopyActions } from './actions/chatCopyActions.js'; import { registerChatDeveloperActions } from './actions/chatDeveloperActions.js'; -import { ChatSubmitAction, registerChatExecuteActions } from './actions/chatExecuteActions.js'; +import { registerChatExecuteActions } from './actions/chatExecuteActions.js'; import { registerChatFileTreeActions } from './actions/chatFileTreeActions.js'; import { ChatGettingStartedContribution } from './actions/chatGettingStarted.js'; import { registerChatExportActions } from './actions/chatImportExport.js'; @@ -90,7 +87,7 @@ import { ChatTransferContribution } from './actions/chatTransfer.js'; import { registerChatCustomizationDiagnosticsAction } from './actions/chatCustomizationDiagnosticsAction.js'; import './agentSessions/agentSessions.contribution.js'; import { backgroundAgentDisplayName } from './agentSessions/agentSessions.js'; -import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; + import { IChatAccessibilityService, IChatCodeBlockContextProviderService, IChatWidgetService, IQuickChatService } from './chat.js'; import { ChatAccessibilityService } from './accessibility/chatAccessibilityService.js'; import './attachments/chatAttachmentModel.js'; @@ -111,7 +108,7 @@ import { ChatEditorInput, ChatEditorInputSerializer } from './widgetHosts/editor import { ChatLayoutService } from './widget/chatLayoutService.js'; import { ChatLanguageModelsDataContribution, LanguageModelsConfigurationService } from './languageModelsConfigurationService.js'; import './chatManagement/chatManagement.contribution.js'; -import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; + import { ChatOutputRendererService, IChatOutputRendererService } from './chatOutputItemRenderer.js'; import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatParticipant.contribution.js'; import { ChatPasteProvidersFeature } from './widget/input/editor/chatPasteProviders.js'; @@ -132,7 +129,7 @@ import { LanguageModelToolsConfirmationService } from './tools/languageModelTool import { LanguageModelToolsService, globalAutoApproveDescription } from './tools/languageModelToolsService.js'; import './promptSyntax/promptCodingAgentActionContribution.js'; import './promptSyntax/promptToolsCodeLensProvider.js'; -import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; +import { ChatSlashCommandsContribution } from './chatSlashCommands.js'; import { PromptUrlHandler } from './promptSyntax/promptUrlHandler.js'; import { ConfigureToolSets, UserToolSetsContributions } from './tools/toolSetsContribution.js'; import { ChatViewsWelcomeHandler } from './viewsWelcome/chatViewsWelcomeHandler.js'; @@ -1398,150 +1395,11 @@ AccessibleViewRegistry.register(new EditsChatAccessibilityHelp()); AccessibleViewRegistry.register(new AgentChatAccessibilityHelp()); registerEditorFeature(ChatInputBoxContentProvider); - -class ChatSlashStaticSlashCommandsContribution extends Disposable { - - static readonly ID = 'workbench.contrib.chatSlashStaticSlashCommands'; - - constructor( - @IChatSlashCommandService slashCommandService: IChatSlashCommandService, - @ICommandService commandService: ICommandService, - @IChatAgentService chatAgentService: IChatAgentService, - @IChatWidgetService chatWidgetService: IChatWidgetService, - @IInstantiationService instantiationService: IInstantiationService, - @IAgentSessionsService agentSessionsService: IAgentSessionsService, - ) { - super(); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'clear', - detail: nls.localize('clear', "Start a new chat and archive the current one"), - sortText: 'z2_clear', - executeImmediately: true, - locations: [ChatAgentLocation.Chat] - }, async (_prompt, _progress, _history, _location, sessionResource) => { - agentSessionsService.getSession(sessionResource)?.setArchived(true); - commandService.executeCommand(ACTION_ID_NEW_CHAT); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'hooks', - detail: nls.localize('hooks', "Configure hooks"), - sortText: 'z3_hooks', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await instantiationService.invokeFunction(showConfigureHooksQuickPick); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'debug', - detail: nls.localize('debug', "Show Chat Debug View"), - sortText: 'z3_debug', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('github.copilot.debug.showChatLogView'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'agents', - detail: nls.localize('agents', "Configure custom agents"), - sortText: 'z3_agents', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.customagents'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'skills', - detail: nls.localize('skills', "Configure skills"), - sortText: 'z3_skills', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.skills'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'instructions', - detail: nls.localize('instructions', "Configure instructions"), - sortText: 'z3_instructions', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.instructions'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'prompts', - detail: nls.localize('prompts', "Configure prompt files"), - sortText: 'z3_prompts', - executeImmediately: true, - silent: true, - locations: [ChatAgentLocation.Chat] - }, async () => { - await commandService.executeCommand('workbench.action.chat.configure.prompts'); - })); - this._store.add(slashCommandService.registerSlashCommand({ - command: 'help', - detail: '', - sortText: 'z1_help', - executeImmediately: true, - locations: [ChatAgentLocation.Chat], - modes: [ChatModeKind.Ask] - }, async (prompt, progress, _history, _location, sessionResource) => { - const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat); - const agents = chatAgentService.getAgents(); - - // Report prefix - if (defaultAgent?.metadata.helpTextPrefix) { - if (isMarkdownString(defaultAgent.metadata.helpTextPrefix)) { - progress.report({ content: defaultAgent.metadata.helpTextPrefix, kind: 'markdownContent' }); - } else { - progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPrefix), kind: 'markdownContent' }); - } - progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); - } - - // Report agent list - const agentText = (await Promise.all(agents - .filter(a => !a.isDefault && !a.isCore) - .filter(a => a.locations.includes(ChatAgentLocation.Chat)) - .map(async a => { - const description = a.description ? `- ${a.description}` : ''; - const agentMarkdown = instantiationService.invokeFunction(accessor => agentToMarkdown(a, sessionResource, true, accessor)); - const agentLine = `- ${agentMarkdown} ${description}`; - const commandText = a.slashCommands.map(c => { - const description = c.description ? `- ${c.description}` : ''; - return `\t* ${agentSlashCommandToMarkdown(a, c, sessionResource)} ${description}`; - }).join('\n'); - - return (agentLine + '\n' + commandText).trim(); - }))).join('\n'); - progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [ChatSubmitAction.ID] } }), kind: 'markdownContent' }); - - // Report help text ending - if (defaultAgent?.metadata.helpTextPostfix) { - progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); - if (isMarkdownString(defaultAgent.metadata.helpTextPostfix)) { - progress.report({ content: defaultAgent.metadata.helpTextPostfix, kind: 'markdownContent' }); - } else { - progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPostfix), kind: 'markdownContent' }); - } - } - - // Without this, the response will be done before it renders and so it will not stream. This ensures that if the response starts - // rendering during the next 200ms, then it will be streamed. Once it starts streaming, the whole response streams even after - // it has received all response data has been received. - await timeout(200); - })); - } -} Registry.as(EditorExtensions.EditorFactory).registerEditorSerializer(ChatEditorInput.TypeID, ChatEditorInputSerializer); registerWorkbenchContribution2(ChatResolverContribution.ID, ChatResolverContribution, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(ChatLanguageModelsDataContribution.ID, ChatLanguageModelsDataContribution, WorkbenchPhase.BlockRestore); -registerWorkbenchContribution2(ChatSlashStaticSlashCommandsContribution.ID, ChatSlashStaticSlashCommandsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(ChatSlashCommandsContribution.ID, ChatSlashCommandsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ChatExtensionPointHandler.ID, ChatExtensionPointHandler, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(LanguageModelToolsExtensionPointHandler.ID, LanguageModelToolsExtensionPointHandler, WorkbenchPhase.BlockRestore); diff --git a/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts new file mode 100644 index 0000000000000..34d97d4a40347 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/chatSlashCommands.ts @@ -0,0 +1,180 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { timeout } from '../../../../base/common/async.js'; +import { MarkdownString, isMarkdownString } from '../../../../base/common/htmlContent.js'; +import { Disposable } from '../../../../base/common/lifecycle.js'; +import * as nls from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IChatAgentService } from '../common/participants/chatAgents.js'; +import { IChatSlashCommandService } from '../common/participants/chatSlashCommands.js'; +import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; +import { ACTION_ID_NEW_CHAT } from './actions/chatActions.js'; +import { ChatSubmitAction, OpenModelPickerAction } from './actions/chatExecuteActions.js'; +import { ConfigureToolsAction } from './actions/chatToolActions.js'; +import { IAgentSessionsService } from './agentSessions/agentSessionsService.js'; +import { IChatWidgetService } from './chat.js'; +import { showConfigureHooksQuickPick } from './promptSyntax/hookActions.js'; +import { agentSlashCommandToMarkdown, agentToMarkdown } from './widget/chatContentParts/chatMarkdownDecorationsRenderer.js'; + +export class ChatSlashCommandsContribution extends Disposable { + + static readonly ID = 'workbench.contrib.chatSlashCommands'; + + constructor( + @IChatSlashCommandService slashCommandService: IChatSlashCommandService, + @ICommandService commandService: ICommandService, + @IChatAgentService chatAgentService: IChatAgentService, + @IChatWidgetService chatWidgetService: IChatWidgetService, + @IInstantiationService instantiationService: IInstantiationService, + @IAgentSessionsService agentSessionsService: IAgentSessionsService, + ) { + super(); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'clear', + detail: nls.localize('clear', "Start a new chat and archive the current one"), + sortText: 'z2_clear', + executeImmediately: true, + locations: [ChatAgentLocation.Chat] + }, async (_prompt, _progress, _history, _location, sessionResource) => { + agentSessionsService.getSession(sessionResource)?.setArchived(true); + commandService.executeCommand(ACTION_ID_NEW_CHAT); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'hooks', + detail: nls.localize('hooks', "Configure hooks"), + sortText: 'z3_hooks', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await instantiationService.invokeFunction(showConfigureHooksQuickPick); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'models', + detail: nls.localize('models', "Open the model picker"), + sortText: 'z3_models', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand(OpenModelPickerAction.ID); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'tools', + detail: nls.localize('tools', "Configure tools"), + sortText: 'z3_tools', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand(ConfigureToolsAction.ID); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'debug', + detail: nls.localize('debug', "Show Chat Debug View"), + sortText: 'z3_debug', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('github.copilot.debug.showChatLogView'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'agents', + detail: nls.localize('agents', "Configure custom agents"), + sortText: 'z3_agents', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.customagents'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'skills', + detail: nls.localize('skills', "Configure skills"), + sortText: 'z3_skills', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.skills'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'instructions', + detail: nls.localize('instructions', "Configure instructions"), + sortText: 'z3_instructions', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.instructions'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'prompts', + detail: nls.localize('prompts', "Configure prompt files"), + sortText: 'z3_prompts', + executeImmediately: true, + silent: true, + locations: [ChatAgentLocation.Chat] + }, async () => { + await commandService.executeCommand('workbench.action.chat.configure.prompts'); + })); + this._store.add(slashCommandService.registerSlashCommand({ + command: 'help', + detail: '', + sortText: 'z1_help', + executeImmediately: true, + locations: [ChatAgentLocation.Chat], + modes: [ChatModeKind.Ask] + }, async (prompt, progress, _history, _location, sessionResource) => { + const defaultAgent = chatAgentService.getDefaultAgent(ChatAgentLocation.Chat); + const agents = chatAgentService.getAgents(); + + // Report prefix + if (defaultAgent?.metadata.helpTextPrefix) { + if (isMarkdownString(defaultAgent.metadata.helpTextPrefix)) { + progress.report({ content: defaultAgent.metadata.helpTextPrefix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPrefix), kind: 'markdownContent' }); + } + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + } + + // Report agent list + const agentText = (await Promise.all(agents + .filter(a => !a.isDefault && !a.isCore) + .filter(a => a.locations.includes(ChatAgentLocation.Chat)) + .map(async a => { + const description = a.description ? `- ${a.description}` : ''; + const agentMarkdown = instantiationService.invokeFunction(accessor => agentToMarkdown(a, sessionResource, true, accessor)); + const agentLine = `- ${agentMarkdown} ${description}`; + const commandText = a.slashCommands.map(c => { + const description = c.description ? `- ${c.description}` : ''; + return `\t* ${agentSlashCommandToMarkdown(a, c, sessionResource)} ${description}`; + }).join('\n'); + + return (agentLine + '\n' + commandText).trim(); + }))).join('\n'); + progress.report({ content: new MarkdownString(agentText, { isTrusted: { enabledCommands: [ChatSubmitAction.ID] } }), kind: 'markdownContent' }); + + // Report help text ending + if (defaultAgent?.metadata.helpTextPostfix) { + progress.report({ content: new MarkdownString('\n\n'), kind: 'markdownContent' }); + if (isMarkdownString(defaultAgent.metadata.helpTextPostfix)) { + progress.report({ content: defaultAgent.metadata.helpTextPostfix, kind: 'markdownContent' }); + } else { + progress.report({ content: new MarkdownString(defaultAgent.metadata.helpTextPostfix), kind: 'markdownContent' }); + } + } + + // Without this, the response will be done before it renders and so it will not stream. This ensures that if the response starts + // rendering during the next 200ms, then it will be streamed. Once it starts streaming, the whole response streams even after + // it has received all response data has been received. + await timeout(200); + })); + } +} From 730cabf7f33e2663219c6d23dfd2177a90f15aff Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:35:20 -0800 Subject: [PATCH 028/221] Fix esbuilt web extensions We should be using cjs here still. Only webviews use esm --- extensions/esbuild-extension-common.mts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/esbuild-extension-common.mts b/extensions/esbuild-extension-common.mts index b5ce1aceb0c78..da028ca7e02db 100644 --- a/extensions/esbuild-extension-common.mts +++ b/extensions/esbuild-extension-common.mts @@ -59,7 +59,7 @@ function resolveOptions(config: RunConfig, outdir: string): BuildOptions { options.format = 'cjs'; options.mainFields = ['module', 'main']; } else if (config.platform === 'browser') { - options.format = 'iife'; + options.format = 'cjs'; options.mainFields = ['browser', 'module', 'main']; options.alias = { 'path': 'path-browserify', From 8785fc52f697ef0992e34259c177546fe480fba7 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:39:08 -0800 Subject: [PATCH 029/221] Fix leak warning around events --- .../workbench/api/browser/mainThreadPower.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadPower.ts b/src/vs/workbench/api/browser/mainThreadPower.ts index dbd8078aa19c1..9f7ea9d7d5993 100644 --- a/src/vs/workbench/api/browser/mainThreadPower.ts +++ b/src/vs/workbench/api/browser/mainThreadPower.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; import { extHostNamedCustomer, IExtHostContext } from '../../services/extensions/common/extHostCustomers.js'; import { ExtHostContext, ExtHostPowerShape, MainContext, MainThreadPowerShape, PowerSaveBlockerType, PowerSystemIdleState, PowerThermalState } from '../common/extHost.protocol.js'; import { IPowerService } from '../../services/power/common/powerService.js'; @@ -12,7 +12,6 @@ import { IPowerService } from '../../services/power/common/powerService.js'; export class MainThreadPower extends Disposable implements MainThreadPowerShape { private readonly proxy: ExtHostPowerShape; - private readonly disposables = this._register(new DisposableStore()); constructor( extHostContext: IExtHostContext, @@ -22,14 +21,14 @@ export class MainThreadPower extends Disposable implements MainThreadPowerShape this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostPower); // Forward power events to extension host - this.powerService.onDidSuspend(this.proxy.$onDidSuspend, this.proxy, this.disposables); - this.powerService.onDidResume(this.proxy.$onDidResume, this.proxy, this.disposables); - this.powerService.onDidChangeOnBatteryPower(this.proxy.$onDidChangeOnBatteryPower, this.proxy, this.disposables); - this.powerService.onDidChangeThermalState((state: PowerThermalState) => this.proxy.$onDidChangeThermalState(state), this, this.disposables); - this.powerService.onDidChangeSpeedLimit(this.proxy.$onDidChangeSpeedLimit, this.proxy, this.disposables); - this.powerService.onWillShutdown(this.proxy.$onWillShutdown, this.proxy, this.disposables); - this.powerService.onDidLockScreen(this.proxy.$onDidLockScreen, this.proxy, this.disposables); - this.powerService.onDidUnlockScreen(this.proxy.$onDidUnlockScreen, this.proxy, this.disposables); + this._register(this.powerService.onDidSuspend(this.proxy.$onDidSuspend, this.proxy)); + this._register(this.powerService.onDidResume(this.proxy.$onDidResume, this.proxy)); + this._register(this.powerService.onDidChangeOnBatteryPower(this.proxy.$onDidChangeOnBatteryPower, this.proxy)); + this._register(this.powerService.onDidChangeThermalState((state: PowerThermalState) => this.proxy.$onDidChangeThermalState(state), this)); + this._register(this.powerService.onDidChangeSpeedLimit(this.proxy.$onDidChangeSpeedLimit, this.proxy)); + this._register(this.powerService.onWillShutdown(this.proxy.$onWillShutdown, this.proxy)); + this._register(this.powerService.onDidLockScreen(this.proxy.$onDidLockScreen, this.proxy)); + this._register(this.powerService.onDidUnlockScreen(this.proxy.$onDidUnlockScreen, this.proxy)); } async $getSystemIdleState(idleThreshold: number): Promise { From c4f3cc823ea9742337d66b039a2573c1655d847b Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Fri, 13 Feb 2026 20:12:13 +1100 Subject: [PATCH 030/221] Add attachmentCapabilities to chat request parsing (background agents) (#295112) --- src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index 0188fc1e12468..da0056dc9df4d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -769,7 +769,7 @@ export class ChatWidget extends Disposable implements IChatWidget { } const previous = this.parsedChatRequest; - this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind }); + this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel.sessionResource, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind, attachmentCapabilities: this.attachmentCapabilities }); if (!previous || !IParsedChatRequest.equals(previous, this.parsedChatRequest)) { this._onDidChangeParsedInput.fire(); } From c4a50d50a2411e84d4a6d90c1294edfd02ab4236 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:22:05 +0000 Subject: [PATCH 031/221] Modal editor: double-click header to maximize, fix outline leak (#295006) * Initial plan * feat: double-click modal editor header to toggle maximize, fix outline leak Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> * . * . * . * . * . * . * . * . * . * . * . --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: bpasero <900690+bpasero@users.noreply.github.com> Co-authored-by: Benjamin Pasero Co-authored-by: Benjamin Pasero --- .../parts/editor/media/modalEditorPart.css | 6 +- .../browser/parts/editor/modalEditorPart.ts | 55 +++++++++++++------ 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css index 19b64fc8c8522..201f1942042f4 100644 --- a/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css +++ b/src/vs/workbench/browser/parts/editor/media/modalEditorPart.css @@ -7,9 +7,11 @@ .monaco-modal-editor-block { position: fixed; width: 100%; + height: 100%; + top: 0; left: 0; - /* z-index for modal editors: below dialogs, quick input, context views, hovers but above other things */ - z-index: 2000; + /* z-index for modal editors: above titlebar (2500) but below dialogs (2575) */ + z-index: 2550; display: flex; justify-content: center; align-items: center; diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 8d2c6acb5920e..9964e55f8407a 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -6,6 +6,7 @@ import './media/modalEditorPart.css'; import { $, addDisposableListener, append, EventHelper, EventType, isHTMLElement } from '../../../../base/browser/dom.js'; import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { KeyCode } from '../../../../base/common/keyCodes.js'; import { Emitter, Event } from '../../../../base/common/event.js'; import { DisposableStore, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { MenuId } from '../../../../platform/actions/common/actions.js'; @@ -28,6 +29,7 @@ import { IHostService } from '../../../services/host/browser/host.js'; import { IWorkbenchLayoutService, Parts } from '../../../services/layout/browser/layoutService.js'; import { mainWindow } from '../../../../base/browser/window.js'; import { localize } from '../../../../nls.js'; +import { CLOSE_MODAL_EDITOR_COMMAND_ID, MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID } from './editorCommands.js'; const defaultModalEditorAllowableCommands = new Set([ 'workbench.action.quit', @@ -36,6 +38,9 @@ const defaultModalEditorAllowableCommands = new Set([ 'workbench.action.closeAllEditors', 'workbench.action.files.save', 'workbench.action.files.saveAll', + CLOSE_MODAL_EDITOR_COMMAND_ID, + MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID, + TOGGLE_MODAL_EDITOR_MAXIMIZED_COMMAND_ID ]); export interface ICreateModalEditorPartResult { @@ -60,7 +65,6 @@ export class ModalEditorPart { // Create modal container const modalElement = $('.monaco-modal-editor-block.dimmed'); - modalElement.tabIndex = -1; this.layoutService.mainContainer.appendChild(modalElement); disposables.add(toDisposable(() => modalElement.remove())); @@ -71,14 +75,15 @@ export class ModalEditorPart { const editorPartContainer = $('.part.editor.modal-editor-part', { role: 'dialog', 'aria-modal': 'true', - 'aria-labelledby': titleId + 'aria-labelledby': titleId, + tabIndex: -1 }); shadowElement.appendChild(editorPartContainer); // Create header with title and close button const headerElement = editorPartContainer.appendChild($('.modal-editor-header')); - // Title element (centered) + // Title element const titleElement = append(headerElement, $('div.modal-editor-title')); titleElement.id = titleId; titleElement.textContent = ''; @@ -102,7 +107,7 @@ export class ModalEditorPart { [IEditorService, modalEditorService] ))); - // Create toolbar driven by MenuId.ModalEditorTitle + // Create toolbar disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { hiddenItemStrategy: HiddenItemStrategy.NoHide, menuOptions: { shouldForwardArgs: true } @@ -118,23 +123,43 @@ export class ModalEditorPart { editorPart.notifyActiveEditorChanged(); }))); - // Handle close on click outside (on the dimmed background) + // Handle double-click on header to toggle maximize + disposables.add(addDisposableListener(headerElement, EventType.DBLCLICK, e => { + EventHelper.stop(e); + + editorPart.toggleMaximized(); + })); + + // Guide focus back into the modal when clicking outside modal disposables.add(addDisposableListener(modalElement, EventType.MOUSE_DOWN, e => { if (e.target === modalElement) { - editorPart.close(); + EventHelper.stop(e, true); + + editorPartContainer.focus(); } })); // Block certain workbench commands from being dispatched while the modal is open disposables.add(addDisposableListener(modalElement, EventType.KEY_DOWN, e => { const event = new StandardKeyboardEvent(e); - const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); - if (resolved.kind === ResultKind.KbFound && resolved.commandId) { - if ( - resolved.commandId.startsWith('workbench.') && - !defaultModalEditorAllowableCommands.has(resolved.commandId) - ) { - EventHelper.stop(event, true); + + // Close on Escape + if (event.equals(KeyCode.Escape)) { + EventHelper.stop(event, true); + + editorPart.close(); + } + + // Prevent unsupported commands + else { + const resolved = this.keybindingService.softDispatch(event, this.layoutService.mainContainer); + if (resolved.kind === ResultKind.KbFound && resolved.commandId) { + if ( + resolved.commandId.startsWith('workbench.') && + !defaultModalEditorAllowableCommands.has(resolved.commandId) + ) { + EventHelper.stop(event, true); + } } } })); @@ -168,10 +193,6 @@ export class ModalEditorPart { height = Math.min(height, availableHeight); // Ensure the modal never exceeds available height (below the title bar) - // Shift the modal block below the title bar - modalElement.style.top = `${titleBarOffset}px`; - modalElement.style.height = `calc(100% - ${titleBarOffset}px)`; - editorPartContainer.style.width = `${width}px`; editorPartContainer.style.height = `${height}px`; From 1da341f487bc9b754402950e4b793ec9ff1c098f Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Fri, 13 Feb 2026 09:24:30 +0000 Subject: [PATCH 032/221] Show warning when claude code hooks are detected but disabled (#294724) * Show warning when claude code hooks are detected but disabled * Fix * Tweak label * Fix --- .../chatCustomizationDiagnosticsAction.ts | 2 + .../contrib/chat/browser/chat.contribution.ts | 9 +++ .../chatDisabledClaudeHooksContentPart.ts | 59 +++++++++++++++++++ .../media/chatDisabledClaudeHooksContent.css | 14 +++++ .../chat/browser/widget/chatListRenderer.ts | 10 +++- .../chat/common/chatService/chatService.ts | 7 ++- .../common/chatService/chatServiceImpl.ts | 18 +++++- .../contrib/chat/common/model/chatModel.ts | 9 ++- .../common/model/chatSessionOperationLog.ts | 1 + .../chat/common/model/chatViewModel.ts | 4 +- .../chat/common/promptSyntax/config/config.ts | 5 ++ .../promptSyntax/service/promptsService.ts | 10 +++- .../service/promptsServiceImpl.ts | 40 ++++++++++--- .../tools/builtinTools/runSubagentTool.ts | 3 +- 14 files changed, 172 insertions(+), 19 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts create mode 100644 src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts index fe17d4b9cd535..225803f5ae065 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatCustomizationDiagnosticsAction.ts @@ -450,6 +450,8 @@ function getSkipReasonMessage(skipReason: PromptFileSkipReason | undefined, erro return nls.localize('status.typeDisabled', 'Disabled'); case 'all-hooks-disabled': return nls.localize('status.allHooksDisabled', 'All hooks disabled via disableAllHooks'); + case 'claude-hooks-disabled': + return nls.localize('status.claudeHooksDisabled', 'Claude hooks disabled via chat.useClaudeHooks setting'); default: return errorMessage ?? nls.localize('status.unknownError', 'Unknown error'); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index a974828d7dda3..b0d814cbf5785 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1003,6 +1003,15 @@ configurationRegistry.registerConfiguration({ }, } }, + [PromptsConfig.USE_CLAUDE_HOOKS]: { + type: 'boolean', + title: nls.localize('chat.useClaudeHooks.title', "Use Claude Hooks",), + markdownDescription: nls.localize('chat.useClaudeHooks.description', "Controls whether hooks from Claude configuration files can execute. When disabled, only Copilot-format hooks are used. Hooks are loaded from the files configured in `#chat.hookFilesLocations#`.",), + default: false, + restricted: true, + disallowConfigurationDefault: true, + tags: ['preview', 'prompts', 'hooks', 'agent'] + }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts new file mode 100644 index 0000000000000..7939aaa309915 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatDisabledClaudeHooksContentPart.ts @@ -0,0 +1,59 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../../../base/browser/dom.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { createMarkdownCommandLink, MarkdownString } from '../../../../../../base/common/htmlContent.js'; +import { Disposable, IDisposable } from '../../../../../../base/common/lifecycle.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { localize } from '../../../../../../nls.js'; +import { IOpenerService } from '../../../../../../platform/opener/common/opener.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { IChatContentPart, IChatContentPartRenderContext } from './chatContentParts.js'; +import { PromptsConfig } from '../../../common/promptSyntax/config/config.js'; +import './media/chatDisabledClaudeHooksContent.css'; + +export class ChatDisabledClaudeHooksContentPart extends Disposable implements IChatContentPart { + public readonly domNode: HTMLElement; + + constructor( + _context: IChatContentPartRenderContext, + @IOpenerService private readonly _openerService: IOpenerService, + @IMarkdownRendererService private readonly _markdownRendererService: IMarkdownRendererService, + ) { + super(); + + this.domNode = dom.$('.chat-disabled-claude-hooks'); + const messageContainer = dom.$('.chat-disabled-claude-hooks-message'); + + const icon = dom.$('.chat-disabled-claude-hooks-icon'); + icon.classList.add(...ThemeIcon.asClassNameArray(Codicon.info)); + + const enableLink = createMarkdownCommandLink({ + title: localize('chat.disabledClaudeHooks.enableLink', "Enable"), + id: 'workbench.action.openSettings', + arguments: [PromptsConfig.USE_CLAUDE_HOOKS], + }); + const message = localize('chat.disabledClaudeHooks.message', "Claude Code hooks are available for this workspace. {0}", enableLink); + const content = new MarkdownString(message, { isTrusted: true }); + + const rendered = this._register(this._markdownRendererService.render(content, { + actionHandler: (href) => openLinkFromMarkdown(this._openerService, href, true), + })); + + messageContainer.appendChild(icon); + messageContainer.appendChild(rendered.element); + this.domNode.appendChild(messageContainer); + } + + hasSameContent(other: IChatRendererContent): boolean { + return other.kind === 'disabledClaudeHooks'; + } + + addDisposable(disposable: IDisposable): void { + this._register(disposable); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css new file mode 100644 index 0000000000000..5874eda712ca9 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatDisabledClaudeHooksContent.css @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +.chat-disabled-claude-hooks-message { + display: flex; + align-items: center; + gap: 8px; + font-style: italic; + font-size: 12px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index dd074702d2bec..df048af089739 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -58,7 +58,7 @@ import { IChatAgentMetadata } from '../../common/participants/chatAgents.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { IChatTextEditGroup } from '../../common/model/chatModel.js'; import { chatSubcommandLeader } from '../../common/requestParser/chatParserTypes.js'; -import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; +import { ChatAgentVoteDirection, ChatAgentVoteDownReason, ChatErrorLevel, ChatRequestQueueKind, IChatConfirmation, IChatContentReference, IChatDisabledClaudeHooksPart, IChatElicitationRequest, IChatElicitationRequestSerialized, IChatExtensionsContent, IChatFollowup, IChatHookPart, IChatMarkdownContent, IChatMcpServersStarting, IChatMcpServersStartingSerialized, IChatMultiDiffData, IChatMultiDiffDataSerialized, IChatPullRequestContent, IChatQuestionCarousel, IChatService, IChatTask, IChatTaskSerialized, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized, IChatTreeData, IChatUndoStop, isChatFollowup } from '../../common/chatService/chatService.js'; import { localChatSessionType } from '../../common/chatSessionsService.js'; import { getChatSessionType } from '../../common/model/chatUri.js'; import { IChatRequestVariableEntry } from '../../common/attachments/chatVariableEntries.js'; @@ -86,6 +86,7 @@ import { ChatQuestionCarouselPart } from './chatContentParts/chatQuestionCarouse import { ChatExtensionsContentPart } from './chatContentParts/chatExtensionsContentPart.js'; import { ChatMarkdownContentPart, codeblockHasClosingBackticks } from './chatContentParts/chatMarkdownContentPart.js'; import { ChatMcpServersInteractionContentPart } from './chatContentParts/chatMcpServersInteractionContentPart.js'; +import { ChatDisabledClaudeHooksContentPart } from './chatContentParts/chatDisabledClaudeHooksContentPart.js'; import { ChatMultiDiffContentPart } from './chatContentParts/chatMultiDiffContentPart.js'; import { ChatProgressContentPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js'; @@ -1001,6 +1002,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer part.kind === 'toolInvocation' && !IChatToolInvocation.isComplete(part))) || (lastPart.kind === 'progressTask' && lastPart.deferred.isSettled) || lastPart.kind === 'mcpServersStarting' || + lastPart.kind === 'disabledClaudeHooks' || lastPart.kind === 'hook' ) { return true; @@ -1742,6 +1744,8 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer; const nonHistoryKinds = new Set(['toolInvocation', 'toolInvocationSerialized', 'undoStop']); @@ -502,6 +504,7 @@ class AbstractResponse implements IResponse { case 'multiDiffData': case 'mcpServersStarting': case 'questionCarousel': + case 'disabledClaudeHooks': // Ignore continue; case 'toolInvocation': @@ -1421,7 +1424,7 @@ interface ISerializableChatResponseData { timeSpentWaiting?: number; } -export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized | IChatQuestionCarousel; +export type SerializedChatResponsePart = IMarkdownString | IChatResponseProgressFileTreeData | IChatContentInlineReference | IChatAgentMarkdownContentWithVulnerability | IChatThinkingPart | IChatProgressResponseContentSerialized | IChatQuestionCarousel | IChatDisabledClaudeHooksPart; export interface ISerializableChatRequestData extends ISerializableChatResponseData { requestId: string; diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index b1b876d8f1f41..767cb087be6a4 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -81,6 +81,7 @@ const responsePartSchema = Adapt.v; + getHooks(token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 25ba3b58bd7fa..2ab2855d70430 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -32,11 +32,11 @@ import { AGENT_MD_FILENAME, CLAUDE_CONFIG_FOLDER, CLAUDE_LOCAL_MD_FILENAME, CLAU import { PROMPT_LANGUAGE_ID, PromptsType, getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptFilesLocator } from '../utils/promptFilesLocator.js'; import { PromptFileParser, ParsedPromptFile, PromptHeaderAttributes } from '../promptFileParser.js'; -import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.js'; +import { IAgentInstructions, type IAgentSource, IChatPromptSlashCommand, IConfiguredHooksInfo, ICustomAgent, IExtensionPromptPath, ILocalPromptPath, IPromptPath, IPromptsService, IAgentSkill, IUserPromptPath, PromptsStorage, ExtensionAgentSourceType, CUSTOM_AGENT_PROVIDER_ACTIVATION_EVENT, INSTRUCTIONS_PROVIDER_ACTIVATION_EVENT, IPromptFileContext, IPromptFileResource, PROMPT_FILE_PROVIDER_ACTIVATION_EVENT, SKILL_PROVIDER_ACTIVATION_EVENT, IPromptDiscoveryInfo, IPromptFileDiscoveryResult, ICustomAgentVisibility, IResolvedAgentFile, AgentFileType, Logger, Target } from './promptsService.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { Schemas } from '../../../../../../base/common/network.js'; import { IChatRequestHooks, IHookCommand, HookType } from '../hookSchema.js'; -import { parseHooksFromFile } from '../hookCompatibility.js'; +import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; @@ -96,7 +96,7 @@ export class PromptsService extends Disposable implements IPromptsService { /** * Cached hooks. Invalidated when hook files change. */ - private readonly cachedHooks: CachedPromise; + private readonly cachedHooks: CachedPromise; /** * Cached skills. Caching only happens if the `onDidChangeSkills` event is used. @@ -179,7 +179,7 @@ export class PromptsService extends Disposable implements IPromptsService { (token) => this.computeHooks(token), () => Event.any( this.getFileLocatorEvent(PromptsType.hook), - Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS)), + Event.filter(this.configurationService.onDidChangeConfiguration, e => e.affectsConfiguration(PromptsConfig.USE_CHAT_HOOKS) || e.affectsConfiguration(PromptsConfig.USE_CLAUDE_HOOKS)), ) )); @@ -1002,16 +1002,17 @@ export class PromptsService extends Disposable implements IPromptsService { return result; } - public getHooks(token: CancellationToken): Promise { + public async getHooks(token: CancellationToken): Promise { return this.cachedHooks.get(token); } - private async computeHooks(token: CancellationToken): Promise { + private async computeHooks(token: CancellationToken): Promise { const useChatHooks = this.configurationService.getValue(PromptsConfig.USE_CHAT_HOOKS); if (!useChatHooks) { return undefined; } + const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); if (hookFiles.length === 0) { @@ -1029,6 +1030,7 @@ export class PromptsService extends Disposable implements IPromptsService { const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; const workspaceRootUri = workspaceFolder?.uri; + let hasDisabledClaudeHooks = false; const collectedHooks: Record = { [HookType.SessionStart]: [], [HookType.UserPromptSubmit]: [], @@ -1054,6 +1056,16 @@ export class PromptsService extends Disposable implements IPromptsService { continue; } + if (format === HookSourceFormat.Claude && useClaudeHooks === false) { + const hasAnyCommands = [...hooks.values()].some(({ hooks: cmds }) => cmds.length > 0); + if (hasAnyCommands) { + hasDisabledClaudeHooks = true; + } + + this.logger.trace(`[PromptsService] Skipping Claude hook file (disabled via setting): ${hookFile.uri}`); + continue; + } + for (const [hookType, { hooks: commands }] of hooks) { for (const command of commands) { collectedHooks[hookType].push(command); @@ -1078,7 +1090,7 @@ export class PromptsService extends Disposable implements IPromptsService { ) as IChatRequestHooks; this.logger.trace(`[PromptsService] Collected hooks: ${JSON.stringify(Object.keys(result))}`); - return result; + return { hooks: result, hasDisabledClaudeHooks }; } public async getPromptDiscoveryInfo(type: PromptsType, token: CancellationToken): Promise { @@ -1331,6 +1343,7 @@ export class PromptsService extends Disposable implements IPromptsService { const workspaceFolder = this.workspaceService.getWorkspace().folders[0]; const workspaceRootUri = workspaceFolder?.uri; + const useClaudeHooks = this.configurationService.getValue(PromptsConfig.USE_CLAUDE_HOOKS); const hookFiles = await this.listPromptFiles(PromptsType.hook, token); for (const promptPath of hookFiles) { const uri = promptPath.uri; @@ -1338,6 +1351,19 @@ export class PromptsService extends Disposable implements IPromptsService { const extensionId = promptPath.extension?.identifier?.value; const name = basename(uri); + // Skip Claude hooks when the setting is disabled + if (getHookSourceFormat(uri) === HookSourceFormat.Claude && useClaudeHooks === false) { + files.push({ + uri, + storage, + status: 'skipped', + skipReason: 'claude-hooks-disabled', + name, + extensionId + }); + continue; + } + try { // Try to parse the JSON to validate it (supports JSONC with comments) const content = await this.fileService.readFile(uri); diff --git a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts index d8784ef6dd740..4e9eacf9ae365 100644 --- a/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts +++ b/src/vs/workbench/contrib/chat/common/tools/builtinTools/runSubagentTool.ts @@ -250,7 +250,8 @@ export class RunSubagentTool extends Disposable implements IToolImpl { // Collect hooks from hook .json files let collectedHooks: IChatRequestHooks | undefined; try { - collectedHooks = await this.promptsService.getHooks(token); + const info = await this.promptsService.getHooks(token); + collectedHooks = info?.hooks; } catch (error) { this.logService.warn('[ChatService] Failed to collect hooks:', error); } From 8f5888f93f641f1282bd20a300fae56f3c330c91 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 13 Feb 2026 10:55:18 +0100 Subject: [PATCH 033/221] Fix watch-client-transpile script to use node instead of npx (#295118) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a43cf973e41c8..d5743f86c506d 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "watch-client": "npm run gulp watch-client", "watch-clientd": "deemon npm run watch-client", "kill-watch-clientd": "deemon --kill npm run watch-client", - "watch-client-transpile": "npx tsx build/next/index.ts transpile --watch", + "watch-client-transpile": "node build/next/index.ts transpile --watch", "watch-client-transpiled": "deemon npm run watch-client-transpile", "kill-watch-client-transpiled": "deemon --kill npm run watch-client-transpile", "watch-extensions": "npm run gulp watch-extensions watch-extension-media", From 7687ea5f10275f13b8650be84298f5e809721d06 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 13 Feb 2026 11:00:14 +0100 Subject: [PATCH 034/221] Add SVG resource patterns for transpile/dev builds (#295119) --- build/next/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/build/next/index.ts b/build/next/index.ts index 2d30bbf737a51..2535364d64e6c 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -210,6 +210,10 @@ const commonResourcePatterns = [ // Tree-sitter queries 'vs/editor/common/languages/highlights/*.scm', 'vs/editor/common/languages/injections/*.scm', + + // SVGs referenced from CSS (needed for transpile/dev builds where CSS is copied as-is) + 'vs/workbench/browser/media/code-icon.svg', + 'vs/workbench/browser/parts/editor/media/letterpress*.svg', ]; // Resources only needed for dev/transpile builds (these get bundled into the main From d0061bb5d5806ab9f5ca1562c52c8d29fd48ec58 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 13 Feb 2026 11:44:17 +0100 Subject: [PATCH 035/221] Window control overlay does not dim when modal custom dialog shows (fix #159671) (#295117) --- src/vs/base/browser/ui/dialog/dialog.ts | 5 +++ src/vs/platform/native/common/native.ts | 7 +-- .../electron-main/nativeHostMainService.ts | 2 +- .../platform/window/electron-main/window.ts | 2 +- .../windows/electron-main/windowImpl.ts | 44 +++++++++++++++++-- .../test/electron-main/windowsFinder.test.ts | 2 +- .../browser/parts/dialogs}/dialog.ts | 24 +++++----- .../parts/dialogs/dialog.web.contribution.ts | 16 +------ .../browser/parts/dialogs/dialogHandler.ts | 6 ++- .../browser/parts/editor/modalEditorPart.ts | 5 +++ .../chat/browser/chatSetup/chatSetupRunner.ts | 8 ++-- .../parts/dialogs/dialog.contribution.ts | 10 +---- .../host/browser/browserHostService.ts | 4 ++ .../workbench/services/host/browser/host.ts | 6 +++ .../electron-browser/nativeHostService.ts | 4 ++ .../progress/browser/progressService.ts | 6 ++- .../test/browser/workbenchTestServices.ts | 2 + 17 files changed, 100 insertions(+), 53 deletions(-) rename src/vs/{platform/dialogs/browser => workbench/browser/parts/dialogs}/dialog.ts (65%) diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 6c972e7866b02..fceb9c852cce1 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -58,6 +58,7 @@ export interface IDialogOptions { readonly disableCloseAction?: boolean; readonly disableCloseButton?: boolean; readonly disableDefaultAction?: boolean; + readonly onVisibilityChange?: (window: Window, visible: boolean) => void; readonly buttonStyles: IButtonStyles; readonly checkboxStyles: ICheckboxStyles; readonly inputBoxStyles: IInputBoxStyles; @@ -536,6 +537,10 @@ export class Dialog extends Disposable { this.element.setAttribute('aria-describedby', 'monaco-dialog-icon monaco-dialog-message-text monaco-dialog-message-detail monaco-dialog-message-body monaco-dialog-footer'); show(this.element); + // Notify visibility change + this.options.onVisibilityChange?.(window, true); + this._register(toDisposable(() => this.options.onVisibilityChange?.(window, false))); + // Focus first element (input or button) if (this.inputs.length > 0) { this.inputs[0].focus(); diff --git a/src/vs/platform/native/common/native.ts b/src/vs/platform/native/common/native.ts index 585585003d353..7369736706ca5 100644 --- a/src/vs/platform/native/common/native.ts +++ b/src/vs/platform/native/common/native.ts @@ -145,12 +145,7 @@ export interface ICommonNativeHostService { toggleWindowAlwaysOnTop(options?: INativeHostOptions): Promise; setWindowAlwaysOnTop(alwaysOnTop: boolean, options?: INativeHostOptions): Promise; - /** - * Only supported on Windows and macOS. Updates the window controls to match the title bar size. - * - * @param options `backgroundColor` and `foregroundColor` are only supported on Windows - */ - updateWindowControls(options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise; + updateWindowControls(options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise; updateWindowAccentColor(color: 'default' | 'off' | string, inactiveColor: string | undefined): Promise; diff --git a/src/vs/platform/native/electron-main/nativeHostMainService.ts b/src/vs/platform/native/electron-main/nativeHostMainService.ts index 22ad671ed56d0..93d000872d635 100644 --- a/src/vs/platform/native/electron-main/nativeHostMainService.ts +++ b/src/vs/platform/native/electron-main/nativeHostMainService.ts @@ -374,7 +374,7 @@ export class NativeHostMainService extends Disposable implements INativeHostMain } } - async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string }): Promise { + async updateWindowControls(windowId: number | undefined, options: INativeHostOptions & { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): Promise { const window = this.windowById(options?.targetWindowId, windowId); window?.updateWindowControls(options); } diff --git a/src/vs/platform/window/electron-main/window.ts b/src/vs/platform/window/electron-main/window.ts index 13268e716eff3..748e90019de9c 100644 --- a/src/vs/platform/window/electron-main/window.ts +++ b/src/vs/platform/window/electron-main/window.ts @@ -38,7 +38,7 @@ export interface IBaseWindow extends IDisposable { readonly isFullScreen: boolean; toggleFullScreen(): void; - updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): void; + updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): void; matches(webContents: electron.WebContents): boolean; } diff --git a/src/vs/platform/windows/electron-main/windowImpl.ts b/src/vs/platform/windows/electron-main/windowImpl.ts index 3841cfa34a759..4faede85f4cb8 100644 --- a/src/vs/platform/windows/electron-main/windowImpl.ts +++ b/src/vs/platform/windows/electron-main/windowImpl.ts @@ -46,6 +46,7 @@ import { IInstantiationService } from '../../instantiation/common/instantiation. import { VSBuffer } from '../../../base/common/buffer.js'; import { errorHandler } from '../../../base/common/errors.js'; import { FocusMode } from '../../native/common/native.js'; +import { Color } from '../../../base/common/color.js'; export interface IWindowCreationOptions { readonly state: IWindowState; @@ -404,7 +405,10 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { private static readonly windowControlHeightStateStorageKey = 'windowControlHeight'; - updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string }): void { + private windowControlsDimmed = false; + private lastWindowControlColors: { backgroundColor?: string; foregroundColor?: string } | undefined; + + updateWindowControls(options: { height?: number; backgroundColor?: string; foregroundColor?: string; dimmed?: boolean }): void { const win = this.win; if (!win) { return; @@ -417,9 +421,25 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { // Windows/Linux: update window controls via setTitleBarOverlay() if (!isMacintosh && useWindowControlsOverlay(this.configurationService)) { + + // Update dimmed state if explicitly provided + if (options.dimmed !== undefined) { + this.windowControlsDimmed = options.dimmed; + } + + const backgroundColor = options.backgroundColor ?? this.lastWindowControlColors?.backgroundColor; + const foregroundColor = options.foregroundColor ?? this.lastWindowControlColors?.foregroundColor; + + if (options.backgroundColor !== undefined || options.foregroundColor !== undefined) { + this.lastWindowControlColors = { backgroundColor, foregroundColor }; + } + + const effectiveBackgroundColor = this.windowControlsDimmed && backgroundColor ? this.dimColor(backgroundColor) : backgroundColor; + const effectiveForegroundColor = this.windowControlsDimmed && foregroundColor ? this.dimColor(foregroundColor) : foregroundColor; + win.setTitleBarOverlay({ - color: options.backgroundColor?.trim() === '' ? undefined : options.backgroundColor, - symbolColor: options.foregroundColor?.trim() === '' ? undefined : options.foregroundColor, + color: effectiveBackgroundColor?.trim() === '' ? undefined : effectiveBackgroundColor, + symbolColor: effectiveForegroundColor?.trim() === '' ? undefined : effectiveForegroundColor, height: options.height ? options.height - 1 : undefined // account for window border }); } @@ -439,6 +459,24 @@ export abstract class BaseWindow extends Disposable implements IBaseWindow { } } + private dimColor(color: string): string { + + // Blend a CSS color with black at 30% opacity to match the + // dimming overlay of `rgba(0, 0, 0, 0.3)` used by modals. + + const parsed = Color.Format.CSS.parse(color); + if (!parsed) { + return color; + } + + const dimFactor = 0.7; // 1 - 0.3 opacity of black overlay + const r = Math.round(parsed.rgba.r * dimFactor); + const g = Math.round(parsed.rgba.g * dimFactor); + const b = Math.round(parsed.rgba.b * dimFactor); + + return `rgb(${r}, ${g}, ${b})`; + } + //#endregion //#region Fullscreen diff --git a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts index 2a95195c06bd9..eb3bad9ad55ae 100644 --- a/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts +++ b/src/vs/platform/windows/test/electron-main/windowsFinder.test.ts @@ -73,7 +73,7 @@ suite('WindowsFinder', () => { isDocumentEdited(): boolean { throw new Error('Method not implemented.'); } updateTouchBar(items: UriDto[][]): void { throw new Error('Method not implemented.'); } serializeWindowState(): IWindowState { throw new Error('Method not implemented'); } - updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined }): void { throw new Error('Method not implemented.'); } + updateWindowControls(options: { height?: number | undefined; backgroundColor?: string | undefined; foregroundColor?: string | undefined; dimmed?: boolean | undefined }): void { throw new Error('Method not implemented.'); } notifyZoomLevel(level: number): void { throw new Error('Method not implemented.'); } matches(webContents: Electron.WebContents): boolean { throw new Error('Method not implemented.'); } dispose(): void { } diff --git a/src/vs/platform/dialogs/browser/dialog.ts b/src/vs/workbench/browser/parts/dialogs/dialog.ts similarity index 65% rename from src/vs/platform/dialogs/browser/dialog.ts rename to src/vs/workbench/browser/parts/dialogs/dialog.ts index 01d12f126da19..d2f18acdb50b0 100644 --- a/src/vs/platform/dialogs/browser/dialog.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialog.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { EventHelper } from '../../../base/browser/dom.js'; -import { StandardKeyboardEvent } from '../../../base/browser/keyboardEvent.js'; -import { IDialogOptions } from '../../../base/browser/ui/dialog/dialog.js'; -import { fromNow } from '../../../base/common/date.js'; -import { localize } from '../../../nls.js'; -import { IKeybindingService } from '../../keybinding/common/keybinding.js'; -import { ResultKind } from '../../keybinding/common/keybindingResolver.js'; -import { ILayoutService } from '../../layout/browser/layoutService.js'; -import { IProductService } from '../../product/common/productService.js'; -import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../theme/browser/defaultStyles.js'; +import { EventHelper } from '../../../../base/browser/dom.js'; +import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js'; +import { IDialogOptions } from '../../../../base/browser/ui/dialog/dialog.js'; +import { fromNow } from '../../../../base/common/date.js'; +import { localize } from '../../../../nls.js'; +import { IHostService } from '../../../services/host/browser/host.js'; +import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; +import { ResultKind } from '../../../../platform/keybinding/common/keybindingResolver.js'; +import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; +import { IProductService } from '../../../../platform/product/common/productService.js'; +import { defaultButtonStyles, defaultCheckboxStyles, defaultInputBoxStyles, defaultDialogStyles } from '../../../../platform/theme/browser/defaultStyles.js'; const defaultDialogAllowableCommands = new Set([ 'workbench.action.quit', @@ -25,7 +26,7 @@ const defaultDialogAllowableCommands = new Set([ 'editor.action.clipboardPasteAction' ]); -export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { +export function createWorkbenchDialogOptions(options: Partial, keybindingService: IKeybindingService, layoutService: ILayoutService, hostService: IHostService, allowableCommands = defaultDialogAllowableCommands): IDialogOptions { return { keyEventProcessor: (event: StandardKeyboardEvent) => { const resolved = keybindingService.softDispatch(event, layoutService.activeContainer); @@ -39,6 +40,7 @@ export function createWorkbenchDialogOptions(options: Partial, k checkboxStyles: defaultCheckboxStyles, inputBoxStyles: defaultInputBoxStyles, dialogStyles: defaultDialogStyles, + onVisibilityChange: (window, visible) => hostService.setWindowDimmed(window, visible), ...options }; } diff --git a/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts b/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts index bba15b35fbba8..7e0e5ce8e9d2f 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialog.web.contribution.ts @@ -3,11 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; -import { ILogService } from '../../../../platform/log/common/log.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { IWorkbenchContribution, WorkbenchPhase, registerWorkbenchContribution2 } from '../../../common/contributions.js'; import { IDialogsModel, IDialogViewItem } from '../../../common/dialogs.js'; @@ -16,9 +12,7 @@ import { DialogService } from '../../../services/dialogs/common/dialogService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { createBrowserAboutDialogDetails } from '../../../../platform/dialogs/browser/dialog.js'; -import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; +import { createBrowserAboutDialogDetails } from './dialog.js'; export class DialogHandlerContribution extends Disposable implements IWorkbenchContribution { @@ -31,18 +25,12 @@ export class DialogHandlerContribution extends Disposable implements IWorkbenchC constructor( @IDialogService private dialogService: IDialogService, - @ILogService logService: ILogService, - @ILayoutService layoutService: ILayoutService, - @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private productService: IProductService, - @IClipboardService clipboardService: IClipboardService, - @IOpenerService openerService: IOpenerService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, ) { super(); - this.impl = new Lazy(() => new BrowserDialogHandler(logService, layoutService, keybindingService, instantiationService, clipboardService, openerService, markdownRendererService)); + this.impl = new Lazy(() => instantiationService.createInstance(BrowserDialogHandler)); this.model = (this.dialogService as DialogService).model; this._register(this.model.onWillShowDialog(() => { diff --git a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts index 7664074eced18..16e11df36305b 100644 --- a/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts +++ b/src/vs/workbench/browser/parts/dialogs/dialogHandler.ts @@ -15,7 +15,8 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboa import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRendererService, openLinkFromMarkdown } from '../../../../platform/markdown/browser/markdownRenderer.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from './dialog.js'; +import { IHostService } from '../../../services/host/browser/host.js'; export class BrowserDialogHandler extends AbstractDialogHandler { @@ -36,6 +37,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { @IClipboardService private readonly clipboardService: IClipboardService, @IOpenerService private readonly openerService: IOpenerService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IHostService private readonly hostService: IHostService, ) { super(); } @@ -119,7 +121,7 @@ export class BrowserDialogHandler extends AbstractDialogHandler { checkboxLabel: checkbox?.label, checkboxChecked: checkbox?.checked, inputs - }, this.keybindingService, this.layoutService, BrowserDialogHandler.ALLOWABLE_COMMANDS) + }, this.keybindingService, this.layoutService, this.hostService, BrowserDialogHandler.ALLOWABLE_COMMANDS) ); dialogDisposables.add(dialog); diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 9964e55f8407a..580732318a46b 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -57,6 +57,7 @@ export class ModalEditorPart { @IEditorService private readonly editorService: IEditorService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, + @IHostService private readonly hostService: IHostService, ) { } @@ -203,6 +204,10 @@ export class ModalEditorPart { disposables.add(Event.runAndSubscribe(this.layoutService.onDidLayoutMainContainer, layoutModal)); disposables.add(editorPart.onDidChangeMaximized(() => layoutModal())); + // Dim window controls to match the modal overlay + this.hostService.setWindowDimmed(mainWindow, true); + disposables.add(toDisposable(() => this.hostService.setWindowDimmed(mainWindow, false))); + // Focus the modal editorPartContainer.focus(); diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index 4c51c01f5ad0e..9929aa7064e18 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -16,7 +16,7 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { createWorkbenchDialogOptions } from '../../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from '../../../../browser/parts/dialogs/dialog.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { ILayoutService } from '../../../../../platform/layout/browser/layoutService.js'; @@ -30,6 +30,7 @@ import { IChatWidgetService } from '../chat.js'; import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IHostService } from '../../../../services/host/browser/host.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -48,7 +49,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService), accessor.get(IHostService)); }); } @@ -71,6 +72,7 @@ export class ChatSetup { @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IHostService private readonly hostService: IHostService ) { } skipDialog(): void { @@ -176,7 +178,7 @@ export class ChatSetup { disableCloseButton: true, renderFooter: footer => footer.appendChild(this.createDialogFooter(disposables, options)), buttonOptions: buttons.map(button => button[2]) - }, this.keybindingService, this.layoutService) + }, this.keybindingService, this.layoutService, this.hostService) )); const { button } = await dialog.show(); diff --git a/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts b/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts index 729fc617379e3..e21db81253e1c 100644 --- a/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts +++ b/src/vs/workbench/electron-browser/parts/dialogs/dialog.contribution.ts @@ -6,8 +6,6 @@ import { IClipboardService } from '../../../../platform/clipboard/common/clipboardService.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { IDialogHandler, IDialogResult, IDialogService } from '../../../../platform/dialogs/common/dialogs.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { ILayoutService } from '../../../../platform/layout/browser/layoutService.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { INativeHostService } from '../../../../platform/native/common/native.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; @@ -19,10 +17,8 @@ import { DialogService } from '../../../services/dialogs/common/dialogService.js import { Disposable } from '../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { Lazy } from '../../../../base/common/lazy.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { createNativeAboutDialogDetails } from '../../../../platform/dialogs/electron-browser/dialog.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { IMarkdownRendererService } from '../../../../platform/markdown/browser/markdownRenderer.js'; export class DialogHandlerContribution extends Disposable implements IWorkbenchContribution { @@ -38,19 +34,15 @@ export class DialogHandlerContribution extends Disposable implements IWorkbenchC @IConfigurationService private configurationService: IConfigurationService, @IDialogService private dialogService: IDialogService, @ILogService logService: ILogService, - @ILayoutService layoutService: ILayoutService, - @IKeybindingService keybindingService: IKeybindingService, @IInstantiationService instantiationService: IInstantiationService, @IProductService private productService: IProductService, @IClipboardService clipboardService: IClipboardService, @INativeHostService private nativeHostService: INativeHostService, @IWorkbenchEnvironmentService private environmentService: IWorkbenchEnvironmentService, - @IOpenerService openerService: IOpenerService, - @IMarkdownRendererService markdownRendererService: IMarkdownRendererService, ) { super(); - this.browserImpl = new Lazy(() => new BrowserDialogHandler(logService, layoutService, keybindingService, instantiationService, clipboardService, openerService, markdownRendererService)); + this.browserImpl = new Lazy(() => instantiationService.createInstance(BrowserDialogHandler)); this.nativeImpl = new Lazy(() => new NativeDialogHandler(logService, nativeHostService, clipboardService)); this.model = (this.dialogService as DialogService).model; diff --git a/src/vs/workbench/services/host/browser/browserHostService.ts b/src/vs/workbench/services/host/browser/browserHostService.ts index de8fda44c33eb..6038ad8ac9411 100644 --- a/src/vs/workbench/services/host/browser/browserHostService.ts +++ b/src/vs/workbench/services/host/browser/browserHostService.ts @@ -577,6 +577,10 @@ export class BrowserHostService extends Disposable implements IHostService { // There seems to be no API to bring a window to front in browsers } + async setWindowDimmed(_targetWindow: Window, _dimmed: boolean): Promise { + // not supported in browser + } + async getCursorScreenPoint(): Promise { return undefined; } diff --git a/src/vs/workbench/services/host/browser/host.ts b/src/vs/workbench/services/host/browser/host.ts index 402d97d4634e4..c7d300bf3c7ea 100644 --- a/src/vs/workbench/services/host/browser/host.ts +++ b/src/vs/workbench/services/host/browser/host.ts @@ -105,6 +105,12 @@ export interface IHostService { */ moveTop(targetWindow: Window): Promise; + /** + * Toggle dimming of window control overlays (e.g. when showing + * a modal dialog or modal editor part). + */ + setWindowDimmed(targetWindow: Window, dimmed: boolean): Promise; + /** * Get the location of the mouse cursor and its display bounds or `undefined` if unavailable. */ diff --git a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts index 16defe36affd4..3838e3bb4e530 100644 --- a/src/vs/workbench/services/host/electron-browser/nativeHostService.ts +++ b/src/vs/workbench/services/host/electron-browser/nativeHostService.ts @@ -173,6 +173,10 @@ class WorkbenchHostService extends Disposable implements IHostService { return this.nativeHostService.moveWindowTop(isAuxiliaryWindow(targetWindow) ? { targetWindowId: targetWindow.vscodeWindowId } : undefined); } + async setWindowDimmed(targetWindow: Window, dimmed: boolean): Promise { + return this.nativeHostService.updateWindowControls({ dimmed, targetWindowId: getWindowId(targetWindow) }); + } + getCursorScreenPoint(): Promise<{ readonly point: IPoint; readonly display: IRectangle }> { return this.nativeHostService.getCursorScreenPoint(); } diff --git a/src/vs/workbench/services/progress/browser/progressService.ts b/src/vs/workbench/services/progress/browser/progressService.ts index daf34ce8ee19d..a03c869f95878 100644 --- a/src/vs/workbench/services/progress/browser/progressService.ts +++ b/src/vs/workbench/services/progress/browser/progressService.ts @@ -23,7 +23,8 @@ import { IViewsService } from '../../views/common/viewsService.js'; import { IPaneCompositePartService } from '../../panecomposite/browser/panecomposite.js'; import { stripIcons } from '../../../../base/common/iconLabels.js'; import { IUserActivityService } from '../../userActivity/common/userActivityService.js'; -import { createWorkbenchDialogOptions } from '../../../../platform/dialogs/browser/dialog.js'; +import { createWorkbenchDialogOptions } from '../../../browser/parts/dialogs/dialog.js'; +import { IHostService } from '../../host/browser/host.js'; export class ProgressService extends Disposable implements IProgressService { @@ -39,6 +40,7 @@ export class ProgressService extends Disposable implements IProgressService { @ILayoutService private readonly layoutService: ILayoutService, @IKeybindingService private readonly keybindingService: IKeybindingService, @IUserActivityService private readonly userActivityService: IUserActivityService, + @IHostService private readonly hostService: IHostService, ) { super(); } @@ -567,7 +569,7 @@ export class ProgressService extends Disposable implements IProgressService { cancelId: buttons.length - 1, disableCloseAction: options.sticky, disableDefaultAction: options.sticky - }, this.keybindingService, this.layoutService) + }, this.keybindingService, this.layoutService, this.hostService) ); disposables.add(dialog); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index 0684952209a18..a911112cb4ac8 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -1379,6 +1379,8 @@ export class TestHostService implements IHostService { async showToast(_options: IToastOptions, token: CancellationToken): Promise { return { supported: false, clicked: false }; } + async setWindowDimmed(_targetWindow: Window, _dimmed: boolean): Promise { } + readonly colorScheme = ColorScheme.DARK; onDidChangeColorScheme = Event.None; } From 3eb326e80760a4a57209e7c24a82720b3cc10737 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 13 Feb 2026 12:18:59 +0100 Subject: [PATCH 036/221] Cmd + w closes the window when maximized chat (fix #293051) (#295133) --- .../browser/parts/auxiliarybar/auxiliaryBarActions.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index 5abfb88dd9cbc..b924015772687 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -264,6 +264,11 @@ class RestoreAuxiliaryBar extends Action2 { group: 'navigation', order: 1, when: AuxiliaryBarMaximizedContext + }, + keybinding: { + weight: KeybindingWeight.WorkbenchContrib, + primary: KeyMod.CtrlCmd | KeyCode.KeyW, + win: { primary: KeyMod.CtrlCmd | KeyCode.F4, secondary: [KeyMod.CtrlCmd | KeyCode.KeyW] }, } }); } From 4d38837a83ecc9ab7444eac34626259736b57c49 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 13 Feb 2026 12:27:05 +0100 Subject: [PATCH 037/221] Move usages tool (#295139) * feat: add UsagesTool for finding code symbol usages and implement related tests * feat: handle late-registered tools in LanguageModelToolsExtensionPointHandler * feat: enhance UsagesTool to utilize ISearchService for symbol searches and improve reference classification --- .../editor/common/languageFeatureRegistry.ts | 11 +- src/vs/editor/common/languageSelector.ts | 15 + .../common/modes/languageSelector.test.ts | 25 +- .../contrib/chat/browser/chat.contribution.ts | 2 + .../contrib/chat/browser/tools/usagesTool.ts | 371 ++++++++++++++++++ .../tools/languageModelToolsContribution.ts | 27 +- .../test/browser/tools/usagesTool.test.ts | 320 +++++++++++++++ 7 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts create mode 100644 src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts diff --git a/src/vs/editor/common/languageFeatureRegistry.ts b/src/vs/editor/common/languageFeatureRegistry.ts index d76b0906419f4..c0a0c07d2f6a9 100644 --- a/src/vs/editor/common/languageFeatureRegistry.ts +++ b/src/vs/editor/common/languageFeatureRegistry.ts @@ -6,7 +6,7 @@ import { Emitter } from '../../base/common/event.js'; import { IDisposable, toDisposable } from '../../base/common/lifecycle.js'; import { ITextModel, shouldSynchronizeModel } from './model.js'; -import { LanguageFilter, LanguageSelector, score } from './languageSelector.js'; +import { LanguageFilter, LanguageSelector, score, selectLanguageIds } from './languageSelector.js'; import { URI } from '../../base/common/uri.js'; interface Entry { @@ -115,6 +115,14 @@ export class LanguageFeatureRegistry { return this._entries.map(entry => entry.provider); } + get registeredLanguageIds(): ReadonlySet { + const result = new Set(); + for (const entry of this._entries) { + selectLanguageIds(entry.selector, result); + } + return result; + } + ordered(model: ITextModel, recursive = false): T[] { const result: T[] = []; this._orderedForEach(model, recursive, entry => result.push(entry.provider)); @@ -226,4 +234,3 @@ function isBuiltinSelector(selector: LanguageSelector): boolean { return Boolean((selector as LanguageFilter).isBuiltin); } - diff --git a/src/vs/editor/common/languageSelector.ts b/src/vs/editor/common/languageSelector.ts index 6374d380f48e5..80ffb5450d19d 100644 --- a/src/vs/editor/common/languageSelector.ts +++ b/src/vs/editor/common/languageSelector.ts @@ -142,3 +142,18 @@ export function targetsNotebooks(selector: LanguageSelector): boolean { return !!(selector).notebookType; } } + +export function selectLanguageIds(selector: LanguageSelector, into: Set): void { + if (typeof selector === 'string') { + into.add(selector); + } else if (Array.isArray(selector)) { + for (const item of selector) { + selectLanguageIds(item, into); + } + } else { + const language = (selector).language; + if (language) { + into.add(language); + } + } +} diff --git a/src/vs/editor/test/common/modes/languageSelector.test.ts b/src/vs/editor/test/common/modes/languageSelector.test.ts index 31f6c051af466..be9c597f98f9d 100644 --- a/src/vs/editor/test/common/modes/languageSelector.test.ts +++ b/src/vs/editor/test/common/modes/languageSelector.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { LanguageSelector, score } from '../../../common/languageSelector.js'; +import { LanguageSelector, score, selectLanguageIds } from '../../../common/languageSelector.js'; suite('LanguageSelector', function () { @@ -173,4 +173,27 @@ suite('LanguageSelector', function () { }, obj.uri, obj.langId, true, undefined, undefined); assert.strictEqual(value, 0); }); + + test('selectLanguageIds', function () { + const result = new Set(); + + selectLanguageIds('typescript', result); + assert.deepStrictEqual([...result], ['typescript']); + + result.clear(); + selectLanguageIds({ language: 'python', scheme: 'file' }, result); + assert.deepStrictEqual([...result], ['python']); + + result.clear(); + selectLanguageIds({ scheme: 'file' }, result); + assert.deepStrictEqual([...result], []); + + result.clear(); + selectLanguageIds(['javascript', { language: 'css' }, { scheme: 'untitled' }], result); + assert.deepStrictEqual([...result].sort(), ['css', 'javascript']); + + result.clear(); + selectLanguageIds('*', result); + assert.deepStrictEqual([...result], ['*']); + }); }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index b0d814cbf5785..aad7733c40fcf 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -60,6 +60,7 @@ import { IPromptsService } from '../common/promptSyntax/service/promptsService.j import { PromptsService } from '../common/promptSyntax/service/promptsServiceImpl.js'; import { LanguageModelToolsExtensionPointHandler } from '../common/tools/languageModelToolsContribution.js'; import { BuiltinToolsContribution } from '../common/tools/builtinTools/tools.js'; +import { UsagesToolContribution } from './tools/usagesTool.js'; import { IVoiceChatService, VoiceChatService } from '../common/voiceChatService.js'; import { registerChatAccessibilityActions } from './actions/chatAccessibilityActions.js'; import { AgentChatAccessibilityHelp, EditsChatAccessibilityHelp, PanelChatAccessibilityHelp, QuickChatAccessibilityHelp } from './actions/chatAccessibilityHelp.js'; @@ -1422,6 +1423,7 @@ registerWorkbenchContribution2(ChatSetupContribution.ID, ChatSetupContribution, registerWorkbenchContribution2(ChatTeardownContribution.ID, ChatTeardownContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatStatusBarEntry.ID, ChatStatusBarEntry, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(BuiltinToolsContribution.ID, BuiltinToolsContribution, WorkbenchPhase.Eventually); +registerWorkbenchContribution2(UsagesToolContribution.ID, UsagesToolContribution, WorkbenchPhase.BlockRestore); registerWorkbenchContribution2(ChatAgentSettingContribution.ID, ChatAgentSettingContribution, WorkbenchPhase.AfterRestored); registerWorkbenchContribution2(ChatAgentActionsContribution.ID, ChatAgentActionsContribution, WorkbenchPhase.Eventually); registerWorkbenchContribution2(ToolReferenceNamesContribution.ID, ToolReferenceNamesContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts new file mode 100644 index 0000000000000..aa8e6731069ef --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/tools/usagesTool.ts @@ -0,0 +1,371 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { MarkdownString } from '../../../../../base/common/htmlContent.js'; +import { escapeRegExpCharacters } from '../../../../../base/common/strings.js'; +import { Disposable, IDisposable } from '../../../../../base/common/lifecycle.js'; +import { ResourceSet } from '../../../../../base/common/map.js'; +import { ThemeIcon } from '../../../../../base/common/themables.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { relativePath } from '../../../../../base/common/resources.js'; +import { Position } from '../../../../../editor/common/core/position.js'; +import { Range } from '../../../../../editor/common/core/range.js'; +import { Location, LocationLink } from '../../../../../editor/common/languages.js'; +import { IModelService } from '../../../../../editor/common/services/model.js'; +import { ILanguageFeaturesService } from '../../../../../editor/common/services/languageFeatures.js'; +import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; +import { getDefinitionsAtPosition, getImplementationsAtPosition, getReferencesAtPosition } from '../../../../../editor/contrib/gotoSymbol/browser/goToSymbol.js'; +import { localize } from '../../../../../nls.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IWorkbenchContribution } from '../../../../common/contributions.js'; +import { ISearchService, QueryType, resultIsMatch } from '../../../../services/search/common/search.js'; +import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolResult, ToolDataSource, ToolProgress, } from '../../common/tools/languageModelToolsService.js'; +import { createToolSimpleTextResult } from '../../common/tools/builtinTools/toolHelpers.js'; + +export const UsagesToolId = 'vscode_listCodeUsages'; + +interface IUsagesToolInput { + symbol: string; + uri?: string; + filePath?: string; + lineContent: string; +} + +const BaseModelDescription = `Find all usages (references, definitions, and implementations) of a code symbol across the workspace. This tool locates where a symbol is referenced, defined, or implemented. + +Input: +- "symbol": The exact name of the symbol to search for (function, class, method, variable, type, etc.). +- "uri": A full URI (e.g. "file:///path/to/file.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "filePath": A workspace-relative file path (e.g. "src/utils/helpers.ts") of a file where the symbol appears. Provide either "uri" or "filePath". +- "lineContent": A substring of the line of code where the symbol appears. This is used to locate the exact position in the file. Must be the actual text from the file - do NOT fabricate it. + +IMPORTANT: The file and line do NOT need to be the definition of the symbol. Any occurrence works - a usage, an import, a call site, etc. You can pick whichever occurrence is most convenient. + +If the tool returns an error, retry with corrected input - ensure the file path is correct, the line content matches the actual file content, and the symbol name appears in that line.`; + +export class UsagesTool extends Disposable implements IToolImpl { + + private readonly _onDidUpdateToolData = this._store.add(new Emitter()); + readonly onDidUpdateToolData = this._onDidUpdateToolData.event; + + constructor( + @ILanguageFeaturesService private readonly _languageFeaturesService: ILanguageFeaturesService, + @IModelService private readonly _modelService: IModelService, + @ISearchService private readonly _searchService: ISearchService, + @ITextModelService private readonly _textModelService: ITextModelService, + @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, + ) { + super(); + + this._store.add(Event.debounce( + this._languageFeaturesService.referenceProvider.onDidChange, + () => { }, + 2000 + )((() => this._onDidUpdateToolData.fire()))); + } + + getToolData(): IToolData { + const languageIds = this._languageFeaturesService.referenceProvider.registeredLanguageIds; + + let modelDescription = BaseModelDescription; + if (languageIds.has('*')) { + modelDescription += '\n\nSupported for all languages.'; + } else if (languageIds.size > 0) { + const sorted = [...languageIds].sort(); + modelDescription += `\n\nCurrently supported for: ${sorted.join(', ')}.`; + } else { + modelDescription += '\n\nNo languages currently have reference providers registered.'; + } + + return { + id: UsagesToolId, + toolReferenceName: 'usages', + canBeReferencedInPrompt: false, + icon: ThemeIcon.fromId(Codicon.references.id), + displayName: localize('tool.usages.displayName', 'List Code Usages'), + userDescription: localize('tool.usages.userDescription', 'Find references, definitions, and implementations of a symbol'), + modelDescription, + source: ToolDataSource.Internal, + inputSchema: { + type: 'object', + properties: { + symbol: { + type: 'string', + description: 'The exact name of the symbol (function, class, method, variable, type, etc.) to find usages of.' + }, + uri: { + type: 'string', + description: 'A full URI of a file where the symbol appears (e.g. "file:///path/to/file.ts"). Provide either "uri" or "filePath".' + }, + filePath: { + type: 'string', + description: 'A workspace-relative file path where the symbol appears (e.g. "src/utils/helpers.ts"). Provide either "uri" or "filePath".' + }, + lineContent: { + type: 'string', + description: 'A substring of the line of code where the symbol appears. Used to locate the exact position. Must be actual text from the file.' + } + }, + required: ['symbol', 'lineContent'] + } + }; + } + + async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { + const input = context.parameters as IUsagesToolInput; + return { + invocationMessage: localize('tool.usages.invocationMessage', 'Analyzing usages of `{0}`', input.symbol), + }; + } + + async invoke(invocation: IToolInvocation, _countTokens: CountTokensCallback, _progress: ToolProgress, token: CancellationToken): Promise { + const input = invocation.parameters as IUsagesToolInput; + + // --- resolve URI --- + const uri = this._resolveUri(input); + if (!uri) { + return this._errorResult('Provide either "uri" (a full URI) or "filePath" (a workspace-relative path) to identify the file.'); + } + + // --- open text model --- + const ref = await this._textModelService.createModelReference(uri); + try { + const model = ref.object.textEditorModel; + + if (!this._languageFeaturesService.referenceProvider.has(model)) { + return this._errorResult(`No reference provider available for this file's language. The usages tool may not support this language.`); + } + + // --- find line containing lineContent --- + const parts = input.lineContent.trim().split(/\s+/); + const lineContent = parts.map(escapeRegExpCharacters).join('\\s+'); + const matches = model.findMatches(lineContent, false, true, false, null, false, 1); + if (matches.length === 0) { + return this._errorResult(`Could not find line content "${input.lineContent}" in ${uri.toString()}. Provide the exact text from the line where the symbol appears.`); + } + const lineNumber = matches[0].range.startLineNumber; + + // --- find symbol in that line --- + const lineText = model.getLineContent(lineNumber); + const column = this._findSymbolColumn(lineText, input.symbol); + if (column === undefined) { + return this._errorResult(`Could not find symbol "${input.symbol}" in the matched line. Ensure the symbol name is correct and appears in the provided line content.`); + } + + const position = new Position(lineNumber, column); + + // --- query references, definitions, implementations in parallel --- + const [definitions, references, implementations] = await Promise.all([ + getDefinitionsAtPosition(this._languageFeaturesService.definitionProvider, model, position, false, token), + getReferencesAtPosition(this._languageFeaturesService.referenceProvider, model, position, false, false, token), + getImplementationsAtPosition(this._languageFeaturesService.implementationProvider, model, position, false, token), + ]); + + if (references.length === 0) { + const result = createToolSimpleTextResult(`No usages found for \`${input.symbol}\`.`); + result.toolResultMessage = new MarkdownString(localize('tool.usages.noResults', 'Analyzed usages of `{0}`, no results', input.symbol)); + return result; + } + + // --- classify and format results with previews --- + const previews = await this._getLinePreviews(input.symbol, references, token); + + const lines: string[] = []; + lines.push(`${references.length} usages of \`${input.symbol}\`:\n`); + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const kind = this._classifyReference(ref, definitions, implementations); + const startLine = Range.lift(ref.range).startLineNumber; + const preview = previews[i]; + if (preview) { + lines.push(``); + lines.push(`\t${preview}`); + lines.push(``); + } else { + lines.push(``); + } + } + + const text = lines.join('\n'); + const result = createToolSimpleTextResult(text); + + result.toolResultMessage = references.length === 1 + ? new MarkdownString(localize('tool.usages.oneResult', 'Analyzed usages of `{0}`, 1 result', input.symbol)) + : new MarkdownString(localize('tool.usages.results', 'Analyzed usages of `{0}`, {1} results', input.symbol, references.length)); + + result.toolResultDetails = references.map((r): Location => ({ uri: r.uri, range: r.range })); + + return result; + } finally { + ref.dispose(); + } + } + + private async _getLinePreviews(symbol: string, references: LocationLink[], token: CancellationToken): Promise<(string | undefined)[]> { + const previews: (string | undefined)[] = new Array(references.length); + + // Build a lookup: (uriString, lineNumber) → index in references array + const lookup = new Map(); + const needSearch = new ResourceSet(); + + for (let i = 0; i < references.length; i++) { + const ref = references[i]; + const lineNumber = Range.lift(ref.range).startLineNumber; + + // Try already-open models first + const existingModel = this._modelService.getModel(ref.uri); + if (existingModel) { + previews[i] = existingModel.getLineContent(lineNumber).trim(); + } else { + lookup.set(`${ref.uri.toString()}:${lineNumber}`, i); + needSearch.add(ref.uri); + } + } + + if (needSearch.size === 0 || token.isCancellationRequested) { + return previews; + } + + // Use ISearchService to search for the symbol name, restricted to the + // referenced files. This is backed by ripgrep for file:// URIs. + try { + // Build includePattern from workspace-relative paths + const folders = this._workspaceContextService.getWorkspace().folders; + const relativePaths: string[] = []; + for (const uri of needSearch) { + const folder = this._workspaceContextService.getWorkspaceFolder(uri); + if (folder) { + const rel = relativePath(folder.uri, uri); + if (rel) { + relativePaths.push(rel); + } + } + } + + if (relativePaths.length > 0) { + const includePattern: Record = {}; + if (relativePaths.length === 1) { + includePattern[relativePaths[0]] = true; + } else { + includePattern[`{${relativePaths.join(',')}}`] = true; + } + + const searchResult = await this._searchService.textSearch( + { + type: QueryType.Text, + contentPattern: { pattern: escapeRegExpCharacters(symbol), isRegExp: true, isWordMatch: true }, + folderQueries: folders.map(f => ({ folder: f.uri })), + includePattern, + }, + token, + ); + + for (const fileMatch of searchResult.results) { + if (!fileMatch.results) { + continue; + } + for (const textMatch of fileMatch.results) { + if (!resultIsMatch(textMatch)) { + continue; + } + for (const range of textMatch.rangeLocations) { + const lineNumber = range.source.startLineNumber + 1; // 0-based → 1-based + const key = `${fileMatch.resource.toString()}:${lineNumber}`; + const idx = lookup.get(key); + if (idx !== undefined) { + previews[idx] = textMatch.previewText.trim(); + lookup.delete(key); + } + } + } + } + } + } catch { + // search might fail, leave remaining previews as undefined + } + + return previews; + } + + private _resolveUri(input: IUsagesToolInput): URI | undefined { + if (input.uri) { + return URI.parse(input.uri); + } + if (input.filePath) { + const folders = this._workspaceContextService.getWorkspace().folders; + if (folders.length === 1) { + return folders[0].toResource(input.filePath); + } + // try each folder, return the first + for (const folder of folders) { + return folder.toResource(input.filePath); + } + } + return undefined; + } + + private _findSymbolColumn(lineText: string, symbol: string): number | undefined { + // use word boundary matching to avoid partial matches + const pattern = new RegExp(`\\b${escapeRegExpCharacters(symbol)}\\b`); + const match = pattern.exec(lineText); + if (match) { + return match.index + 1; // 1-based column + } + return undefined; + } + + private _classifyReference(ref: LocationLink, definitions: LocationLink[], implementations: LocationLink[]): string { + if (definitions.some(d => this._overlaps(ref, d))) { + return 'definition'; + } + if (implementations.some(d => this._overlaps(ref, d))) { + return 'implementation'; + } + return 'reference'; + } + + private _overlaps(a: LocationLink, b: LocationLink): boolean { + if (a.uri.toString() !== b.uri.toString()) { + return false; + } + return Range.areIntersectingOrTouching(a.range, b.range); + } + + private _errorResult(message: string): IToolResult { + const result = createToolSimpleTextResult(message); + result.toolResultMessage = new MarkdownString(message); + return result; + } +} + +export class UsagesToolContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'chat.usagesTool'; + + constructor( + @ILanguageModelToolsService toolsService: ILanguageModelToolsService, + @IInstantiationService instantiationService: IInstantiationService, + ) { + super(); + + const usagesTool = this._store.add(instantiationService.createInstance(UsagesTool)); + + let registration: IDisposable | undefined; + const registerUsagesTool = () => { + registration?.dispose(); + toolsService.flushToolUpdates(); + const toolData = usagesTool.getToolData(); + registration = toolsService.registerTool(toolData, usagesTool); + }; + registerUsagesTool(); + this._store.add(usagesTool.onDidUpdateToolData(registerUsagesTool)); + this._store.add({ dispose: () => registration?.dispose() }); + } +} diff --git a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts index 96fb13cb66942..a17eb174f3886 100644 --- a/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts +++ b/src/vs/workbench/contrib/chat/common/tools/languageModelToolsContribution.ts @@ -327,6 +327,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri const tools: IToolData[] = []; const toolSets: IToolSet[] = []; + const missingToolNames: string[] = []; for (const toolName of toolSet.tools) { const toolObj = languageModelToolsService.getToolByName(toolName); @@ -339,7 +340,7 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri toolSets.push(toolSetObj); continue; } - extension.collector.warn(`Tool set '${toolSet.name}' CANNOT find tool or tool set by name: ${toolName}`); + missingToolNames.push(toolName); } if (toolSets.length === 0 && tools.length === 0) { @@ -373,6 +374,30 @@ export class LanguageModelToolsExtensionPointHandler implements IWorkbenchContri toolSets.forEach(toolSet => store.add(obj.addToolSet(toolSet, tx))); }); + // Listen for late-registered tools that weren't available at contribution time + if (missingToolNames.length > 0) { + const pending = new Set(missingToolNames); + const listener = store.add(languageModelToolsService.onDidChangeTools(() => { + for (const toolName of pending) { + const toolObj = languageModelToolsService.getToolByName(toolName); + if (toolObj) { + store.add(obj.addTool(toolObj)); + pending.delete(toolName); + } else { + const toolSetObj = languageModelToolsService.getToolSetByName(toolName); + if (toolSetObj) { + store.add(obj.addToolSet(toolSetObj)); + pending.delete(toolName); + } + } + } + if (pending.size === 0) { + // done + store.delete(listener); + } + })); + } + this._registrationDisposables.set(toToolSetKey(extension.description.identifier, toolSet.name), store); } } diff --git a/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts new file mode 100644 index 0000000000000..e0e20ec03f629 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/tools/usagesTool.test.ts @@ -0,0 +1,320 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { Range } from '../../../../../../editor/common/core/range.js'; +import { DefinitionProvider, ImplementationProvider, Location, ReferenceProvider } from '../../../../../../editor/common/languages.js'; +import { ITextModel } from '../../../../../../editor/common/model.js'; +import { LanguageFeaturesService } from '../../../../../../editor/common/services/languageFeaturesService.js'; +import { IModelService } from '../../../../../../editor/common/services/model.js'; +import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; +import { createTextModel } from '../../../../../../editor/test/common/testTextModel.js'; +import { IWorkspaceContextService, IWorkspaceFolder } from '../../../../../../platform/workspace/common/workspace.js'; +import { FileMatch, ISearchComplete, ISearchService, ITextQuery, OneLineRange, TextSearchMatch } from '../../../../../services/search/common/search.js'; +import { UsagesTool, UsagesToolId } from '../../../browser/tools/usagesTool.js'; +import { IToolInvocation, IToolResult, IToolResultTextPart, ToolProgress } from '../../../common/tools/languageModelToolsService.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; + +function getTextContent(result: IToolResult): string { + const part = result.content.find((p): p is IToolResultTextPart => p.kind === 'text'); + return part?.value ?? ''; +} + +suite('UsagesTool', () => { + + const disposables = new DisposableStore(); + let langFeatures: LanguageFeaturesService; + + const testUri = URI.parse('file:///test/file.ts'); + const testContent = [ + 'import { MyClass } from "./myClass";', + '', + 'function doSomething() {', + '\tconst instance = new MyClass();', + '\tinstance.run();', + '}', + ].join('\n'); + + function createMockModelService(models?: ITextModel[]): IModelService { + return { + _serviceBrand: undefined, + getModel: (uri: URI) => models?.find(m => m.uri.toString() === uri.toString()) ?? null, + } as unknown as IModelService; + } + + function createMockSearchService(searchImpl?: (query: ITextQuery) => ISearchComplete): ISearchService { + return { + _serviceBrand: undefined, + textSearch: async (query: ITextQuery) => searchImpl?.(query) ?? { results: [], messages: [] }, + } as unknown as ISearchService; + } + + function createMockTextModelService(model: ITextModel): ITextModelService { + return { + _serviceBrand: undefined, + createModelReference: async () => ({ + object: { textEditorModel: model }, + dispose: () => { }, + }), + registerTextModelContentProvider: () => ({ dispose: () => { } }), + canHandleResource: () => false, + } as unknown as ITextModelService; + } + + function createMockWorkspaceService(): IWorkspaceContextService { + const folderUri = URI.parse('file:///test'); + const folder = { + uri: folderUri, + toResource: (relativePath: string) => URI.parse(`file:///test/${relativePath}`), + } as unknown as IWorkspaceFolder; + return { + _serviceBrand: undefined, + getWorkspace: () => ({ folders: [folder] }), + getWorkspaceFolder: (uri: URI) => { + if (uri.toString().startsWith(folderUri.toString())) { + return folder; + } + return null; + }, + } as unknown as IWorkspaceContextService; + } + + function createInvocation(parameters: Record): IToolInvocation { + return { parameters } as unknown as IToolInvocation; + } + + const noopCountTokens = async () => 0; + const noopProgress: ToolProgress = { report() { } }; + + function createTool(textModelService: ITextModelService, workspaceService: IWorkspaceContextService, options?: { modelService?: IModelService; searchService?: ISearchService }): UsagesTool { + return new UsagesTool(langFeatures, options?.modelService ?? createMockModelService(), options?.searchService ?? createMockSearchService(), textModelService, workspaceService); + } + + setup(() => { + langFeatures = new LanguageFeaturesService(); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + suite('getToolData', () => { + + test('reports no providers when none registered', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + const data = tool.getToolData(); + assert.strictEqual(data.id, UsagesToolId); + assert.ok(data.modelDescription.includes('No languages currently have reference providers')); + }); + + test('lists registered language ids', () => { + const model = disposables.add(createTextModel('', 'typescript', undefined, testUri)); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('typescript')); + }); + + test('reports all languages for wildcard', () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + disposables.add(langFeatures.referenceProvider.register('*', { provideReferences: () => [] })); + const data = tool.getToolData(); + assert.ok(data.modelDescription.includes('all languages')); + }); + }); + + suite('invoke', () => { + + test('returns error when no uri or filePath provided', async () => { + const tool = disposables.add(createTool(createMockTextModelService(null!), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', lineContent: 'MyClass' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Provide either')); + }); + + test('returns error when line content not found', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'nonexistent line' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find line content')); + }); + + test('returns error when symbol not found in line', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + disposables.add(langFeatures.referenceProvider.register('typescript', { provideReferences: () => [] })); + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'NotHere', uri: testUri.toString(), lineContent: 'function doSomething' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + assert.ok(getTextContent(result).includes('Could not find symbol')); + }); + + test('finds references and classifies them with usage tags', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + + const refProvider: ReferenceProvider = { + provideReferences: (_model: ITextModel): Location[] => [ + { uri: testUri, range: new Range(1, 10, 1, 17) }, + { uri: testUri, range: new Range(4, 23, 4, 30) }, + { uri: otherUri, range: new Range(5, 1, 5, 8) }, + ] + }; + const defProvider: DefinitionProvider = { + provideDefinition: () => [{ uri: testUri, range: new Range(1, 10, 1, 17) }] + }; + const implProvider: ImplementationProvider = { + provideImplementation: () => [{ uri: otherUri, range: new Range(5, 1, 5, 8) }] + }; + + disposables.add(langFeatures.referenceProvider.register('typescript', refProvider)); + disposables.add(langFeatures.definitionProvider.register('typescript', defProvider)); + disposables.add(langFeatures.implementationProvider.register('typescript', implProvider)); + + // Model is open for testUri so IModelService returns it; otherUri needs search + const searchCalled: ITextQuery[] = []; + const searchService = createMockSearchService(query => { + searchCalled.push(query); + const fileMatch = new FileMatch(otherUri); + fileMatch.results = [new TextSearchMatch( + 'export class MyClass implements IMyClass {', + new OneLineRange(4, 0, 7) // 0-based line 4 = 1-based line 5 + )]; + return { results: [fileMatch], messages: [] }; + }); + const modelService = createMockModelService([model]); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + + // Check overall structure + assert.ok(text.includes('3 usages of `MyClass`')); + + // Check usage tag format + assert.ok(text.includes(``)); + assert.ok(text.includes(``)); + assert.ok(text.includes(``)); + + // Check that previews from open model are included (testUri lines) + assert.ok(text.includes('import { MyClass } from "./myClass"')); + assert.ok(text.includes('const instance = new MyClass()')); + + // Check that preview from search service is included (otherUri) + assert.ok(text.includes('export class MyClass implements IMyClass {')); + + // Check closing tags + assert.ok(text.includes('')); + + // Verify search service was called for the non-open file + assert.strictEqual(searchCalled.length, 1); + assert.ok(searchCalled[0].contentPattern.pattern.includes('MyClass')); + assert.ok(searchCalled[0].contentPattern.isWordMatch); + }); + + test('uses self-closing tag when no preview available', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + const otherUri = URI.parse('file:///test/other.ts'); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: otherUri, range: new Range(10, 5, 10, 12) }, + ] + })); + + // Search returns no results for this file (symbol renamed/aliased) + const searchService = createMockSearchService(() => ({ results: [], messages: [] })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + const text = getTextContent(result); + assert.ok(text.includes(``)); + }); + + test('does not call search service for files already open in model service', async () => { + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, testUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: testUri, range: new Range(1, 10, 1, 17) }, + ] + })); + + let searchCalled = false; + const searchService = createMockSearchService(() => { + searchCalled = true; + return { results: [], messages: [] }; + }); + const modelService = createMockModelService([model]); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService(), { modelService, searchService })); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', uri: testUri.toString(), lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + assert.strictEqual(searchCalled, false, 'search service should not be called when all files are open'); + }); + + test('handles whitespace normalization in lineContent', async () => { + const content = 'function doSomething(x: number) {}'; + const model = disposables.add(createTextModel(content, 'typescript', undefined, testUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: testUri, range: new Range(1, 12, 1, 23) }, + ] + })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'doSomething', uri: testUri.toString(), lineContent: 'function doSomething(x: number)' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + }); + + test('resolves filePath via workspace folders', async () => { + const fileUri = URI.parse('file:///test/src/file.ts'); + const model = disposables.add(createTextModel(testContent, 'typescript', undefined, fileUri)); + + disposables.add(langFeatures.referenceProvider.register('typescript', { + provideReferences: (): Location[] => [ + { uri: fileUri, range: new Range(1, 10, 1, 17) }, + ] + })); + + const tool = disposables.add(createTool(createMockTextModelService(model), createMockWorkspaceService())); + const result = await tool.invoke( + createInvocation({ symbol: 'MyClass', filePath: 'src/file.ts', lineContent: 'import { MyClass }' }), + noopCountTokens, noopProgress, CancellationToken.None + ); + + assert.ok(getTextContent(result).includes('1 usages')); + }); + }); +}); From 970fb634fcb3021db41af7344b408c62e3767d0b Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Fri, 13 Feb 2026 13:09:46 +0100 Subject: [PATCH 038/221] fix declare const enum not inlined by esbuild, fixes #295046 (#295149) --- .../code-no-declare-const-enum.ts | 52 +++++++++++++++++++ eslint.config.js | 1 + .../chat/common/tools/promptTsxTypes.ts | 7 ++- 3 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 .eslint-plugin-local/code-no-declare-const-enum.ts diff --git a/.eslint-plugin-local/code-no-declare-const-enum.ts b/.eslint-plugin-local/code-no-declare-const-enum.ts new file mode 100644 index 0000000000000..b448adee89c53 --- /dev/null +++ b/.eslint-plugin-local/code-no-declare-const-enum.ts @@ -0,0 +1,52 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as eslint from 'eslint'; + +/** + * Disallows `declare const enum` declarations. esbuild does not inline + * `declare const enum` values, leaving the enum identifier in the output + * which causes a ReferenceError at runtime. + * + * Use `const enum` (without `declare`) instead. + * + * See https://github.com/evanw/esbuild/issues/4394 + */ +export default new class NoDeclareConstEnum implements eslint.Rule.RuleModule { + + readonly meta: eslint.Rule.RuleMetaData = { + messages: { + noDeclareConstEnum: '"declare const enum" is not supported by esbuild. Use "const enum" instead. See https://github.com/evanw/esbuild/issues/4394', + }, + schema: false, + fixable: 'code', + }; + + create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { + return { + TSEnumDeclaration(node: any) { + if (node.const && node.declare) { + context.report({ + node, + messageId: 'noDeclareConstEnum', + fix: (fixer) => { + // Remove "declare " from "declare const enum" + const sourceCode = context.sourceCode; + const text = sourceCode.getText(node); + const declareIndex = text.indexOf('declare'); + if (declareIndex !== -1) { + return fixer.removeRange([ + node.range[0] + declareIndex, + node.range[0] + declareIndex + 'declare '.length + ]); + } + return null; + } + }); + } + } + }; + } +}; diff --git a/eslint.config.js b/eslint.config.js index dfb489e0f0bc6..a0bcfc0556037 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -78,6 +78,7 @@ export default tseslint.config( 'semi': 'warn', 'local/code-translation-remind': 'warn', 'local/code-no-native-private': 'warn', + 'local/code-no-declare-const-enum': 'warn', 'local/code-parameter-properties-must-have-explicit-accessibility': 'warn', 'local/code-no-nls-in-standalone-editor': 'warn', 'local/code-no-potentially-unsafe-disposables': 'warn', diff --git a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts index 20de711820c65..53617f61395cd 100644 --- a/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts +++ b/src/vs/workbench/contrib/chat/common/tools/promptTsxTypes.ts @@ -7,9 +7,12 @@ * This is a subset of the types export from jsonTypes.d.ts in @vscode/prompt-tsx. * It's just the types needed to stringify prompt-tsx tool results. * It should be kept in sync with the types in that file. + * + * Note: do NOT use `declare` with const enums, esbuild doesn't inline them. + * See https://github.com/evanw/esbuild/issues/4394 */ -export declare const enum PromptNodeType { +export const enum PromptNodeType { Piece = 1, Text = 2 } @@ -23,7 +26,7 @@ export interface TextJSON { * less descriptive than the actual constructor, as we only care to preserve * the element data that the renderer cares about. */ -export declare const enum PieceCtorKind { +export const enum PieceCtorKind { BaseChatMessage = 1, Other = 2, ImageChatMessage = 3 From 768af7b87f58d3fff561240f4ffb92bcc7055e78 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:49:35 -0800 Subject: [PATCH 039/221] notifications -> notification --- src/vs/workbench/contrib/terminal/terminal.all.ts | 2 +- .../workbench/contrib/terminal/terminalContribExports.ts | 2 +- .../browser/terminal.notification.contribution.ts} | 8 ++++---- .../browser/terminalNotificationHandler.ts} | 2 +- .../common/terminalNotificationConfiguration.ts} | 0 .../test/browser/terminalNotification.test.ts} | 6 +++--- 6 files changed, 10 insertions(+), 10 deletions(-) rename src/vs/workbench/contrib/terminalContrib/{notifications/browser/terminal.notifications.contribution.ts => notification/browser/terminal.notification.contribution.ts} (92%) rename src/vs/workbench/contrib/terminalContrib/{notifications/browser/terminal.notifications.handler.ts => notification/browser/terminalNotificationHandler.ts} (99%) rename src/vs/workbench/contrib/terminalContrib/{notifications/common/terminalNotificationsConfiguration.ts => notification/common/terminalNotificationConfiguration.ts} (100%) rename src/vs/workbench/contrib/terminalContrib/{notifications/test/browser/terminalNotifications.test.ts => notification/test/browser/terminalNotification.test.ts} (96%) diff --git a/src/vs/workbench/contrib/terminal/terminal.all.ts b/src/vs/workbench/contrib/terminal/terminal.all.ts index a61db29612f3d..c586b200ef6cf 100644 --- a/src/vs/workbench/contrib/terminal/terminal.all.ts +++ b/src/vs/workbench/contrib/terminal/terminal.all.ts @@ -24,7 +24,7 @@ import '../terminalContrib/commandGuide/browser/terminal.commandGuide.contributi import '../terminalContrib/history/browser/terminal.history.contribution.js'; import '../terminalContrib/inlineHint/browser/terminal.initialHint.contribution.js'; import '../terminalContrib/links/browser/terminal.links.contribution.js'; -import '../terminalContrib/notifications/browser/terminal.notifications.contribution.js'; +import '../terminalContrib/notification/browser/terminal.notification.contribution.js'; import '../terminalContrib/zoom/browser/terminal.zoom.contribution.js'; import '../terminalContrib/stickyScroll/browser/terminal.stickyScroll.contribution.js'; import '../terminalContrib/quickAccess/browser/terminal.quickAccess.contribution.js'; diff --git a/src/vs/workbench/contrib/terminal/terminalContribExports.ts b/src/vs/workbench/contrib/terminal/terminalContribExports.ts index f6918c387cad0..a24b204a899d5 100644 --- a/src/vs/workbench/contrib/terminal/terminalContribExports.ts +++ b/src/vs/workbench/contrib/terminal/terminalContribExports.ts @@ -14,7 +14,7 @@ import { terminalCommandGuideConfiguration } from '../terminalContrib/commandGui import { TerminalDeveloperCommandId } from '../terminalContrib/developer/common/terminal.developer.js'; import { defaultTerminalFindCommandToSkipShell } from '../terminalContrib/find/common/terminal.find.js'; import { defaultTerminalHistoryCommandsToSkipShell, terminalHistoryConfiguration } from '../terminalContrib/history/common/terminal.history.js'; -import { terminalOscNotificationsConfiguration } from '../terminalContrib/notifications/common/terminalNotificationsConfiguration.js'; +import { terminalOscNotificationsConfiguration } from '../terminalContrib/notification/common/terminalNotificationConfiguration.js'; import { TerminalStickyScrollSettingId, terminalStickyScrollConfiguration } from '../terminalContrib/stickyScroll/common/terminalStickyScrollConfiguration.js'; import { defaultTerminalSuggestCommandsToSkipShell } from '../terminalContrib/suggest/common/terminal.suggest.js'; import { TerminalSuggestSettingId, terminalSuggestConfiguration } from '../terminalContrib/suggest/common/terminalSuggestConfiguration.js'; diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts similarity index 92% rename from src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts rename to src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts index 1ffe20afaf724..4cf58b88a2a42 100644 --- a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -11,14 +11,14 @@ import { INotificationService } from '../../../../../platform/notification/commo import { ITerminalLogService } from '../../../../../platform/terminal/common/terminal.js'; import type { ITerminalContribution, ITerminalInstance, IXtermTerminal } from '../../../terminal/browser/terminal.js'; import { registerTerminalContribution, type ITerminalContributionContext } from '../../../terminal/browser/terminalExtensions.js'; -import { TerminalOscNotificationsSettingId } from '../common/terminalNotificationsConfiguration.js'; -import { Osc99NotificationHandler } from './terminal.notifications.handler.js'; +import { TerminalOscNotificationsSettingId } from '../common/terminalNotificationConfiguration.js'; +import { TerminalNotificationHandler } from './terminalNotificationHandler.js'; class TerminalOscNotificationsContribution extends Disposable implements ITerminalContribution { static readonly ID = 'terminal.oscNotifications'; - private readonly _handler: Osc99NotificationHandler; + private readonly _handler: TerminalNotificationHandler; constructor( private readonly _ctx: ITerminalContributionContext, @@ -27,7 +27,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin @ITerminalLogService private readonly _logService: ITerminalLogService, ) { super(); - this._handler = this._register(new Osc99NotificationHandler({ + this._handler = this._register(new TerminalNotificationHandler({ isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications), isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), isTerminalVisible: () => this._ctx.instance.isVisible, diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts similarity index 99% rename from src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts rename to src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 06a0ad0ab9367..41bc22f729392 100644 --- a/src/vs/workbench/contrib/terminalContrib/notifications/browser/terminal.notifications.handler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -55,7 +55,7 @@ export interface IOsc99NotificationHost { writeToProcess(data: string): void; } -export class Osc99NotificationHandler extends Disposable { +export class TerminalNotificationHandler extends Disposable { private readonly _osc99PendingNotifications = new Map(); private _osc99PendingAnonymous: IOsc99NotificationState | undefined; private readonly _osc99ActiveNotifications = new Map(); diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts similarity index 100% rename from src/vs/workbench/contrib/terminalContrib/notifications/common/terminalNotificationsConfiguration.ts rename to src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts diff --git a/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts similarity index 96% rename from src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts rename to src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts index 27a210c334629..9b34d94044818 100644 --- a/src/vs/workbench/contrib/terminalContrib/notifications/test/browser/terminalNotifications.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -7,7 +7,7 @@ import { strictEqual } from 'assert'; import { Emitter, Event } from '../../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { NotificationPriority, Severity, type INotification, type INotificationActions, type INotificationHandle, type INotificationProgress, type NotificationMessage } from '../../../../../../platform/notification/common/notification.js'; -import { Osc99NotificationHandler, type IOsc99NotificationHost } from '../../browser/terminal.notifications.handler.js'; +import { TerminalNotificationHandler, type IOsc99NotificationHost } from '../../browser/terminalNotificationHandler.js'; class TestNotificationProgress implements INotificationProgress { infinite(): void { } @@ -124,11 +124,11 @@ suite('Terminal OSC 99 notifications', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); let host: TestOsc99Host; - let handler: Osc99NotificationHandler; + let handler: TerminalNotificationHandler; setup(() => { host = new TestOsc99Host(); - handler = store.add(new Osc99NotificationHandler(host)); + handler = store.add(new TerminalNotificationHandler(host)); }); teardown(() => { From 8d43dd606a44399c5d54410a4d6a675ae7d2a5a4 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 13 Feb 2026 13:37:03 +0000 Subject: [PATCH 040/221] Add support for reduced transparency in accessibility settings --- extensions/theme-2026/themes/styles.css | 164 ++++++++++++++++++ .../browser/accessibilityService.ts | 40 +++++ .../accessibility/common/accessibility.ts | 2 + .../test/browser/accessibilityService.test.ts | 112 ++++++++++++ .../test/common/testAccessibilityService.ts | 2 + .../browser/workbench.contribution.ts | 12 ++ 6 files changed, 332 insertions(+) create mode 100644 src/vs/platform/accessibility/test/browser/accessibilityService.test.ts diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 35fe38ae75a50..b94ea198c4c45 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -682,3 +682,167 @@ opacity: 1; color: var(--vscode-descriptionForeground); } + +/* ============================================================================================ + * Reduced Transparency - disable backdrop-filter blur and color-mix transparency effects + * for improved rendering performance. Controlled by workbench.reduceTransparency setting. + * ============================================================================================ */ + +/* Reset blur variables to none */ +.monaco-workbench.monaco-reduce-transparency { + --backdrop-blur-sm: none; + --backdrop-blur-md: none; + --backdrop-blur-lg: none; +} + +/* Quick Input (Command Palette) */ +.monaco-workbench.monaco-reduce-transparency .quick-input-widget { + background-color: var(--vscode-quickInput-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Notifications */ +.monaco-workbench.monaco-reduce-transparency .notification-toast-container { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-notifications-background) !important; +} + +.monaco-workbench.monaco-reduce-transparency .notifications-center { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-notifications-background) !important; +} + +/* Context Menu / Action Widget */ +.monaco-workbench.monaco-reduce-transparency .action-widget { + background: var(--vscode-menu-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Suggest Widget */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .suggest-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editorSuggestWidget-background) !important; +} + +/* Find Widget */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .find-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-workbench.monaco-reduce-transparency .inline-chat-gutter-menu { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Dialog */ +.monaco-workbench.monaco-reduce-transparency .monaco-dialog-box { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editor-background) !important; +} + +/* Peek View */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .peekview-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-peekViewEditor-background) !important; +} + +/* Hover */ +.monaco-reduce-transparency .monaco-hover { + background-color: var(--vscode-editorHoverWidget-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-reduce-transparency .monaco-hover.workbench-hover, +.monaco-reduce-transparency .workbench-hover { + background-color: var(--vscode-editorHoverWidget-background) !important; + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Keybinding Widget */ +.monaco-workbench.monaco-reduce-transparency .defineKeybindingWidget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Chat Editor Overlay */ +.monaco-workbench.monaco-reduce-transparency .chat-editor-overlay-widget, +.monaco-workbench.monaco-reduce-transparency .chat-diff-change-content-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Debug Toolbar */ +.monaco-workbench.monaco-reduce-transparency .debug-toolbar { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +.monaco-workbench.monaco-reduce-transparency .debug-hover-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Parameter Hints */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .parameter-hints-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-editorWidget-background) !important; +} + +/* Sticky Scroll */ +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--vscode-editor-background) !important; +} + +.monaco-workbench.monaco-reduce-transparency .monaco-editor .sticky-widget .sticky-widget-lines { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; + background: var(--vscode-editor-background) !important; +} + +/* Rename Box */ +.monaco-reduce-transparency .monaco-editor .rename-box.preview { + -webkit-backdrop-filter: none !important; + backdrop-filter: none !important; +} + +/* Notebook */ +.monaco-workbench.monaco-reduce-transparency .notebookOverlay .monaco-list-row .cell-title-toolbar { + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +/* Command Center */ +.monaco-workbench.monaco-reduce-transparency .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center { + background: var(--vscode-commandCenter-background) !important; + -webkit-backdrop-filter: none; + backdrop-filter: none; +} + +.monaco-workbench.monaco-reduce-transparency .part.titlebar > .titlebar-container > .titlebar-center > .window-title > .command-center .action-item.command-center-center:hover { + background: var(--vscode-commandCenter-activeBackground) !important; +} + +/* Breadcrumbs */ +.monaco-workbench.monaco-reduce-transparency .breadcrumbs-picker-widget { + -webkit-backdrop-filter: none; + backdrop-filter: none; + background: var(--vscode-breadcrumbPicker-background) !important; +} + +/* Quick Input filter input */ +.monaco-workbench.monaco-reduce-transparency .quick-input-widget .quick-input-filter .monaco-inputbox { + background: var(--vscode-input-background) !important; +} diff --git a/src/vs/platform/accessibility/browser/accessibilityService.ts b/src/vs/platform/accessibility/browser/accessibilityService.ts index d6db2a65b33da..35f6639e2697f 100644 --- a/src/vs/platform/accessibility/browser/accessibilityService.ts +++ b/src/vs/platform/accessibility/browser/accessibilityService.ts @@ -24,6 +24,10 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe protected _systemMotionReduced: boolean; protected readonly _onDidChangeReducedMotion = this._register(new Emitter()); + protected _configTransparencyReduced: 'auto' | 'on' | 'off'; + protected _systemTransparencyReduced: boolean; + protected readonly _onDidChangeReducedTransparency = this._register(new Emitter()); + private _linkUnderlinesEnabled: boolean; protected readonly _onDidChangeLinkUnderline = this._register(new Emitter()); @@ -45,6 +49,10 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._configMotionReduced = this._configurationService.getValue('workbench.reduceMotion'); this._onDidChangeReducedMotion.fire(); } + if (e.affectsConfiguration('workbench.reduceTransparency')) { + this._configTransparencyReduced = this._configurationService.getValue('workbench.reduceTransparency'); + this._onDidChangeReducedTransparency.fire(); + } })); updateContextKey(); this._register(this.onDidChangeScreenReaderOptimized(() => updateContextKey())); @@ -53,9 +61,14 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._systemMotionReduced = reduceMotionMatcher.matches; this._configMotionReduced = this._configurationService.getValue<'auto' | 'on' | 'off'>('workbench.reduceMotion'); + const reduceTransparencyMatcher = mainWindow.matchMedia(`(prefers-reduced-transparency: reduce)`); + this._systemTransparencyReduced = reduceTransparencyMatcher.matches; + this._configTransparencyReduced = this._configurationService.getValue<'auto' | 'on' | 'off'>('workbench.reduceTransparency'); + this._linkUnderlinesEnabled = this._configurationService.getValue('accessibility.underlineLinks'); this.initReducedMotionListeners(reduceMotionMatcher); + this.initReducedTransparencyListeners(reduceTransparencyMatcher); this.initLinkUnderlineListeners(); } @@ -78,6 +91,24 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe this._register(this.onDidChangeReducedMotion(() => updateRootClasses())); } + private initReducedTransparencyListeners(reduceTransparencyMatcher: MediaQueryList) { + + this._register(addDisposableListener(reduceTransparencyMatcher, 'change', () => { + this._systemTransparencyReduced = reduceTransparencyMatcher.matches; + if (this._configTransparencyReduced === 'auto') { + this._onDidChangeReducedTransparency.fire(); + } + })); + + const updateRootClasses = () => { + const reduce = this.isTransparencyReduced(); + this._layoutService.mainContainer.classList.toggle('monaco-reduce-transparency', reduce); + }; + + updateRootClasses(); + this._register(this.onDidChangeReducedTransparency(() => updateRootClasses())); + } + private initLinkUnderlineListeners() { this._register(this._configurationService.onDidChangeConfiguration(e => { if (e.affectsConfiguration('accessibility.underlineLinks')) { @@ -119,6 +150,15 @@ export class AccessibilityService extends Disposable implements IAccessibilitySe return config === 'on' || (config === 'auto' && this._systemMotionReduced); } + get onDidChangeReducedTransparency(): Event { + return this._onDidChangeReducedTransparency.event; + } + + isTransparencyReduced(): boolean { + const config = this._configTransparencyReduced; + return config === 'on' || (config === 'auto' && this._systemTransparencyReduced); + } + alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } diff --git a/src/vs/platform/accessibility/common/accessibility.ts b/src/vs/platform/accessibility/common/accessibility.ts index 1757eb84e024b..741d5fffc5b34 100644 --- a/src/vs/platform/accessibility/common/accessibility.ts +++ b/src/vs/platform/accessibility/common/accessibility.ts @@ -14,10 +14,12 @@ export interface IAccessibilityService { readonly onDidChangeScreenReaderOptimized: Event; readonly onDidChangeReducedMotion: Event; + readonly onDidChangeReducedTransparency: Event; alwaysUnderlineAccessKeys(): Promise; isScreenReaderOptimized(): boolean; isMotionReduced(): boolean; + isTransparencyReduced(): boolean; getAccessibilitySupport(): AccessibilitySupport; setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void; alert(message: string): void; diff --git a/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts b/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts new file mode 100644 index 0000000000000..869869d10508f --- /dev/null +++ b/src/vs/platform/accessibility/test/browser/accessibilityService.test.ts @@ -0,0 +1,112 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { TestInstantiationService } from '../../../instantiation/test/common/instantiationServiceMock.js'; +import { IConfigurationService, IConfigurationChangeEvent } from '../../../configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../contextkey/common/contextkey.js'; +import { MockContextKeyService } from '../../../keybinding/test/common/mockKeybindingService.js'; +import { ILayoutService } from '../../../layout/browser/layoutService.js'; +import { AccessibilityService } from '../../browser/accessibilityService.js'; + +suite('AccessibilityService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + let configurationService: TestConfigurationService; + let container: HTMLElement; + + function createService(config: Record = {}): AccessibilityService { + const instantiationService = store.add(new TestInstantiationService()); + + configurationService = new TestConfigurationService({ + 'editor.accessibilitySupport': 'off', + 'workbench.reduceMotion': 'off', + 'workbench.reduceTransparency': 'off', + 'accessibility.underlineLinks': false, + ...config, + }); + instantiationService.stub(IConfigurationService, configurationService); + + instantiationService.stub(IContextKeyService, store.add(new MockContextKeyService())); + + container = document.createElement('div'); + instantiationService.stub(ILayoutService, { + mainContainer: container, + activeContainer: container, + getContainer() { return container; }, + onDidLayoutContainer: Event.None, + }); + + return store.add(instantiationService.createInstance(AccessibilityService)); + } + + suite('isTransparencyReduced', () => { + + test('returns false when config is off', () => { + const service = createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(service.isTransparencyReduced(), false); + }); + + test('returns true when config is on', () => { + const service = createService({ 'workbench.reduceTransparency': 'on' }); + assert.strictEqual(service.isTransparencyReduced(), true); + }); + + test('adds CSS class when config is on', () => { + createService({ 'workbench.reduceTransparency': 'on' }); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), true); + }); + + test('does not add CSS class when config is off', () => { + createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), false); + }); + + test('fires event and updates class on config change', () => { + const service = createService({ 'workbench.reduceTransparency': 'off' }); + assert.strictEqual(service.isTransparencyReduced(), false); + + let fired = false; + store.add(service.onDidChangeReducedTransparency(() => { fired = true; })); + + // Simulate config change + configurationService.setUserConfiguration('workbench.reduceTransparency', 'on'); + configurationService.onDidChangeConfigurationEmitter.fire({ + affectsConfiguration(id: string) { return id === 'workbench.reduceTransparency'; }, + } satisfies Partial as unknown as IConfigurationChangeEvent); + + assert.strictEqual(fired, true); + assert.strictEqual(service.isTransparencyReduced(), true); + assert.strictEqual(container.classList.contains('monaco-reduce-transparency'), true); + }); + }); + + suite('isMotionReduced', () => { + + test('returns false when config is off', () => { + const service = createService({ 'workbench.reduceMotion': 'off' }); + assert.strictEqual(service.isMotionReduced(), false); + }); + + test('returns true when config is on', () => { + const service = createService({ 'workbench.reduceMotion': 'on' }); + assert.strictEqual(service.isMotionReduced(), true); + }); + + test('adds CSS classes when config is on', () => { + createService({ 'workbench.reduceMotion': 'on' }); + assert.strictEqual(container.classList.contains('monaco-reduce-motion'), true); + assert.strictEqual(container.classList.contains('monaco-enable-motion'), false); + }); + + test('adds CSS classes when config is off', () => { + createService({ 'workbench.reduceMotion': 'off' }); + assert.strictEqual(container.classList.contains('monaco-reduce-motion'), false); + assert.strictEqual(container.classList.contains('monaco-enable-motion'), true); + }); + }); +}); diff --git a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts index 4f21111492ebe..6ef551ba9f21b 100644 --- a/src/vs/platform/accessibility/test/common/testAccessibilityService.ts +++ b/src/vs/platform/accessibility/test/common/testAccessibilityService.ts @@ -12,9 +12,11 @@ export class TestAccessibilityService implements IAccessibilityService { onDidChangeScreenReaderOptimized = Event.None; onDidChangeReducedMotion = Event.None; + onDidChangeReducedTransparency = Event.None; isScreenReaderOptimized(): boolean { return false; } isMotionReduced(): boolean { return true; } + isTransparencyReduced(): boolean { return false; } alwaysUnderlineAccessKeys(): Promise { return Promise.resolve(false); } setAccessibilitySupport(accessibilitySupport: AccessibilitySupport): void { } getAccessibilitySupport(): AccessibilitySupport { return AccessibilitySupport.Unknown; } diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index a54437a263347..b1c5637a750c3 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -715,6 +715,18 @@ const registry = Registry.as(ConfigurationExtensions.Con tags: ['accessibility'], enum: ['on', 'off', 'auto'] }, + 'workbench.reduceTransparency': { + type: 'string', + description: localize('workbench.reduceTransparency', "Controls whether the workbench should render with fewer transparency and blur effects for improved performance."), + 'enumDescriptions': [ + localize('workbench.reduceTransparency.on', "Always render without transparency and blur effects."), + localize('workbench.reduceTransparency.off', "Do not reduce transparency and blur effects."), + localize('workbench.reduceTransparency.auto', "Reduce transparency and blur effects based on OS configuration."), + ], + default: 'off', + tags: ['accessibility'], + enum: ['on', 'off', 'auto'] + }, 'workbench.navigationControl.enabled': { 'type': 'boolean', 'default': true, From 7c6cca7b14427b588e97048b9a8953cbdbebce54 Mon Sep 17 00:00:00 2001 From: Johannes Date: Thu, 12 Feb 2026 12:08:56 +0100 Subject: [PATCH 041/221] Add native private field to property transform for esbuild bundles --- build/gulpfile.vscode.ts | 1 + build/gulpfile.vscode.web.ts | 1 + build/next/index.ts | 32 ++- build/next/private-to-property.ts | 191 ++++++++++++++ build/next/test/private-to-property.test.ts | 275 ++++++++++++++++++++ build/package.json | 2 +- 6 files changed, 498 insertions(+), 4 deletions(-) create mode 100644 build/next/private-to-property.ts create mode 100644 build/next/test/private-to-property.test.ts diff --git a/build/gulpfile.vscode.ts b/build/gulpfile.vscode.ts index f6e4e7afe49fb..a103f116c1e6d 100644 --- a/build/gulpfile.vscode.ts +++ b/build/gulpfile.vscode.ts @@ -187,6 +187,7 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, target: const args = [scriptPath, 'bundle', '--out', outDir, '--target', target]; if (minify) { args.push('--minify'); + args.push('--mangle-privates'); } if (nls) { args.push('--nls'); diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index cdaf57000fac1..e9cc3720fcf7f 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -39,6 +39,7 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean): Promis const args = [scriptPath, 'bundle', '--out', outDir, '--target', 'web']; if (minify) { args.push('--minify'); + args.push('--mangle-privates'); } if (nls) { args.push('--nls'); diff --git a/build/next/index.ts b/build/next/index.ts index 2535364d64e6c..8fded5ef748bb 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -10,6 +10,7 @@ import { promisify } from 'util'; import glob from 'glob'; import gulpWatch from '../lib/watch/index.ts'; import { nlsPlugin, createNLSCollector, finalizeNLS, postProcessNLS } from './nls-plugin.ts'; +import { convertPrivateFields, type ConvertPrivateFieldsResult } from './private-to-property.ts'; import { getVersion } from '../lib/getVersion.ts'; import product from '../../product.json' with { type: 'json' }; import packageJson from '../../package.json' with { type: 'json' }; @@ -41,6 +42,7 @@ const options = { watch: process.argv.includes('--watch'), minify: process.argv.includes('--minify'), nls: process.argv.includes('--nls'), + manglePrivates: process.argv.includes('--mangle-privates'), excludeTests: process.argv.includes('--exclude-tests'), out: getArgValue('--out'), target: getArgValue('--target') ?? 'desktop', // 'desktop' | 'server' | 'server-web' | 'web' @@ -740,7 +742,7 @@ async function transpile(outDir: string, excludeTests: boolean): Promise { // Bundle (Goal 2: JS → bundled JS) // ============================================================================ -async function bundle(outDir: string, doMinify: boolean, doNls: boolean, target: BuildTarget, sourceMapBaseUrl?: string): Promise { +async function bundle(outDir: string, doMinify: boolean, doNls: boolean, doManglePrivates: boolean, target: BuildTarget, sourceMapBaseUrl?: string): Promise { await cleanDir(outDir); // Write build date file (used by packaging to embed in product.json) @@ -748,7 +750,7 @@ async function bundle(outDir: string, doMinify: boolean, doNls: boolean, target: await fs.promises.mkdir(outDirPath, { recursive: true }); await fs.promises.writeFile(path.join(outDirPath, 'date'), new Date().toISOString(), 'utf8'); - console.log(`[bundle] ${SRC_DIR} → ${outDir} (target: ${target})${doMinify ? ' (minify)' : ''}${doNls ? ' (nls)' : ''}`); + console.log(`[bundle] ${SRC_DIR} → ${outDir} (target: ${target})${doMinify ? ' (minify)' : ''}${doNls ? ' (nls)' : ''}${doManglePrivates ? ' (mangle-privates)' : ''}`); const t1 = Date.now(); // Read TSLib for banner @@ -902,6 +904,7 @@ ${tslib}`, // Post-process and write all output files let bundled = 0; + const mangleStats: { file: string; result: ConvertPrivateFieldsResult }[] = []; for (const { result } of buildResults) { if (!result.outputFiles) { continue; @@ -918,6 +921,15 @@ ${tslib}`, content = postProcessNLS(content, indexMap, preserveEnglish); } + // Convert native #private fields to regular properties + if (file.path.endsWith('.js') && doManglePrivates) { + const mangleResult = convertPrivateFields(content, file.path); + content = mangleResult.code; + if (mangleResult.editCount > 0) { + mangleStats.push({ file: path.relative(path.join(REPO_ROOT, outDir), file.path), result: mangleResult }); + } + } + // Rewrite sourceMappingURL to CDN URL if configured if (sourceMapBaseUrl) { const relativePath = path.relative(path.join(REPO_ROOT, outDir), file.path); @@ -940,6 +952,19 @@ ${tslib}`, bundled++; } + // Log mangle-privates stats + if (doManglePrivates && mangleStats.length > 0) { + let totalClasses = 0, totalFields = 0, totalEdits = 0, totalElapsed = 0; + for (const { file, result } of mangleStats) { + console.log(`[mangle-privates] ${file}: ${result.classCount} classes, ${result.fieldCount} fields, ${result.editCount} edits, ${result.elapsed}ms`); + totalClasses += result.classCount; + totalFields += result.fieldCount; + totalEdits += result.editCount; + totalElapsed += result.elapsed; + } + console.log(`[mangle-privates] Total: ${totalClasses} classes, ${totalFields} fields, ${totalEdits} edits, ${totalElapsed}ms`); + } + // Copy resources (exclude dev files and tests for production) await copyResources(outDir, target, true, true); @@ -1075,6 +1100,7 @@ Options for 'transpile': Options for 'bundle': --minify Minify the output bundles --nls Process NLS (localization) strings + --mangle-privates Convert native #private fields to regular properties --out Output directory (default: out-vscode) --target Build target: desktop (default), server, server-web, web --source-map-base-url Rewrite sourceMappingURL to CDN URL @@ -1118,7 +1144,7 @@ async function main(): Promise { break; case 'bundle': - await bundle(options.out ?? OUT_VSCODE_DIR, options.minify, options.nls, options.target as BuildTarget, options.sourceMapBaseUrl); + await bundle(options.out ?? OUT_VSCODE_DIR, options.minify, options.nls, options.manglePrivates, options.target as BuildTarget, options.sourceMapBaseUrl); break; default: diff --git a/build/next/private-to-property.ts b/build/next/private-to-property.ts new file mode 100644 index 0000000000000..64f1c4e74bf97 --- /dev/null +++ b/build/next/private-to-property.ts @@ -0,0 +1,191 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as ts from 'typescript'; + +/** + * Converts native ES private fields (`#foo`) into regular JavaScript properties with short, + * globally unique names (e.g., `$a`, `$b`). This achieves two goals: + * + * 1. **Performance**: Native private fields are slower than regular properties in V8. + * 2. **Mangling**: Short replacement names reduce bundle size. + * + * ## Why not simply strip `#`? + * + * - **Inheritance collision**: If `class B extends A` and both declare `#x`, stripping `#` + * yields `x` on both - collision on child instances. + * - **Public property shadowing**: `class Foo extends Error { static #name = ... }` - stripping + * `#` produces `name` which shadows `Error.name`. + * + * ## Strategy: Globally unique names with `$` prefix + * + * Each (class, privateFieldName) pair gets a unique name from a global counter: `$a`, `$b`, ... + * This guarantees no inheritance collision and no shadowing of public properties. + * + * ## Why this is safe with syntax-only analysis + * + * Native `#` fields are **lexically scoped** to their declaring class body. Every declaration + * and every usage site is syntactically inside the class body. A single AST walk is sufficient + * to find all sites - no cross-file analysis or type checker needed. + */ + +// Short name generator: $a, $b, ..., $z, $A, ..., $Z, $aa, $ab, ... +const CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; + +function generateShortName(index: number): string { + let name = ''; + do { + name = CHARS[index % CHARS.length] + name; + index = Math.floor(index / CHARS.length) - 1; + } while (index >= 0); + return '$' + name; +} + +interface Edit { + start: number; + end: number; + newText: string; +} + +// Private name → replacement name per class (identified by position in file) +type ClassScope = Map; + +export interface ConvertPrivateFieldsResult { + readonly code: string; + readonly classCount: number; + readonly fieldCount: number; + readonly editCount: number; + readonly elapsed: number; +} + +/** + * Converts all native `#` private fields/methods in the given JavaScript source to regular + * properties with short, globally unique names. + * + * @param code The JavaScript source code (typically a bundled output file). + * @param filename Used for TypeScript parser diagnostics only. + * @returns The transformed source code with `#` fields replaced, plus stats. + */ +export function convertPrivateFields(code: string, filename: string): ConvertPrivateFieldsResult { + const t1 = Date.now(); + // Quick bail-out: if there are no `#` characters, nothing to do + if (!code.includes('#')) { + return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1 }; + } + + const sourceFile = ts.createSourceFile(filename, code, ts.ScriptTarget.ESNext, false, ts.ScriptKind.JS); + + // Global counter for unique name generation + let nameCounter = 0; + let classCount = 0; + + // Collect all edits + const edits: Edit[] = []; + + // Class stack for resolving private names in nested classes. + // When a PrivateIdentifier is encountered, we search from innermost to outermost + // class scope - matching JS lexical resolution semantics. + const classStack: ClassScope[] = []; + + visit(sourceFile); + + if (edits.length === 0) { + return { code, classCount: 0, fieldCount: 0, editCount: 0, elapsed: Date.now() - t1 }; + } + + // Apply edits using substring concatenation (O(N+K), not O(N*K) like char-array splice) + edits.sort((a, b) => a.start - b.start); + const parts: string[] = []; + let lastEnd = 0; + for (const edit of edits) { + parts.push(code.substring(lastEnd, edit.start)); + parts.push(edit.newText); + lastEnd = edit.end; + } + parts.push(code.substring(lastEnd)); + return { code: parts.join(''), classCount, fieldCount: nameCounter, editCount: edits.length, elapsed: Date.now() - t1 }; + + // --- AST walking --- + + function visit(node: ts.Node): void { + if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) { + visitClass(node); + return; + } + ts.forEachChild(node, visit); + } + + function visitClass(node: ts.ClassDeclaration | ts.ClassExpression): void { + // 1) Collect all private field/method/accessor declarations in THIS class + const scope: ClassScope = new Map(); + for (const member of node.members) { + if (member.name && ts.isPrivateIdentifier(member.name)) { + const name = member.name.text; + if (!scope.has(name)) { + scope.set(name, generateShortName(nameCounter++)); + } + } + } + + if (scope.size > 0) { + classCount++; + } + classStack.push(scope); + + // 2) Walk the class body, replacing PrivateIdentifier nodes + ts.forEachChild(node, function walkInClass(child: ts.Node): void { + // Nested class: process independently with its own scope + if ((ts.isClassDeclaration(child) || ts.isClassExpression(child)) && child !== node) { + visitClass(child); + return; + } + + // Handle `#field in expr` (ergonomic brand check) - needs string literal replacement + if (ts.isBinaryExpression(child) && + child.operatorToken.kind === ts.SyntaxKind.InKeyword && + ts.isPrivateIdentifier(child.left)) { + const resolved = resolvePrivateName(child.left.text); + if (resolved !== undefined) { + edits.push({ + start: child.left.getStart(sourceFile), + end: child.left.getEnd(), + newText: `'${resolved}'` + }); + } + // Still need to walk the right-hand side for any private field usages + ts.forEachChild(child.right, walkInClass); + return; + } + + // Normal PrivateIdentifier usage (declaration, property access, method call) + if (ts.isPrivateIdentifier(child)) { + const resolved = resolvePrivateName(child.text); + if (resolved !== undefined) { + edits.push({ + start: child.getStart(sourceFile), + end: child.getEnd(), + newText: resolved + }); + } + return; + } + + ts.forEachChild(child, walkInClass); + }); + + classStack.pop(); + } + + function resolvePrivateName(name: string): string | undefined { + // Walk from innermost to outermost class scope (matches JS lexical resolution) + for (let i = classStack.length - 1; i >= 0; i--) { + const resolved = classStack[i].get(name); + if (resolved !== undefined) { + return resolved; + } + } + return undefined; + } +} diff --git a/build/next/test/private-to-property.test.ts b/build/next/test/private-to-property.test.ts new file mode 100644 index 0000000000000..3cde63a4bdaf4 --- /dev/null +++ b/build/next/test/private-to-property.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { convertPrivateFields } from '../private-to-property.ts'; + +suite('convertPrivateFields', () => { + + test('no # characters — quick bail-out', () => { + const result = convertPrivateFields('const x = 1; function foo() { return x; }', 'test.js'); + assert.strictEqual(result.code, 'const x = 1; function foo() { return x; }'); + assert.strictEqual(result.editCount, 0); + assert.strictEqual(result.classCount, 0); + assert.strictEqual(result.fieldCount, 0); + }); + + test('class without private fields — identity', () => { + const code = 'class Plain { x = 1; get() { return this.x; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(result.code, code); + assert.strictEqual(result.editCount, 0); + }); + + test('basic private field', () => { + const code = 'class Foo { #x = 1; get() { return this.#x; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x'), 'should not contain #x'); + assert.ok(result.code.includes('$a'), 'should contain replacement $a'); + assert.strictEqual(result.classCount, 1); + assert.strictEqual(result.fieldCount, 1); + assert.strictEqual(result.editCount, 2); + }); + + test('multiple private fields in one class', () => { + const code = 'class Foo { #x = 1; #y = 2; get() { return this.#x + this.#y; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + assert.ok(!result.code.includes('#y')); + assert.strictEqual(result.fieldCount, 2); + assert.strictEqual(result.editCount, 4); + }); + + test('inheritance — same private name in parent and child get different replacements', () => { + const code = [ + 'class Parent { #a = 1; getA() { return this.#a; } }', + 'class Child extends Parent { #a = 2; getChildA() { return this.#a; } }', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#a')); + assert.ok(result.code.includes('$a'), 'Parent should get $a'); + assert.ok(result.code.includes('$b'), 'Child should get $b'); + }); + + test('static private field — no clash with inherited public property', () => { + const code = [ + 'class MyError extends Error {', + ' static #name = "MyError";', + ' check(data) { return data.name !== MyError.#name; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#name')); + assert.ok(result.code.includes('$a')); + assert.ok(result.code.includes('data.name'), 'public property should be preserved'); + }); + + test('private method', () => { + const code = [ + 'class Bar {', + ' #normalize(s) { return s.toLowerCase(); }', + ' process(s) { return this.#normalize(s); }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#normalize')); + assert.strictEqual(result.fieldCount, 1); + }); + + test('getter/setter pair', () => { + const code = [ + 'class WithAccessors {', + ' #_val;', + ' get #val() { return this.#_val; }', + ' set #val(v) { this.#_val = v; }', + ' init() { this.#val = 42; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#_val')); + assert.ok(!result.code.includes('#val')); + assert.strictEqual(result.fieldCount, 2); + }); + + test('nested classes — separate scopes', () => { + const code = [ + 'class Outer {', + ' #x = 1;', + ' method() {', + ' class Inner {', + ' #y = 2;', + ' foo() { return this.#y; }', + ' }', + ' return this.#x;', + ' }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + assert.ok(!result.code.includes('#y')); + assert.strictEqual(result.classCount, 2); + }); + + test('nested class accessing outer private field', () => { + const code = [ + 'class Outer {', + ' #x = 1;', + ' method() {', + ' class Inner {', + ' foo(o) { return o.#x; }', + ' }', + ' return this.#x;', + ' }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + const matches = result.code.match(/\$a/g); + assert.strictEqual(matches?.length, 3, 'decl + this.#x + o.#x = 3'); + }); + + test('nested classes — same private name get different replacements', () => { + const code = [ + 'class Outer {', + ' #x = 1;', + ' m() {', + ' class Inner {', + ' #x = 2;', + ' f() { return this.#x; }', + ' }', + ' return this.#x;', + ' }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#x')); + assert.ok(result.code.includes('$a'), 'Outer.#x → $a'); + assert.ok(result.code.includes('$b'), 'Inner.#x → $b'); + }); + + test('unrelated classes with same private name', () => { + const code = [ + 'class A { #data = 1; get() { return this.#data; } }', + 'class B { #data = 2; get() { return this.#data; } }', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#data')); + assert.ok(result.code.includes('$a')); + assert.ok(result.code.includes('$b')); + }); + + test('cross-instance access', () => { + const code = [ + 'class Foo {', + ' #secret = 42;', + ' equals(other) { return this.#secret === other.#secret; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#secret')); + const matches = result.code.match(/\$a/g); + assert.strictEqual(matches?.length, 3); + }); + + test('string containing # is not modified', () => { + const code = [ + 'class Foo {', + ' #x = 1;', + ' label = "use #x for private";', + ' get() { return this.#x; }', + '}', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('"use #x for private"'), 'string preserved'); + assert.ok(!result.code.includes('this.#x'), 'usage replaced'); + }); + + test('#field in expr — brand check uses quoted string', () => { + const code = 'class Foo { #brand; static check(x) { if (#brand in x) return true; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(!result.code.includes('#brand')); + assert.ok(result.code.includes('\'$a\' in x'), 'quoted string for in-check'); + }); + + test('string #brand in obj is not treated as private field', () => { + const code = 'class Foo { #brand = true; isFoo(obj) { return "#brand" in obj; } }'; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('"#brand" in obj'), 'string literal preserved'); + }); + + test('transformed code is valid JavaScript', () => { + const code = [ + 'class Base { #id = 0; getId() { return this.#id; } }', + 'class Derived extends Base { #name; constructor(n) { super(); this.#name = n; } getName() { return this.#name; } }', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.doesNotThrow(() => new Function(result.code)); + }); + + test('transformed code executes correctly', () => { + const code = [ + 'class Counter {', + ' #count = 0;', + ' increment() { this.#count++; }', + ' get value() { return this.#count; }', + '}', + 'const c = new Counter();', + 'c.increment(); c.increment(); c.increment();', + 'return c.value;', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(new Function(result.code)(), 3); + }); + + test('transformed code executes correctly with inheritance', () => { + const code = [ + 'class Animal {', + ' #sound;', + ' constructor(s) { this.#sound = s; }', + ' speak() { return this.#sound; }', + '}', + 'class Dog extends Animal {', + ' #tricks = [];', + ' constructor() { super("woof"); }', + ' learn(trick) { this.#tricks.push(trick); }', + ' show() { return this.#tricks.join(","); }', + '}', + 'const d = new Dog();', + 'd.learn("sit"); d.learn("shake");', + 'return d.speak() + ":" + d.show();', + ].join('\n'); + const result = convertPrivateFields(code, 'test.js'); + assert.strictEqual(new Function(result.code)(), 'woof:sit,shake'); + }); + + suite('name generation', () => { + + test('generates $a through $Z for 52 fields', () => { + const fields = []; + const usages = []; + for (let i = 0; i < 52; i++) { + fields.push(`#f${i};`); + usages.push(`this.#f${i}`); + } + const code = `class Big { ${fields.join(' ')} get() { return ${usages.join(' + ')}; } }`; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('$a')); + assert.ok(result.code.includes('$Z')); + assert.strictEqual(result.fieldCount, 52); + }); + + test('wraps to $aa after $Z', () => { + const fields = []; + const usages = []; + for (let i = 0; i < 53; i++) { + fields.push(`#f${i};`); + usages.push(`this.#f${i}`); + } + const code = `class Big { ${fields.join(' ')} get() { return ${usages.join(' + ')}; } }`; + const result = convertPrivateFields(code, 'test.js'); + assert.ok(result.code.includes('$aa')); + }); + }); +}); diff --git a/build/package.json b/build/package.json index b6ccfdfc302dc..e889b6ac54d7b 100644 --- a/build/package.json +++ b/build/package.json @@ -70,7 +70,7 @@ "pretypecheck": "npm run copy-policy-dto", "typecheck": "cd .. && npx tsgo --project build/tsconfig.json", "watch": "npm run typecheck -- --watch", - "test": "mocha --ui tdd 'lib/**/*.test.ts'" + "test": "mocha --ui tdd '{lib,next}/**/*.test.ts'" }, "optionalDependencies": { "tree-sitter-typescript": "^0.23.2", From 8bc0e774014c5fdba3440b60f1db63ae9fdf8032 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 13 Feb 2026 13:47:44 +0000 Subject: [PATCH 042/221] Enhance quick input widget styling for better visibility and interaction --- extensions/theme-2026/themes/styles.css | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 35fe38ae75a50..8d4dfeb4b60a6 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -199,6 +199,14 @@ outline: none !important; } +.monaco-workbench .quick-input-widget .quick-input-list .monaco-list-rows { + background: transparent !important; +} + +.monaco-workbench.vs .quick-input-widget .quick-input-list .monaco-list-row:hover:not(.selected):not(.focused) { + background-color: color-mix(in srgb, var(--vscode-list-hoverBackground) 95%, black) !important; +} + .monaco-workbench .monaco-editor .suggest-widget .monaco-list { border-radius: var(--radius-lg); } From 9d1ba054ae8a32e15d41154b4226b6cfe3995b3e Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Fri, 13 Feb 2026 13:52:46 +0000 Subject: [PATCH 043/221] Update codicons version to 0.0.45-7 and add new icon 'openInWindow' --- package-lock.json | 8 ++++---- package.json | 2 +- remote/web/package-lock.json | 8 ++++---- remote/web/package.json | 2 +- src/vs/base/common/codiconsLibrary.ts | 1 + 5 files changed, 11 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6bc08ff095670..66ff655b1b9e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -2947,9 +2947,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-6", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", - "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", + "version": "0.0.45-7", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-7.tgz", + "integrity": "sha512-z7B3hI6LfrFY8uo0PrAHkZ0K0XQbKTshIHlDe5qyf0j6sjM2vNlzdj2FA8HgasYKBQ3zzpZzD/GK8on2A1AKRA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/deviceid": { diff --git a/package.json b/package.json index d5743f86c506d..5de9732104172 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 48c97fba4c5fd..663f8c5c185d2 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-6", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-6.tgz", - "integrity": "sha512-HjJmIxw6anUPk/yiQTyF60ERjARNfc/A11kKoiO7jg2bzNeaCexunu4oUo/W8lHGr/dvHxYcruM1V3ZoGxyFNQ==", + "version": "0.0.45-7", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-7.tgz", + "integrity": "sha512-z7B3hI6LfrFY8uo0PrAHkZ0K0XQbKTshIHlDe5qyf0j6sjM2vNlzdj2FA8HgasYKBQ3zzpZzD/GK8on2A1AKRA==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 4f591ed99068f..97b681d67b2af 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-6", + "@vscode/codicons": "^0.0.45-7", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.21", diff --git a/src/vs/base/common/codiconsLibrary.ts b/src/vs/base/common/codiconsLibrary.ts index 0ff9490a05801..f541d4face8e6 100644 --- a/src/vs/base/common/codiconsLibrary.ts +++ b/src/vs/base/common/codiconsLibrary.ts @@ -654,4 +654,5 @@ export const codiconsLibrary = { ask: register('ask', 0xec80), openai: register('openai', 0xec81), claude: register('claude', 0xec82), + openInWindow: register('open-in-window', 0xec83), } as const; From bccf22cb8d8cfef91bb899d25c7a688beb50661f Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 13 Feb 2026 14:59:16 +0100 Subject: [PATCH 044/221] Exclude --- build/next/index.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/build/next/index.ts b/build/next/index.ts index 8fded5ef748bb..21a6aa99f8a86 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -63,6 +63,17 @@ const UTF8_BOM = Buffer.from([0xef, 0xbb, 0xbf]); // Entry Points (from build/buildfile.ts) // ============================================================================ +// Extension host bundles are excluded from private field mangling because they +// expose API surface to extensions where encapsulation matters. +const extensionHostEntryPoints = [ + 'vs/workbench/api/node/extensionHostProcess', + 'vs/workbench/api/worker/extensionHostWorkerMain', +]; + +function isExtensionHostBundle(filePath: string): boolean { + return extensionHostEntryPoints.some(ep => filePath.endsWith(`${ep}.js`)); +} + // Workers - shared between targets const workerEntryPoints = [ 'vs/editor/common/services/editorWebWorkerMain', @@ -921,8 +932,10 @@ ${tslib}`, content = postProcessNLS(content, indexMap, preserveEnglish); } - // Convert native #private fields to regular properties - if (file.path.endsWith('.js') && doManglePrivates) { + // Convert native #private fields to regular properties. + // Skip extension host bundles - they expose API surface to extensions + // where true encapsulation matters more than the perf gain. + if (file.path.endsWith('.js') && doManglePrivates && !isExtensionHostBundle(file.path)) { const mangleResult = convertPrivateFields(content, file.path); content = mangleResult.code; if (mangleResult.editCount > 0) { From f29cef468dc179f543a252954e3e99dd016a461b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:16:03 +0000 Subject: [PATCH 045/221] Fix Windows test failure: mock Linux remote environment The tests were failing on Windows because the sandbox service returns false from isEnabled() when running on Windows. Fixed by making MockRemoteAgentService return a Linux remote environment, simulating a remote connection which allows the tests to pass on all platforms. Co-authored-by: jrieken <1794099+jrieken@users.noreply.github.com> --- .../browser/terminalSandboxService.test.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts index 3b5f156b3c031..55901336b328d 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/browser/terminalSandboxService.test.ts @@ -19,6 +19,8 @@ import { TerminalChatAgentToolsSettingId } from '../../common/terminalChatAgentT import { Event, Emitter } from '../../../../../../base/common/event.js'; import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; import { VSBuffer } from '../../../../../../base/common/buffer.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { IRemoteAgentEnvironment } from '../../../../../../platform/remote/common/remoteAgentEnvironment.js'; suite('TerminalSandboxService - allowTrustedDomains', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -48,8 +50,32 @@ suite('TerminalSandboxService - allowTrustedDomains', () => { } class MockRemoteAgentService { - async getEnvironment() { - return null; + async getEnvironment(): Promise { + // Return a Linux environment to ensure tests pass on Windows + // (sandbox is not supported on Windows) + return { + os: OperatingSystem.Linux, + tmpDir: URI.file('/tmp'), + appRoot: URI.file('/app'), + pid: 1234, + connectionToken: 'test-token', + settingsPath: URI.file('/settings'), + mcpResource: URI.file('/mcp'), + logsPath: URI.file('/logs'), + extensionHostLogsPath: URI.file('/ext-logs'), + globalStorageHome: URI.file('/global'), + workspaceStorageHome: URI.file('/workspace'), + localHistoryHome: URI.file('/history'), + userHome: URI.file('/home/user'), + arch: 'x64', + marks: [], + useHostProxy: false, + profiles: { + all: [], + home: URI.file('/profiles') + }, + isUnsupportedGlibc: false + }; } } From d45ab2fa3486d7d6397a420fb2d643311d98a4e9 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:21:43 +0000 Subject: [PATCH 046/221] Fix question carousel reappearing after window reload (#295007) --- .../contrib/chat/common/model/chatModel.ts | 11 +++++ .../chat/test/common/model/chatModel.test.ts | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index d9bf9d1fe5a59..c07de21aed866 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -2311,6 +2311,17 @@ export class ChatModel extends Disposable implements IChatModel { modelState = { value: ResponseModelState.Cancelled, completedAt: Date.now() }; } + // Mark question carousels as used after + // deserialization. After a reload, the extension is no longer listening for + // their responses, so they cannot be interacted with. + if (raw.response) { + for (const part of raw.response) { + if (hasKey(part, { kind: true }) && (part.kind === 'questionCarousel')) { + part.isUsed = true; + } + } + } + request.response = new ChatResponseModel({ responseContent: raw.response ?? [new MarkdownString(raw.response)], session: this, diff --git a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts index 934d86152e6b7..c0800d4b46d91 100644 --- a/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/model/chatModel.test.ts @@ -167,6 +167,51 @@ suite('ChatModel', () => { assert.strictEqual(request1.response!.shouldBeRemovedOnSend, undefined); }); + test('deserialization marks unused question carousels as used', async () => { + const serializableData: ISerializableChatData3 = { + version: 3, + sessionId: 'test-session', + creationDate: Date.now(), + customTitle: undefined, + initialLocation: ChatAgentLocation.Chat, + requests: [{ + requestId: 'req1', + message: { text: 'hello', parts: [] }, + variableData: { variables: [] }, + response: [ + { value: 'some text', isTrusted: false }, + { + kind: 'questionCarousel' as const, + questions: [{ id: 'q1', title: 'Question 1', type: 'text' as const }], + allowSkip: true, + resolveId: 'resolve1', + isUsed: false, + }, + ], + modelState: { value: 2 /* ResponseModelState.Cancelled */, completedAt: Date.now() }, + }], + responderUsername: 'bot', + }; + + const model = testDisposables.add(instantiationService.createInstance( + ChatModel, + { value: serializableData, serializer: undefined! }, + { initialLocation: ChatAgentLocation.Chat, canUseTools: true } + )); + + const requests = model.getRequests(); + assert.strictEqual(requests.length, 1); + const response = requests[0].response!; + + // The question carousel should be marked as used after deserialization + const carouselPart = response.response.value.find(p => p.kind === 'questionCarousel'); + assert.ok(carouselPart); + assert.strictEqual(carouselPart.isUsed, true); + + // The response should be complete (not stuck in NeedsInput) + assert.strictEqual(response.isComplete, true); + }); + test('inputModel.toJSON filters extension-contributed contexts', async function () { const model = testDisposables.add(instantiationService.createInstance(ChatModel, undefined, { initialLocation: ChatAgentLocation.Chat, canUseTools: true })); From e1f64b1e02f21b166705f453b0e47b818b05a6c8 Mon Sep 17 00:00:00 2001 From: Simon Siefke Date: Fri, 13 Feb 2026 15:29:46 +0100 Subject: [PATCH 047/221] fix: memory leak in tunnel view (#287142) * fix: memory leak in tunnel view * fix: memory leak in tunnel view * fix: memory leak in tunnel view * Some clean up --------- Co-authored-by: Alex Ross <38270282+alexr00@users.noreply.github.com> --- .../contrib/remote/browser/tunnelView.ts | 54 +++++++++---------- 1 file changed, 26 insertions(+), 28 deletions(-) diff --git a/src/vs/workbench/contrib/remote/browser/tunnelView.ts b/src/vs/workbench/contrib/remote/browser/tunnelView.ts index 9b0241b7a2dd3..366bca29f921c 100644 --- a/src/vs/workbench/contrib/remote/browser/tunnelView.ts +++ b/src/vs/workbench/contrib/remote/browser/tunnelView.ts @@ -18,7 +18,7 @@ import { IQuickInputService, IQuickPickItem, QuickPickInput } from '../../../../ import { ICommandService, ICommandHandler, CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { Event } from '../../../../base/common/event.js'; import { IWorkbenchEnvironmentService } from '../../../services/environment/common/environmentService.js'; -import { Disposable, IDisposable, toDisposable, dispose, DisposableStore } from '../../../../base/common/lifecycle.js'; +import { toDisposable, dispose, DisposableStore } from '../../../../base/common/lifecycle.js'; import { ActionBar } from '../../../../base/browser/ui/actionbar/actionbar.js'; import { IconLabel } from '../../../../base/browser/ui/iconLabel/iconLabel.js'; import { ActionRunner, IAction } from '../../../../base/common/actions.js'; @@ -320,7 +320,8 @@ class PrivacyColumn implements ITableColumn { } interface IActionBarTemplateData { - elementDisposable: IDisposable; + readonly elementDisposables: DisposableStore; + readonly templateDisposables: DisposableStore; container: HTMLElement; label: IconLabel; button?: Button; @@ -338,7 +339,7 @@ interface ActionBarCell { editId: TunnelEditId; } -class ActionBarRenderer extends Disposable implements ITableRenderer { +class ActionBarRenderer implements ITableRenderer { readonly templateId = 'actionbar'; private inputDone?: (success: boolean, finishEditing: boolean) => void; private _actionRunner: ActionRunner | undefined; @@ -353,8 +354,6 @@ class ActionBarRenderer extends Disposable implements ITableRenderer { + templateData.elementDisposables.add(templateData.button.onDidClick(() => { this.commandService.executeCommand(ForwardPortAction.INLINE_ID); })); } @@ -464,10 +467,8 @@ class ActionBarRenderer extends Disposable implements ITableRenderer action.id.toLowerCase().indexOf('label') >= 0); @@ -489,12 +490,13 @@ class ActionBarRenderer extends Disposable implements ITableRenderer { + templateData.elementDisposables.add(toDisposable(() => { done(false, false); - }); + })); } disposeElement(element: ActionBarCell, index: number, templateData: IActionBarTemplateData) { - templateData.elementDisposable.dispose(); + templateData.elementDisposables.clear(); } disposeTemplate(templateData: IActionBarTemplateData): void { - templateData.label.dispose(); - templateData.actionBar.dispose(); - templateData.elementDisposable.dispose(); - templateData.button?.dispose(); + templateData.templateDisposables.dispose(); } } @@ -1817,4 +1816,3 @@ MenuRegistry.appendMenuItem(MenuId.TunnelLocalAddressInline, ({ })); registerColor('ports.iconRunningProcessForeground', STATUS_BAR_REMOTE_ITEM_BACKGROUND, nls.localize('portWithRunningProcess.foreground', "The color of the icon for a port that has an associated running process.")); - From d0a3dbb109bdaf57c2ede119cf899b84cfe651b7 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 13 Feb 2026 15:30:41 +0100 Subject: [PATCH 048/221] add signout to default account (#295174) --- .../inlineCompletions/test/browser/utils.ts | 1 + .../standalone/browser/standaloneServices.ts | 4 ++++ .../defaultAccount/common/defaultAccount.ts | 2 ++ .../services/accounts/browser/defaultAccount.ts | 14 ++++++++++++++ .../test/browser/accountPolicyService.test.ts | 2 ++ .../test/browser/multiplexPolicyService.test.ts | 2 ++ 6 files changed, 25 insertions(+) diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 6892e304e968d..f4b3115a52ad7 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -279,6 +279,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, refresh: async () => { return null; }, signIn: async () => { return null; }, + signOut: async () => { }, }); options.serviceCollection.set(IRenameSymbolTrackerService, new NullRenameSymbolTrackerService()); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 90c8c11a1f8c3..0824dfbb53337 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1139,6 +1139,10 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { async signIn(): Promise { return null; } + + async signOut(): Promise { + // no-op + } } export interface IEditorOverrideServices { diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index 63a9b956608ac..cd67c68841230 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -15,6 +15,7 @@ export interface IDefaultAccountProvider { getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; + signOut(): Promise; } export const IDefaultAccountService = createDecorator('defaultAccountService'); @@ -29,4 +30,5 @@ export interface IDefaultAccountService { setDefaultAccountProvider(provider: IDefaultAccountProvider): void; refresh(): Promise; signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; + signOut(): Promise; } diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index 9f6ab53f4b30a..3fba2439fa500 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -29,6 +29,7 @@ import { equals } from '../../../../base/common/objects.js'; import { IDefaultChatAgent } from '../../../../base/common/product.js'; import { IRequestContext } from '../../../../base/parts/request/common/request.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; interface IDefaultAccountConfig { readonly preferredExtensions: string[]; @@ -177,6 +178,11 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount return this.defaultAccountProvider?.signIn(options) ?? null; } + async signOut(): Promise { + await this.initBarrier.wait(); + await this.defaultAccountProvider?.signOut(); + } + private setDefaultAccount(account: IDefaultAccount | null): void { if (equals(this.defaultAccount, account)) { return; @@ -244,6 +250,7 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid @IContextKeyService contextKeyService: IContextKeyService, @IStorageService private readonly storageService: IStorageService, @IHostService private readonly hostService: IHostService, + @ICommandService private readonly commandService: ICommandService, ) { super(); this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); @@ -822,6 +829,13 @@ class DefaultAccountProvider extends Disposable implements IDefaultAccountProvid return this.defaultAccount; } + async signOut(): Promise { + if (!this.defaultAccount) { + return; + } + this.commandService.executeCommand('_signOutOfAccount', { providerId: this.defaultAccount.authenticationProvider.id, accountLabel: this.defaultAccount.accountName }); + } + } class DefaultAccountProviderContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index cb06ab105a116..db6c6bc4aa5ed 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -49,6 +49,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { async signIn(): Promise { return null; } + + async signOut(): Promise { } } suite('AccountPolicyService', () => { diff --git a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts index eb0e740f798a3..512d8d9111084 100644 --- a/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/multiplexPolicyService.test.ts @@ -56,6 +56,8 @@ class DefaultAccountProvider implements IDefaultAccountProvider { async signIn(): Promise { return null; } + + async signOut(): Promise { } } suite('MultiplexPolicyService', () => { From 814a9ce765445a004810bd4331f303349fd5dd70 Mon Sep 17 00:00:00 2001 From: Johannes Date: Fri, 13 Feb 2026 15:38:39 +0100 Subject: [PATCH 049/221] Refactor resource copying for transpile and bundle builds - Implemented copyAllNonTsFiles() for transpile/dev builds to copy all non-TS files from src/ to output, aligning with previous gulp behavior. - Retained copyResources() for production builds using curated resource patterns. - Removed unnecessary devOnlyResourcePatterns and testFixturePatterns. - Updated watch mode to accept any non-TS file changes. --- build/next/index.ts | 145 ++++++++++++++---------------------------- build/next/working.md | 14 ++++ 2 files changed, 60 insertions(+), 99 deletions(-) diff --git a/build/next/index.ts b/build/next/index.ts index 21a6aa99f8a86..73a08933db167 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -229,18 +229,6 @@ const commonResourcePatterns = [ 'vs/workbench/browser/parts/editor/media/letterpress*.svg', ]; -// Resources only needed for dev/transpile builds (these get bundled into the main -// JS/CSS bundles for production, so separate copies are redundant) -const devOnlyResourcePatterns = [ - // Fonts (esbuild file loader copies to media/codicon.ttf for production) - 'vs/base/browser/ui/codicons/codicon/codicon.ttf', - - // Vendor JavaScript libraries (bundled into workbench main JS for production) - 'vs/base/common/marked/marked.js', - 'vs/base/common/semver/semver.js', - 'vs/base/browser/dompurify/dompurify.js', -]; - // Resources for desktop target const desktopResourcePatterns = [ ...commonResourcePatterns, @@ -368,28 +356,6 @@ function getResourcePatternsForTarget(target: BuildTarget): string[] { } } -// Test fixtures (only copied for development builds, not production) -const testFixturePatterns = [ - '**/test/**/*.json', - '**/test/**/*.txt', - '**/test/**/*.snap', - '**/test/**/*.tst', - '**/test/**/*.html', - '**/test/**/*.js', - '**/test/**/*.jxs', - '**/test/**/*.tsx', - '**/test/**/*.css', - '**/test/**/*.png', - '**/test/**/*.md', - '**/test/**/*.zip', - '**/test/**/*.pdf', - '**/test/**/*.qwoff', - '**/test/**/*.wuff', - '**/test/**/*.less', - // Files without extensions (executables, etc.) - '**/test/**/fixtures/executable/*', -]; - // ============================================================================ // Utilities // ============================================================================ @@ -511,34 +477,57 @@ async function compileStandaloneFiles(outDir: string, doMinify: boolean, target: console.log(`[standalone] Done`); } -async function copyCssFiles(outDir: string, excludeTests = false): Promise { - // Copy all CSS files from src to output (they're imported by JS) - const cssFiles = await globAsync('**/*.css', { +/** + * Copy ALL non-TypeScript files from src/ to the output directory. + * This matches the old gulp build behavior where `gulp.src('src/**')` streams + * every file and non-TS files bypass the compiler via tsFilter.restore. + * Used for development/transpile builds only - production bundles use + * copyResources() with curated per-target patterns instead. + */ +async function copyAllNonTsFiles(outDir: string, excludeTests: boolean): Promise { + console.log(`[resources] Copying all non-TS files to ${outDir}...`); + + const ignorePatterns = [ + // Exclude .ts files but keep .d.ts files (they're needed at runtime for type references) + '**/*.ts', + ]; + if (excludeTests) { + ignorePatterns.push('**/test/**'); + } + + const files = await globAsync('**/*', { + cwd: path.join(REPO_ROOT, SRC_DIR), + nodir: true, + ignore: ignorePatterns, + }); + + // Re-include .d.ts files that were excluded by the *.ts ignore + const dtsFiles = await globAsync('**/*.d.ts', { cwd: path.join(REPO_ROOT, SRC_DIR), ignore: excludeTests ? ['**/test/**'] : [], }); - for (const file of cssFiles) { + const allFiles = [...new Set([...files, ...dtsFiles])]; + + await Promise.all(allFiles.map(file => { const srcPath = path.join(REPO_ROOT, SRC_DIR, file); const destPath = path.join(REPO_ROOT, outDir, file); + return copyFile(srcPath, destPath); + })); - await copyFile(srcPath, destPath); - } - - return cssFiles.length; + console.log(`[resources] Copied ${allFiles.length} files`); } -async function copyResources(outDir: string, target: BuildTarget, excludeDevFiles = false, excludeTests = false): Promise { +/** + * Copy curated resource files for production bundles. + * Uses specific per-target patterns matching the old build's vscodeResourceIncludes, + * serverResourceIncludes, etc. Only called by bundle() - transpile uses copyAllNonTsFiles(). + */ +async function copyResources(outDir: string, target: BuildTarget): Promise { console.log(`[resources] Copying to ${outDir} for target '${target}'...`); let copied = 0; - const ignorePatterns: string[] = []; - if (excludeTests) { - ignorePatterns.push('**/test/**'); - } - if (excludeDevFiles) { - ignorePatterns.push('**/*-dev.html'); - } + const ignorePatterns = ['**/test/**', '**/*-dev.html']; const resourcePatterns = getResourcePatternsForTarget(target); for (const pattern of resourcePatterns) { @@ -556,47 +545,7 @@ async function copyResources(outDir: string, target: BuildTarget, excludeDevFile } } - // Copy test fixtures (only for development builds) - if (!excludeTests) { - for (const pattern of testFixturePatterns) { - const files = await globAsync(pattern, { - cwd: path.join(REPO_ROOT, SRC_DIR), - }); - - for (const file of files) { - const srcPath = path.join(REPO_ROOT, SRC_DIR, file); - const destPath = path.join(REPO_ROOT, outDir, file); - - await copyFile(srcPath, destPath); - copied++; - } - } - } - - // Copy dev-only resources (vendor JS, codicon font) - only for development/transpile - // builds. In production bundles these are inlined by esbuild. - if (!excludeDevFiles) { - for (const pattern of devOnlyResourcePatterns) { - const files = await globAsync(pattern, { - cwd: path.join(REPO_ROOT, SRC_DIR), - ignore: ignorePatterns, - }); - for (const file of files) { - await copyFile(path.join(REPO_ROOT, SRC_DIR, file), path.join(REPO_ROOT, outDir, file)); - copied++; - } - } - } - - // Copy CSS files (only for development/transpile builds, not production bundles - // where CSS is already bundled into combined files like workbench.desktop.main.css) - if (!excludeDevFiles) { - const cssCount = await copyCssFiles(outDir, excludeTests); - copied += cssCount; - console.log(`[resources] Copied ${copied} files (${cssCount} CSS)`); - } else { - console.log(`[resources] Copied ${copied} files (CSS skipped - bundled)`); - } + console.log(`[resources] Copied ${copied} files`); } // ============================================================================ @@ -978,8 +927,8 @@ ${tslib}`, console.log(`[mangle-privates] Total: ${totalClasses} classes, ${totalFields} fields, ${totalEdits} edits, ${totalElapsed}ms`); } - // Copy resources (exclude dev files and tests for production) - await copyResources(outDir, target, true, true); + // Copy resources (curated per-target patterns for production) + await copyResources(outDir, target); // Compile standalone TypeScript files (like Electron preload scripts) that cannot be bundled await compileStandaloneFiles(outDir, doMinify, target); @@ -1012,7 +961,7 @@ async function watch(): Promise { const t1 = Date.now(); try { await transpile(outDir, false); - await copyResources(outDir, 'desktop', false, false); + await copyAllNonTsFiles(outDir, false); console.log(`Finished transpilation with 0 errors after ${Date.now() - t1} ms`); } catch (err) { console.error('[watch] Initial build failed:', err); @@ -1063,9 +1012,6 @@ async function watch(): Promise { } }; - // Extensions to watch and copy (non-TypeScript resources) - const copyExtensions = ['.css', '.html', '.js', '.json', '.ttf', '.svg', '.png', '.mp3', '.scm', '.sh', '.ps1', '.psm1', '.fish', '.zsh', '.scpt']; - // Watch src directory using existing gulp-watch based watcher let debounceTimer: ReturnType | undefined; const srcDir = path.join(REPO_ROOT, SRC_DIR); @@ -1074,7 +1020,8 @@ async function watch(): Promise { watchStream.on('data', (file: { path: string }) => { if (file.path.endsWith('.ts') && !file.path.endsWith('.d.ts')) { pendingTsFiles.add(file.path); - } else if (copyExtensions.some(ext => file.path.endsWith(ext))) { + } else { + // Copy any non-TS file (matches old gulp build's `src/**` behavior) pendingCopyFiles.add(file.path); } @@ -1151,7 +1098,7 @@ async function main(): Promise { console.log(`[transpile] ${SRC_DIR} → ${outDir}${options.excludeTests ? ' (excluding tests)' : ''}`); const t1 = Date.now(); await transpile(outDir, options.excludeTests); - await copyResources(outDir, 'desktop', false, options.excludeTests); + await copyAllNonTsFiles(outDir, options.excludeTests); console.log(`[transpile] Done in ${Date.now() - t1}ms`); } break; diff --git a/build/next/working.md b/build/next/working.md index bbf23a99806b1..71aac3fbdaf4c 100644 --- a/build/next/working.md +++ b/build/next/working.md @@ -97,6 +97,20 @@ Two placeholders that need injection: **Lesson:** Don't add new output file formats that create parity differences with the old build. The old build is the reference. +### 7. Resource Copying: Transpile vs Bundle + +**Problem:** The new build used curated, specific resource pattern lists (e.g., `desktopResourcePatterns`) for **both** transpile/dev and production/bundle builds. Team members kept discovering missing resources because every new non-TS file in `src/` required manually adding its pattern. + +**Root cause:** The old gulp build uses `gulp.src('src/**')` for dev/transpile — a catch-all glob that streams **every file** in `src/`. Non-TS files bypass the compiler via `tsFilter` + `tsFilter.restore` and land in `out/` untouched. This is inherently complete. The old build only uses curated resource lists for **production packaging** (`vscodeResourceIncludes`, `serverResourceIncludes` in the gulpfiles). + +**Fix:** +- **Transpile/dev path** (`transpile` command, `--watch` mode): Now uses `copyAllNonTsFiles()` which copies ALL non-TS files from `src/` to the output, matching old `gulp.src('src/**')` behavior. No curated patterns needed. +- **Bundle/production path** (`bundle` command): Continues using `copyResources()` with curated per-target patterns, matching old `vscodeResourceIncludes` etc. +- Removed `devOnlyResourcePatterns` and `testFixturePatterns` — no longer needed since the broad copy handles all dev resources. +- Watch mode incremental copy now accepts **any** non-`.ts` file change (removed the `copyExtensions` allowlist). + +**Lesson:** Dev builds should copy everything (completeness matters); production builds should be selective (size matters). Don't mix the two strategies. + --- ## Testing the Fix From 60bf10573c009ec03223e4ab520a692bf229f003 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 14:42:23 +0000 Subject: [PATCH 050/221] Suppress chat tips in terminal/editor inline chat (#295170) --- .../contrib/chat/browser/chatTipService.ts | 17 ++++++++++++- .../chat/test/browser/chatTipService.test.ts | 24 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 862445cf50bcc..8cc8caf3e83f8 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -9,7 +9,7 @@ import { ContextKeyExpr, ContextKeyExpression, IContextKeyService } from '../../ import { createDecorator, IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { ChatContextKeys } from '../common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../common/constants.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { ICommandService } from '../../../../platform/commands/common/commands.js'; @@ -655,6 +655,11 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Only show tips in the main chat panel, not in terminal/editor inline chat + if (!this._isChatLocation(contextKeyService)) { + return undefined; + } + // Check if this is the request that was assigned a tip (for stable rerenders) if (this._tipRequestId === requestId && this._shownTip) { return this._createTip(this._shownTip); @@ -693,6 +698,11 @@ export class ChatTipService extends Disposable implements IChatTipService { return undefined; } + // Only show tips in the main chat panel, not in terminal/editor inline chat + if (!this._isChatLocation(contextKeyService)) { + return undefined; + } + // Return the already-shown tip for stable rerenders if (this._tipRequestId === 'welcome' && this._shownTip) { return this._createTip(this._shownTip); @@ -769,6 +779,11 @@ export class ChatTipService extends Disposable implements IChatTipService { return true; } + private _isChatLocation(contextKeyService: IContextKeyService): boolean { + const location = contextKeyService.getContextKeyValue(ChatContextKeys.location.key); + return !location || location === ChatAgentLocation.Chat; + } + private _isCopilotEnabled(): boolean { const defaultChatAgent = this._productService.defaultChatAgent; return !!defaultChatAgent?.chatExtensionId; diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index 873bf370b9219..bcd40b0676716 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -19,7 +19,7 @@ import { ChatTipService, ITipDefinition, TipEligibilityTracker } from '../../bro import { AgentFileType, IPromptPath, IPromptsService, IResolvedAgentFile, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { ChatModeKind } from '../../common/constants.js'; +import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; @@ -142,6 +142,28 @@ suite('ChatTipService', () => { assert.strictEqual(tip, undefined, 'Should not return a tip when tips setting is disabled'); }); + test('returns undefined when location is terminal', () => { + const service = createService(); + const now = Date.now(); + + const terminalContextKeyService = new MockContextKeyServiceWithRulesMatching(); + terminalContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.Terminal); + + const tip = service.getNextTip('request-1', now + 1000, terminalContextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip in terminal inline chat'); + }); + + test('returns undefined when location is editor inline', () => { + const service = createService(); + const now = Date.now(); + + const editorContextKeyService = new MockContextKeyServiceWithRulesMatching(); + editorContextKeyService.createKey(ChatContextKeys.location.key, ChatAgentLocation.EditorInline); + + const tip = service.getNextTip('request-1', now + 1000, editorContextKeyService); + assert.strictEqual(tip, undefined, 'Should not return a tip in editor inline chat'); + }); + test('old requests do not consume the session tip allowance', () => { const service = createService(); const now = Date.now(); From 0c797c52669745f1987dfe7838fe70210f11d5ed Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Fri, 13 Feb 2026 06:57:14 -0800 Subject: [PATCH 051/221] clean up css + fix questions carousel reload (#295084) * clean up css + fix questions carousel reload * fix hygeinee --- .../media/chatQuestionCarousel.css | 769 ++++++++---------- .../contrib/chat/browser/widget/chatWidget.ts | 11 + .../chat/browser/widget/media/chat.css | 17 - 3 files changed, 352 insertions(+), 445 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css index 98c05a0bc8cfa..4a6cbd669af0f 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatQuestionCarousel.css @@ -3,484 +3,397 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -.chat-question-carousel-container { - margin: 8px 0; - border: 1px solid var(--vscode-chat-requestBorder); - border-radius: 4px; - display: flex; - flex-direction: column; - overflow: hidden; - container-type: inline-size; -} - -.chat-question-carousel-summary { - display: flex; - flex-direction: column; - gap: 8px; - padding: 12px 16px; +/* question carousel - this is above edits and todos */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { + display: none; } -.chat-question-summary-item { - display: flex; - flex-direction: row; - flex-wrap: wrap; - align-items: baseline; - gap: 0; - font-size: var(--vscode-chat-font-size-body-s); -} - -.chat-question-summary-label { - color: var(--vscode-descriptionForeground); - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-label::after { - content: ': '; - white-space: pre; -} - -.chat-question-summary-answer-title { - color: var(--vscode-foreground); - font-weight: 600; - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-answer-desc { - color: var(--vscode-foreground); - word-wrap: break-word; - overflow-wrap: break-word; -} - -.chat-question-summary-skipped { - color: var(--vscode-descriptionForeground); - font-style: italic; - font-size: var(--vscode-chat-font-size-body-s); -} - -.chat-question-header-row { - display: flex; - justify-content: space-between; - align-items: center; - gap: 8px; - min-width: 0; - padding-bottom: 5px; - margin-left: -16px; - margin-right: -16px; - padding-left: 16px; - padding-right: 16px; - border-bottom: 1px solid var(--vscode-chat-requestBorder); -} - -.chat-question-header-row .chat-question-title { - flex: 1; - min-width: 0; - word-wrap: break-word; - overflow-wrap: break-word; -} - -/* Close button container in header */ -.chat-question-close-container { - flex-shrink: 0; -} - -.chat-question-close-container .monaco-button.chat-question-close { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; - background: transparent !important; - color: var(--vscode-foreground) !important; -} - -.chat-question-close-container .monaco-button.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; -} - -/* Footer row with step indicator and navigation */ -.chat-question-footer-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 16px; - border-top: 1px solid var(--vscode-chat-requestBorder); - background: var(--vscode-chat-requestBackground); -} - -/* Step indicator (e.g., "2/4") */ -.chat-question-step-indicator { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); -} - -.chat-question-carousel-nav { - display: flex; - align-items: center; - gap: 4px; - flex-shrink: 0; - margin-left: auto; -} - -.chat-question-nav-arrows { - display: flex; - align-items: center; - gap: 4px; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { - min-width: 22px; - width: 22px; - height: 22px; - padding: 0; - border: none; -} - -/* Secondary buttons (prev, next) use gray secondary background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { - background: var(--vscode-button-secondaryBackground) !important; - color: var(--vscode-button-secondaryForeground) !important; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { - background: var(--vscode-button-secondaryHoverBackground) !important; -} - -/* Submit button (next on last question) uses primary background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { - background: var(--vscode-button-background) !important; - color: var(--vscode-button-foreground) !important; - width: auto; - min-width: auto; - padding: 0 8px; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { - background: var(--vscode-button-hoverBackground) !important; -} - -/* Close button uses transparent background */ -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { - background: transparent !important; - color: var(--vscode-foreground) !important; -} - -.chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { - background: var(--vscode-toolbar-hoverBackground) !important; +/* input specific styling */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { + margin: 0; + border: 1px solid var(--vscode-input-border, transparent); + background-color: var(--vscode-editor-background); + border-radius: 4px; } -.chat-question-carousel-content { +/* general questions styling */ +.interactive-session .chat-question-carousel-container { + margin: 8px 0; + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: 4px; display: flex; flex-direction: column; - background: var(--vscode-chat-requestBackground); - padding: 8px 16px 10px 16px; overflow: hidden; + container-type: inline-size; } -.chat-question-title { - font-weight: 500; - font-size: var(--vscode-chat-font-size-body-s); - margin: 0; -} - -.chat-question-title-main { - font-weight: 500; -} - -.chat-question-title-subtitle { - font-weight: normal; - color: var(--vscode-descriptionForeground); -} - -.chat-question-message { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - margin: 0; - line-height: 1.5; -} +/* container and header */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { + width: 100%; + position: relative; -.chat-question-message p { - margin-top: 0; - margin-bottom: 4px; + .chat-question-carousel-content { + display: flex; + flex-direction: column; + background: var(--vscode-chat-requestBackground); + padding: 8px 16px 10px 16px; + overflow: hidden; + + .chat-question-header-row { + display: flex; + justify-content: space-between; + gap: 8px; + min-width: 0; + padding-bottom: 5px; + margin-left: -16px; + margin-right: -16px; + padding-left: 16px; + padding-right: 16px; + border-bottom: 1px solid var(--vscode-chat-requestBorder); + + .chat-question-title { + flex: 1; + min-width: 0; + word-wrap: break-word; + overflow-wrap: break-word; + font-weight: 500; + font-size: var(--vscode-chat-font-size-body-s); + margin: 0; + + .chat-question-title-main { + font-weight: 500; + } + + .chat-question-title-subtitle { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } + + .chat-question-close-container { + flex-shrink: 0; + + .monaco-button.chat-question-close { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + background: transparent !important; + color: var(--vscode-foreground) !important; + } + + .monaco-button.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } + } + } + } } -.chat-question-input-container { +/* questions list and freeform area */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-input-container { display: flex; flex-direction: column; margin-top: 4px; min-width: 0; -} -/* List-style selection UI (similar to QuickPick) */ -.chat-question-list { - display: flex; - flex-direction: column; - gap: 3px; - outline: none; - padding: 4px 0; -} - -.chat-question-list:focus-visible { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: -1px; -} - -.chat-question-list-item { - display: flex; - align-items: center; - gap: 8px; - padding: 3px 8px; - cursor: pointer; - border-radius: 3px; - user-select: none; -} - -.chat-question-list-item:hover { - background-color: var(--vscode-list-hoverBackground); -} - -.interactive-input-part .chat-question-carousel-widget-container .chat-question-input-container { + /* some hackiness to get the focus looking right */ .chat-question-list-item:focus:not(.selected), .chat-question-list:focus { outline: none; } -} - -/* 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-visible { + outline: 1px solid var(--vscode-focusBorder); + outline-offset: -1px; + } -.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: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); -} + .chat-question-list { + display: flex; + flex-direction: column; + gap: 3px; + outline: none; + padding: 4px 0; + + .chat-question-list-item { + display: flex; + align-items: center; + gap: 8px; + padding: 3px 8px; + cursor: pointer; + border-radius: 3px; + user-select: none; + + .chat-question-list-indicator { + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + margin-left: auto; + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); + } + + /* Label in list item */ + .chat-question-list-label { + font-size: var(--vscode-chat-font-size-body-s); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .chat-question-list-label-title { + font-weight: 600; + } + + .chat-question-list-label-desc { + font-weight: normal; + color: var(--vscode-descriptionForeground); + } + } + + .chat-question-list-item:hover { + background-color: var(--vscode-list-hoverBackground); + } + + /* 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-label { + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-list-label-desc { + color: var(--vscode-list-activeSelectionForeground); + opacity: 0.8; + } + + .chat-question-list-indicator.codicon-check { + color: var(--vscode-list-activeSelectionForeground); + } + + .chat-question-list-number { + background-color: transparent; + color: var(--vscode-list-activeSelectionForeground); + border-color: var(--vscode-list-activeSelectionForeground); + border-bottom-color: var(--vscode-list-activeSelectionForeground); + box-shadow: none; + } + } + + .chat-question-list-item.selected:hover { + background-color: var(--vscode-list-activeSelectionBackground); + } + + /* Checkbox for multi-select */ + .chat-question-list-checkbox { + flex-shrink: 0; + } + + .chat-question-list-checkbox.monaco-custom-toggle { + margin-right: 0; + } + } -/* todo: change to use keybinding service so we don't have to recreate this */ -.chat-question-list-number, -.chat-question-freeform-number { - display: inline-flex; - align-items: center; - justify-content: center; - min-width: 14px; - padding: 0px 4px; - border-style: solid; - border-width: 1px; - border-radius: 3px; - font-size: 11px; - font-weight: normal; - background-color: var(--vscode-keybindingLabel-background); - color: var(--vscode-keybindingLabel-foreground); - border-color: var(--vscode-keybindingLabel-border); - border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); - box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); - flex-shrink: 0; -} + .chat-question-freeform { + margin-left: 8px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + + .chat-question-freeform-number { + height: fit-content; + } + + /* this is probably legacy too */ + .chat-question-freeform-label { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } + + .chat-question-freeform-textarea { + width: 100%; + min-height: 24px; + max-height: 200px; + padding: 3px 8px; + border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); + background-color: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border-radius: 4px; + resize: none; + font-family: var(--vscode-chat-font-family, inherit); + font-size: var(--vscode-chat-font-size-body-s); + box-sizing: border-box; + overflow-y: hidden; + align-content: center; + } + + .chat-question-freeform-textarea:focus { + outline: 1px solid var(--vscode-focusBorder); + border-color: var(--vscode-focusBorder); + } + + .chat-question-freeform-textarea::placeholder { + color: var(--vscode-input-placeholderForeground); + } -.chat-question-freeform-number { - height: fit-content; -} + } -.chat-question-list-item.selected .chat-question-list-number { - background-color: transparent; - color: var(--vscode-list-activeSelectionForeground); - border-color: var(--vscode-list-activeSelectionForeground); - border-bottom-color: var(--vscode-list-activeSelectionForeground); - box-shadow: none; + /* todo: change to use keybinding service so we don't have to recreate this */ + .chat-question-list-number, + .chat-question-freeform-number { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 14px; + padding: 0px 4px; + border-style: solid; + border-width: 1px; + border-radius: 3px; + font-size: 11px; + font-weight: normal; + background-color: var(--vscode-keybindingLabel-background); + color: var(--vscode-keybindingLabel-foreground); + border-color: var(--vscode-keybindingLabel-border); + border-bottom-color: var(--vscode-keybindingLabel-bottomBorder); + box-shadow: inset 0 -1px 0 var(--vscode-widget-shadow); + flex-shrink: 0; + } } -/* Selection indicator (checkmark) for single select - positioned on right */ -.chat-question-list-indicator { - width: 16px; - height: 16px; +/* footer with step indicator and nav buttons */ +.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-footer-row { display: flex; + justify-content: space-between; align-items: center; - justify-content: center; - flex-shrink: 0; - margin-left: auto; -} + padding: 4px 16px; + border-top: 1px solid var(--vscode-chat-requestBorder); + background: var(--vscode-chat-requestBackground); -.chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground, var(--vscode-foreground)); -} + .chat-question-step-indicator { + font-size: var(--vscode-chat-font-size-body-s); + color: var(--vscode-descriptionForeground); + } -.chat-question-list-item.selected .chat-question-list-indicator.codicon-check { - color: var(--vscode-list-activeSelectionForeground); -} + .chat-question-carousel-nav { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + margin-left: auto; + } -/* Checkbox for multi-select */ -.chat-question-list-checkbox { - flex-shrink: 0; -} + .chat-question-nav-arrows { + display: flex; + align-items: center; + gap: 4px; + } -.chat-question-list-checkbox.monaco-custom-toggle { - margin-right: 0; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow { + min-width: 22px; + width: 22px; + height: 22px; + padding: 0; + border: none; + } + /* Secondary buttons (prev, next) use gray secondary background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev, + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next { + background: var(--vscode-button-secondaryBackground) !important; + color: var(--vscode-button-secondaryForeground) !important; + } -/* Label in list item */ -.chat-question-list-label { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-prev:hover:not(.disabled), + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-next:hover:not(.disabled) { + background: var(--vscode-button-secondaryHoverBackground) !important; + } -.chat-question-list-label-title { - font-weight: 600; -} + /* Submit button (next on last question) uses primary background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit { + background: var(--vscode-button-background) !important; + color: var(--vscode-button-foreground) !important; + width: auto; + min-width: auto; + padding: 0 8px; + } -.chat-question-list-label-desc { - font-weight: normal; - color: var(--vscode-descriptionForeground); -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-nav-submit:hover:not(.disabled) { + background: var(--vscode-button-hoverBackground) !important; + } -.chat-question-list-item.selected .chat-question-list-label { - color: var(--vscode-list-activeSelectionForeground); -} + /* Close button uses transparent background */ + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close { + background: transparent !important; + color: var(--vscode-foreground) !important; + } -.chat-question-list-item.selected .chat-question-list-label-desc { - color: var(--vscode-list-activeSelectionForeground); - opacity: 0.8; -} + .chat-question-carousel-nav .monaco-button.chat-question-nav-arrow.chat-question-close:hover:not(.disabled) { + background: var(--vscode-toolbar-hoverBackground) !important; + } -/* Legacy styles for backwards compatibility (to be removed) */ -.chat-question-options { - display: flex; - flex-direction: column; - gap: 0; - min-width: 0; } -.chat-question-option { +/* summary (after finished) */ +.interactive-session .chat-question-carousel-summary { display: flex; - align-items: flex-start; + flex-direction: column; gap: 8px; - padding: 3px 0; - min-width: 0; -} - -.chat-question-option input[type="radio"], -.chat-question-option input[type="checkbox"] { - appearance: none; - -webkit-appearance: none; - -moz-appearance: none; - width: 18px; - height: 18px; - min-width: 18px; - min-height: 18px; - flex-shrink: 0; - margin: 0; - border: 1px solid var(--vscode-checkbox-border); - background-color: var(--vscode-checkbox-background); - border-radius: 3px; - cursor: pointer; - position: relative; - outline: none; -} - -.chat-question-option input[type="radio"] { - border-radius: 50%; -} - -.chat-question-option input[type="radio"]:checked, -.chat-question-option input[type="checkbox"]:checked { - background-color: var(--vscode-checkbox-selectBackground, var(--vscode-checkbox-background)); -} - -.chat-question-option input[type="checkbox"]:checked::after { - content: ''; - position: absolute; - top: 3px; - left: 6px; - width: 4px; - height: 8px; - border: solid var(--vscode-checkbox-foreground); - border-width: 0 2px 2px 0; - transform: rotate(45deg); -} - -.chat-question-option input[type="radio"]:checked::after { - content: ''; - position: absolute; - top: 4px; - left: 4px; - width: 8px; - height: 8px; - border-radius: 50%; - background-color: var(--vscode-checkbox-foreground); -} - -.chat-question-option input[type="radio"]:focus, -.chat-question-option input[type="checkbox"]:focus { - outline: 1px solid var(--vscode-focusBorder); - outline-offset: 1px; -} - -.chat-question-option input[type="radio"]:hover, -.chat-question-option input[type="checkbox"]:hover { - background-color: var(--vscode-inputOption-hoverBackground); -} - -.chat-question-option label { - flex: 1; - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-foreground); - cursor: pointer; - word-wrap: break-word; - overflow-wrap: break-word; - min-width: 0; -} + padding: 8px 16px; + margin-bottom: 4px; + .chat-question-summary-item { + display: flex; + flex-direction: row; + flex-wrap: wrap; + align-items: baseline; + gap: 0; + font-size: var(--vscode-chat-font-size-body-s); + } -.chat-question-freeform { - margin-left: 8px; - display: flex; - flex-direction: row; - align-items: center; - gap: 8px; -} + .chat-question-summary-label { + color: var(--vscode-descriptionForeground); + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-label { - font-size: var(--vscode-chat-font-size-body-s); - color: var(--vscode-descriptionForeground); -} + .chat-question-summary-label::after { + content: ': '; + white-space: pre; + } -.chat-question-freeform-textarea { - width: 100%; - min-height: 24px; - max-height: 200px; - padding: 3px 8px; - border: 1px solid var(--vscode-input-border, var(--vscode-chat-requestBorder)); - background-color: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border-radius: 4px; - resize: none; - font-family: var(--vscode-chat-font-family, inherit); - font-size: var(--vscode-chat-font-size-body-s); - box-sizing: border-box; - overflow-y: hidden; - align-content: center; -} + .chat-question-summary-answer-title { + color: var(--vscode-foreground); + font-weight: 600; + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-textarea:focus { - outline: 1px solid var(--vscode-focusBorder); - border-color: var(--vscode-focusBorder); -} + .chat-question-summary-answer-desc { + color: var(--vscode-foreground); + word-wrap: break-word; + overflow-wrap: break-word; + } -.chat-question-freeform-textarea::placeholder { - color: var(--vscode-input-placeholderForeground); + .chat-question-summary-skipped { + color: var(--vscode-descriptionForeground); + font-style: italic; + font-size: var(--vscode-chat-font-size-body-s); + } } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts index da0056dc9df4d..fe9b64f5b8578 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatWidget.ts @@ -1935,6 +1935,17 @@ export class ChatWidget extends Disposable implements IChatWidget { this.container.setAttribute('data-session-id', model.sessionId); this.viewModel = this.instantiationService.createInstance(ChatViewModel, model, this._codeBlockModelCollection, undefined); + // mark any question carousels as used on reload + for (const request of model.getRequests()) { + if (request.response) { + for (const part of request.response.entireResponse.value) { + if (part.kind === 'questionCarousel' && !part.isUsed) { + part.isUsed = true; + } + } + } + } + // Pass input model reference to input part for state syncing this.inputPart.setInputModel(model.inputModel, model.getRequests().length === 0); this.listWidget.setViewModel(this.viewModel); diff --git a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css index 7e106073f2963..8ed7dc44ac561 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/media/chat.css +++ b/src/vs/workbench/contrib/chat/browser/widget/media/chat.css @@ -1077,23 +1077,6 @@ have to be updated for changes to the rules above, or to support more deeply nes position: relative; } -/* question carousel - this is above edits and todos */ -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container { - width: 100%; - position: relative; -} - -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container:empty { - display: none; -} - -.interactive-session .interactive-input-part > .chat-question-carousel-widget-container .chat-question-carousel-container { - margin: 0px; - border: 1px solid var(--vscode-input-border, transparent); - background-color: var(--vscode-editor-background); - border-radius: 4px; -} - /* Chat Todo List Widget Container - mirrors chat-editing-session styling */ .interactive-session .interactive-input-part > .chat-todo-list-widget-container { margin-bottom: -4px; From 3c0d80a814b3a26f8b4a5a7c07f43d38cd112f47 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:33:51 -0800 Subject: [PATCH 052/221] Deny git log --output in terminal auto approve Fixes #295196 --- .../common/terminalChatAgentToolsConfiguration.ts | 1 + .../test/electron-browser/runInTerminalTool.test.ts | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 5903834e459cd..059b63a92c5e5 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -206,6 +206,7 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary` and `--no-pager` immediately after `git` '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+status\\b/': true, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b/': true, + '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+log\\b.*\\s--output(=|\\s|$)/': false, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+show\\b/': true, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+diff\\b/': true, '/^git(\\s+(-C\\s+\\S+|--no-pager))*\\s+ls-files\\b/': true, diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 896b2d3ebca3e..e362782b44b24 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -319,6 +319,9 @@ suite('RunInTerminalTool', () => { 'docker compose events', ]; const confirmationRequiredTestCases = [ + // git log file output + 'git log --output=log.txt', + // Dangerous file operations 'rm README.md', 'rmdir folder', From 1e6c7290e301136ab50ee008971d2f7c6a56c6d3 Mon Sep 17 00:00:00 2001 From: Robo Date: Sat, 14 Feb 2026 00:47:09 +0900 Subject: [PATCH 053/221] fix: remove appx when windows10 context menu is forced (#295187) --- build/win32/code.iss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build/win32/code.iss b/build/win32/code.iss index 0e2b143f3b8d9..f7091b28e5597 100644 --- a/build/win32/code.iss +++ b/build/win32/code.iss @@ -1508,7 +1508,7 @@ var begin // Check if the user has forced Windows 10 style context menus on Windows 11 SubKey := 'Software\Classes\CLSID\{86ca1aa0-34aa-4e8b-a509-50c905bae2a2}\InprocServer32'; - Result := RegKeyExists(HKEY_CURRENT_USER, SubKey) or RegKeyExists(HKEY_LOCAL_MACHINE, SubKey); + Result := RegKeyExists(HKEY_CURRENT_USER, SubKey); end; function ShouldUseWindows11ContextMenu(): Boolean; @@ -1675,6 +1675,12 @@ begin if CurStep = ssPostInstall then begin #ifdef AppxPackageName + // Remove the appx package when user has forced Windows 10 context menus via + // registry. This handles the case where the user previously had the appx + // installed but now wants the classic context menu style. + if IsWindows10ContextMenuForced() then begin + RemoveAppxPackage(); + end; // Remove the old context menu registry keys if ShouldUseWindows11ContextMenu() then begin RegDeleteKeyIncludingSubkeys({#EnvironmentRootKey}, 'Software\Classes\*\shell\{#RegValueName}'); From 6e326e9ee1abcce8387b3e0cb9f8595506cb2578 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 13 Feb 2026 15:50:51 +0000 Subject: [PATCH 054/221] Remove confusing GitHub repository tip from chat tips (#295177) --- .../contrib/chat/browser/chatTipService.ts | 83 +------------------ .../chat/test/browser/chatTipService.test.ts | 70 +--------------- 2 files changed, 3 insertions(+), 150 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 8cc8caf3e83f8..4a5eb05db6527 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -106,18 +106,6 @@ export interface ITipDefinition { * The tip won't be shown if the tool it describes has already been used. */ readonly excludeWhenToolsInvoked?: string[]; - /** - * Tool set reference names. If any tool belonging to one of these tool sets - * has ever been invoked in this workspace, the tip becomes ineligible. - * Unlike {@link excludeWhenToolsInvoked}, this does not require listing - * individual tool IDs, it checks all tools that belong to the named sets. - */ - readonly excludeWhenAnyToolSetToolInvoked?: string[]; - /** - * Tool set reference names where at least one must be registered for the tip to be eligible. - * If none of the listed tool sets are registered, the tip is not shown. - */ - readonly requiresAnyToolSetRegistered?: string[]; /** * If set, exclude this tip when prompt files of the specified type exist in the workspace. */ @@ -210,16 +198,6 @@ const TIP_CATALOG: ITipDefinition[] = [ when: ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), excludeWhenToolsInvoked: ['renderMermaidDiagram'], }, - { - id: 'tip.githubRepo', - message: localize('tip.githubRepo', "Tip: Mention a GitHub repository (@owner/repo) in your prompt to let the agent search code, browse issues, and explore pull requests from that repo."), - when: ContextKeyExpr.and( - ChatContextKeys.chatModeKind.isEqualTo(ChatModeKind.Agent), - ContextKeyExpr.notEquals('gitOpenRepositoryCount', '0'), - ), - excludeWhenAnyToolSetToolInvoked: ['github', 'github-pull-request'], - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }, { id: 'tip.subagents', message: localize('tip.subagents', "Tip: Ask the agent to work in parallel to complete large tasks faster."), @@ -265,9 +243,6 @@ export class TipEligibilityTracker extends Disposable { private readonly _pendingModes: Set; private readonly _pendingTools: Set; - /** Tool set reference names monitored via {@link ITipDefinition.excludeWhenAnyToolSetToolInvoked}. */ - private readonly _monitoredToolSets: Set; - private readonly _commandListener = this._register(new MutableDisposable()); private readonly _toolListener = this._register(new MutableDisposable()); @@ -333,13 +308,6 @@ export class TipEligibilityTracker extends Disposable { } } - this._monitoredToolSets = new Set(); - for (const tip of tips) { - for (const name of tip.excludeWhenAnyToolSetToolInvoked ?? []) { - this._monitoredToolSets.add(name); - } - } - // --- Set up command listener (auto-disposes when all seen) -------------- if (this._pendingCommands.size > 0) { @@ -358,44 +326,17 @@ export class TipEligibilityTracker extends Disposable { // --- Set up tool listener (auto-disposes when all seen) ----------------- - if (this._pendingTools.size > 0 || this._monitoredToolSets.size > 0) { + if (this._pendingTools.size > 0) { this._toolListener.value = this._languageModelToolsService.onDidInvokeTool(e => { - let changed = false; - // Track explicit tool IDs if (this._pendingTools.has(e.toolId)) { this._invokedTools.add(e.toolId); this._pendingTools.delete(e.toolId); - changed = true; - } - - // Track tools belonging to monitored tool sets - if (this._monitoredToolSets.size > 0 && !this._invokedTools.has(e.toolId)) { - for (const setName of this._monitoredToolSets) { - const toolSet = this._languageModelToolsService.getToolSetByName(setName); - if (toolSet) { - for (const tool of toolSet.getTools()) { - if (tool.id === e.toolId) { - this._invokedTools.add(e.toolId); - // Remove set name from monitoring since ANY tool from the set excludes the tip. - // The tip remains excluded via _invokedTools even after we stop monitoring. - this._monitoredToolSets.delete(setName); - changed = true; - break; - } - } - } - if (changed) { - break; - } - } - } - if (changed) { this._persistSet(TipEligibilityTracker._TOOLS_STORAGE_KEY, this._invokedTools); } - if (this._pendingTools.size === 0 && this._monitoredToolSets.size === 0) { + if (this._pendingTools.size === 0) { this._toolListener.clear(); } }); @@ -477,30 +418,10 @@ export class TipEligibilityTracker extends Disposable { } } } - if (tip.excludeWhenAnyToolSetToolInvoked) { - for (const setName of tip.excludeWhenAnyToolSetToolInvoked) { - const toolSet = this._languageModelToolsService.getToolSetByName(setName); - if (toolSet) { - for (const tool of toolSet.getTools()) { - if (this._invokedTools.has(tool.id)) { - this._logService.debug('#ChatTips: tip excluded because tool set tool was invoked', tip.id, setName, tool.id); - return true; - } - } - } - } - } if (tip.excludeWhenPromptFilesExist && this._excludedByFiles.has(tip.id)) { this._logService.debug('#ChatTips: tip excluded because prompt files exist', tip.id); return true; } - if (tip.requiresAnyToolSetRegistered) { - const hasAny = tip.requiresAnyToolSetRegistered.some(name => this._languageModelToolsService.getToolSetByName(name)); - if (!hasAny) { - this._logService.debug('#ChatTips: tip excluded because no required tool sets are registered', tip.id); - return true; - } - } return false; } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts index bcd40b0676716..e923a89075113 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatTipService.test.ts @@ -21,7 +21,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ChatAgentLocation, ChatModeKind } from '../../common/constants.js'; import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; -import { ILanguageModelToolsService, IToolData, ToolDataSource } from '../../common/tools/languageModelToolsService.js'; +import { ILanguageModelToolsService } from '../../common/tools/languageModelToolsService.js'; import { MockLanguageModelToolsService } from '../common/tools/mockLanguageModelToolsService.js'; class MockContextKeyServiceWithRulesMatching extends MockContextKeyService { @@ -635,74 +635,6 @@ suite('ChatTipService', () => { assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when no skill files exist'); }); - test('excludes tip when requiresAnyToolSetRegistered tool sets are not registered', () => { - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - createMockToolsService(), - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded when no required tool sets are registered'); - }); - - test('excludes tip when a tool belonging to a monitored tool set has been invoked', () => { - const mockToolsService = createMockToolsService(); - const toolInSet: IToolData = { id: 'mcp_github_get_me', source: ToolDataSource.Internal, displayName: 'Get Me', modelDescription: 'Get Me' }; - mockToolsService.addRegisteredToolSetName('github', [toolInSet]); - - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - excludeWhenAnyToolSetToolInvoked: ['github'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - mockToolsService, - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded before any tool set tool is invoked'); - - mockToolsService.fireOnDidInvokeTool({ toolId: 'mcp_github_get_me', sessionResource: undefined, requestId: undefined, subagentInvocationId: undefined }); - - assert.strictEqual(tracker.isExcluded(tip), true, 'Should be excluded after a tool from the monitored tool set is invoked'); - }); - - test('does not exclude tip when at least one requiresAnyToolSetRegistered tool set is registered', () => { - const mockToolsService = createMockToolsService(); - mockToolsService.addRegisteredToolSetName('github'); - - const tip: ITipDefinition = { - id: 'tip.githubRepo', - message: 'test', - requiresAnyToolSetRegistered: ['github', 'github-pull-request'], - }; - - const tracker = testDisposables.add(new TipEligibilityTracker( - [tip], - { onDidExecuteCommand: Event.None, onWillExecuteCommand: Event.None } as Partial as ICommandService, - storageService, - createMockPromptsService() as IPromptsService, - mockToolsService, - new NullLogService(), - )); - - assert.strictEqual(tracker.isExcluded(tip), false, 'Should not be excluded when at least one required tool set is registered'); - }); - test('re-checks agent file exclusion when onDidChangeCustomAgents fires', async () => { const agentChangeEmitter = testDisposables.add(new Emitter()); let agentFiles: IPromptPath[] = []; From 2a69f02deded062ee6884db1aff1bc2f55c23e9f Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Fri, 13 Feb 2026 10:00:50 -0600 Subject: [PATCH 055/221] add tips toolbar (#295175) --- src/vs/platform/actions/common/actions.ts | 1 + .../contrib/chat/browser/chatTipService.ts | 75 ++++++++++++ .../chatContentParts/chatTipContentPart.ts | 113 +++++++++++++++++- .../chatContentParts/media/chatTipContent.css | 49 +++++++- 4 files changed, 235 insertions(+), 3 deletions(-) diff --git a/src/vs/platform/actions/common/actions.ts b/src/vs/platform/actions/common/actions.ts index a81fe449d6253..e9356c85f9df0 100644 --- a/src/vs/platform/actions/common/actions.ts +++ b/src/vs/platform/actions/common/actions.ts @@ -274,6 +274,7 @@ export class MenuId { static readonly ChatTitleBarMenu = new MenuId('ChatTitleBarMenu'); static readonly ChatAttachmentsContext = new MenuId('ChatAttachmentsContext'); static readonly ChatTipContext = new MenuId('ChatTipContext'); + static readonly ChatTipToolbar = new MenuId('ChatTipToolbar'); static readonly ChatToolOutputResourceToolbar = new MenuId('ChatToolOutputResourceToolbar'); static readonly ChatTextEditorMenu = new MenuId('ChatTextEditorMenu'); static readonly ChatToolOutputResourceContext = new MenuId('ChatToolOutputResourceContext'); diff --git a/src/vs/workbench/contrib/chat/browser/chatTipService.ts b/src/vs/workbench/contrib/chat/browser/chatTipService.ts index 4a5eb05db6527..45a0d7a5724ae 100644 --- a/src/vs/workbench/contrib/chat/browser/chatTipService.ts +++ b/src/vs/workbench/contrib/chat/browser/chatTipService.ts @@ -37,6 +37,16 @@ export interface IChatTipService { */ readonly onDidDismissTip: Event; + /** + * Fired when the user navigates to a different tip (previous/next). + */ + readonly onDidNavigateTip: Event; + + /** + * Fired when the tip widget is hidden without dismissing the tip. + */ + readonly onDidHideTip: Event; + /** * Fired when tips are disabled. */ @@ -72,10 +82,28 @@ export interface IChatTipService { */ dismissTip(): void; + /** + * Hides the tip widget without permanently dismissing the tip. + * The tip may be shown again in a future session. + */ + hideTip(): void; + /** * Disables tips permanently by setting the `chat.tips.enabled` configuration to false. */ disableTips(): Promise; + + /** + * Navigates to the next tip in the catalog without permanently dismissing the current one. + * @param contextKeyService The context key service to evaluate tip eligibility. + */ + navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined; + + /** + * Navigates to the previous tip in the catalog without permanently dismissing the current one. + * @param contextKeyService The context key service to evaluate tip eligibility. + */ + navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined; } export interface ITipDefinition { @@ -472,6 +500,12 @@ export class ChatTipService extends Disposable implements IChatTipService { private readonly _onDidDismissTip = this._register(new Emitter()); readonly onDidDismissTip = this._onDidDismissTip.event; + private readonly _onDidNavigateTip = this._register(new Emitter()); + readonly onDidNavigateTip = this._onDidNavigateTip.event; + + private readonly _onDidHideTip = this._register(new Emitter()); + readonly onDidHideTip = this._onDidHideTip.event; + private readonly _onDidDisableTips = this._register(new Emitter()); readonly onDidDisableTips = this._onDidDisableTips.event; @@ -557,6 +591,13 @@ export class ChatTipService extends Disposable implements IChatTipService { } } + hideTip(): void { + this._hasShownRequestTip = false; + this._shownTip = undefined; + this._tipRequestId = undefined; + this._onDidHideTip.fire(); + } + async disableTips(): Promise { this._hasShownRequestTip = false; this._shownTip = undefined; @@ -688,6 +729,40 @@ export class ChatTipService extends Disposable implements IChatTipService { return this._createTip(selectedTip); } + navigateToNextTip(contextKeyService: IContextKeyService): IChatTip | undefined { + return this._navigateTip(1, contextKeyService); + } + + navigateToPreviousTip(contextKeyService: IContextKeyService): IChatTip | undefined { + return this._navigateTip(-1, contextKeyService); + } + + private _navigateTip(direction: 1 | -1, contextKeyService: IContextKeyService): IChatTip | undefined { + if (!this._shownTip) { + return undefined; + } + + const currentIndex = TIP_CATALOG.findIndex(t => t.id === this._shownTip!.id); + if (currentIndex === -1) { + return undefined; + } + + const dismissedIds = new Set(this._getDismissedTipIds()); + for (let i = 1; i < TIP_CATALOG.length; i++) { + const idx = ((currentIndex + direction * i) % TIP_CATALOG.length + TIP_CATALOG.length) % TIP_CATALOG.length; + const candidate = TIP_CATALOG[idx]; + if (!dismissedIds.has(candidate.id) && this._isEligible(candidate, contextKeyService)) { + this._shownTip = candidate; + this._storageService.store(ChatTipService._LAST_TIP_ID_KEY, candidate.id, StorageScope.PROFILE, StorageTarget.USER); + const tip = this._createTip(candidate); + this._onDidNavigateTip.fire(tip); + return tip; + } + } + + return undefined; + } + private _isEligible(tip: ITipDefinition, contextKeyService: IContextKeyService): boolean { if (tip.when && !contextKeyService.contextMatchesRules(tip.when)) { this._logService.debug('#ChatTips: tip is not eligible due to when clause', tip.id, tip.when.serialize()); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts index 7a5ba17dcaa9a..7ccef8a28bed8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatTipContentPart.ts @@ -13,10 +13,11 @@ import { Emitter } from '../../../../../../base/common/event.js'; import { Disposable, MutableDisposable } from '../../../../../../base/common/lifecycle.js'; import { localize, localize2 } from '../../../../../../nls.js'; import { getFlatContextMenuActions } from '../../../../../../platform/actions/browser/menuEntryActionViewItem.js'; +import { MenuWorkbenchToolBar } from '../../../../../../platform/actions/browser/toolbar.js'; import { Action2, IMenuService, MenuId, registerAction2 } from '../../../../../../platform/actions/common/actions.js'; import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../../../platform/contextview/browser/contextView.js'; -import { ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IInstantiationService, ServicesAccessor } from '../../../../../../platform/instantiation/common/instantiation.js'; import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; import { ChatContextKeys } from '../../../common/actions/chatContextKeys.js'; import { IChatTip, IChatTipService } from '../../chatTipService.js'; @@ -30,6 +31,7 @@ export class ChatTipContentPart extends Disposable { public readonly onDidHide = this._onDidHide.event; private readonly _renderedContent = this._register(new MutableDisposable()); + private readonly _toolbar = this._register(new MutableDisposable()); private readonly _inChatTipContextKey: IContextKey; @@ -41,6 +43,7 @@ export class ChatTipContentPart extends Disposable { @IContextMenuService private readonly _contextMenuService: IContextMenuService, @IMenuService private readonly _menuService: IMenuService, @IContextKeyService private readonly _contextKeyService: IContextKeyService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, ) { super(); @@ -66,6 +69,14 @@ export class ChatTipContentPart extends Disposable { } })); + this._register(this._chatTipService.onDidNavigateTip(tip => { + this._renderTip(tip); + })); + + this._register(this._chatTipService.onDidHideTip(() => { + this._onDidHide.fire(); + })); + this._register(this._chatTipService.onDidDisableTips(() => { this._onDidHide.fire(); })); @@ -93,10 +104,22 @@ export class ChatTipContentPart extends Disposable { private _renderTip(tip: IChatTip): void { dom.clearNode(this.domNode); + this._toolbar.clear(); + this.domNode.appendChild(renderIcon(Codicon.lightbulb)); const markdownContent = this._renderer.render(tip.content); this._renderedContent.value = markdownContent; this.domNode.appendChild(markdownContent.element); + + // Toolbar with previous, next, and dismiss actions via MenuWorkbenchToolBar + const toolbarContainer = $('.chat-tip-toolbar'); + this._toolbar.value = this._instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, MenuId.ChatTipToolbar, { + menuOptions: { + shouldForwardArgs: true, + }, + }); + this.domNode.appendChild(toolbarContainer); + const textContent = markdownContent.element.textContent ?? localize('chatTip', "Chat tip"); const hasLink = /\[.*?\]\(.*?\)/.test(tip.content.value); const ariaLabel = hasLink @@ -107,6 +130,94 @@ export class ChatTipContentPart extends Disposable { } } +//#region Tip toolbar actions + +registerAction2(class PreviousTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.previousTip', + title: localize2('chatTip.previous', "Previous Tip"), + icon: Codicon.chevronLeft, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 1, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatTipService = accessor.get(IChatTipService); + const contextKeyService = accessor.get(IContextKeyService); + chatTipService.navigateToPreviousTip(contextKeyService); + } +}); + +registerAction2(class NextTipAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.nextTip', + title: localize2('chatTip.next', "Next Tip"), + icon: Codicon.chevronRight, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 2, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatTipService = accessor.get(IChatTipService); + const contextKeyService = accessor.get(IContextKeyService); + chatTipService.navigateToNextTip(contextKeyService); + } +}); + +registerAction2(class DismissTipToolbarAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.dismissTipToolbar', + title: localize2('chatTip.dismissButton', "Dismiss Tip"), + icon: Codicon.check, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 3, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).dismissTip(); + } +}); + +registerAction2(class CloseTipToolbarAction extends Action2 { + constructor() { + super({ + id: 'workbench.action.chat.closeTip', + title: localize2('chatTip.close', "Close Tips"), + icon: Codicon.close, + f1: false, + menu: [{ + id: MenuId.ChatTipToolbar, + group: 'navigation', + order: 4, + }] + }); + } + + override async run(accessor: ServicesAccessor): Promise { + accessor.get(IChatTipService).hideTip(); + } +}); + +//#endregion + //#region Tip context menu actions registerAction2(class DismissTipAction extends Action2 { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css index 3077e67d2b5d3..67ecd94b482f8 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/media/chatTipContent.css @@ -16,7 +16,6 @@ font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); position: relative; - overflow: hidden; } .interactive-item-container .chat-tip-widget .codicon-lightbulb { @@ -37,6 +36,52 @@ margin: 0; } +.interactive-item-container .chat-tip-widget .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar { + opacity: 0; + pointer-events: none; +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar > .monaco-toolbar { + position: absolute; + top: -15px; + right: 10px; + height: 26px; + line-height: 26px; + background-color: var(--vscode-editorWidget-background); + border: 1px solid var(--vscode-chat-requestBorder); + border-radius: var(--vscode-cornerRadius-medium); + z-index: 100; + transition: opacity 0.1s ease-in-out; +} + +.interactive-item-container .chat-tip-widget:hover .chat-tip-toolbar, +.interactive-item-container .chat-tip-widget:focus-within .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget:hover .chat-tip-toolbar, +.chat-getting-started-tip-container .chat-tip-widget:focus-within .chat-tip-toolbar { + opacity: 1; + pointer-events: auto; +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item { + height: 24px; + width: 24px; + margin: 1px 2px; +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label { + color: var(--vscode-descriptionForeground); +} + +.interactive-item-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover, +.chat-getting-started-tip-container .chat-tip-widget .chat-tip-toolbar .action-item .action-label:hover { + background-color: var(--vscode-toolbar-hoverBackground); + color: var(--vscode-foreground); +} + .chat-getting-started-tip-container { margin-bottom: -4px; /* Counter the flex gap */ width: 100%; @@ -57,7 +102,7 @@ font-size: var(--vscode-chat-font-size-body-s); font-family: var(--vscode-chat-font-family, inherit); color: var(--vscode-descriptionForeground); - overflow: hidden; + position: relative; } .chat-getting-started-tip-container .chat-tip-widget a { From bf09717b4002205d2e70cc8939517804c46f6087 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 13 Feb 2026 17:09:53 +0100 Subject: [PATCH 056/221] editor - support toggled state for modal editor size (#295199) editor - support toggled state for modal editor --- src/vs/workbench/browser/parts/editor/editorCommands.ts | 1 - src/vs/workbench/browser/parts/editor/modalEditorPart.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/browser/parts/editor/editorCommands.ts b/src/vs/workbench/browser/parts/editor/editorCommands.ts index 693918dc2cacf..70508b4a9b380 100644 --- a/src/vs/workbench/browser/parts/editor/editorCommands.ts +++ b/src/vs/workbench/browser/parts/editor/editorCommands.ts @@ -1447,7 +1447,6 @@ function registerModalEditorCommands(): void { icon: Codicon.screenFull, toggled: { condition: EditorPartModalMaximizedContext, - icon: Codicon.screenNormal, title: localize('restoreModalEditorSize', "Restore Modal Editor") }, menu: { diff --git a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts index 580732318a46b..8571437e5dc58 100644 --- a/src/vs/workbench/browser/parts/editor/modalEditorPart.ts +++ b/src/vs/workbench/browser/parts/editor/modalEditorPart.ts @@ -111,6 +111,7 @@ export class ModalEditorPart { // Create toolbar disposables.add(scopedInstantiationService.createInstance(MenuWorkbenchToolBar, actionBarContainer, MenuId.ModalEditorTitle, { hiddenItemStrategy: HiddenItemStrategy.NoHide, + highlightToggledItems: true, menuOptions: { shouldForwardArgs: true } })); From 71a9aaa7c38a2c0a66c15ed339bac2e235661983 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 08:15:47 -0800 Subject: [PATCH 057/221] Use correct method for writing to proc --- .../notification/browser/terminal.notification.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts index 4cf58b88a2a42..6f1c54eb597b7 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -35,7 +35,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin notify: notification => this._notificationService.notify(notification), updateEnableNotifications: value => this._configurationService.updateValue(TerminalOscNotificationsSettingId.EnableNotifications, value), logWarn: message => this._logService.warn(message), - writeToProcess: data => { void this._ctx.processManager.write(data); } + writeToProcess: data => { void this._ctx.instance.sendText(data, false); } })); } From 7f21d0217ccb0a23e9b90b84a31d42dd931e7741 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Fri, 13 Feb 2026 17:22:55 +0100 Subject: [PATCH 058/221] layout - show toggled icons again for maximised panel/chat (#295189) * layout - show toggled icons again for maximised panel/chat * wording * ccr * ccr * ccr * ccr --- .../parts/auxiliarybar/auxiliaryBarActions.ts | 32 +++++++-------- .../browser/parts/panel/panelActions.ts | 40 ++++++------------- 2 files changed, 27 insertions(+), 45 deletions(-) diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts index b924015772687..4eefda8085bb1 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarActions.ts @@ -20,7 +20,6 @@ import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; import { SwitchCompositeViewAction } from '../compositeBarActions.js'; const maximizeIcon = registerIcon('auxiliarybar-maximize', Codicon.screenFull, localize('maximizeIcon', 'Icon to maximize the secondary side bar.')); -const restoreIcon = registerIcon('auxiliarybar-restore', Codicon.screenNormal, localize('restoreIcon', 'Icon to restore the secondary side bar.')); const closeIcon = registerIcon('auxiliarybar-close', Codicon.close, localize('closeIcon', 'Icon to close the secondary side bar.')); const auxiliaryBarRightIcon = registerIcon('auxiliarybar-right-layout-icon', Codicon.layoutSidebarRight, localize('toggleAuxiliaryIconRight', 'Icon to toggle the secondary side bar off in its right position.')); @@ -224,17 +223,10 @@ class MaximizeAuxiliaryBar extends Action2 { super({ id: MaximizeAuxiliaryBar.ID, title: localize2('maximizeAuxiliaryBar', 'Maximize Secondary Side Bar'), - tooltip: localize('maximizeAuxiliaryBarTooltip', "Maximize Secondary Side Bar Size"), + tooltip: localize('maximizeAuxiliaryBarTooltip', "Maximize Secondary Side Bar"), category: Categories.View, f1: true, precondition: AuxiliaryBarMaximizedContext.negate(), - icon: maximizeIcon, - menu: { - id: MenuId.AuxiliaryBarTitle, - group: 'navigation', - order: 1, - when: AuxiliaryBarMaximizedContext.negate() - } }); } @@ -254,17 +246,10 @@ class RestoreAuxiliaryBar extends Action2 { super({ id: RestoreAuxiliaryBar.ID, title: localize2('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), - tooltip: localize('restoreAuxiliaryBarTooltip', "Restore Secondary Side Bar Size"), + tooltip: localize('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), category: Categories.View, f1: true, precondition: AuxiliaryBarMaximizedContext, - icon: restoreIcon, - menu: { - id: MenuId.AuxiliaryBarTitle, - group: 'navigation', - order: 1, - when: AuxiliaryBarMaximizedContext - }, keybinding: { weight: KeybindingWeight.WorkbenchContrib, primary: KeyMod.CtrlCmd | KeyCode.KeyW, @@ -289,8 +274,19 @@ class ToggleMaximizedAuxiliaryBar extends Action2 { super({ id: ToggleMaximizedAuxiliaryBar.ID, title: localize2('toggleMaximizedAuxiliaryBar', 'Toggle Maximized Secondary Side Bar'), + tooltip: localize('maximizeAuxiliaryBarTooltip2', "Maximize Secondary Side Bar"), f1: true, - category: Categories.View + category: Categories.View, + icon: maximizeIcon, + toggled: { + condition: AuxiliaryBarMaximizedContext, + tooltip: localize('restoreAuxiliaryBar', 'Restore Secondary Side Bar'), + }, + menu: { + id: MenuId.AuxiliaryBarTitle, + group: 'navigation', + order: 1, + } }); } diff --git a/src/vs/workbench/browser/parts/panel/panelActions.ts b/src/vs/workbench/browser/parts/panel/panelActions.ts index 3432144229c2f..10fcbca0b96e5 100644 --- a/src/vs/workbench/browser/parts/panel/panelActions.ts +++ b/src/vs/workbench/browser/parts/panel/panelActions.ts @@ -23,7 +23,6 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { SwitchCompositeViewAction } from '../compositeBarActions.js'; const maximizeIcon = registerIcon('panel-maximize', Codicon.screenFull, localize('maximizeIcon', 'Icon to maximize a panel.')); -const restoreIcon = registerIcon('panel-restore', Codicon.screenNormal, localize('restoreIcon', 'Icon to restore a panel.')); const closeIcon = registerIcon('panel-close', Codicon.close, localize('closeIcon', 'Icon to close a panel.')); const panelIcon = registerIcon('panel-layout-icon', Codicon.layoutPanel, localize('togglePanelOffIcon', 'Icon to toggle the panel off when it is on.')); const panelOffIcon = registerIcon('panel-layout-icon-off', Codicon.layoutPanelOff, localize('togglePanelOnIcon', 'Icon to toggle the panel on when it is off.')); @@ -274,18 +273,27 @@ registerAction2(class extends SwitchCompositeViewAction { }); const panelMaximizationSupportedWhen = ContextKeyExpr.or(PanelAlignmentContext.isEqualTo('center'), ContextKeyExpr.and(PanelPositionContext.notEqualsTo('bottom'), PanelPositionContext.notEqualsTo('top'))); -const ToggleMaximizedPanelActionId = 'workbench.action.toggleMaximizedPanel'; registerAction2(class extends Action2 { constructor() { super({ - id: ToggleMaximizedPanelActionId, + id: 'workbench.action.toggleMaximizedPanel', title: localize2('toggleMaximizedPanel', 'Toggle Maximized Panel'), - tooltip: localize('maximizePanel', "Maximize Panel Size"), + tooltip: localize('maximizePanel', "Maximize Panel"), category: Categories.View, f1: true, icon: maximizeIcon, - precondition: panelMaximizationSupportedWhen, // the workbench grid currently prevents us from supporting panel maximization with non-center panel alignment + precondition: panelMaximizationSupportedWhen, + toggled: { + condition: PanelMaximizedContext, + tooltip: localize('minimizePanel', "Restore Panel") + }, + menu: [{ + id: MenuId.PanelTitle, + group: 'navigation', + order: 1, + when: panelMaximizationSupportedWhen + }] }); } run(accessor: ServicesAccessor) { @@ -309,28 +317,6 @@ registerAction2(class extends Action2 { } }); -MenuRegistry.appendMenuItem(MenuId.PanelTitle, { - command: { - id: ToggleMaximizedPanelActionId, - title: localize('maximizePanel', "Maximize Panel Size"), - icon: maximizeIcon - }, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(panelMaximizationSupportedWhen, PanelMaximizedContext.negate()) -}); - -MenuRegistry.appendMenuItem(MenuId.PanelTitle, { - command: { - id: ToggleMaximizedPanelActionId, - title: localize('minimizePanel', "Restore Panel Size"), - icon: restoreIcon - }, - group: 'navigation', - order: 1, - when: ContextKeyExpr.and(panelMaximizationSupportedWhen, PanelMaximizedContext) -}); - MenuRegistry.appendMenuItems([ { id: MenuId.LayoutControlMenu, From 3a641783608a0b36300987c9f7d6ff0e1d4fce40 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Fri, 13 Feb 2026 18:17:23 +0100 Subject: [PATCH 059/221] refacotring (#295213) --- .../parts/auxiliarybar/auxiliaryBarPart.ts | 1 + .../workbench/browser/parts/paneCompositePart.ts | 15 +++------------ src/vs/workbench/browser/parts/panel/panelPart.ts | 1 + .../browser/parts/sidebar/sidebarPart.ts | 1 + 4 files changed, 6 insertions(+), 12 deletions(-) diff --git a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts index bb02c39dc9600..82738560d3802 100644 --- a/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts +++ b/src/vs/workbench/browser/parts/auxiliarybar/auxiliaryBarPart.ts @@ -114,6 +114,7 @@ export class AuxiliaryBarPart extends AbstractPaneCompositePart { ViewContainerLocation.AuxiliaryBar, Extensions.Auxiliary, MenuId.AuxiliaryBarTitle, + undefined, notificationService, storageService, contextMenuService, diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index e46ba514858cb..1f1187c362507 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -145,6 +145,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart this.actionViewItemProvider(action, options), orientation: ActionsOrientation.HORIZONTAL, @@ -708,14 +707,6 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Fri, 13 Feb 2026 09:46:47 -0800 Subject: [PATCH 060/221] Hide browser on settings overlay (#295214) --- .../contrib/browserView/electron-browser/overlayManager.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts index 33329d25ca5ab..a882c91452e88 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/overlayManager.ts @@ -25,6 +25,7 @@ const OVERLAY_DEFINITIONS: ReadonlyArray<{ className: string; type: BrowserOverl { className: 'editor-widget', type: BrowserOverlayType.Hover }, { className: 'suggest-details-container', type: BrowserOverlayType.Hover }, { className: 'monaco-dialog-modal-block', type: BrowserOverlayType.Dialog }, + { className: 'monaco-modal-editor-block', type: BrowserOverlayType.Dialog }, { className: 'notifications-center', type: BrowserOverlayType.Notification }, { className: 'notification-toast-container', type: BrowserOverlayType.Notification }, // Context view is very generic, so treat the content as unknown From 65bfb3303c6841bbc774981c735d71b64a2ab919 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:53:57 -0800 Subject: [PATCH 061/221] Improve body/title formatting --- .../notification/browser/terminalNotificationHandler.ts | 2 +- .../notification/test/browser/terminalNotification.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 41bc22f729392..9d8a435b8f31c 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -422,7 +422,7 @@ export class TerminalNotificationHandler extends Disposable { const hasTitle = title.trim().length > 0; const hasBody = body.trim().length > 0; if (hasTitle && hasBody) { - return `${title}\n${body}`; + return `${title}: ${body}`; } if (hasTitle) { return title; diff --git a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts index 9b34d94044818..fce8080b3be4d 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -151,7 +151,7 @@ suite('Terminal OSC 99 notifications', () => { handler.handleSequence('i=1:p=body;World'); strictEqual(host.notifications.length, 1); - strictEqual(host.notifications[0].message, 'Hello\nWorld'); + strictEqual(host.notifications[0].message, 'Hello: World'); }); test('decodes base64 payloads', () => { From 5f24d1f188ae102395d4f6b1aa3e697e672d51c6 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Fri, 13 Feb 2026 09:55:40 -0800 Subject: [PATCH 062/221] Bump distro --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 5de9732104172..e8a6f0832f2d9 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "code-oss-dev", "version": "1.110.0", - "distro": "c3c943b0a748aa10a5d6818ab8e7c9e34e458d83", + "distro": "68c946526275fa6c9bec4d4cfe1eb331f1062ee4", "author": { "name": "Microsoft Corporation" }, @@ -243,4 +243,4 @@ "optionalDependencies": { "windows-foreground-love": "0.6.1" } -} +} \ No newline at end of file From 48c903255b60d2a62990bb21c05da3715af44de2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:08:06 -0800 Subject: [PATCH 063/221] Fix custom buttons --- .../browser/terminalNotificationHandler.ts | 61 ++++++++++++------- .../test/browser/terminalNotification.test.ts | 24 ++++++++ 2 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts index 9d8a435b8f31c..c0397f46461fb 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminalNotificationHandler.ts @@ -20,6 +20,7 @@ const enum Osc99PayloadType { } type Osc99Occasion = 'always' | 'unfocused' | 'invisible'; +type Osc99CloseReason = 'button' | 'secondary' | 'auto' | 'protocol'; interface IOsc99NotificationState { id: string | undefined; @@ -42,6 +43,7 @@ interface IOsc99ActiveNotification { reportOnActivate: boolean; reportOnClose: boolean; focusOnActivate: boolean; + closeReason: Osc99CloseReason | undefined; } export interface IOsc99NotificationHost { @@ -119,8 +121,9 @@ export class TerminalNotificationHandler extends Disposable { return true; } - this._showOsc99Notification(state); - this._clearOsc99PendingState(id); + if (this._showOsc99Notification(state)) { + this._clearOsc99PendingState(id); + } return true; } @@ -294,10 +297,10 @@ export class TerminalNotificationHandler extends Disposable { } } - private _showOsc99Notification(state: IOsc99NotificationState): void { + private _showOsc99Notification(state: IOsc99NotificationState): boolean { const message = this._getOsc99NotificationMessage(state); if (!message) { - return; + return false; } const severity = state.urgency === 2 ? Severity.Warning : Severity.Info; @@ -310,6 +313,7 @@ export class TerminalNotificationHandler extends Disposable { const actionStore = this._register(new DisposableStore()); const handleRef: { current: INotificationHandle | undefined } = { current: undefined }; + const activeRef: { current: IOsc99ActiveNotification | undefined } = { current: undefined }; const reportActivation = (buttonIndex?: number, forceFocus?: boolean) => { if (forceFocus || state.focusOnActivate) { this._host.focusTerminal(); @@ -326,21 +330,14 @@ export class TerminalNotificationHandler extends Disposable { continue; } const action = actionStore.add(new Action(`terminal.osc99.button.${i}`, label, undefined, true, () => { + if (activeRef.current) { + activeRef.current.closeReason = 'button'; + } reportActivation(i + 1); handleRef.current?.close(); })); primaryActions.push(action); } - primaryActions.push(actionStore.add(new Action( - 'terminal.osc99.focus', - localize('terminalNotificationFocus', 'Focus Terminal'), - undefined, - true, - () => { - reportActivation(undefined, true); - handleRef.current?.close(); - } - ))); const secondaryActions: IAction[] = []; secondaryActions.push(actionStore.add(new Action( @@ -348,7 +345,12 @@ export class TerminalNotificationHandler extends Disposable { localize('terminalNotificationDismiss', 'Dismiss'), undefined, true, - () => handleRef.current?.close() + () => { + if (activeRef.current) { + activeRef.current.closeReason = 'secondary'; + } + handleRef.current?.close(); + } ))); secondaryActions.push(actionStore.add(new Action( 'terminal.osc99.disable', @@ -357,6 +359,9 @@ export class TerminalNotificationHandler extends Disposable { true, async () => { await this._host.updateEnableNotifications(false); + if (activeRef.current) { + activeRef.current.closeReason = 'secondary'; + } handleRef.current?.close(); } ))); @@ -366,6 +371,7 @@ export class TerminalNotificationHandler extends Disposable { if (state.id) { const existing = this._osc99ActiveNotifications.get(state.id); if (existing) { + activeRef.current = existing; existing.handle.updateMessage(message); existing.handle.updateSeverity(severity); existing.handle.updateActions(actions); @@ -375,8 +381,8 @@ export class TerminalNotificationHandler extends Disposable { existing.reportOnActivate = state.reportOnActivate; existing.reportOnClose = state.reportOnClose; existing.autoCloseDisposable?.dispose(); - existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing.handle, state.autoCloseMs); - return; + existing.autoCloseDisposable = this._scheduleOsc99AutoClose(existing, state.autoCloseMs); + return true; } } @@ -397,10 +403,18 @@ export class TerminalNotificationHandler extends Disposable { autoCloseDisposable: undefined, reportOnActivate: state.reportOnActivate, reportOnClose: state.reportOnClose, - focusOnActivate: state.focusOnActivate + focusOnActivate: state.focusOnActivate, + closeReason: undefined }; - active.autoCloseDisposable = this._scheduleOsc99AutoClose(handle, state.autoCloseMs); + activeRef.current = active; + active.autoCloseDisposable = this._scheduleOsc99AutoClose(active, state.autoCloseMs); this._register(handle.onDidClose(() => { + if (active.reportOnActivate && active.closeReason === undefined) { + if (active.focusOnActivate) { + this._host.focusTerminal(); + } + this._sendOsc99ActivationReport(active.id); + } if (active.reportOnClose) { this._sendOsc99CloseReport(active.id); } @@ -414,6 +428,7 @@ export class TerminalNotificationHandler extends Disposable { if (active.id) { this._osc99ActiveNotifications.set(active.id, active); } + return true; } private _getOsc99NotificationMessage(state: IOsc99NotificationState): string | undefined { @@ -446,11 +461,14 @@ export class TerminalNotificationHandler extends Disposable { } } - private _scheduleOsc99AutoClose(handle: INotificationHandle, autoCloseMs: number | undefined): IDisposable | undefined { + private _scheduleOsc99AutoClose(active: IOsc99ActiveNotification, autoCloseMs: number | undefined): IDisposable | undefined { if (autoCloseMs === undefined || autoCloseMs <= 0) { return undefined; } - return disposableTimeout(() => handle.close(), autoCloseMs, this._store); + return disposableTimeout(() => { + active.closeReason = 'auto'; + active.handle.close(); + }, autoCloseMs, this._store); } private _closeOsc99Notification(id: string | undefined): void { @@ -459,6 +477,7 @@ export class TerminalNotificationHandler extends Disposable { } const active = this._osc99ActiveNotifications.get(id); if (active) { + active.closeReason = 'protocol'; active.handle.close(); } this._osc99PendingNotifications.delete(id); diff --git a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts index fce8080b3be4d..518e4821c57f2 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/test/browser/terminalNotification.test.ts @@ -181,6 +181,30 @@ suite('Terminal OSC 99 notifications', () => { strictEqual(host.writes[0], '\x1b]99;i=btn;1\x1b\\'); }); + test('supports buttons before title and reports body activation', async () => { + handler.handleSequence('i=btn:p=buttons;One\u2028Two'); + handler.handleSequence('i=btn:a=report;Buttons test'); + + strictEqual(host.notifications.length, 1); + const actions = host.notifications[0].actions; + if (!actions?.primary || actions.primary.length !== 2) { + throw new Error('Expected two primary actions'); + } + strictEqual(actions.primary[0].label, 'One'); + strictEqual(actions.primary[1].label, 'Two'); + + await actions.primary[1].run(); + strictEqual(host.writes[0], '\x1b]99;i=btn;2\x1b\\'); + }); + + test('reports activation when notification closes without button action', () => { + handler.handleSequence('i=btn:p=buttons;One\u2028Two'); + handler.handleSequence('i=btn:a=report;Buttons test'); + + host.notifications[0].close(); + strictEqual(host.writes[0], '\x1b]99;i=btn;\x1b\\'); + }); + test('sends close report when requested', () => { handler.handleSequence('i=close:c=1:p=title;Bye'); strictEqual(host.notifications.length, 1); From 2d4622e0abf598266c81595810c3567293b72e5d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:11:54 -0800 Subject: [PATCH 064/221] Clarify support in config --- .../notification/common/terminalNotificationConfiguration.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts index 2ad950e8d7b45..f4e1e8dc3c20c 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/common/terminalNotificationConfiguration.ts @@ -13,7 +13,7 @@ export const enum TerminalOscNotificationsSettingId { export const terminalOscNotificationsConfiguration: IStringDictionary = { [TerminalOscNotificationsSettingId.EnableNotifications]: { - description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown."), + description: localize('terminal.integrated.enableNotifications', "Controls whether notifications sent from the terminal via OSC 99 are shown. This uses notifications inside the product instead of desktop notifications. Sounds, icons and filtering are not supported."), type: 'boolean', default: true }, From c6636b60fb19b1537fd650fd74457c31078c8a86 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:19:53 -0800 Subject: [PATCH 065/221] Use type param in getValue --- .../notification/browser/terminal.notification.contribution.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts index 6f1c54eb597b7..85e3b84aa0a10 100644 --- a/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/notification/browser/terminal.notification.contribution.ts @@ -28,7 +28,7 @@ class TerminalOscNotificationsContribution extends Disposable implements ITermin ) { super(); this._handler = this._register(new TerminalNotificationHandler({ - isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications), + isEnabled: () => this._configurationService.getValue(TerminalOscNotificationsSettingId.EnableNotifications) === true, isWindowFocused: () => dom.getActiveWindow().document.hasFocus(), isTerminalVisible: () => this._ctx.instance.isVisible, focusTerminal: () => this._ctx.instance.focus(true), From 7392f65cf6eae59af7dd7fc9f5318aedc1d109d9 Mon Sep 17 00:00:00 2001 From: eli-w-king Date: Fri, 13 Feb 2026 10:35:38 -0800 Subject: [PATCH 066/221] Revert "non-responsive panel content motion (#295011)" This reverts commit 1443559d93517d8339ce3eab65dcd1d877984fc0. --- src/vs/base/browser/ui/splitview/splitview.ts | 37 ++++--------------- 1 file changed, 8 insertions(+), 29 deletions(-) diff --git a/src/vs/base/browser/ui/splitview/splitview.ts b/src/vs/base/browser/ui/splitview/splitview.ts index 2c72bd8d86976..c6ac50b2dedc9 100644 --- a/src/vs/base/browser/ui/splitview/splitview.ts +++ b/src/vs/base/browser/ui/splitview/splitview.ts @@ -908,18 +908,13 @@ export class SplitView