From 83c84e781041b3410c7798c44671de99dee4ce3e Mon Sep 17 00:00:00 2001 From: chetanr-25 Date: Sun, 18 Jan 2026 02:42:08 +0530 Subject: [PATCH 01/31] Avoid deprecated CSSStyleSheet.rules usage --- src/vs/base/browser/domStylesheets.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts index c338502d541da..191778f71551f 100644 --- a/src/vs/base/browser/domStylesheets.ts +++ b/src/vs/base/browser/domStylesheets.ts @@ -111,16 +111,15 @@ function getSharedStyleSheet(): HTMLStyleElement { return _sharedStyleSheet; } -function getDynamicStyleSheetRules(style: HTMLStyleElement) { - if (style?.sheet?.rules) { - return style.sheet.rules; // Chrome, IE +function getDynamicStyleSheetRules(style: HTMLStyleElement): CSSRuleList { + if (style.sheet) { + return style.sheet.cssRules; } - if (style?.sheet?.cssRules) { - return style.sheet.cssRules; // FF - } - return []; + return { length: 0 } as CSSRuleList; } + + export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { if (!style || !cssText) { return; From 118e74be4534d791a01a45db786e18bca0b96eb8 Mon Sep 17 00:00:00 2001 From: chetanr-25 Date: Wed, 21 Jan 2026 04:37:00 +0530 Subject: [PATCH 02/31] Fix getDynamicStyleSheetRules without type assertions --- src/vs/base/browser/domStylesheets.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts index 191778f71551f..c3217441dc4ca 100644 --- a/src/vs/base/browser/domStylesheets.ts +++ b/src/vs/base/browser/domStylesheets.ts @@ -115,11 +115,19 @@ function getDynamicStyleSheetRules(style: HTMLStyleElement): CSSRuleList { if (style.sheet) { return style.sheet.cssRules; } - return { length: 0 } as CSSRuleList; + + const emptyRules: CSSRule[] = []; + + return { + length: 0, + item: () => null, + [Symbol.iterator]: () => emptyRules.values() + }; } + export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { if (!style || !cssText) { return; From 74fcb3b75e4056b756bf8b8ea23c083af8e79adf Mon Sep 17 00:00:00 2001 From: andysharman Date: Mon, 30 Mar 2026 16:06:23 -0700 Subject: [PATCH 03/31] feat: add A/B test for default new session mode Replace the temporary chat.defaultMode experiment with a proper configuration setting chat.newSession.defaultMode whose default is driven by TAS treatment chatDefaultNewSessionMode. - Add DefaultNewSessionMode to ChatConfiguration enum - Register setting dynamically via ChatAgentSettingContribution following the registerMaxRequestsSetting pattern - Rewrite _setEmptyModelState to sync read the config setting with case-insensitive custom mode name matching - Remove unused validateChatMode, isChatMode, and getDefaultModeExperimentStorageKey - Remove IWorkbenchAssignmentService from ChatInputPart (moved to ChatAgentSettingContribution) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/chat.contribution.ts | 24 ++++++++ .../browser/widget/input/chatInputPart.ts | 57 +++++++++---------- .../contrib/chat/common/constants.ts | 16 +----- 3 files changed, 51 insertions(+), 46 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 045810171de2a..a98317cbd7bb6 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -758,6 +758,11 @@ configurationRegistry.registerConfiguration({ } } }, + [ChatConfiguration.DefaultNewSessionMode]: { + type: 'string', + description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), + default: '', + }, [AgentHostEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.enabled', "When enabled, some agents run in a separate agent host process."), @@ -1570,6 +1575,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr this.newChatButtonExperimentIcon = ChatContextKeys.newChatButtonExperimentIcon.bindTo(this.contextKeyService); this.registerMaxRequestsSetting(); this.registerNewChatButtonIcon(); + this.registerDefaultModeSetting(); } @@ -1610,6 +1616,24 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr } }); } + + private registerDefaultModeSetting(): void { + this.experimentService.getTreatment('chatDefaultNewSessionMode').then(value => { + const node: IConfigurationNode = { + id: 'chatSidebar', + title: nls.localize('interactiveSessionConfigurationTitle', "Chat"), + type: 'object', + properties: { + [ChatConfiguration.DefaultNewSessionMode]: { + type: 'string', + description: nls.localize('chat.newSession.defaultMode', "The default mode for new chat sessions. When empty, the chat view's default mode is used."), + default: typeof value === 'string' ? value : '', + } + } + }; + configurationRegistry.updateConfigurations({ add: [node], remove: [] }); + }); + } } class ChatForegroundSessionCountContribution extends Disposable implements IWorkbenchContribution { diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 77a3a5da12eb1..87387cb93d90e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -73,7 +73,6 @@ import { IWorkspaceContextService, WorkbenchState } from '../../../../../../plat import { IWorkbenchLayoutService, Position } from '../../../../../services/layout/browser/layoutService.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../../../../common/views.js'; import { ResourceLabels } from '../../../../../browser/labels.js'; -import { IWorkbenchAssignmentService } from '../../../../../services/assignment/common/assignmentService.js'; import { IChatEntitlementService } from '../../../../../services/chat/common/chatEntitlementService.js'; import { ACTIVE_GROUP, IEditorService, SIDE_GROUP } from '../../../../../services/editor/common/editorService.js'; import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; @@ -86,7 +85,7 @@ import { ChatRequestVariableSet, IChatRequestVariableEntry, isElementVariableEnt import { ChatMode, getModeNameForTelemetry, IChatMode, IChatModeService } from '../../../common/chatModes.js'; import { IChatFollowup, IChatQuestionCarousel, IChatService, IChatSessionContext } from '../../../common/chatService/chatService.js'; import { agentOptionId, IChatSessionProviderOptionGroup, IChatSessionProviderOptionItem, IChatSessionsService, isIChatSessionFileChange2, localChatSessionType } from '../../../common/chatSessionsService.js'; -import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel, validateChatMode } from '../../../common/constants.js'; +import { ChatAgentLocation, ChatConfiguration, ChatModeKind, ChatPermissionLevel } from '../../../common/constants.js'; import { IChatEditingSession, IModifiedFileEntry, ModifiedFileEntryState } from '../../../common/editing/chatEditingService.js'; import { ILanguageModelChatMetadata, ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../../common/languageModels.js'; import { IChatModelInputState, IChatRequestModeInfo, IInputModel } from '../../../common/model/chatModel.js'; @@ -537,7 +536,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge @IStorageService private readonly storageService: IStorageService, @IChatAgentService private readonly agentService: IChatAgentService, @ISharedWebContentExtractorService private readonly sharedWebExtracterService: ISharedWebContentExtractorService, - @IWorkbenchAssignmentService private readonly experimentService: IWorkbenchAssignmentService, @IChatEntitlementService private readonly entitlementService: IChatEntitlementService, @IChatModeService private readonly chatModeService: IChatModeService, @ILanguageModelToolsService private readonly toolService: ILanguageModelToolsService, @@ -924,7 +922,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.selectedToolsModel.resetSessionEnablementState(); this._chatSessionIsEmpty = chatSessionIsEmpty; - // TODO@roblourens This is for an experiment which will be obsolete in a month or two and can then be removed. if (chatSessionIsEmpty) { this._setEmptyModelState(); } @@ -941,29 +938,32 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private _setEmptyModelState() { - const storageKey = this.getDefaultModeExperimentStorageKey(); - const hasSetDefaultMode = this.storageService.getBoolean(storageKey, StorageScope.WORKSPACE, false); - if (!hasSetDefaultMode) { - const isAnonymous = this.entitlementService.anonymous; - this.experimentService.getTreatment('chat.defaultMode') - .then((defaultModeTreatment => { - if (isAnonymous) { - // be deterministic for anonymous users - // to support agentic flows with default - // model. - defaultModeTreatment = ChatModeKind.Agent; - } + if (this.entitlementService.anonymous) { + // Be deterministic for anonymous users to support + // agentic flows with default model. + this.setChatMode(ChatModeKind.Agent, false); + this.checkModelSupported(); + return; + } - if (typeof defaultModeTreatment === 'string') { - this.storageService.store(storageKey, true, StorageScope.WORKSPACE, StorageTarget.MACHINE); - const defaultMode = validateChatMode(defaultModeTreatment); - if (defaultMode) { - this.logService.trace(`Applying default mode from experiment: ${defaultMode}`); - this.setChatMode(defaultMode, false); - this.checkModelSupported(); - } - } - })); + const rawDefaultMode = this.configurationService.getValue(ChatConfiguration.DefaultNewSessionMode); + if (typeof rawDefaultMode === 'string') { + const defaultMode = rawDefaultMode.trim(); + if (defaultMode) { + // Custom modes are loaded asynchronously, so they may not be available yet + // at session initialization time. Built-in modes (ask, edit, agent) are always available. + const defaultModeLower = defaultMode.toLowerCase(); + const resolved = this.chatModeService.findModeById(defaultMode) + ?? this.chatModeService.findModeByName(defaultMode) + ?? this.chatModeService.getModes().custom.find(m => m.name.get().toLowerCase() === defaultModeLower); + if (resolved) { + this.logService.trace(`[ChatInputPart] Applying default mode from setting: ${defaultMode} -> ${resolved.id}`); + this.setChatMode(resolved.id, false); + this.checkModelSupported(); + } else { + this.logService.trace(`[ChatInputPart] Default mode '${defaultMode}' not found in available modes`); + } + } } } @@ -1315,11 +1315,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - private getDefaultModeExperimentStorageKey(): string { - const tag = this.options.widgetViewKindTag; - return `chat.${tag}.hasSetDefaultModeByExperiment`; - } - logInputHistory(): void { const historyStr = this.history.values.map(entry => JSON.stringify(entry)).join('\n'); this.logService.info(`[${this.location}] Chat input history:`, historyStr); diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index eeba510b72b6e..84e1a03035d27 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -65,6 +65,7 @@ export enum ChatConfiguration { ArtifactsRulesByMimeType = 'chat.artifacts.rules.byMimeType', ArtifactsRulesByFilePath = 'chat.artifacts.rules.byFilePath', CustomizationsProviderApi = 'chat.customizations.providerApi.enabled', + DefaultNewSessionMode = 'chat.newSession.defaultMode', } /** @@ -76,17 +77,6 @@ export enum ChatModeKind { Agent = 'agent' } -export function validateChatMode(mode: unknown): ChatModeKind | undefined { - switch (mode) { - case ChatModeKind.Ask: - case ChatModeKind.Edit: - case ChatModeKind.Agent: - return mode as ChatModeKind; - default: - return undefined; - } -} - /** * The permission level controlling tool auto-approval behavior. */ @@ -107,10 +97,6 @@ export function isAutoApproveLevel(level: ChatPermissionLevel | undefined): bool return level === ChatPermissionLevel.AutoApprove || level === ChatPermissionLevel.Autopilot; } -export function isChatMode(mode: unknown): mode is ChatModeKind { - return !!validateChatMode(mode); -} - // Thinking display modes for pinned content export enum ThinkingDisplayMode { Collapsed = 'collapsed', From 9fbd00406ec8d78958f6f1e8ea8b0442d9a0a0b6 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:56:36 -0700 Subject: [PATCH 04/31] Give method a more accurate name This function does way more than compute the visible options groups. Let's give it a name that reflects this so it's clear it should be refactored --- .../contrib/chat/browser/widget/input/chatInputPart.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 564ba05312d99..924ce03d171f7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -587,7 +587,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Listen for session type changes from the welcome page delegate if (this.options.sessionTypePickerDelegate?.onDidChangeActiveSessionProvider) { this._register(this.options.sessionTypePickerDelegate.onDidChangeActiveSessionProvider(async (newSessionType) => { - this.computeVisibleOptionGroups(); + this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); this.agentSessionTypeKey.set(newSessionType); this.chatSessionSupportsDelegationKey.set(this.chatSessionsService.supportsDelegationForSessionType(newSessionType)); this.updateWidgetLockStateFromSessionType(newSessionType); @@ -853,7 +853,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._lastSessionPickerAction = action; this._lastSessionPickerOptions = pickerOptions; - const result = this.computeVisibleOptionGroups(); + const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); if (!result) { return []; } @@ -1601,7 +1601,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * @returns The result containing visible group IDs and related context, or undefined * if there are no visible option groups */ - private computeVisibleOptionGroups(): { + private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(): { visibleGroupIds: Set; optionGroups: IChatSessionProviderOptionGroup[]; sessionResource: URI | undefined; @@ -1719,7 +1719,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ private refreshChatSessionPickers(): void { // Use the shared helper to compute visibility and update context keys - const result = this.computeVisibleOptionGroups(); + const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); if (!result) { // No visible options - helper already updated context keys @@ -1929,7 +1929,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; - this.computeVisibleOptionGroups(); + this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); // Initialize lock state when rendering with a pre-selected session provider (e.g., welcome view restore) const delegate = this.options.sessionTypePickerDelegate; From 46f5ee7f54e7bd2e4e07135bf2d16c56d6fc492f Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:59:58 -0700 Subject: [PATCH 05/31] Extract out updateStateAndChatModeForCustomAgentTargetIfNeeded Start breaking up the function because it's doing too much --- .../browser/widget/input/chatInputPart.ts | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 924ce03d171f7..3edae97ccac09 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1614,30 +1614,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this._widget?.viewModel?.model.sessionResource; - // Check if this session type has a customAgentTarget - const customAgentTarget = sessionResource && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)); - this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); - - // Check if this session type requires custom models - const requiresCustomModels = sessionResource && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(sessionResource)); - this.chatSessionHasTargetedModels.set(!!requiresCustomModels); - - // Handle agent option from session - set initial mode - if (customAgentTarget) { - const contribution = sessionResource && this.chatSessionsService.getChatSessionContribution(getChatSessionType(sessionResource)); - const agentOption = this.chatSessionsService.getSessionOption(sessionResource, agentOptionId); - if (typeof agentOption !== 'undefined' && !contribution?.useRequestToPopulateBuiltInPickers) { - const agentId = (typeof agentOption === 'string' ? agentOption : agentOption.id) || ChatMode.Agent.id; - const currentMode = this._currentModeObservable.get(); - const isDefaultAgent = agentId === ChatMode.Agent.id; - const needsUpdate = isDefaultAgent - ? currentMode.id !== ChatMode.Agent.id - : currentMode.label.get() !== agentId; // Extensions use Label (name) as identifier for custom agents. - - if (needsUpdate) { - this.setChatMode(agentId); - } - } + if (sessionResource) { + this.updateStateForCustomAgentTargetIfNeeded(sessionResource); } // Step 1: Determine the session type @@ -1713,6 +1691,33 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return { visibleGroupIds, optionGroups, sessionResource, effectiveSessionType }; } + private updateStateForCustomAgentTargetIfNeeded(sessionResource: URI) { + const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)); + this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); + + // Check if this session type requires custom models + const requiresCustomModels = this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(sessionResource)); + this.chatSessionHasTargetedModels.set(!!requiresCustomModels); + + // Handle agent option from session - set initial mode + if (customAgentTarget) { + const contribution = this.chatSessionsService.getChatSessionContribution(getChatSessionType(sessionResource)); + const agentOption = this.chatSessionsService.getSessionOption(sessionResource, agentOptionId); + if (typeof agentOption !== 'undefined' && !contribution?.useRequestToPopulateBuiltInPickers) { + const agentId = (typeof agentOption === 'string' ? agentOption : agentOption.id) || ChatMode.Agent.id; + const currentMode = this._currentModeObservable.get(); + const isDefaultAgent = agentId === ChatMode.Agent.id; + const needsUpdate = isDefaultAgent + ? currentMode.id !== ChatMode.Agent.id + : currentMode.label.get() !== agentId; // Extensions use Label (name) as identifier for custom agents. + + if (needsUpdate) { + this.setChatMode(agentId); + } + } + } + } + /** * Refresh all registered option groups for the current chat session. * Fires events for each option group with their current selection. From e81132749f3e36ec1895367e1c57cca527ebf7b3 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:12:15 -0700 Subject: [PATCH 06/31] Break up methods Start splitting up methods so that each one has a clearer responsibility --- .../browser/widget/input/chatInputPart.ts | 68 +++++++++++-------- 1 file changed, 39 insertions(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 3edae97ccac09..02a92c9b2c39d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1607,32 +1607,63 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge sessionResource: URI | undefined; effectiveSessionType: string; } | undefined { - const setNoOptions = () => { + const sessionResource = this._widget?.viewModel?.model.sessionResource; + + if (sessionResource) { + this.updateStateAndChatModeForSessionCustomAgentTarget(sessionResource); + } + + const result = this.getVisibleOptionGroups(sessionResource); + if (!result) { this.chatSessionHasOptions.set(false); this.chatSessionOptionsValid.set(true); - }; + return undefined; + } - const sessionResource = this._widget?.viewModel?.model.sessionResource; + const allOptionsValid = this.areAllOptionsValid(sessionResource, result); + this.chatSessionHasOptions.set(true); + this.chatSessionOptionsValid.set(allOptionsValid); + + return result; + } + + private areAllOptionsValid(sessionResource: URI | undefined, result: { visibleGroupIds: Set; optionGroups: IChatSessionProviderOptionGroup[]; sessionResource: URI | undefined; effectiveSessionType: string }) { if (sessionResource) { - this.updateStateForCustomAgentTargetIfNeeded(sessionResource); + for (const groupId of result.visibleGroupIds) { + const optionGroup = result.optionGroups.find(g => g.id === groupId); + const currentOption = this.chatSessionsService.getSessionOption(sessionResource, groupId); + if (optionGroup && currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? + if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { + return false; + } + } + } } + return true; + } + + private getVisibleOptionGroups(sessionResource: URI | undefined): { + visibleGroupIds: Set; + optionGroups: IChatSessionProviderOptionGroup[]; + sessionResource: URI | undefined; + effectiveSessionType: string; + } | undefined { // Step 1: Determine the session type // - Panel/Editor: Use actual session's type (ctx available) // - Welcome view: Use delegate's type (ctx may not exist yet) const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); const effectiveSessionType = delegateSessionType ?? (sessionResource ? getChatSessionType(sessionResource) : undefined); - if (!effectiveSessionType) { - setNoOptions(); return undefined; } // Step 2: Get option groups for this session type const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); if (!optionGroups || optionGroups.length === 0) { - setNoOptions(); return undefined; } @@ -1664,34 +1695,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } if (visibleGroupIds.size === 0) { - setNoOptions(); return undefined; } - // Validate selected options exist in their respective groups - let allOptionsValid = true; - if (sessionResource) { - for (const groupId of visibleGroupIds) { - const optionGroup = optionGroups.find(g => g.id === groupId); - const currentOption = this.chatSessionsService.getSessionOption(sessionResource, groupId); - if (optionGroup && currentOption) { - const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? - if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { - allOptionsValid = false; - break; - } - } - } - } - - this.chatSessionHasOptions.set(true); - this.chatSessionOptionsValid.set(allOptionsValid); - return { visibleGroupIds, optionGroups, sessionResource, effectiveSessionType }; } - private updateStateForCustomAgentTargetIfNeeded(sessionResource: URI) { + private updateStateAndChatModeForSessionCustomAgentTarget(sessionResource: URI) { const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)); this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); From b65953804c7e59ee0f7d14db9c059f3ee1e65b20 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:20:10 -0700 Subject: [PATCH 07/31] Clean up cruft from extractions --- .../browser/widget/input/chatInputPart.ts | 27 +++++++++---------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 02a92c9b2c39d..a122e57e48a58 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -1620,25 +1620,23 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return undefined; } - const allOptionsValid = this.areAllOptionsValid(sessionResource, result); + const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, result) : true; this.chatSessionHasOptions.set(true); this.chatSessionOptionsValid.set(allOptionsValid); - return result; + return { ...result, sessionResource }; } - private areAllOptionsValid(sessionResource: URI | undefined, result: { visibleGroupIds: Set; optionGroups: IChatSessionProviderOptionGroup[]; sessionResource: URI | undefined; effectiveSessionType: string }) { - if (sessionResource) { - for (const groupId of result.visibleGroupIds) { - const optionGroup = result.optionGroups.find(g => g.id === groupId); - const currentOption = this.chatSessionsService.getSessionOption(sessionResource, groupId); - if (optionGroup && currentOption) { - const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; - // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? - if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { - return false; - } + private areAllOptionsValid(sessionResource: URI, result: { visibleGroupIds: Set; optionGroups: IChatSessionProviderOptionGroup[] }): boolean { + for (const groupId of result.visibleGroupIds) { + const optionGroup = result.optionGroups.find(g => g.id === groupId); + const currentOption = this.chatSessionsService.getSessionOption(sessionResource, groupId); + if (optionGroup && currentOption) { + const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; + // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? + if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { + return false; } } } @@ -1648,7 +1646,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private getVisibleOptionGroups(sessionResource: URI | undefined): { visibleGroupIds: Set; optionGroups: IChatSessionProviderOptionGroup[]; - sessionResource: URI | undefined; effectiveSessionType: string; } | undefined { @@ -1698,7 +1695,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return undefined; } - return { visibleGroupIds, optionGroups, sessionResource, effectiveSessionType }; + return { visibleGroupIds, optionGroups, effectiveSessionType }; } private updateStateAndChatModeForSessionCustomAgentTarget(sessionResource: URI) { From 7862f53819f54dc8d4b5f9ffa35dbdc2d74f2173 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:28:52 -0700 Subject: [PATCH 08/31] Give return type more accurate names and simplify - `optionsGroups` is confusing unless you also know there's `visibleOptionsGroups` - Then `visibleOptionIds` returns the ids instead of the actual groups? But then in most cases the caller then needs to map back through to get the group so let's just return the group directly? --- .../browser/widget/input/chatInputPart.ts | 55 +++++++++---------- 1 file changed, 27 insertions(+), 28 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index a122e57e48a58..b8c6bf8130c7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -858,15 +858,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return []; } - const { visibleGroupIds, optionGroups, effectiveSessionType } = result; + const { visibleOptionGroups, effectiveSessionType } = result; this.chatSessionPickerWidgets.clearAndDisposeAll(); const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; - for (const optionGroup of optionGroups) { - if (!visibleGroupIds.has(optionGroup.id)) { - continue; - } - + for (const optionGroup of visibleOptionGroups) { const initialItem = this.getCurrentOptionForGroup(optionGroup.id); const initialState = { group: optionGroup, item: initialItem }; @@ -1602,8 +1598,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * if there are no visible option groups */ private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(): { - visibleGroupIds: Set; - optionGroups: IChatSessionProviderOptionGroup[]; + visibleOptionGroups: IChatSessionProviderOptionGroup[]; + allOptionGroups: IChatSessionProviderOptionGroup[]; sessionResource: URI | undefined; effectiveSessionType: string; } | undefined { @@ -1620,7 +1616,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return undefined; } - const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, result) : true; + const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, result.visibleOptionGroups) : true; this.chatSessionHasOptions.set(true); this.chatSessionOptionsValid.set(allOptionsValid); @@ -1628,11 +1624,10 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return { ...result, sessionResource }; } - private areAllOptionsValid(sessionResource: URI, result: { visibleGroupIds: Set; optionGroups: IChatSessionProviderOptionGroup[] }): boolean { - for (const groupId of result.visibleGroupIds) { - const optionGroup = result.optionGroups.find(g => g.id === groupId); - const currentOption = this.chatSessionsService.getSessionOption(sessionResource, groupId); - if (optionGroup && currentOption) { + private areAllOptionsValid(sessionResource: URI, visibleOptionGroups: readonly IChatSessionProviderOptionGroup[]): boolean { + for (const optionGroup of visibleOptionGroups) { + const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); + if (currentOption) { const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; // TODO: @osortega @joshspicer should we add a `placeHolder` item to option groups to straighten this check? if (!optionGroup.items.some(item => item.id === currentOptionId) && typeof currentOption === 'string') { @@ -1644,8 +1639,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } private getVisibleOptionGroups(sessionResource: URI | undefined): { - visibleGroupIds: Set; - optionGroups: IChatSessionProviderOptionGroup[]; + visibleOptionGroups: IChatSessionProviderOptionGroup[]; + allOptionGroups: IChatSessionProviderOptionGroup[]; effectiveSessionType: string; } | undefined { @@ -1659,15 +1654,15 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Step 2: Get option groups for this session type - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); - if (!optionGroups || optionGroups.length === 0) { + const allOptionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); + if (!allOptionGroups || allOptionGroups.length === 0) { return undefined; } // Update context keys with current option values before evaluating `when` clauses. // This ensures interdependent `when` expressions work correctly. if (sessionResource) { - for (const optionGroup of optionGroups) { + for (const optionGroup of allOptionGroups) { const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id); if (currentOption) { const optionId = typeof currentOption === 'string' ? currentOption : currentOption.id; @@ -1677,8 +1672,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } // Step 3: Filter to visible groups (has items AND passes `when` clause AND session has option configured) - const visibleGroupIds = new Set(); - for (const optionGroup of optionGroups) { + const visibleGroups = new Map(); + for (const optionGroup of allOptionGroups) { const hasItems = optionGroup.items.length > 0 || (optionGroup.commands || []).length > 0; const passesWhenClause = this.evaluateOptionGroupVisibility(optionGroup); @@ -1687,15 +1682,19 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionHasOption = !sessionResource || this.chatSessionsService.getSessionOption(sessionResource, optionGroup.id) !== undefined; if (hasItems && passesWhenClause && sessionHasOption) { - visibleGroupIds.add(optionGroup.id); + visibleGroups.set(optionGroup.id, optionGroup); } } - if (visibleGroupIds.size === 0) { + if (visibleGroups.size === 0) { return undefined; } - return { visibleGroupIds, optionGroups, effectiveSessionType }; + return { + allOptionGroups, + visibleOptionGroups: Array.from(visibleGroups.values()), + effectiveSessionType + }; } private updateStateAndChatModeForSessionCustomAgentTarget(sessionResource: URI) { @@ -1739,13 +1738,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const { visibleGroupIds, optionGroups, sessionResource } = result; + const { allOptionGroups, visibleOptionGroups, sessionResource } = result; // Check if widgets need recreation (different set of visible groups) const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = - currentWidgetGroupIds.size !== visibleGroupIds.size || - !Array.from(visibleGroupIds).every(id => currentWidgetGroupIds.has(id)); + currentWidgetGroupIds.size !== visibleOptionGroups.length || + !visibleOptionGroups.every(group => currentWidgetGroupIds.has(group.id)); if (needsRecreation && this._lastSessionPickerAction && this.chatSessionPickerContainer) { const widgets = this.createChatSessionPickerWidgets(this._lastSessionPickerAction, this._lastSessionPickerOptions); @@ -1767,7 +1766,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge for (const [optionGroupId] of this.chatSessionPickerWidgets) { const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroupId); if (currentOption) { - const optionGroup = optionGroups.find(g => g.id === optionGroupId); + const optionGroup = allOptionGroups.find(g => g.id === optionGroupId); if (optionGroup) { const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; const item = optionGroup.items.find((m: IChatSessionProviderOptionItem) => m.id === currentOptionId); From fb560e0eeb0810ba418e2ed00f48c6e7257c7c8d Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:33:05 -0700 Subject: [PATCH 09/31] Make callers pass in sessionResource This duplicates some code but make the actual method cleaner. It also lets us cleanly remove one of the return values to get us closer to being able to have this method return just what it describes --- .../browser/widget/input/chatInputPart.ts | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index b8c6bf8130c7a..520aa28e4ac28 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -587,7 +587,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Listen for session type changes from the welcome page delegate if (this.options.sessionTypePickerDelegate?.onDidChangeActiveSessionProvider) { this._register(this.options.sessionTypePickerDelegate.onDidChangeActiveSessionProvider(async (newSessionType) => { - this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); + this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(this.getCurrentSessionResource()); this.agentSessionTypeKey.set(newSessionType); this.chatSessionSupportsDelegationKey.set(this.chatSessionsService.supportsDelegationForSessionType(newSessionType)); this.updateWidgetLockStateFromSessionType(newSessionType); @@ -853,7 +853,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._lastSessionPickerAction = action; this._lastSessionPickerOptions = pickerOptions; - const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); + const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(this.getCurrentSessionResource()); if (!result) { return []; } @@ -1597,14 +1597,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * @returns The result containing visible group IDs and related context, or undefined * if there are no visible option groups */ - private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(): { + private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): { visibleOptionGroups: IChatSessionProviderOptionGroup[]; allOptionGroups: IChatSessionProviderOptionGroup[]; - sessionResource: URI | undefined; effectiveSessionType: string; } | undefined { - const sessionResource = this._widget?.viewModel?.model.sessionResource; - if (sessionResource) { this.updateStateAndChatModeForSessionCustomAgentTarget(sessionResource); } @@ -1621,7 +1618,11 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this.chatSessionHasOptions.set(true); this.chatSessionOptionsValid.set(allOptionsValid); - return { ...result, sessionResource }; + return result; + } + + private getCurrentSessionResource() { + return this._widget?.viewModel?.model.sessionResource; } private areAllOptionsValid(sessionResource: URI, visibleOptionGroups: readonly IChatSessionProviderOptionGroup[]): boolean { @@ -1730,7 +1731,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge */ private refreshChatSessionPickers(): void { // Use the shared helper to compute visibility and update context keys - const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); + const sessionResource = this.getCurrentSessionResource(); + const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); if (!result) { // No visible options - helper already updated context keys @@ -1738,7 +1740,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const { allOptionGroups, visibleOptionGroups, sessionResource } = result; + const { allOptionGroups, visibleOptionGroups } = result; // Check if widgets need recreation (different set of visible groups) const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); @@ -1940,7 +1942,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; - this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(); + this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(this.getCurrentSessionResource()); // Initialize lock state when rendering with a pre-selected session provider (e.g., welcome view restore) const delegate = this.options.sessionTypePickerDelegate; From dfc33a28d620ad826b8c9cf5f7b3e3c2d787428e Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:39:14 -0700 Subject: [PATCH 10/31] Also remove effective sessionType from return value This is not related to what the method says it does --- .../browser/widget/input/chatInputPart.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 520aa28e4ac28..050663abe7dab 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -853,12 +853,18 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._lastSessionPickerAction = action; this._lastSessionPickerOptions = pickerOptions; - const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(this.getCurrentSessionResource()); + const sessionResource = this.getCurrentSessionResource(); + const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); if (!result) { return []; } - const { visibleOptionGroups, effectiveSessionType } = result; + const effectiveSessionType = this.getEffectiveSessionType(sessionResource); + if (!effectiveSessionType) { + return []; + } + + const { visibleOptionGroups } = result; this.chatSessionPickerWidgets.clearAndDisposeAll(); const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; @@ -1600,7 +1606,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): { visibleOptionGroups: IChatSessionProviderOptionGroup[]; allOptionGroups: IChatSessionProviderOptionGroup[]; - effectiveSessionType: string; } | undefined { if (sessionResource) { this.updateStateAndChatModeForSessionCustomAgentTarget(sessionResource); @@ -1642,7 +1647,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private getVisibleOptionGroups(sessionResource: URI | undefined): { visibleOptionGroups: IChatSessionProviderOptionGroup[]; allOptionGroups: IChatSessionProviderOptionGroup[]; - effectiveSessionType: string; } | undefined { // Step 1: Determine the session type @@ -1694,7 +1698,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return { allOptionGroups, visibleOptionGroups: Array.from(visibleGroups.values()), - effectiveSessionType }; } @@ -1807,8 +1810,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return; } - const effectiveSessionType = this.getEffectiveSessionType(sessionResource, this.options.sessionTypePickerDelegate); - const optionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); + const effectiveSessionType = this.getEffectiveSessionType(sessionResource); + const optionGroups = effectiveSessionType ? this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType) : undefined; const optionGroup = optionGroups?.find(g => g.id === optionGroupId); if (!optionGroup || optionGroup.items.length === 0) { return; @@ -1842,8 +1845,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return false; } - private getEffectiveSessionType(sessionResource: URI | undefined, delegate: ISessionTypePickerDelegate | undefined): string { - return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() || (sessionResource && getChatSessionType(sessionResource)) || ''; + private getEffectiveSessionType(sessionResource: URI | undefined): string | undefined { + return this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.() ?? (sessionResource ? getChatSessionType(sessionResource) : undefined); } /** From f5d73a7fccde2dbf63d13c7e258a9f5a971e73d6 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:46:47 -0700 Subject: [PATCH 11/31] Extract out getAllOptionsGroups This part also isn't directly related to the original method so should be split out. I'm actually thinking that we may be able to remove it entirely in `refreshChatSessionPickers` since the widgets should only exist for visible options groups, but I don't want to make that change just yet --- .../browser/widget/input/chatInputPart.ts | 49 +++++++++---------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 050663abe7dab..402183c7509c6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -854,8 +854,8 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._lastSessionPickerOptions = pickerOptions; const sessionResource = this.getCurrentSessionResource(); - const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); - if (!result) { + const visibleOptionGroups = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); + if (!visibleOptionGroups) { return []; } @@ -864,7 +864,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return []; } - const { visibleOptionGroups } = result; this.chatSessionPickerWidgets.clearAndDisposeAll(); const widgets: (ChatSessionPickerActionItem | SearchableOptionPickerActionItem)[] = []; @@ -1603,27 +1602,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * @returns The result containing visible group IDs and related context, or undefined * if there are no visible option groups */ - private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): { - visibleOptionGroups: IChatSessionProviderOptionGroup[]; - allOptionGroups: IChatSessionProviderOptionGroup[]; - } | undefined { + private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] | undefined { if (sessionResource) { this.updateStateAndChatModeForSessionCustomAgentTarget(sessionResource); } - const result = this.getVisibleOptionGroups(sessionResource); - if (!result) { + const visibleOptionGroups = this.getVisibleOptionGroups(sessionResource); + if (!visibleOptionGroups) { this.chatSessionHasOptions.set(false); this.chatSessionOptionsValid.set(true); return undefined; } - const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, result.visibleOptionGroups) : true; + const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, visibleOptionGroups) : true; this.chatSessionHasOptions.set(true); this.chatSessionOptionsValid.set(allOptionsValid); - return result; + return visibleOptionGroups; } private getCurrentSessionResource() { @@ -1644,12 +1640,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return true; } - private getVisibleOptionGroups(sessionResource: URI | undefined): { - visibleOptionGroups: IChatSessionProviderOptionGroup[]; - allOptionGroups: IChatSessionProviderOptionGroup[]; - } | undefined { - - // Step 1: Determine the session type + private getAllOptionsGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] | undefined { // - Panel/Editor: Use actual session's type (ctx available) // - Welcome view: Use delegate's type (ctx may not exist yet) const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); @@ -1663,6 +1654,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge if (!allOptionGroups || allOptionGroups.length === 0) { return undefined; } + return allOptionGroups; + } + + private getVisibleOptionGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] | undefined { + const allOptionGroups = this.getAllOptionsGroups(sessionResource); + if (!allOptionGroups) { + return undefined; + } // Update context keys with current option values before evaluating `when` clauses. // This ensures interdependent `when` expressions work correctly. @@ -1676,7 +1675,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - // Step 3: Filter to visible groups (has items AND passes `when` clause AND session has option configured) + // Filter to visible groups (has items AND passes `when` clause AND session has option configured) const visibleGroups = new Map(); for (const optionGroup of allOptionGroups) { const hasItems = optionGroup.items.length > 0 || (optionGroup.commands || []).length > 0; @@ -1695,10 +1694,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return undefined; } - return { - allOptionGroups, - visibleOptionGroups: Array.from(visibleGroups.values()), - }; + return Array.from(visibleGroups.values()); } private updateStateAndChatModeForSessionCustomAgentTarget(sessionResource: URI) { @@ -1735,15 +1731,14 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge private refreshChatSessionPickers(): void { // Use the shared helper to compute visibility and update context keys const sessionResource = this.getCurrentSessionResource(); - const result = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); - - if (!result) { + const allOptionsGroups = this.getAllOptionsGroups(sessionResource); + const visibleOptionGroups = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); + if (!allOptionsGroups || !visibleOptionGroups) { // No visible options - helper already updated context keys this.hideAllSessionPickerWidgets(); return; } - const { allOptionGroups, visibleOptionGroups } = result; // Check if widgets need recreation (different set of visible groups) const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); @@ -1771,7 +1766,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge for (const [optionGroupId] of this.chatSessionPickerWidgets) { const currentOption = this.chatSessionsService.getSessionOption(sessionResource, optionGroupId); if (currentOption) { - const optionGroup = allOptionGroups.find(g => g.id === optionGroupId); + const optionGroup = allOptionsGroups.find(g => g.id === optionGroupId); if (optionGroup) { const currentOptionId = typeof currentOption === 'string' ? currentOption : currentOption.id; const item = optionGroup.items.find((m: IChatSessionProviderOptionItem) => m.id === currentOptionId); From 50e7aa7545ca8ea9eaa8b65f8c323faa4284cc39 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 12:57:42 -0700 Subject: [PATCH 12/31] Reduce usage of `T[] | undefined` I'm not a fan of types like this unless there's a clear meaning of `undefined` values. In this case it's just being used to signal that there are no options groups That makes the code fragile because both the callers and implementers have to agree that the method will not return an empty array. It's likely that either the caller will do extra checks for both `!undefined && !arr.length` or that we will accidentally break this contract in the implementation --- .../browser/widget/input/chatInputPart.ts | 33 +++++++------------ 1 file changed, 11 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 402183c7509c6..9bd545320bf04 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -855,7 +855,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this.getCurrentSessionResource(); const visibleOptionGroups = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); - if (!visibleOptionGroups) { + if (!visibleOptionGroups.length) { return []; } @@ -1598,20 +1598,17 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * * This method also updates the `chatSessionHasOptions` context key, which controls * whether the picker action is shown in the toolbar via its `when` clause. - * - * @returns The result containing visible group IDs and related context, or undefined - * if there are no visible option groups */ - private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] | undefined { + private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { if (sessionResource) { this.updateStateAndChatModeForSessionCustomAgentTarget(sessionResource); } const visibleOptionGroups = this.getVisibleOptionGroups(sessionResource); - if (!visibleOptionGroups) { + if (!visibleOptionGroups.length) { this.chatSessionHasOptions.set(false); this.chatSessionOptionsValid.set(true); - return undefined; + return []; } const allOptionsValid = sessionResource ? this.areAllOptionsValid(sessionResource, visibleOptionGroups) : true; @@ -1640,27 +1637,24 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return true; } - private getAllOptionsGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] | undefined { + private getAllOptionsGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { // - Panel/Editor: Use actual session's type (ctx available) // - Welcome view: Use delegate's type (ctx may not exist yet) const delegateSessionType = this.options.sessionTypePickerDelegate?.getActiveSessionProvider?.(); const effectiveSessionType = delegateSessionType ?? (sessionResource ? getChatSessionType(sessionResource) : undefined); if (!effectiveSessionType) { - return undefined; + return []; } // Step 2: Get option groups for this session type const allOptionGroups = this.chatSessionsService.getOptionGroupsForSessionType(effectiveSessionType); - if (!allOptionGroups || allOptionGroups.length === 0) { - return undefined; - } - return allOptionGroups; + return allOptionGroups ?? []; } - private getVisibleOptionGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] | undefined { + private getVisibleOptionGroups(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { const allOptionGroups = this.getAllOptionsGroups(sessionResource); - if (!allOptionGroups) { - return undefined; + if (!allOptionGroups.length) { + return []; } // Update context keys with current option values before evaluating `when` clauses. @@ -1690,10 +1684,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge } } - if (visibleGroups.size === 0) { - return undefined; - } - return Array.from(visibleGroups.values()); } @@ -1733,13 +1723,12 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge const sessionResource = this.getCurrentSessionResource(); const allOptionsGroups = this.getAllOptionsGroups(sessionResource); const visibleOptionGroups = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); - if (!allOptionsGroups || !visibleOptionGroups) { + if (!allOptionsGroups.length || !visibleOptionGroups.length) { // No visible options - helper already updated context keys this.hideAllSessionPickerWidgets(); return; } - // Check if widgets need recreation (different set of visible groups) const currentWidgetGroupIds = new Set(this.chatSessionPickerWidgets.keys()); const needsRecreation = From f8577c8687be1b07b92458bdb508e5dbe358426d Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 1 Apr 2026 22:30:45 -0700 Subject: [PATCH 13/31] Remove `getDynamicStyleSheetRules` We should just be able to use the value directly now --- src/vs/base/browser/domStylesheets.ts | 21 ++------------------- 1 file changed, 2 insertions(+), 19 deletions(-) diff --git a/src/vs/base/browser/domStylesheets.ts b/src/vs/base/browser/domStylesheets.ts index c3217441dc4ca..72fa42df1a6a5 100644 --- a/src/vs/base/browser/domStylesheets.ts +++ b/src/vs/base/browser/domStylesheets.ts @@ -89,7 +89,7 @@ function cloneGlobalStyleSheet(globalStylesheet: HTMLStyleElement, globalStylesh targetWindow.document.head.appendChild(clone); disposables.add(toDisposable(() => clone.remove())); - for (const rule of getDynamicStyleSheetRules(globalStylesheet)) { + for (const rule of globalStylesheet.sheet?.cssRules ?? []) { clone.sheet?.insertRule(rule.cssText, clone.sheet?.cssRules.length); } @@ -111,23 +111,6 @@ function getSharedStyleSheet(): HTMLStyleElement { return _sharedStyleSheet; } -function getDynamicStyleSheetRules(style: HTMLStyleElement): CSSRuleList { - if (style.sheet) { - return style.sheet.cssRules; - } - - const emptyRules: CSSRule[] = []; - - return { - length: 0, - item: () => null, - [Symbol.iterator]: () => emptyRules.values() - }; -} - - - - export function createCSSRule(selector: string, cssText: string, style = getSharedStyleSheet()): void { if (!style || !cssText) { return; @@ -146,7 +129,7 @@ export function removeCSSRulesContainingSelector(ruleName: string, style = getSh return; } - const rules = getDynamicStyleSheetRules(style); + const rules = style.sheet?.cssRules ?? []; const toDelete: number[] = []; for (let i = 0; i < rules.length; i++) { const rule = rules[i]; From 19f386d55b451ab63d1cf59bb3f699aeb6e07ccc Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Thu, 2 Apr 2026 00:55:55 -0700 Subject: [PATCH 14/31] Fix merge issue Some of the ugly chat mode stuff was removed after I did this refactoring. The merge I made incorrectly restored that code --- .../browser/widget/input/chatInputPart.ts | 46 +++++-------------- 1 file changed, 11 insertions(+), 35 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts index 05f31b2733a47..ff1191098096d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/input/chatInputPart.ts @@ -587,7 +587,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Listen for session type changes from the welcome page delegate if (this.options.sessionTypePickerDelegate?.onDidChangeActiveSessionProvider) { this._register(this.options.sessionTypePickerDelegate.onDidChangeActiveSessionProvider(async (newSessionType) => { - this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(this.getCurrentSessionResource()); + this.getVisibleOptionGroupsModeAndUpdateContextKeys(this.getCurrentSessionResource()); this.agentSessionTypeKey.set(newSessionType); this.chatSessionSupportsDelegationKey.set(this.chatSessionsService.supportsDelegationForSessionType(newSessionType)); this.updateWidgetLockStateFromSessionType(newSessionType); @@ -831,7 +831,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge this._lastSessionPickerOptions = pickerOptions; const sessionResource = this.getCurrentSessionResource(); - const visibleOptionGroups = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); + const visibleOptionGroups = this.getVisibleOptionGroupsModeAndUpdateContextKeys(sessionResource); if (!visibleOptionGroups.length) { return []; } @@ -1576,10 +1576,13 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge * This method also updates the `chatSessionHasOptions` context key, which controls * whether the picker action is shown in the toolbar via its `when` clause. */ - private getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { - if (sessionResource) { - this.updateStateAndChatModeForSessionCustomAgentTarget(sessionResource); - } + private getVisibleOptionGroupsModeAndUpdateContextKeys(sessionResource: URI | undefined): IChatSessionProviderOptionGroup[] { + const customAgentTarget = sessionResource && this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)); + this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); + + // Check if this session type requires custom models + const requiresCustomModels = sessionResource && this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(sessionResource)); + this.chatSessionHasTargetedModels.set(!!requiresCustomModels); const visibleOptionGroups = this.getVisibleOptionGroups(sessionResource); if (!visibleOptionGroups.length) { @@ -1664,33 +1667,6 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge return Array.from(visibleGroups.values()); } - private updateStateAndChatModeForSessionCustomAgentTarget(sessionResource: URI) { - const customAgentTarget = this.chatSessionsService.getCustomAgentTargetForSessionType(getChatSessionType(sessionResource)); - this.chatSessionHasCustomAgentTarget.set(customAgentTarget !== Target.Undefined); - - // Check if this session type requires custom models - const requiresCustomModels = this.chatSessionsService.requiresCustomModelsForSessionType(getChatSessionType(sessionResource)); - this.chatSessionHasTargetedModels.set(!!requiresCustomModels); - - // Handle agent option from session - set initial mode - if (customAgentTarget) { - const contribution = this.chatSessionsService.getChatSessionContribution(getChatSessionType(sessionResource)); - const agentOption = this.chatSessionsService.getSessionOption(sessionResource, agentOptionId); - if (typeof agentOption !== 'undefined' && !contribution?.useRequestToPopulateBuiltInPickers) { - const agentId = (typeof agentOption === 'string' ? agentOption : agentOption.id) || ChatMode.Agent.id; - const currentMode = this._currentModeObservable.get(); - const isDefaultAgent = agentId === ChatMode.Agent.id; - const needsUpdate = isDefaultAgent - ? currentMode.id !== ChatMode.Agent.id - : currentMode.label.get() !== agentId; // Extensions use Label (name) as identifier for custom agents. - - if (needsUpdate) { - this.setChatMode(agentId); - } - } - } - } - /** * Refresh all registered option groups for the current chat session. * Fires events for each option group with their current selection. @@ -1699,7 +1675,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge // Use the shared helper to compute visibility and update context keys const sessionResource = this.getCurrentSessionResource(); const allOptionsGroups = this.getAllOptionsGroups(sessionResource); - const visibleOptionGroups = this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(sessionResource); + const visibleOptionGroups = this.getVisibleOptionGroupsModeAndUpdateContextKeys(sessionResource); if (!allOptionsGroups.length || !visibleOptionGroups.length) { // No visible options - helper already updated context keys this.hideAllSessionPickerWidgets(); @@ -1906,7 +1882,7 @@ export class ChatInputPart extends Disposable implements IHistoryNavigationWidge render(container: HTMLElement, initialValue: string, widget: IChatWidget) { this._widget = widget; - this.getVisibleOptionGroupsAndSetChatModeAndUpdateContextKeys(this.getCurrentSessionResource()); + this.getVisibleOptionGroupsModeAndUpdateContextKeys(this.getCurrentSessionResource()); // Initialize lock state when rendering with a pre-selected session provider (e.g., welcome view restore) const delegate = this.options.sessionTypePickerDelegate; From 69eb46235615cbdffb974a0f33a76395e180d2a1 Mon Sep 17 00:00:00 2001 From: Raymond Zhao <7199958+rzhao271@users.noreply.github.com> Date: Thu, 2 Apr 2026 09:50:47 -0700 Subject: [PATCH 15/31] chore: run npm audit fix (#307444) * chore: bump lodash * chore: add override --- build/package-lock.json | 6 +- build/vite/package-lock.json | 6 +- .../mermaid-chat-features/package-lock.json | 329 ++++++------------ extensions/mermaid-chat-features/package.json | 3 + package-lock.json | 12 +- remote/package-lock.json | 6 +- 6 files changed, 134 insertions(+), 228 deletions(-) diff --git a/build/package-lock.json b/build/package-lock.json index 1eff16c6b119c..92f3b6a4a3e70 100644 --- a/build/package-lock.json +++ b/build/package-lock.json @@ -4490,9 +4490,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, diff --git a/build/vite/package-lock.json b/build/vite/package-lock.json index 7658f382c9328..70e0339f77fa0 100644 --- a/build/vite/package-lock.json +++ b/build/vite/package-lock.json @@ -1068,9 +1068,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { diff --git a/extensions/mermaid-chat-features/package-lock.json b/extensions/mermaid-chat-features/package-lock.json index 23436bd9f005c..26443f16e1a21 100644 --- a/extensions/mermaid-chat-features/package-lock.json +++ b/extensions/mermaid-chat-features/package-lock.json @@ -33,15 +33,6 @@ "url": "https://github.com/sponsors/antfu" } }, - "node_modules/@antfu/utils": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/@antfu/utils/-/utils-9.2.0.tgz", - "integrity": "sha512-Oq1d9BGZakE/FyoEtcNeSwM7MpDO2vUBi11RWBZXf75zPsbUVWmUs03EqkRFrcgbXyKTas0BdZWC1wcuSoqSAw==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/@braintree/sanitize-url": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz", @@ -49,42 +40,42 @@ "license": "MIT" }, "node_modules/@chevrotain/cst-dts-gen": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.1.tgz", - "integrity": "sha512-fRHyv6/f542qQqiRGalrfJl/evD39mAvbJLCekPazhiextEatq1Jx1K/i9gSd5NNO0ds03ek0Cbo/4uVKmOBcw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.1.2.tgz", + "integrity": "sha512-XTsjvDVB5nDZBQB8o0o/0ozNelQtn2KrUVteIHSlPd2VAV2utEb6JzyCJaJ8tGxACR4RiBNWy5uYUHX2eji88Q==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/gast": "11.1.1", - "@chevrotain/types": "11.1.1", + "@chevrotain/gast": "11.1.2", + "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/gast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.1.tgz", - "integrity": "sha512-Ko/5vPEYy1vn5CbCjjvnSO4U7GgxyGm+dfUZZJIWTlQFkXkyym0jFYrWEU10hyCjrA7rQtiHtBr0EaZqvHFZvg==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/gast/-/gast-11.1.2.tgz", + "integrity": "sha512-Z9zfXR5jNZb1Hlsd/p+4XWeUFugrHirq36bKzPWDSIacV+GPSVXdk+ahVWZTwjhNwofAWg/sZg58fyucKSQx5g==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/types": "11.1.1", + "@chevrotain/types": "11.1.2", "lodash-es": "4.17.23" } }, "node_modules/@chevrotain/regexp-to-ast": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.1.tgz", - "integrity": "sha512-ctRw1OKSXkOrR8VTvOxrQ5USEc4sNrfwXHa1NuTcR7wre4YbjPcKw+82C2uylg/TEwFRgwLmbhlln4qkmDyteg==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.1.2.tgz", + "integrity": "sha512-nMU3Uj8naWer7xpZTYJdxbAs6RIv/dxYzkYU8GSwgUtcAAlzjcPfX1w+RKRcYG8POlzMeayOQ/znfwxEGo5ulw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/types": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.1.tgz", - "integrity": "sha512-wb2ToxG8LkgPYnKe9FH8oGn3TMCBdnwiuNC5l5y+CtlaVRbCytU0kbVsk6CGrqTL4ZN4ksJa0TXOYbxpbthtqw==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/types/-/types-11.1.2.tgz", + "integrity": "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==", "license": "Apache-2.0" }, "node_modules/@chevrotain/utils": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.1.tgz", - "integrity": "sha512-71eTYMzYXYSFPrbg/ZwftSaSDld7UYlS8OQa3lNnn9jzNtpFbaReRRyghzqS7rI3CDaorqpPJJcXGHK+FE1TVQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/@chevrotain/utils/-/utils-11.1.2.tgz", + "integrity": "sha512-4mudFAQ6H+MqBTfqLmU7G1ZwRzCLfJEooL/fsF6rCX5eePMbGhoy5n4g+G4vlh2muDcsCTJtL+uKbOzWxs5LHA==", "license": "Apache-2.0" }, "node_modules/@iconify/types": { @@ -94,37 +85,20 @@ "license": "MIT" }, "node_modules/@iconify/utils": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.0.1.tgz", - "integrity": "sha512-A78CUEnFGX8I/WlILxJCuIJXloL0j/OJ9PSchPAfCargEIKmUBWvvEMmKWB5oONwiUqlNt+5eRufdkLxeHIWYw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@iconify/utils/-/utils-3.1.0.tgz", + "integrity": "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==", "license": "MIT", "dependencies": { "@antfu/install-pkg": "^1.1.0", - "@antfu/utils": "^9.2.0", "@iconify/types": "^2.0.0", - "debug": "^4.4.1", - "globals": "^15.15.0", - "kolorist": "^1.8.0", - "local-pkg": "^1.1.1", - "mlly": "^1.7.4" - } - }, - "node_modules/@iconify/utils/node_modules/globals": { - "version": "15.15.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", - "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "mlly": "^1.8.0" } }, "node_modules/@mermaid-js/parser": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.0.0.tgz", - "integrity": "sha512-vvK0Hi/VWndxoh03Mmz6wa1KDriSPjS2XMZL/1l19HFwygiObEEoEwSDxOqyLzzAI6J2PU3261JjTMTO7x+BPw==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mermaid-js/parser/-/parser-1.1.0.tgz", + "integrity": "sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==", "license": "MIT", "dependencies": { "langium": "^4.0.0" @@ -406,6 +380,16 @@ "license": "MIT", "optional": true }, + "node_modules/@upsetjs/venn.js": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@upsetjs/venn.js/-/venn.js-2.0.0.tgz", + "integrity": "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==", + "license": "MIT", + "optionalDependencies": { + "d3-selection": "^3.0.0", + "d3-transition": "^3.0.1" + } + }, "node_modules/@vscode/codicons": { "version": "0.0.36", "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.36.tgz", @@ -414,9 +398,9 @@ "license": "CC-BY-4.0" }, "node_modules/acorn": { - "version": "8.15.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", - "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -426,16 +410,16 @@ } }, "node_modules/chevrotain": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.1.tgz", - "integrity": "sha512-f0yv5CPKaFxfsPTBzX7vGuim4oIC1/gcS7LUGdBSwl2dU6+FON6LVUksdOo1qJjoUvXNn45urgh8C+0a24pACQ==", + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-11.1.2.tgz", + "integrity": "sha512-opLQzEVriiH1uUQ4Kctsd49bRoFDXGGSC4GUqj7pGyxM3RehRhvTlZJc1FL/Flew2p5uwxa1tUDWKzI4wNM8pg==", "license": "Apache-2.0", "dependencies": { - "@chevrotain/cst-dts-gen": "11.1.1", - "@chevrotain/gast": "11.1.1", - "@chevrotain/regexp-to-ast": "11.1.1", - "@chevrotain/types": "11.1.1", - "@chevrotain/utils": "11.1.1", + "@chevrotain/cst-dts-gen": "11.1.2", + "@chevrotain/gast": "11.1.2", + "@chevrotain/regexp-to-ast": "11.1.2", + "@chevrotain/types": "11.1.2", + "@chevrotain/utils": "11.1.2", "lodash-es": "4.17.23" } }, @@ -452,18 +436,18 @@ } }, "node_modules/commander": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", - "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", "license": "MIT", "engines": { - "node": ">= 12" + "node": ">= 10" } }, "node_modules/confbox": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.2.tgz", - "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", "license": "MIT" }, "node_modules/cose-base": { @@ -693,15 +677,6 @@ "node": ">=12" } }, - "node_modules/d3-dsv/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "license": "MIT", - "engines": { - "node": ">= 10" - } - }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -974,9 +949,9 @@ } }, "node_modules/dagre-d3-es": { - "version": "7.0.13", - "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.13.tgz", - "integrity": "sha512-efEhnxpSuwpYOKRm/L5KbqoZmNNukHa/Flty4Wp62JRvgH2ojwVgPgdYyr4twpieZnyRDdIH7PY2mopX26+j2Q==", + "version": "7.0.14", + "resolved": "https://registry.npmjs.org/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz", + "integrity": "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==", "license": "MIT", "dependencies": { "d3": "^7.9.0", @@ -984,32 +959,15 @@ } }, "node_modules/dayjs": { - "version": "1.11.18", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", - "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, - "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/delaunator": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz", - "integrity": "sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.1.0.tgz", + "integrity": "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==", "license": "ISC", "dependencies": { "robust-predicates": "^3.0.2" @@ -1027,12 +985,6 @@ "@types/trusted-types": "^2.0.7" } }, - "node_modules/exsolve": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.7.tgz", - "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", - "license": "MIT" - }, "node_modules/hachure-fill": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz", @@ -1061,9 +1013,9 @@ } }, "node_modules/katex": { - "version": "0.16.22", - "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.22.tgz", - "integrity": "sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==", + "version": "0.16.44", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.44.tgz", + "integrity": "sha512-EkxoDTk8ufHqHlf9QxGwcxeLkWRR3iOuYfRpfORgYfqc8s13bgb+YtRY59NK5ZpRaCwq1kqA6a5lpX8C/eLphQ==", "funding": [ "https://opencollective.com/katex", "https://github.com/sponsors/katex" @@ -1076,17 +1028,20 @@ "katex": "cli.js" } }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/khroma": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/khroma/-/khroma-2.1.0.tgz", "integrity": "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==" }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "license": "MIT" - }, "node_modules/langium": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/langium/-/langium-4.2.1.tgz", @@ -1110,27 +1065,10 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, - "node_modules/local-pkg": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-1.1.2.tgz", - "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", - "license": "MIT", - "dependencies": { - "mlly": "^1.7.4", - "pkg-types": "^2.3.0", - "quansync": "^0.2.11" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/marked": { @@ -1146,27 +1084,28 @@ } }, "node_modules/mermaid": { - "version": "11.12.3", - "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.12.3.tgz", - "integrity": "sha512-wN5ZSgJQIC+CHJut9xaKWsknLxaFBwCPwPkGTSUYrTiHORWvpT8RxGk849HPnpUAQ+/9BPRqYb80jTpearrHzQ==", + "version": "11.14.0", + "resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz", + "integrity": "sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==", "license": "MIT", "dependencies": { "@braintree/sanitize-url": "^7.1.1", - "@iconify/utils": "^3.0.1", - "@mermaid-js/parser": "^1.0.0", + "@iconify/utils": "^3.0.2", + "@mermaid-js/parser": "^1.1.0", "@types/d3": "^7.4.3", - "cytoscape": "^3.29.3", + "@upsetjs/venn.js": "^2.0.0", + "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", - "dagre-d3-es": "7.0.13", - "dayjs": "^1.11.18", - "dompurify": "^3.2.5", - "katex": "^0.16.22", + "dagre-d3-es": "7.0.14", + "dayjs": "^1.11.19", + "dompurify": "^3.3.1", + "katex": "^0.16.25", "khroma": "^2.1.0", "lodash-es": "^4.17.23", - "marked": "^16.2.1", + "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", @@ -1174,44 +1113,21 @@ } }, "node_modules/mlly": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.0.tgz", - "integrity": "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==", + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", "license": "MIT", "dependencies": { - "acorn": "^8.15.0", + "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", - "ufo": "^1.6.1" + "ufo": "^1.6.3" } }, - "node_modules/mlly/node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "license": "MIT" - }, - "node_modules/mlly/node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/package-manager-detector": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.3.0.tgz", - "integrity": "sha512-ZsEbbZORsyHuO00lY1kV3/t72yp6Ysay6Pd17ZAlNGuGwmWDLCJxFpRs0IzfXfj1o4icJOkUEioexFHzyPurSQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", + "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", "license": "MIT" }, "node_modules/path-data-parser": { @@ -1227,14 +1143,14 @@ "license": "MIT" }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", - "pathe": "^2.0.3" + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" } }, "node_modules/points-on-curve": { @@ -1253,26 +1169,10 @@ "points-on-curve": "0.2.0" } }, - "node_modules/quansync": { - "version": "0.2.11", - "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", - "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", - "funding": [ - { - "type": "individual", - "url": "https://github.com/sponsors/antfu" - }, - { - "type": "individual", - "url": "https://github.com/sponsors/sxzz" - } - ], - "license": "MIT" - }, "node_modules/robust-predicates": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz", - "integrity": "sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", + "integrity": "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==", "license": "Unlicense" }, "node_modules/roughjs": { @@ -1306,10 +1206,13 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.1.tgz", - "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", - "license": "MIT" + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", + "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/ts-dedent": { "version": "2.2.0", @@ -1321,9 +1224,9 @@ } }, "node_modules/ufo": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.1.tgz", - "integrity": "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==", + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, "node_modules/undici-types": { diff --git a/extensions/mermaid-chat-features/package.json b/extensions/mermaid-chat-features/package.json index 68f5271fef668..551dc478460d1 100644 --- a/extensions/mermaid-chat-features/package.json +++ b/extensions/mermaid-chat-features/package.json @@ -132,5 +132,8 @@ "dependencies": { "dompurify": "^3.3.2", "mermaid": "^11.12.3" + }, + "overrides": { + "lodash-es": "4.18.1" } } diff --git a/package-lock.json b/package-lock.json index 94ca551a845c5..a85ff2458bad0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13109,16 +13109,16 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lodash.camelcase": { diff --git a/remote/package-lock.json b/remote/package-lock.json index 39910b1088054..f8aa17123d3fd 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -1220,9 +1220,9 @@ } }, "node_modules/lodash-es": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", - "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", "license": "MIT" }, "node_modules/lru-cache": { From 3151748fe5b0e820422d372020269ebd77ac459d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 2 Apr 2026 17:42:01 +0000 Subject: [PATCH 16/31] fix: guard debugger detach against destroyed WebContents (fixes #306923) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a BrowserView is disposed after its WebContents has already been destroyed (e.g., renderer crash, window close during IPC), calling _electronDebugger.detach() throws "target closed while handling command". The existing try/catch catches it but logService.error() sends it to telemetry. Add an isDestroyed() guard to detachElectronDebugger() so it returns early when the WebContents is already gone — matching the existing pattern in BrowserView.dispose() (line 707). The try/catch and logService.error() remain intact for genuinely unexpected errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../platform/browserView/electron-main/browserViewDebugger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts index ebdbdb3bf3e83..a2379fccf2810 100644 --- a/src/vs/platform/browserView/electron-main/browserViewDebugger.ts +++ b/src/vs/platform/browserView/electron-main/browserViewDebugger.ts @@ -163,7 +163,7 @@ export class BrowserViewDebugger extends Disposable implements ICDPTarget { * Detach from the Electron debugger */ private detachElectronDebugger(): void { - if (!this._electronDebugger.isAttached()) { + if (this.view.webContents.isDestroyed() || !this._electronDebugger.isAttached()) { return; } From a7ef6b23b5d9acaef56d2f970d196e508a5c51b8 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:00:45 -0700 Subject: [PATCH 17/31] Support deferred results from playwright code tool (#307274) * Support deferred results from playwright code tool * feedback * fix --- .../browserView/common/playwrightService.ts | 30 ++++- .../browserView/node/playwrightService.ts | 109 ++++++++++++++---- .../browserView/node/playwrightTab.ts | 16 ++- .../tools/browserToolHelpers.ts | 45 ++++++-- .../tools/runPlaywrightCodeTool.ts | 69 ++++++----- 5 files changed, 206 insertions(+), 63 deletions(-) diff --git a/src/vs/platform/browserView/common/playwrightService.ts b/src/vs/platform/browserView/common/playwrightService.ts index 1615924a5cfe6..8f9c2694c652e 100644 --- a/src/vs/platform/browserView/common/playwrightService.ts +++ b/src/vs/platform/browserView/common/playwrightService.ts @@ -8,6 +8,14 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; export const IPlaywrightService = createDecorator('playwrightService'); +export interface IInvokeFunctionResult { + result?: unknown; + error?: string; + summary: string; + /** When present the function did not complete within the timeout. Pass this ID to {@link IPlaywrightService.waitForDeferredResult} to keep waiting. */ + deferredResultId?: string; +} + /** * A service for using Playwright to connect to and automate the integrated browser. * @@ -74,12 +82,30 @@ export interface IPlaywrightService { /** * Run a function with access to a Playwright page and return a result for tool output, including error handling. * The first function argument is always the Playwright `page` object, and additional arguments can be passed after. + * + * When {@link timeoutMs} is provided, the call races against that timeout. + * If the timeout fires before the function completes, or the function is otherwise interrupted, + * the in-flight promise is stored as a *deferred result* and the returned object includes a + * {@link deferredResultId} that can be passed to {@link waitForDeferredResult} to resume waiting. + * When {@link timeoutMs} is omitted the function runs to completion with no deferral. + * * @param pageId The browser view ID identifying the page to operate on. * @param fnDef The function code to execute. Should contain the function definition but not its invocation, e.g. `async (page, arg1, arg2) => { ... }`. * @param args Additional arguments to pass to the function after the `page` object. - * @returns The result of the function execution, including a page summary. + * @param timeoutMs Maximum time (in ms) to wait for the function to complete before deferring. When omitted the call awaits indefinitely. + * @returns The result of the function execution, including a page summary and optionally a deferredResultId if the call did not complete. + */ + invokeFunction(pageId: string, fnDef: string, args?: unknown[], timeoutMs?: number): Promise; + + /** + * Continue waiting for a previously deferred function invocation. + * + * @param deferredResultId The ID returned from a timed-out {@link invokeFunction} call. + * @param timeoutMs Maximum time (in ms) to wait before returning a deferred result again. + * @returns The same shape as {@link invokeFunction}. If the result is still not + * available after the timeout, {@link deferredResultId} is returned again. */ - invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }>; + waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise; /** * Responds to a file chooser dialog on the given page. diff --git a/src/vs/platform/browserView/node/playwrightService.ts b/src/vs/platform/browserView/node/playwrightService.ts index 70fba85e7d0a7..91f512d00634e 100644 --- a/src/vs/platform/browserView/node/playwrightService.ts +++ b/src/vs/platform/browserView/node/playwrightService.ts @@ -3,15 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Disposable, DisposableStore } from '../../../base/common/lifecycle.js'; -import { DeferredPromise } from '../../../base/common/async.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable } from '../../../base/common/lifecycle.js'; +import { DeferredPromise, disposableTimeout, raceTimeout } from '../../../base/common/async.js'; import { Emitter, Event } from '../../../base/common/event.js'; import { ILogService } from '../../log/common/log.js'; -import { IPlaywrightService } from '../common/playwrightService.js'; +import { IInvokeFunctionResult, IPlaywrightService } from '../common/playwrightService.js'; import { IBrowserViewGroupRemoteService } from '../node/browserViewGroupRemoteService.js'; import { IBrowserViewGroup } from '../common/browserViewGroup.js'; -import { PlaywrightTab } from './playwrightTab.js'; +import { PlaywrightTab, DialogInterruptedError } from './playwrightTab.js'; import { CDPEvent, CDPRequest, CDPResponse } from '../common/cdp/types.js'; +import { generateUuid } from '../../../base/common/uuid.js'; // eslint-disable-next-line local/code-import-patterns import type { Browser, BrowserContext, Page } from 'playwright-core'; @@ -29,6 +30,8 @@ declare module 'playwright-core' { } } +const DEFERRED_RESULT_CLEANUP_MS = 5 * 60_000; // 5 minutes + /** * Shared-process implementation of {@link IPlaywrightService}. * @@ -45,6 +48,12 @@ export class PlaywrightService extends Disposable implements IPlaywrightService private _browser: Browser | undefined; private _initPromise: Promise | undefined; + /** In-flight deferred results keyed by their generated ID. */ + private readonly _deferredResults = this._register(new DisposableMap; + } & IDisposable>()); + constructor( private readonly windowId: number, private readonly browserViewGroupRemoteService: IBrowserViewGroupRemoteService, @@ -157,29 +166,87 @@ export class PlaywrightService extends Disposable implements IPlaywrightService return this._pages.runAgainstPage(pageId, (page) => fn(page, args)); } - async invokeFunction(pageId: string, fnDef: string, ...args: unknown[]): Promise<{ result: unknown; summary: string }> { + private async invokeFunctionWithDeferral(pageId: string, fnDef: string, args: unknown[], timeoutMs: number): Promise { + await this.initialize(); + + const vm = await import('vm'); + const fn = vm.compileFunction(`return (${fnDef})(page, ...args)`, ['page', 'args'], { parsingContext: vm.createContext() }); + + return this._runWithDeferral(pageId, (page) => fn(page, args ?? []), timeoutMs); + } + + async invokeFunction(pageId: string, fnDef: string, args: unknown[] = [], timeoutMs?: number): Promise { this.logService.info(`[PlaywrightService] Invoking function on view ${pageId}`); + if (timeoutMs !== undefined) { + return this.invokeFunctionWithDeferral(pageId, fnDef, args, timeoutMs); + } + + let result, error; try { - let result; - try { - result = await this.invokeFunctionRaw(pageId, fnDef, ...args); - } catch (err: unknown) { - result = err instanceof Error ? err.message : String(err); - } + result = await this.invokeFunctionRaw(pageId, fnDef, ...args); + } catch (err: unknown) { + error = err instanceof Error ? err.message : String(err); + } - let summary; - try { - summary = await this._pages.getSummary(pageId); - } catch (err: unknown) { - summary = err instanceof Error ? err.message : String(err); - } - return { result, summary }; + const summary = await this._pages.getSummary(pageId); + + return { result, error, summary }; + } + + async waitForDeferredResult(deferredResultId: string, timeoutMs: number): Promise { + const entry = this._deferredResults.get(deferredResultId); + if (!entry) { + throw new Error(`No deferred result found with ID "${deferredResultId}". It may have been cleaned up or already consumed.`); + } + + const { pageId, promise } = entry; + // Remove eagerly — _runWithDeferral will re-insert if interrupted again. + this._deferredResults.deleteAndDispose(deferredResultId); + + // The callback ignores the page param since execution is already in-flight. + return this._runWithDeferral(pageId, () => promise, timeoutMs, deferredResultId); + } + + /** + * Run a callback against a page with deferred result support. + */ + private async _runWithDeferral(pageId: string, callback: (page: Page) => Promise, timeoutMs: number, existingDeferredId?: string): Promise { + const effectiveTimeout = timeoutMs; + + // Start execution via safeRunAgainstPage, but capture the raw promise + // independently so it can be deferred if a dialog or timeout interrupts. + const deferred = new DeferredPromise(); + const wrappedPromise = this._pages.runAgainstPage(pageId, async (page) => { + const promise = callback(page); + promise.catch(() => { /* prevent unhandled rejection if deferred */ }); + deferred.settleWith(promise); + return promise; + }); + + let result, error; + let interrupted = false; + + try { + result = await raceTimeout(wrappedPromise, effectiveTimeout, () => { interrupted = true; }); } catch (err: unknown) { - const errorMessage = err instanceof Error ? err.message : String(err); - this.logService.error('[PlaywrightService] Script execution failed:', errorMessage); - throw err; + if (err instanceof DialogInterruptedError) { + interrupted = true; + } + error = err instanceof Error ? err.message : String(err); } + + let deferredResultId: string | undefined; + if (interrupted) { + deferredResultId = existingDeferredId ?? generateUuid(); + const cleanup = disposableTimeout(() => this._deferredResults.deleteAndDispose(deferredResultId!), DEFERRED_RESULT_CLEANUP_MS); + this._deferredResults.set(deferredResultId, { pageId, promise: deferred.p, dispose: () => cleanup.dispose() }); + + this.logService.info(`[PlaywrightService] Execution interrupted, deferred as ${deferredResultId}`); + } + + const summary = await this._pages.getSummary(pageId); + return { result, error, summary, deferredResultId }; } async replyToFileChooser(pageId: string, files: string[]): Promise<{ summary: string }> { diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 0a73676455fe1..52aff1089975b 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -16,6 +16,18 @@ declare module 'playwright-core' { } } +/** + * Thrown when a dialog (alert, confirm, prompt) opens while a page action is + * running. The caller should defer the underlying promise and let the agent + * handle the dialog before retrying. + */ +export class DialogInterruptedError extends Error { + constructor() { + super('Action was interrupted by a dialog'); + this.name = 'DialogInterruptedError'; + } +} + /** * Wrapper around a Playwright page that tracks additional state like active dialogs and recent console messages, * and can produce a summary of the page's current state for use in tools. @@ -152,7 +164,7 @@ export class PlaywrightTab { return raceCancellablePromises([dialogOpened, actionCompleted]).then(() => { if (!actionDidComplete) { // A dialog was opened before the action completed. Note we don't cancel the action, just ignore its result. - throw new Error('Action was interrupted by a dialog'); + throw new DialogInterruptedError(); } return result!; }); @@ -185,7 +197,7 @@ export class PlaywrightTab { `Recent events:`, ...logs.map(log => `- [${new Date(log.time).toISOString()}] (${log.type}) ${log.description}`) ] : []), - ...(snapshot ? ['Snapshot:', snapshot] : []) + `Snapshot: ${snapshotFromPage ? snapshot ? `\n${snapshot}` : '' : ''}`, ].join('\n'); } diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts index f6568c66bf197..d5eeb3e8af342 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/browserToolHelpers.ts @@ -7,7 +7,7 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize } from '../../../../../nls.js'; import { BrowserViewUri } from '../../../../../platform/browserView/common/browserViewUri.js'; -import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; +import { IInvokeFunctionResult, IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { IEditorService } from '../../../../services/editor/common/editorService.js'; import { IToolResult } from '../../../chat/common/tools/languageModelToolsService.js'; import { BrowserEditorInput } from '../../common/browserEditorInput.js'; @@ -42,6 +42,9 @@ export async function playwrightInvokeRaw( /** * Shared helper for running a Playwright function against a page and returning * a tool result. Handles success/error formatting. + * + * Calls {@link IPlaywrightService.invokeFunction} without a timeout so the + * action runs to completion — no deferred results are ever produced. */ export async function playwrightInvoke( playwrightService: IPlaywrightService, @@ -50,18 +53,44 @@ export async function playwrightInvoke( ...args: TArgs ): Promise { try { - const result = await playwrightService.invokeFunction(pageId, fn.toString(), ...args); - return { - content: [ - { kind: 'text', value: result.result ? JSON.stringify(result.result) : 'Script executed successfully' }, - { kind: 'text', value: result.summary } - ] - }; + const result = await playwrightService.invokeFunction(pageId, fn.toString(), args); + return invokeFunctionResultToToolResult(result); } catch (e) { return errorResult(e instanceof Error ? e.message : String(e)); } } +/** + * Convert an {@link IInvokeFunctionResult} to an {@link IToolResult}, + * including any {@link IInvokeFunctionResult.deferredResultId}. + */ +export function invokeFunctionResultToToolResult(result: IInvokeFunctionResult, code?: string): IToolResult { + const content: IToolResult['content'] = []; + if (result.result !== undefined) { + content.push({ kind: 'text', value: `Result: ${JSON.stringify(result.result)}` }); + } + if (result.error) { + content.push({ kind: 'text', value: result.error }); + } + if (result.deferredResultId) { + content.push({ kind: 'text', value: `[deferredResultId=${result.deferredResultId}] The code has not finished executing yet. Call run_playwright_code again with this deferredResultId and the same pageId (no code) to continue waiting.` }); + } + content.push({ kind: 'text', value: result.summary }); + return { + content, + ...(code ? { + toolResultDetails: { + input: code, + inputLanguage: 'javascript', + output: result.result || result.error + ? [{ type: 'embed' as const, isText: true, value: JSON.stringify(result.result ?? result.error, null, 2) }] + : [], + isError: !!result.error, + }, + } : {}), + }; +} + export function errorResult(message: string): IToolResult { return { content: [{ kind: 'text', value: message }], diff --git a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts index e07efe6265d93..dcea1e03405f6 100644 --- a/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts +++ b/src/vs/workbench/contrib/browserView/electron-browser/tools/runPlaywrightCodeTool.ts @@ -9,7 +9,7 @@ import { MarkdownString } from '../../../../../base/common/htmlContent.js'; import { localize } from '../../../../../nls.js'; import { IPlaywrightService } from '../../../../../platform/browserView/common/playwrightService.js'; import { ToolDataSource, type CountTokensCallback, type IPreparedToolInvocation, type IToolData, type IToolImpl, type IToolInvocation, type IToolInvocationPreparationContext, type IToolResult, type ToolProgress } from '../../../chat/common/tools/languageModelToolsService.js'; -import { errorResult } from './browserToolHelpers.js'; +import { errorResult, invokeFunctionResultToToolResult } from './browserToolHelpers.js'; import { OpenPageToolId } from './openBrowserTool.js'; export const RunPlaywrightCodeToolData: IToolData = { @@ -29,16 +29,30 @@ export const RunPlaywrightCodeToolData: IToolData = { }, code: { type: 'string', - description: `The Playwright code to execute. The code must be concise, serve one clear purpose, and be self-contained. You **must not** directly access \`document\` or \`window\` using this tool. You must access it via the provided \`page\` object, e.g. "return page.evaluate(() => document.title)".` + description: `The Playwright code to execute. The code must be concise, serve one clear purpose, and be self-contained. You **must not** directly access \`document\` or \`window\` using this tool. You must access it via the provided \`page\` object, e.g. "return page.evaluate(() => document.title)". Omit this when resuming a deferred execution via deferredResultId.` + }, + deferredResultId: { + type: 'string', + description: `If a previous call returned a deferredResultId, pass it here to continue waiting for that execution to complete.` + }, + timeoutMs: { + type: 'number', + description: `Maximum time in milliseconds to wait for the code to complete. Defaults to 5000 (5 seconds).` }, }, - required: ['pageId', 'code'], + required: ['pageId'], + oneOf: [ + { required: ['code'] }, + { required: ['deferredResultId'] }, + ] }, }; interface IRunPlaywrightCodeToolParams { pageId: string; - code: string; + code?: string; + deferredResultId?: string; + timeoutMs?: number; } export class RunPlaywrightCodeTool implements IToolImpl { @@ -48,6 +62,14 @@ export class RunPlaywrightCodeTool implements IToolImpl { async prepareToolInvocation(context: IToolInvocationPreparationContext, _token: CancellationToken): Promise { const params = context.parameters as IRunPlaywrightCodeToolParams; + + if (params.deferredResultId) { + return { + invocationMessage: new MarkdownString(localize('browser.runCode.waitInvocation', "Waiting for Playwright code to complete...")), + pastTenseMessage: new MarkdownString(localize('browser.runCode.waitPast', "Waited for Playwright code")), + }; + } + const code = params.code ?? ''; return { invocationMessage: new MarkdownString(localize('browser.runCode.invocation', "Running Playwright code...")), @@ -68,41 +90,28 @@ export class RunPlaywrightCodeTool implements IToolImpl { return errorResult(`No page ID provided. Use '${OpenPageToolId}' first.`); } + // Resume waiting for a deferred execution + if (params.deferredResultId) { + try { + const result = await this.playwrightService.waitForDeferredResult(params.deferredResultId, params.timeoutMs ?? 5_000); + return invokeFunctionResultToToolResult(result); + } catch (e) { + return errorResult(e instanceof Error ? e.message : String(e)); + } + } + if (!params.code) { - return errorResult('The "code" parameter is required.'); + return errorResult('Either "code" or "deferredResultId" must be provided.'); } let result; try { - result = await this.playwrightService.invokeFunction(params.pageId, `async (page) => { ${params.code} }`); + result = await this.playwrightService.invokeFunction(params.pageId, `async (page) => { ${params.code} }`, undefined, params.timeoutMs ?? 5_000); } catch (e) { const message = e instanceof Error ? e.message : String(e); return errorResult(`Code execution failed: ${message}`); } - const json = JSON.stringify(result.result || null); - - let outputMessage; - if (result.result) { - outputMessage = new MarkdownString(); - outputMessage.appendMarkdown(localize('browser.runCode.outputLabel', 'Output:')); - outputMessage.appendText('\n'); - outputMessage.appendCodeblock('json', json); - } - - return { - content: [ - { kind: 'text', value: result.result ? json : 'Code executed successfully' }, - { kind: 'text', value: result.summary } - ], - toolResultDetails: { - input: params.code.trim(), - inputLanguage: 'javascript', - output: result.result - ? [{ type: 'embed', isText: true, value: JSON.stringify(result.result, null, 2) }] - : [], - isError: false, - }, - }; + return invokeFunctionResultToToolResult(result, params.code.trim()); } } From aca234e39671565188b22a4595cb3a499e06fd94 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:04:48 -0400 Subject: [PATCH 18/31] sessions: update icon in left sidebar visibility toggle button (#306878) * sessions: use sidebar visibility codicons for toggle Swap the agent sessions sidebar toggle from the tasklist icon to the layout-sidebar-left variants so the open and closed states are represented explicitly. Keep the unread badge behavior on the custom title bar action when the sidebar is hidden. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: sync sidebar toggle icon with layout visibility Drive the custom title bar toggle icon from the layout service visibility state instead of the menu action checked state so it stays current when the sidebar visibility changes in place. Keep refreshing the unread badge from the same visibility-aware autorun. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/layoutActions.ts | 6 ++++-- .../contrib/sessions/browser/sessionsTitleBarWidget.ts | 8 ++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/src/vs/sessions/browser/layoutActions.ts b/src/vs/sessions/browser/layoutActions.ts index bd8d5bbc1c104..a15c7e5e49619 100644 --- a/src/vs/sessions/browser/layoutActions.ts +++ b/src/vs/sessions/browser/layoutActions.ts @@ -18,7 +18,8 @@ import { IWorkbenchLayoutService, Parts } from '../../workbench/services/layout/ // Register Icons const panelCloseIcon = registerIcon('agent-panel-close', Codicon.close, localize('agentPanelCloseIcon', "Icon to close the panel.")); -const sidebarToggleIcon = registerIcon('agent-sidebar-toggle', Codicon.tasklist, localize('agentSidebarToggleIcon', "Icon to toggle the sessions sidebar.")); +const sidebarToggleClosedIcon = registerIcon('agent-sidebar-toggle-closed', Codicon.layoutSidebarLeftOff, localize('agentSidebarToggleClosedIcon', "Icon for the sessions sidebar when closed.")); +const sidebarToggleOpenIcon = registerIcon('agent-sidebar-toggle-open', Codicon.layoutSidebarLeft, localize('agentSidebarToggleOpenIcon', "Icon for the sessions sidebar when open.")); class ToggleSidebarVisibilityAction extends Action2 { @@ -28,9 +29,10 @@ class ToggleSidebarVisibilityAction extends Action2 { super({ id: ToggleSidebarVisibilityAction.ID, title: localize2('toggleSidebar', 'Toggle Primary Side Bar Visibility'), - icon: sidebarToggleIcon, + icon: sidebarToggleClosedIcon, toggled: { condition: SideBarVisibleContext, + icon: sidebarToggleOpenIcon, }, metadata: { description: localize('openAndCloseSidebar', 'Open/Show and Close/Hide Sidebar'), diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index dfdb4aa6d4b9e..6818eef1bb8e2 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -24,6 +24,7 @@ import { IActionViewItemService } from '../../../../platform/actions/browser/act import { ISessionsManagementService } from './sessionsManagementService.js'; import { autorun, observableSignalFromEvent } from '../../../../base/common/observable.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; +import { Codicon } from '../../../../base/common/codicons.js'; import { IsAuxiliaryWindowContext } from '../../../../workbench/common/contextkeys.js'; import { ChatSessionProviderIdContext, IsNewChatSessionContext, SessionsWelcomeVisibleContext } from '../../../common/contextkeys.js'; import { ISessionsProvidersService } from './sessionsProvidersService.js'; @@ -333,10 +334,17 @@ class SidebarToggleActionViewItem extends ActionViewItem { session.status.read(reader); session.isRead.read(reader); } + this.updateClass(); this._updateBadge(); })); } + protected override getClass(): string | undefined { + return this.layoutService.isVisible(Parts.SIDEBAR_PART) + ? ThemeIcon.asClassName(Codicon.layoutSidebarLeft) + : ThemeIcon.asClassName(Codicon.layoutSidebarLeftOff); + } + private _updateBadge(): void { if (!this._countBadge) { return; From 449cb2b19b0f8185f3db5d1f236c60d49c1c867b Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Thu, 2 Apr 2026 20:07:03 +0200 Subject: [PATCH 19/31] [json] Unnecessary log when request canceled (#307443) --- extensions/json-language-features/server/src/utils/runner.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/extensions/json-language-features/server/src/utils/runner.ts b/extensions/json-language-features/server/src/utils/runner.ts index f7762f6ff31a0..5d4e541c6bd45 100644 --- a/extensions/json-language-features/server/src/utils/runner.ts +++ b/extensions/json-language-features/server/src/utils/runner.ts @@ -65,6 +65,5 @@ export function runSafe(runtime: RuntimeEnvironment, func: () => T, errorV } function cancelValue() { - console.log('cancelled'); return new ResponseError(LSPErrorCodes.RequestCancelled, 'Request cancelled'); } From e1e2920188af64bc157cd83d3105a0c6e3431fce Mon Sep 17 00:00:00 2001 From: Yogeshwaran C <84272111+yogeshwaran-c@users.noreply.github.com> Date: Thu, 2 Apr 2026 23:43:12 +0530 Subject: [PATCH 20/31] fix: include additional toggles in find input arrow key navigation (#306559) --- src/vs/base/browser/ui/findinput/findInput.ts | 19 ++++++++++++++++++- .../contrib/find/notebookFindReplaceWidget.ts | 6 ++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/vs/base/browser/ui/findinput/findInput.ts b/src/vs/base/browser/ui/findinput/findInput.ts index 80f9fea1f8792..aefd0b69b1ef4 100644 --- a/src/vs/base/browser/ui/findinput/findInput.ts +++ b/src/vs/base/browser/ui/findinput/findInput.ts @@ -174,9 +174,9 @@ export class FindInput extends Widget { })); // Arrow-Key support to navigate between options - const indexes = [this.caseSensitive.domNode, this.wholeWords.domNode, this.regex.domNode]; this.onkeydown(this.domNode, (event: IKeyboardEvent) => { if (event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.RightArrow) || event.equals(KeyCode.Escape)) { + const indexes = this.getToggleDomNodes(); const index = indexes.indexOf(this.domNode.ownerDocument.activeElement); if (index >= 0) { let newIndex: number = -1; @@ -315,6 +315,23 @@ export class FindInput extends Widget { this.updateInputBoxPadding(); } + protected getToggleDomNodes(): HTMLElement[] { + const nodes: HTMLElement[] = []; + if (this.caseSensitive) { + nodes.push(this.caseSensitive.domNode); + } + if (this.wholeWords) { + nodes.push(this.wholeWords.domNode); + } + if (this.regex) { + nodes.push(this.regex.domNode); + } + for (const toggle of this.additionalToggles) { + nodes.push(toggle.domNode); + } + return nodes; + } + public setActions(actions: ReadonlyArray | undefined, actionViewItemProvider?: IActionViewItemProvider): void { this.inputBox.setActions(actions, actionViewItemProvider); } diff --git a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts index 1184ba6cdf9df..4123eb8fc5088 100644 --- a/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/contrib/find/notebookFindReplaceWidget.ts @@ -282,6 +282,12 @@ export class NotebookFindInput extends FindInput { this._findFilter.applyStyles(this._filterChecked); } + protected override getToggleDomNodes(): HTMLElement[] { + const nodes = super.getToggleDomNodes(); + nodes.push(this._findFilter.container); + return nodes; + } + getCellToolbarActions(menu: IMenu): { primary: IAction[]; secondary: IAction[] } { return getActionBarActions(menu.getActions({ shouldForwardArgs: true }), g => /^inline/.test(g)); } From acdb71d337317f4d516e83fa04f47f1b3802ac3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joaqu=C3=ADn=20Ruales?= <1588988+jruales@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:33:51 -0700 Subject: [PATCH 21/31] Integrated Browser: Upgrade Playwright to 1.59 (#307459) * Upgrade Playwright to 1.59 * don't upgrade test * Fix full/incremental logic * Update * Fix package-lock. Apply PR suggestions --- package-lock.json | 21 ++++--------------- package.json | 2 +- .../browserView/node/playwrightTab.ts | 18 ++++++++++++---- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/package-lock.json b/package-lock.json index a85ff2458bad0..364025c2b98a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,7 +52,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.12", "open": "^10.1.2", - "playwright-core": "1.59.0-alpha-2026-02-20", + "playwright-core": "1.59.1", "ssh2": "^1.16.0", "tas-client": "0.3.1", "undici": "^7.24.0", @@ -4496,19 +4496,6 @@ "agent-browser": "bin/agent-browser.js" } }, - "node_modules/agent-browser/node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "playwright-core": "cli.js" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -15515,9 +15502,9 @@ } }, "node_modules/playwright-core": { - "version": "1.59.0-alpha-2026-02-20", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.0-alpha-2026-02-20.tgz", - "integrity": "sha512-BK7oUBgMSbxfkQ579s270t0EkEyT2L2DA7qfMV4kaHanQOO0UK4mfyVLpWQsa+vUr/l7LxJGWsKlWcXD2QU9NQ==", + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/package.json b/package.json index 98c390f5b5468..c6be57a1ca475 100644 --- a/package.json +++ b/package.json @@ -124,7 +124,7 @@ "native-keymap": "^3.3.5", "node-pty": "^1.2.0-beta.12", "open": "^10.1.2", - "playwright-core": "1.59.0-alpha-2026-02-20", + "playwright-core": "1.59.1", "ssh2": "^1.16.0", "tas-client": "0.3.1", "undici": "^7.24.0", diff --git a/src/vs/platform/browserView/node/playwrightTab.ts b/src/vs/platform/browserView/node/playwrightTab.ts index 52aff1089975b..ceddf207a6e78 100644 --- a/src/vs/platform/browserView/node/playwrightTab.ts +++ b/src/vs/platform/browserView/node/playwrightTab.ts @@ -9,10 +9,12 @@ import { Emitter, Event } from '../../../base/common/event.js'; import { CancellationToken } from '../../../base/common/cancellation.js'; import { createCancelablePromise, raceCancellablePromises } from '../../../base/common/async.js'; +type IAiAriaSnapshotOptions = NonNullable[0]> & { _track?: string }; + declare module 'playwright-core' { interface Page { - // A hidden Playwright method that returns an AI-friendly snapshot of the page. - _snapshotForAI(options?: { track?: string }): Promise<{ full: string; incremental?: string }>; + // We defined this here to be able to use the unofficial `_track` option + ariaSnapshot(options?: IAiAriaSnapshotOptions): Promise; } } @@ -177,7 +179,7 @@ export class PlaywrightTab { this._needsFullSnapshot = false; } - const snapshotFromPage = await this.safeRunAgainstPage((page) => page._snapshotForAI({ track: 'response' })).catch(() => { + const snapshotFromPage = await this.safeRunAgainstPage((page) => this.getAiSnapshot(page, full)).catch(() => { this._needsFullSnapshot = true; return undefined; }); @@ -186,7 +188,7 @@ export class PlaywrightTab { const logs = this._logs; this._logs = []; - const snapshot = (full ? snapshotFromPage?.full : snapshotFromPage?.incremental ?? snapshotFromPage?.full)?.trim() ?? ''; + const snapshot = snapshotFromPage?.trim() ?? ''; return [ ...(title ? [`Page Title: ${title}`] : []), @@ -201,6 +203,14 @@ export class PlaywrightTab { ].join('\n'); } + private getAiSnapshot(page: playwright.Page, full: boolean): Promise { + const options: IAiAriaSnapshotOptions = { mode: 'ai' }; + if (!full) { + options._track = 'response'; + } + return page.ariaSnapshot(options); + } + private async runAndWaitForCompletion(callback: (token: CancellationToken) => Promise, token = CancellationToken.None): Promise { const requests: playwright.Request[] = []; From ccf5e83152f3a969a6de8830c3cf334183f36143 Mon Sep 17 00:00:00 2001 From: moss <45252670+mossgowild@users.noreply.github.com> Date: Fri, 3 Apr 2026 02:48:30 +0800 Subject: [PATCH 22/31] fix: prevent catastrophic regex backtracking in _extractImagesFromOutput (#307447) fix: prevent catastrophic regex backtracking in _extractImagesFromOutput (#307431) --- .../browser/tools/runInTerminalTool.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 a6abdf30da9b4..c39cd3ae75a50 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1452,15 +1452,20 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { * Returns data content parts for any found images that exist on disk. */ private async _extractImagesFromOutput(output: string, cwd: URI | undefined): Promise { - const normalizedOutput = output.replace(/\r?\n/g, ''); - - // Match paths ending with image extensions. A leading / or \ is sufficient - // to identify a path segment; the full path up to the extension is captured. - const pathPattern = /(?:[^\s]*[\/\\][^\s]*\.(?:png|jpe?g|gif|webp|bmp))/gi; + // Match paths containing at least one / or \ and ending with an image + // extension. Each atom uses [^\s/\\]* so it cannot consume separators, + // which keeps the [/\\] tokens unambiguous and prevents catastrophic + // backtracking on long strings. + const pathPattern = /[^\s/\\]*(?:[/\\][^\s/\\]*)+\.(?:png|jpe?g|gif|webp|bmp)/gi; const matches = new Set(); - for (const match of normalizedOutput.matchAll(pathPattern)) { - matches.add(match[0]); + for (const line of output.split(/\r?\n/)) { + if (line.length > 10_000) { + continue; + } + for (const match of line.matchAll(pathPattern)) { + matches.add(match[0]); + } } if (matches.size === 0) { From 4370953ab5117ccc41f5a1f38324a5c1bf010ef9 Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:14:42 -0700 Subject: [PATCH 23/31] Update xterm to 6.1.0-beta.196 (#307288) --- package-lock.json | 96 ++++++++++++++++++------------------ package.json | 20 ++++---- remote/package-lock.json | 96 ++++++++++++++++++------------------ remote/package.json | 20 ++++---- remote/web/package-lock.json | 88 ++++++++++++++++----------------- remote/web/package.json | 18 +++---- 6 files changed, 169 insertions(+), 169 deletions(-) diff --git a/package-lock.json b/package-lock.json index 364025c2b98a1..70d1ae1551faf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,16 +32,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", @@ -4285,30 +4285,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.191.tgz", - "integrity": "sha512-u+0smTVylu9IAntE2OPfD1ZqZ5TNIcSWM1fA/tu1tryGIscax9cn6V2U7T4kkTEFf3A4a3PKgCrKufBclXebjQ==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.196.tgz", + "integrity": "sha512-OEYGoh++tQ1zk1RbfR0suJY+qv7tlHGzopS00G7tLgma9VaWm2a5rcu04uCtEQ0NPuPygEmOruDiTmf+PbxhdQ==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.191.tgz", - "integrity": "sha512-gX2WEQV2N7A9fx5VpzXK9GIqazUJKBreqEOuUcQPnY2ECJqVsC0uaoK9A/rhm2JuSkHRcqBt/pRVlT4Ki5KsZg==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.196.tgz", + "integrity": "sha512-ioiof4g9yfbZXu7ReoKodJjnfh3ZpACaYZLdrxgcsOZ6y0mZ/1EMDdTpZxLrGCqI+8uOj+9qa6oI2uTsC3LBQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.191.tgz", - "integrity": "sha512-LFAPEUna5o7nZSJrlV8rrhU6WIC30btdqPf4PNAo5pxWjCWPBqlD1XkTmeYWGpsxk2OaXec/MGjG03k6Rg2z2w==", + "version": "0.11.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.196.tgz", + "integrity": "sha512-Mf3V6WxNxuP8hcw3Jk+FMRYi6z2xwCfDnjoGn+bpLRV5Qnu6X7uZGOWCSemRgRJeaM0ufxHt6a4Lnvmpb4yjGQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -4318,7 +4318,7 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures/node_modules/lru-cache": { @@ -4340,63 +4340,63 @@ "license": "ISC" }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.191.tgz", - "integrity": "sha512-aImBOklbz37LqF4JsXe6cSTyFIpV5hxU4G/DS1IaFqbYPPMwWGdOUIB3L+TWwcMJh9hv1iwx5PbxNNi0KaylVw==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.196.tgz", + "integrity": "sha512-WDg5A0ZU0vUJjUnDwhFjdMi9ErTMdSZaZhB/+6FgMKyAzlvTkcLjo0HHNFPOr8U3r+BSMqLm6pgwxcwvlLBe1Q==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.191.tgz", - "integrity": "sha512-fv+TQkhDXYxAeJ31Mi4C4BPFLcffFOHl/vDceMlzaqHB+XHTBo6BDRcXAWBAgA0rvChZrYYTQ/JCPq9R506JSw==", + "version": "0.17.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.196.tgz", + "integrity": "sha512-dsokQjZdPIOG76LcqARs7jzSmKP/VdBKE9HgZuidHbhOUn4T2Yb2IjfdZkEkWcLA+p/c+R/YPNh2TyC4PQv9vg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.191.tgz", - "integrity": "sha512-Kzj/J52MEH8B1p84CZ0XEY3/engNDO1sJKmpZDLr0WP+EKvh5uwZe0bD5X5tNLNM5ZEFpMWailwTrGF9tSAd2g==", + "version": "0.15.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.196.tgz", + "integrity": "sha512-/FWg+jh+OrMBpNFLCLMmuprF1k5le9LN+8XNm04hZuImrjgvdA+sf76zmIcHb6PlkPsyDHY1cm+R93ObtJkRqA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.191.tgz", - "integrity": "sha512-6FR4CjI0te0bLWF7jr7ujpjVOW1kGFl+rKWbblFRNhENuN6gT1phIm4V1L4N9Rr6seTku272YRnO0nAHWZNe6g==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.196.tgz", + "integrity": "sha512-OVwpNleRVX/UdZT9/yDvweMM8BhqYc9HVtBY1ob8Ig9UikTXBdiB0eouOY5AKrCdSXS4G+clI7Yvso6VaRgMAg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.190", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.190.tgz", - "integrity": "sha512-9ABzdVhovpseQuD7v5sDz/4UZXvX7y8CH+EaF+LiP/HK6JwdWzVo9OftyWGNdHrOqo43PO7RTeC3eUQq9qqQGw==", + "version": "0.20.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.195.tgz", + "integrity": "sha512-+Oftc7iKfdQ3nBAqhrdGlDR6msMxiurY+/GSvSItAiTd+euOGdbxfznE8TP7Q5Vm044ev5JajULDH3gJbhPJEA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.191.tgz", - "integrity": "sha512-2dXTApeat9zr/clkEydw/uoBi3WEoXDGZZIW1aLthpj2pOqHfxlOdWIpPHeVhR07TAYble2OZJ0ydjfXWntNgA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.196.tgz", + "integrity": "sha512-xDrTvf+W2mqrKRZvexFLf5imgfbAbWixwH4kR9AIMEzi+Ud+8djY1GBRFxumORI/ckoogmdkgFH2RVl6Dm1deA==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.191.tgz", - "integrity": "sha512-c50KmCnftJRZa3cXVBxcixTyeq4Brs603DHxYGZ0z0a58LkhKdfzOfKwYKxHfiZlsOlX58Xk21BJ4d1UrCuCAA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.196.tgz", + "integrity": "sha512-+EjrGyk5WoJL89YL/EKrNJZNEJeikkUf7vwgVGVX4/gX0WYUn8Aci+j91DM4XwYoyGmcfMvKM/u1GdX7tDPmtA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/package.json b/package.json index c6be57a1ca475..2e3f41fb4baff 100644 --- a/package.json +++ b/package.json @@ -104,16 +104,16 @@ "@vscode/windows-mutex": "^0.5.0", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", "jschardet": "3.1.4", diff --git a/remote/package-lock.json b/remote/package-lock.json index f8aa17123d3fd..097931ffcf8bd 100644 --- a/remote/package-lock.json +++ b/remote/package-lock.json @@ -25,16 +25,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", @@ -752,30 +752,30 @@ "license": "MIT" }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.191.tgz", - "integrity": "sha512-u+0smTVylu9IAntE2OPfD1ZqZ5TNIcSWM1fA/tu1tryGIscax9cn6V2U7T4kkTEFf3A4a3PKgCrKufBclXebjQ==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.196.tgz", + "integrity": "sha512-OEYGoh++tQ1zk1RbfR0suJY+qv7tlHGzopS00G7tLgma9VaWm2a5rcu04uCtEQ0NPuPygEmOruDiTmf+PbxhdQ==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.191.tgz", - "integrity": "sha512-gX2WEQV2N7A9fx5VpzXK9GIqazUJKBreqEOuUcQPnY2ECJqVsC0uaoK9A/rhm2JuSkHRcqBt/pRVlT4Ki5KsZg==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.196.tgz", + "integrity": "sha512-ioiof4g9yfbZXu7ReoKodJjnfh3ZpACaYZLdrxgcsOZ6y0mZ/1EMDdTpZxLrGCqI+8uOj+9qa6oI2uTsC3LBQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.191.tgz", - "integrity": "sha512-LFAPEUna5o7nZSJrlV8rrhU6WIC30btdqPf4PNAo5pxWjCWPBqlD1XkTmeYWGpsxk2OaXec/MGjG03k6Rg2z2w==", + "version": "0.11.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.196.tgz", + "integrity": "sha512-Mf3V6WxNxuP8hcw3Jk+FMRYi6z2xwCfDnjoGn+bpLRV5Qnu6X7uZGOWCSemRgRJeaM0ufxHt6a4Lnvmpb4yjGQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -785,67 +785,67 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.191.tgz", - "integrity": "sha512-aImBOklbz37LqF4JsXe6cSTyFIpV5hxU4G/DS1IaFqbYPPMwWGdOUIB3L+TWwcMJh9hv1iwx5PbxNNi0KaylVw==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.196.tgz", + "integrity": "sha512-WDg5A0ZU0vUJjUnDwhFjdMi9ErTMdSZaZhB/+6FgMKyAzlvTkcLjo0HHNFPOr8U3r+BSMqLm6pgwxcwvlLBe1Q==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.191.tgz", - "integrity": "sha512-fv+TQkhDXYxAeJ31Mi4C4BPFLcffFOHl/vDceMlzaqHB+XHTBo6BDRcXAWBAgA0rvChZrYYTQ/JCPq9R506JSw==", + "version": "0.17.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.196.tgz", + "integrity": "sha512-dsokQjZdPIOG76LcqARs7jzSmKP/VdBKE9HgZuidHbhOUn4T2Yb2IjfdZkEkWcLA+p/c+R/YPNh2TyC4PQv9vg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.191.tgz", - "integrity": "sha512-Kzj/J52MEH8B1p84CZ0XEY3/engNDO1sJKmpZDLr0WP+EKvh5uwZe0bD5X5tNLNM5ZEFpMWailwTrGF9tSAd2g==", + "version": "0.15.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.196.tgz", + "integrity": "sha512-/FWg+jh+OrMBpNFLCLMmuprF1k5le9LN+8XNm04hZuImrjgvdA+sf76zmIcHb6PlkPsyDHY1cm+R93ObtJkRqA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.191.tgz", - "integrity": "sha512-6FR4CjI0te0bLWF7jr7ujpjVOW1kGFl+rKWbblFRNhENuN6gT1phIm4V1L4N9Rr6seTku272YRnO0nAHWZNe6g==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.196.tgz", + "integrity": "sha512-OVwpNleRVX/UdZT9/yDvweMM8BhqYc9HVtBY1ob8Ig9UikTXBdiB0eouOY5AKrCdSXS4G+clI7Yvso6VaRgMAg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.190", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.190.tgz", - "integrity": "sha512-9ABzdVhovpseQuD7v5sDz/4UZXvX7y8CH+EaF+LiP/HK6JwdWzVo9OftyWGNdHrOqo43PO7RTeC3eUQq9qqQGw==", + "version": "0.20.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.195.tgz", + "integrity": "sha512-+Oftc7iKfdQ3nBAqhrdGlDR6msMxiurY+/GSvSItAiTd+euOGdbxfznE8TP7Q5Vm044ev5JajULDH3gJbhPJEA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/headless": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.191.tgz", - "integrity": "sha512-2dXTApeat9zr/clkEydw/uoBi3WEoXDGZZIW1aLthpj2pOqHfxlOdWIpPHeVhR07TAYble2OZJ0ydjfXWntNgA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/headless/-/headless-6.1.0-beta.196.tgz", + "integrity": "sha512-xDrTvf+W2mqrKRZvexFLf5imgfbAbWixwH4kR9AIMEzi+Ud+8djY1GBRFxumORI/ckoogmdkgFH2RVl6Dm1deA==", "license": "MIT", "workspaces": [ "addons/*" ] }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.191.tgz", - "integrity": "sha512-c50KmCnftJRZa3cXVBxcixTyeq4Brs603DHxYGZ0z0a58LkhKdfzOfKwYKxHfiZlsOlX58Xk21BJ4d1UrCuCAA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.196.tgz", + "integrity": "sha512-+EjrGyk5WoJL89YL/EKrNJZNEJeikkUf7vwgVGVX4/gX0WYUn8Aci+j91DM4XwYoyGmcfMvKM/u1GdX7tDPmtA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/package.json b/remote/package.json index 84447dd4c4cd8..9a39d202641e3 100644 --- a/remote/package.json +++ b/remote/package.json @@ -20,16 +20,16 @@ "@vscode/vscode-languagedetection": "1.0.23", "@vscode/windows-process-tree": "^0.6.0", "@vscode/windows-registry": "^1.2.0", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/headless": "^6.1.0-beta.191", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/headless": "^6.1.0-beta.196", + "@xterm/xterm": "^6.1.0-beta.196", "cookie": "^0.7.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.2", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 6c311d20a520f..ee9f4879f98f9 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -14,15 +14,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/xterm": "^6.1.0-beta.196", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", @@ -100,30 +100,30 @@ } }, "node_modules/@xterm/addon-clipboard": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.191.tgz", - "integrity": "sha512-u+0smTVylu9IAntE2OPfD1ZqZ5TNIcSWM1fA/tu1tryGIscax9cn6V2U7T4kkTEFf3A4a3PKgCrKufBclXebjQ==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-clipboard/-/addon-clipboard-0.3.0-beta.196.tgz", + "integrity": "sha512-OEYGoh++tQ1zk1RbfR0suJY+qv7tlHGzopS00G7tLgma9VaWm2a5rcu04uCtEQ0NPuPygEmOruDiTmf+PbxhdQ==", "license": "MIT", "dependencies": { "js-base64": "^3.7.5" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-image": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.191.tgz", - "integrity": "sha512-gX2WEQV2N7A9fx5VpzXK9GIqazUJKBreqEOuUcQPnY2ECJqVsC0uaoK9A/rhm2JuSkHRcqBt/pRVlT4Ki5KsZg==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-image/-/addon-image-0.10.0-beta.196.tgz", + "integrity": "sha512-ioiof4g9yfbZXu7ReoKodJjnfh3ZpACaYZLdrxgcsOZ6y0mZ/1EMDdTpZxLrGCqI+8uOj+9qa6oI2uTsC3LBQQ==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-ligatures": { - "version": "0.11.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.191.tgz", - "integrity": "sha512-LFAPEUna5o7nZSJrlV8rrhU6WIC30btdqPf4PNAo5pxWjCWPBqlD1XkTmeYWGpsxk2OaXec/MGjG03k6Rg2z2w==", + "version": "0.11.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-ligatures/-/addon-ligatures-0.11.0-beta.196.tgz", + "integrity": "sha512-Mf3V6WxNxuP8hcw3Jk+FMRYi6z2xwCfDnjoGn+bpLRV5Qnu6X7uZGOWCSemRgRJeaM0ufxHt6a4Lnvmpb4yjGQ==", "license": "MIT", "dependencies": { "lru-cache": "^6.0.0", @@ -133,58 +133,58 @@ "node": ">8.0.0" }, "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-progress": { - "version": "0.3.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.191.tgz", - "integrity": "sha512-aImBOklbz37LqF4JsXe6cSTyFIpV5hxU4G/DS1IaFqbYPPMwWGdOUIB3L+TWwcMJh9hv1iwx5PbxNNi0KaylVw==", + "version": "0.3.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-progress/-/addon-progress-0.3.0-beta.196.tgz", + "integrity": "sha512-WDg5A0ZU0vUJjUnDwhFjdMi9ErTMdSZaZhB/+6FgMKyAzlvTkcLjo0HHNFPOr8U3r+BSMqLm6pgwxcwvlLBe1Q==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-search": { - "version": "0.17.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.191.tgz", - "integrity": "sha512-fv+TQkhDXYxAeJ31Mi4C4BPFLcffFOHl/vDceMlzaqHB+XHTBo6BDRcXAWBAgA0rvChZrYYTQ/JCPq9R506JSw==", + "version": "0.17.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-search/-/addon-search-0.17.0-beta.196.tgz", + "integrity": "sha512-dsokQjZdPIOG76LcqARs7jzSmKP/VdBKE9HgZuidHbhOUn4T2Yb2IjfdZkEkWcLA+p/c+R/YPNh2TyC4PQv9vg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-serialize": { - "version": "0.15.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.191.tgz", - "integrity": "sha512-Kzj/J52MEH8B1p84CZ0XEY3/engNDO1sJKmpZDLr0WP+EKvh5uwZe0bD5X5tNLNM5ZEFpMWailwTrGF9tSAd2g==", + "version": "0.15.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.196.tgz", + "integrity": "sha512-/FWg+jh+OrMBpNFLCLMmuprF1k5le9LN+8XNm04hZuImrjgvdA+sf76zmIcHb6PlkPsyDHY1cm+R93ObtJkRqA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-unicode11": { - "version": "0.10.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.191.tgz", - "integrity": "sha512-6FR4CjI0te0bLWF7jr7ujpjVOW1kGFl+rKWbblFRNhENuN6gT1phIm4V1L4N9Rr6seTku272YRnO0nAHWZNe6g==", + "version": "0.10.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/addon-unicode11/-/addon-unicode11-0.10.0-beta.196.tgz", + "integrity": "sha512-OVwpNleRVX/UdZT9/yDvweMM8BhqYc9HVtBY1ob8Ig9UikTXBdiB0eouOY5AKrCdSXS4G+clI7Yvso6VaRgMAg==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/addon-webgl": { - "version": "0.20.0-beta.190", - "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.190.tgz", - "integrity": "sha512-9ABzdVhovpseQuD7v5sDz/4UZXvX7y8CH+EaF+LiP/HK6JwdWzVo9OftyWGNdHrOqo43PO7RTeC3eUQq9qqQGw==", + "version": "0.20.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.20.0-beta.195.tgz", + "integrity": "sha512-+Oftc7iKfdQ3nBAqhrdGlDR6msMxiurY+/GSvSItAiTd+euOGdbxfznE8TP7Q5Vm044ev5JajULDH3gJbhPJEA==", "license": "MIT", "peerDependencies": { - "@xterm/xterm": "^6.1.0-beta.191" + "@xterm/xterm": "^6.1.0-beta.196" } }, "node_modules/@xterm/xterm": { - "version": "6.1.0-beta.191", - "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.191.tgz", - "integrity": "sha512-c50KmCnftJRZa3cXVBxcixTyeq4Brs603DHxYGZ0z0a58LkhKdfzOfKwYKxHfiZlsOlX58Xk21BJ4d1UrCuCAA==", + "version": "6.1.0-beta.196", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.1.0-beta.196.tgz", + "integrity": "sha512-+EjrGyk5WoJL89YL/EKrNJZNEJeikkUf7vwgVGVX4/gX0WYUn8Aci+j91DM4XwYoyGmcfMvKM/u1GdX7tDPmtA==", "license": "MIT", "workspaces": [ "addons/*" diff --git a/remote/web/package.json b/remote/web/package.json index e0487531ed39c..53d00c81eeba8 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -9,15 +9,15 @@ "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", - "@xterm/addon-clipboard": "^0.3.0-beta.191", - "@xterm/addon-image": "^0.10.0-beta.191", - "@xterm/addon-ligatures": "^0.11.0-beta.191", - "@xterm/addon-progress": "^0.3.0-beta.191", - "@xterm/addon-search": "^0.17.0-beta.191", - "@xterm/addon-serialize": "^0.15.0-beta.191", - "@xterm/addon-unicode11": "^0.10.0-beta.191", - "@xterm/addon-webgl": "^0.20.0-beta.190", - "@xterm/xterm": "^6.1.0-beta.191", + "@xterm/addon-clipboard": "^0.3.0-beta.196", + "@xterm/addon-image": "^0.10.0-beta.196", + "@xterm/addon-ligatures": "^0.11.0-beta.196", + "@xterm/addon-progress": "^0.3.0-beta.196", + "@xterm/addon-search": "^0.17.0-beta.196", + "@xterm/addon-serialize": "^0.15.0-beta.196", + "@xterm/addon-unicode11": "^0.10.0-beta.196", + "@xterm/addon-webgl": "^0.20.0-beta.195", + "@xterm/xterm": "^6.1.0-beta.196", "jschardet": "3.1.4", "katex": "^0.16.22", "tas-client": "0.3.1", From 24712cc2c99e689b6fa5866a9ff42011e8ef6f47 Mon Sep 17 00:00:00 2001 From: Kyle Cutler <67761731+kycutler@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:24:11 -0700 Subject: [PATCH 24/31] Support more native editing shortcuts in browser (#307488) Support ctrl/cmd+arrow editing shortcuts in browser --- .../electron-browser/preload-browserView.ts | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/src/vs/platform/browserView/electron-browser/preload-browserView.ts b/src/vs/platform/browserView/electron-browser/preload-browserView.ts index 592560e5f1d8d..4bd3276745cab 100644 --- a/src/vs/platform/browserView/electron-browser/preload-browserView.ts +++ b/src/vs/platform/browserView/electron-browser/preload-browserView.ts @@ -27,6 +27,20 @@ // ### ### // ####################################################################### + // Ctrl/Cmd keybindings that correspond to native editing shortcuts and should be handled by the browser / OS and not forwarded to the workbench. + const nativeCtrlCmdKeybindings = { + mac: { + always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'backspace', 'delete']), + noShift: new Set(['a', 'c', 'v', 'x', 'z']), + withShift: new Set(['v', 'z']), + }, + nonMac: { + always: new Set(['arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'home', 'end', 'backspace', 'delete']), + noShift: new Set(['a', 'c', 'v', 'x', 'z', 'y']), + withShift: new Set(['v', 'z']), + } + }; + // Listen for keydown events that the page did not handle and forward them for shortcut handling. window.addEventListener('keydown', (event) => { // Require that the event is trusted -- i.e. user-initiated. @@ -51,6 +65,11 @@ return; } + // Never handle plain modifier key presses as keybindings + if (event.key === 'Control' || event.key === 'Shift' || event.key === 'Alt' || event.key === 'Meta') { + return; + } + const isMac = navigator.platform.indexOf('Mac') >= 0; // Alt+Key special character handling (Alt + Numpad keys on Windows/Linux, Alt + any key on Mac) @@ -60,18 +79,20 @@ } } - // Allow native shortcuts (copy, paste, cut, undo, redo, select all) to be handled by the browser + // Allow native shortcuts to be handled by the browser const ctrlCmd = isMac ? event.metaKey : event.ctrlKey; if (ctrlCmd && !event.altKey) { const key = event.key.toLowerCase(); - if (!event.shiftKey && (key === 'a' || key === 'c' || key === 'v' || key === 'x' || key === 'z')) { - return; - } - if (event.shiftKey && (key === 'v' || key === 'z')) { + const keySetsToCheck = [ + nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'].always, + nativeCtrlCmdKeybindings[isMac ? 'mac' : 'nonMac'][event.shiftKey ? 'withShift' : 'noShift'], + ]; + if (keySetsToCheck.some(set => set.has(key))) { return; } - // Ctrl+Y is redo on Windows/Linux - if (!event.shiftKey && key === 'y' && !isMac) { + + // Emoji picker on Mac + if (isMac && event.ctrlKey && !event.shiftKey && key === ' ') { return; } } From 6f6ad97af2be81aa7875384d7eec84715465e6ed Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Thu, 2 Apr 2026 20:17:22 +0000 Subject: [PATCH 25/31] Sessions - bulk update context keys to avoid flickering (#307497) --- .../contrib/changes/browser/changesView.ts | 201 +++++++++++------- 1 file changed, 128 insertions(+), 73 deletions(-) diff --git a/src/vs/sessions/contrib/changes/browser/changesView.ts b/src/vs/sessions/contrib/changes/browser/changesView.ts index d349090f5ee27..5e6550fa6f936 100644 --- a/src/vs/sessions/contrib/changes/browser/changesView.ts +++ b/src/vs/sessions/contrib/changes/browser/changesView.ts @@ -15,7 +15,7 @@ import { Codicon } from '../../../../base/common/codicons.js'; import { Iterable } from '../../../../base/common/iterator.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../base/common/lifecycle.js'; import { Event } from '../../../../base/common/event.js'; -import { autorun, constObservable, derived, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; +import { autorun, constObservable, derived, derivedObservableWithCache, derivedOpts, IObservable, IObservableWithChange, ISettableObservable, ObservablePromise, observableSignalFromEvent, observableValue, runOnChange } from '../../../../base/common/observable.js'; import { basename } from '../../../../base/common/path.js'; import { IResourceNode, ResourceTree } from '../../../../base/common/resourceTree.js'; import { ProgressBar } from '../../../../base/browser/ui/progressbar/progressbar.js'; @@ -30,7 +30,7 @@ import { MenuId, Action2, MenuItemAction, registerAction2 } from '../../../../pl import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { IActionWidgetDropdownActionProvider } from '../../../../platform/actionWidget/browser/actionWidgetDropdown.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -66,7 +66,7 @@ import { CodeReviewStateKind, getCodeReviewFilesFromSessionChanges, getCodeRevie import { IAgentFeedbackService } from '../../agentFeedback/browser/agentFeedbackService.js'; import { GitDiffChange, IGitRepository, IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { CIStatusWidget } from './checksWidget.js'; -import { arrayEqualsC } from '../../../../base/common/equals.js'; +import { arrayEqualsC, structuralEquals } from '../../../../base/common/equals.js'; import { GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../sessions/common/sessionData.js'; import { Orientation } from '../../../../base/browser/ui/sash/sash.js'; import { IView, Sizing, SplitView } from '../../../../base/browser/ui/splitview/splitview.js'; @@ -527,6 +527,16 @@ export class ChangesViewPane extends ViewPane { private splitView: SplitView | undefined; private splitViewContainer: HTMLElement | undefined; + private readonly isMergeBaseBranchProtectedContextKey: IContextKey; + private readonly isolationModeContextKey: IContextKey; + private readonly hasGitRepositoryContextKey: IContextKey; + private readonly hasChangesContextKey: IContextKey; + private readonly hasIncomingChangesContextKey: IContextKey; + private readonly hasOpenPullRequestContextKey: IContextKey; + private readonly hasOutgoingChangesContextKey: IContextKey; + private readonly hasPullRequestContextKey: IContextKey; + private readonly hasUncommittedChangesContextKey: IContextKey; + private readonly renderDisposables = this._register(new DisposableStore()); // Track current body dimensions for list layout @@ -558,6 +568,17 @@ export class ChangesViewPane extends ViewPane { this.viewModel = this.instantiationService.createInstance(ChangesViewModel); this._register(this.viewModel); + // Context keys + this.isMergeBaseBranchProtectedContextKey = isMergeBaseBranchProtectedContextKey.bindTo(this.scopedContextKeyService); + this.isolationModeContextKey = isolationModeContextKey.bindTo(this.scopedContextKeyService); + this.hasGitRepositoryContextKey = hasGitRepositoryContextKey.bindTo(this.scopedContextKeyService); + this.hasChangesContextKey = ChatContextKeys.hasAgentSessionChanges.bindTo(this.scopedContextKeyService); + this.hasIncomingChangesContextKey = hasIncomingChangesContextKey.bindTo(this.scopedContextKeyService); + this.hasOutgoingChangesContextKey = hasOutgoingChangesContextKey.bindTo(this.scopedContextKeyService); + this.hasUncommittedChangesContextKey = hasUncommittedChangesContextKey.bindTo(this.scopedContextKeyService); + this.hasPullRequestContextKey = hasPullRequestContextKey.bindTo(this.scopedContextKeyService); + this.hasOpenPullRequestContextKey = hasOpenPullRequestContextKey.bindTo(this.scopedContextKeyService); + // Version mode this._register(bindContextKey(changesVersionModeContextKey, this.scopedContextKeyService, reader => { return this.viewModel.versionModeObs.read(reader); @@ -775,7 +796,6 @@ export class ChangesViewPane extends ViewPane { const isLoadingChangesObs = derived(reader => { // If there is a git repository, wait for the repository to be opened first, // as there are many context keys that depend on the repository information. - // We want to avoid flickering of the actions. const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); if (hasGitRepository && this.viewModel.activeSessionRepositoryObs.read(reader) === undefined) { return true; @@ -860,57 +880,12 @@ export class ChangesViewPane extends ViewPane { if (this.actionsContainer) { dom.clearNode(this.actionsContainer); - let lastHasChanges = false; - this.renderDisposables.add(bindContextKey(ChatContextKeys.hasAgentSessionChanges, this.scopedContextKeyService, reader => { - if (isLoadingChangesObs.read(reader)) { - return lastHasChanges; - } - const { files } = topLevelStats.read(reader); - lastHasChanges = files > 0; - return lastHasChanges; - })); - - this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { - const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader); - return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error; - })); - - this.renderDisposables.add(bindContextKey(isolationModeContextKey, this.scopedContextKeyService, reader => { - return this.viewModel.activeSessionIsolationModeObs.read(reader); - })); - - this.renderDisposables.add(bindContextKey(hasGitRepositoryContextKey, this.scopedContextKeyService, reader => { - return this.viewModel.activeSessionHasGitRepositoryObs.read(reader); - })); - - this.renderDisposables.add(bindContextKey(isMergeBaseBranchProtectedContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - return activeSession?.workspace.read(reader)?.repositories[0]?.baseBranchProtected === true; - })); - - this.renderDisposables.add(bindContextKey(hasPullRequestContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - const gitHubInfo = activeSession?.gitHubInfo.read(reader); - return gitHubInfo?.pullRequest?.uri !== undefined; - })); - - this.renderDisposables.add(bindContextKey(hasOpenPullRequestContextKey, this.scopedContextKeyService, reader => { - const activeSession = this.sessionManagementService.activeSession.read(reader); - const gitHubInfo = activeSession?.gitHubInfo.read(reader); - if (gitHubInfo?.pullRequest?.uri === undefined) { - return false; - } - const iconId = gitHubInfo.pullRequest.icon?.id; - return iconId !== undefined && - (iconId === Codicon.gitPullRequestDraft.id || - iconId === Codicon.gitPullRequest.id); - })); + // Bind context keys + this._bindContextKeys(isLoadingChangesObs, topLevelStats); - this.renderDisposables.add(bindContextKey(hasIncomingChangesContextKey, this.scopedContextKeyService, reader => { - const repository = this.viewModel.activeSessionRepositoryObs.read(reader); - const repositoryState = repository?.state.read(reader); - return (repositoryState?.HEAD?.behind ?? 0) > 0; - })); + const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); + const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); + this.renderDisposables.add(scopedInstantiationService); const outgoingChangesObs = derived(reader => { const repository = this.viewModel.activeSessionRepositoryObs.read(reader); @@ -919,25 +894,6 @@ export class ChangesViewPane extends ViewPane { return repositoryState?.HEAD?.ahead ?? 0; }); - this.renderDisposables.add(bindContextKey(hasOutgoingChangesContextKey, this.scopedContextKeyService, reader => { - const outgoingChanges = outgoingChangesObs.read(reader); - return outgoingChanges > 0; - })); - - this.renderDisposables.add(bindContextKey(hasUncommittedChangesContextKey, this.scopedContextKeyService, reader => { - const repository = this.viewModel.activeSessionRepositoryObs.read(reader); - const repositoryState = repository?.state.read(reader); - - return (repositoryState?.mergeChanges.length ?? 0) > 0 || - (repositoryState?.indexChanges.length ?? 0) > 0 || - (repositoryState?.workingTreeChanges.length ?? 0) > 0 || - (repositoryState?.untrackedChanges.length ?? 0) > 0; - })); - - const scopedServiceCollection = new ServiceCollection([IContextKeyService, this.scopedContextKeyService]); - const scopedInstantiationService = this.instantiationService.createChild(scopedServiceCollection); - this.renderDisposables.add(scopedInstantiationService); - this.renderDisposables.add(autorun(reader => { const outgoingChanges = outgoingChangesObs.read(reader); const sessionResource = this.viewModel.activeSessionResourceObs.read(reader); @@ -1187,6 +1143,105 @@ export class ChangesViewPane extends ViewPane { })); } + private _bindContextKeys(isLoadingChangesObs: IObservable, topLevelStats: IObservable<{ files: number }>): void { + // Request in progress (can be updated independently since it only affects action enablement, and not visibility) + this.renderDisposables.add(bindContextKey(ChatContextKeys.requestInProgress, this.scopedContextKeyService, reader => { + const activeSessionStatus = this.sessionManagementService.activeSession.read(reader)?.status.read(reader); + return activeSessionStatus !== SessionStatus.Completed && activeSessionStatus !== SessionStatus.Error; + })); + + type ContextKeys = { + readonly hasChanges: boolean; + readonly isolationMode: IsolationMode; + readonly hasGitRepository: boolean; + readonly isMergeBaseBranchProtected: boolean; + readonly hasPullRequest: boolean; + readonly hasOpenPullRequest: boolean; + readonly hasIncomingChanges: boolean; + readonly hasOutgoingChanges: boolean; + readonly hasUncommittedChanges: boolean; + }; + + // The following context keys have to be updated together based on the combined entries + // to avoid flickering of actions when switching between sessions and changes are loading + const contextKeysRawObs = derivedObservableWithCache( + this, (reader, lastValue) => { + const isLoading = isLoadingChangesObs.read(reader); + if (isLoading) { + return lastValue; + } + + const activeSession = this.sessionManagementService.activeSession.read(reader); + const repository = this.viewModel.activeSessionRepositoryObs.read(reader); + + // Changes state + const { files } = topLevelStats.read(reader); + const hasChanges = files > 0; + + // Session state + const isolationMode = this.viewModel.activeSessionIsolationModeObs.read(reader); + const hasGitRepository = this.viewModel.activeSessionHasGitRepositoryObs.read(reader); + const isMergeBaseBranchProtected = activeSession?.workspace.read(reader)?.repositories[0]?.baseBranchProtected === true; + + // Pull request state + const gitHubInfo = activeSession?.gitHubInfo.read(reader); + const hasPullRequest = gitHubInfo?.pullRequest?.uri !== undefined; + const hasOpenPullRequest = hasPullRequest && + (gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequestDraft.id || + gitHubInfo.pullRequest.icon?.id === Codicon.gitPullRequest.id); + + // Repository state + const repositoryState = repository?.state.read(reader); + const hasIncomingChanges = (repositoryState?.HEAD?.behind ?? 0) > 0; + const hasOutgoingChanges = (repositoryState?.HEAD?.ahead ?? 0) > 0; + const hasUncommittedChanges = (repositoryState?.mergeChanges.length ?? 0) > 0 || + (repositoryState?.indexChanges.length ?? 0) > 0 || + (repositoryState?.workingTreeChanges.length ?? 0) > 0 || + (repositoryState?.untrackedChanges.length ?? 0) > 0; + + return { + hasChanges, + isolationMode, + hasGitRepository, + isMergeBaseBranchProtected, + hasPullRequest, + hasOpenPullRequest, + hasIncomingChanges, + hasOutgoingChanges, + hasUncommittedChanges, + }; + }); + + // Create a derived observable that only emits when the + // context keys actually change to avoid unnecessary updates + const contextKeysObs = derivedOpts({ + equalsFn: structuralEquals + }, reader => { + const contextKeysRaw = contextKeysRawObs.read(reader); + return contextKeysRaw; + }); + + // Bulk update the context keys + this.renderDisposables.add(autorun(reader => { + const contextKeys = contextKeysObs.read(reader); + if (!contextKeys) { + return; + } + + this.scopedContextKeyService.bufferChangeEvents(() => { + this.hasChangesContextKey.set(contextKeys.hasChanges); + this.isMergeBaseBranchProtectedContextKey.set(contextKeys.isMergeBaseBranchProtected); + this.isolationModeContextKey.set(contextKeys.isolationMode); + this.hasGitRepositoryContextKey.set(contextKeys.hasGitRepository); + this.hasPullRequestContextKey.set(contextKeys.hasPullRequest); + this.hasOpenPullRequestContextKey.set(contextKeys.hasOpenPullRequest); + this.hasIncomingChangesContextKey.set(contextKeys.hasIncomingChanges); + this.hasOutgoingChangesContextKey.set(contextKeys.hasOutgoingChanges); + this.hasUncommittedChangesContextKey.set(contextKeys.hasUncommittedChanges); + }); + })); + } + /** Layout the tree within its SplitView pane. */ private _layoutTreeInPane(paneHeight: number): void { if (!this.tree) { From 11bc1e6db9968dfaaeb0ffedc6080c4d3221738e Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Thu, 2 Apr 2026 13:28:03 -0700 Subject: [PATCH 26/31] chat: improve ChatSessionCustomizationProvider API (#307278) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chat: add Plugins to ChatSessionCustomizationType Adds a static Plugins instance so extensions can use ChatSessionCustomizationType.Plugins instead of constructing new ChatSessionCustomizationType('plugins') manually. Also adds explicit mapping in mainThread section conversion. * chat: rename unsupportedTypes to supportedTypes in customization provider metadata Inverts the semantics from a blacklist (types to hide) to a whitelist (types to show). More natural API: providers declare what they support rather than what they don't. When omitted, all sections are shown. * fix: pass through groupKey from provider items and infer storage for auto-grouping - fetchItemsFromProvider now passes through groupKey, badge, badgeTooltip - When no groupKey, infers storage from URI (workspace folder = local, else = user) - Include BUILTIN_STORAGE in external provider's visible sources so items with groupKey 'builtin' get a Built-in group header * fix: enhance debug panel to dump full harness descriptor and item details - Report now shows active harness metadata: id, label, hiddenSections, workspaceSubpaths, hideGenerateButton, requiredAgentId, instructionFileFilter - External provider items now dump groupKey, badge, status, scheme - Accepts full IHarnessDescriptor instead of just itemProvider * feat: add 'Generate Customization Debug Report' command Registers a command palette action that generates a debug report for the current customization section. Available when the Chat Customizations editor is focused. Dumps harness metadata, provider items with all fields, and widget state into a new editor tab. * fix: proper auto-grouping for provider items by URI scheme and path - file: under workspace folder → Workspace group (storage: local) - file: elsewhere → User group (storage: user) - vscode-userdata: → Extensions group (storage: extension, read-only) - non-file schemes (copilotcli:, claude-code:) → Built-in group - Also catch and log errors in loadItems to surface silent failures * cleanup: improve type safety in group filter logic * refactor: split filterItems into provider vs core paths with shared helpers Extracts shared logic into reusable helpers: - applySearchFilter(): search query matching with highlights - buildGroupedEntries(): assigns items to groups, builds display entries - commitDisplayEntries(): splices into list and updates empty state Splits grouping logic into two clear paths: - filterItemsForProvider(): sync layout OR simple storage grouping (Workspace, User, Extensions, Built-in) - filterItemsForCore(): instruction categories OR full storage grouping with visibleSources filtering * refactor: extract fetchCoreItemsForSection for clean provider/core separation Splits fetchItemsForSection into three methods: - fetchItemsForSection(): dispatcher that routes to provider or core path - fetchProviderItemsForSection(): clean provider path (provider + optional sync) - fetchCoreItemsForSection(): legacy core path with full promptsService pipeline The core path is now fully isolated with a TODO marker for future removal. When provider API becomes the sole path, delete fetchCoreItemsForSection() and all its helpers (applyBuiltinGroupKeys, applyStorageSourceFilter filters, workspaceSubpath/instructionFileFilter logic). * fix: treat vscode-userdata: items as built-in in provider path Extension-contributed items (Ask, Explore, Plan) use vscode-userdata: URIs. Previously _inferStorageAndGroup mapped these to storage=extension which showed them in an 'Extensions' group, while the core path treated them as 'Built-in'. Now all non-file schemes map to the Built-in group for consistency. * fix: enrich provider items with skill descriptions from promptsService When the provider doesn't supply a description (e.g. skills from chatPromptFileService which only have URIs), fall back to descriptions from promptsService.findAgentSkills(). This ensures skill descriptions show in the provider path just like in the core path. * fix: address Copilot review feedback - Add precondition to Generate Debug Report command so it only appears when chat features are enabled - Log errors via onUnexpectedError in loadItems instead of silently swallowing them - Handle unknown type IDs in supportedTypes→hiddenSections mapping by explicitly filtering with a guard instead of relying on filter(Boolean) --- .../api/browser/mainThreadChatAgents2.ts | 35 +- .../workbench/api/common/extHost.protocol.ts | 2 +- .../api/common/extHostChatAgents2.ts | 2 +- src/vs/workbench/api/common/extHostTypes.ts | 1 + .../aiCustomizationDebugPanel.ts | 41 +- .../aiCustomizationListWidget.ts | 365 ++++++++++++------ .../aiCustomizationManagement.contribution.ts | 30 ++ .../aiCustomizationManagement.ts | 1 + ...osed.chatSessionCustomizationProvider.d.ts | 10 +- 9 files changed, 341 insertions(+), 146 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index a68b3c38e3dd1..60fd93fdc758c 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -45,7 +45,7 @@ import { ExtHostChatAgentsShape2, ExtHostContext, IChatSessionCustomizationItemD import { NotebookDto } from './mainThreadNotebookDto.js'; import { isUntitledChatSession } from '../../contrib/chat/common/model/chatUri.js'; import { ICustomizationHarnessService, IExternalCustomizationItem, IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../contrib/chat/common/customizationHarnessService.js'; -import { AICustomizationManagementSection } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { AICustomizationManagementSection, BUILTIN_STORAGE } from '../../contrib/chat/common/aiCustomizationWorkspaceService.js'; import { IConfigurationService } from '../../../platform/configuration/common/configuration.js'; interface AgentData { @@ -634,17 +634,28 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA }, }; - // Convert metadata to a harness descriptor - const hiddenSections = metadata.unsupportedTypes?.map(type => { - switch (type) { - case 'agent': return AICustomizationManagementSection.Agents; - case 'skill': return AICustomizationManagementSection.Skills; - case 'instructions': return AICustomizationManagementSection.Instructions; - case 'prompt': return AICustomizationManagementSection.Prompts; - case 'hook': return AICustomizationManagementSection.Hooks; - default: return type; + // Convert supportedTypes whitelist to hiddenSections blacklist. + // Sections not in the supported list are hidden. When supportedTypes + // is omitted, all sections are shown. + const typeToSection: Record = { + 'agent': AICustomizationManagementSection.Agents, + 'skill': AICustomizationManagementSection.Skills, + 'instructions': AICustomizationManagementSection.Instructions, + 'prompt': AICustomizationManagementSection.Prompts, + 'hook': AICustomizationManagementSection.Hooks, + 'plugins': AICustomizationManagementSection.Plugins, + }; + let hiddenSections: string[] | undefined; + if (metadata.supportedTypes) { + const supportedSections = new Set(); + for (const t of metadata.supportedTypes) { + const section = typeToSection[t]; + if (section) { + supportedSections.add(section); + } } - }); + hiddenSections = Object.values(typeToSection).filter(section => !supportedSections.has(section)); + } const descriptor: IHarnessDescriptor = { id: chatSessionType, @@ -654,7 +665,7 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA getStorageSourceFilter: () => ({ // Extension-provided harnesses manage their own items via the provider, // so we show all sources for storage-filter-based flows. - sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension], + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.plugin, PromptsStorage.extension, BUILTIN_STORAGE], }), itemProvider, }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 187a771114d4d..351f1756db06b 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1681,7 +1681,7 @@ export interface ISkillDto { export interface IChatSessionCustomizationProviderMetadataDto { readonly label: string; readonly iconId?: string; - readonly unsupportedTypes?: readonly string[]; + readonly supportedTypes?: readonly string[]; } export interface IChatSessionCustomizationItemDto { diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index f6d282ea53cf8..8f5b62a9f12cd 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -671,7 +671,7 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS const metadataDto: IChatSessionCustomizationProviderMetadataDto = { label: metadata.label, iconId: metadata.iconId, - unsupportedTypes: metadata.unsupportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), + supportedTypes: metadata.supportedTypes?.map(t => typeConvert.ChatSessionCustomizationType.from(t)), }; this._proxy.$registerChatSessionCustomizationProvider(handle, chatSessionType, metadataDto, extension.identifier); diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 2e2646a0de13e..347d80f9be090 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -3574,6 +3574,7 @@ export class ChatSessionCustomizationType { static readonly Instructions = new ChatSessionCustomizationType('instructions'); static readonly Prompt = new ChatSessionCustomizationType('prompt'); static readonly Hook = new ChatSessionCustomizationType('hook'); + static readonly Plugins = new ChatSessionCustomizationType('plugins'); constructor(public readonly id: string) { } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts index 62b0b87c72ece..d255da3cfcd90 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationDebugPanel.ts @@ -9,7 +9,7 @@ import { IPromptsService, PromptsStorage, IPromptPath } from '../../common/promp import { PromptsType } from '../../common/promptSyntax/promptTypes.js'; import { IAICustomizationWorkspaceService, applyStorageSourceFilter, IStorageSourceFilter } from '../../common/aiCustomizationWorkspaceService.js'; import { AICustomizationManagementSection } from './aiCustomizationManagement.js'; -import { IExternalCustomizationItemProvider } from '../../common/customizationHarnessService.js'; +import { IExternalCustomizationItemProvider, IHarnessDescriptor } from '../../common/customizationHarnessService.js'; /** * Maps section ID to prompt type. Duplicated from aiCustomizationListWidget @@ -35,7 +35,7 @@ function sectionToPromptType(section: AICustomizationManagementSection): Prompts * Snapshot of the list widget's internal state, passed in to avoid coupling. */ export interface IDebugWidgetState { - readonly allItems: readonly { readonly storage?: PromptsStorage }[]; + readonly allItems: readonly { readonly name?: string; readonly storage?: PromptsStorage; readonly groupKey?: string }[]; readonly displayEntries: readonly { type: string; label?: string; count?: number; collapsed?: boolean }[]; } @@ -48,8 +48,9 @@ export async function generateCustomizationDebugReport( promptsService: IPromptsService, workspaceService: IAICustomizationWorkspaceService, widgetState: IDebugWidgetState, - externalProvider?: IExternalCustomizationItemProvider, + activeDescriptor?: IHarnessDescriptor, ): Promise { + const externalProvider = activeDescriptor?.itemProvider; const promptType = sectionToPromptType(section); const filter = workspaceService.getStorageSourceFilter(promptType); const lines: string[] = []; @@ -59,6 +60,22 @@ export async function generateCustomizationDebugReport( lines.push(`Active root: ${workspaceService.getActiveProjectRoot()?.fsPath ?? '(none)'}`); lines.push(`Sections: [${workspaceService.managementSections.join(', ')}]`); lines.push(`Filter sources: [${filter.sources.join(', ')}]`); + + // Dump active harness descriptor + if (activeDescriptor) { + lines.push(''); + lines.push('--- Active Harness ---'); + lines.push(` id: ${activeDescriptor.id}`); + lines.push(` label: ${activeDescriptor.label}`); + lines.push(` hasItemProvider: ${!!activeDescriptor.itemProvider}`); + lines.push(` hasSyncProvider: ${!!activeDescriptor.syncProvider}`); + lines.push(` hiddenSections: ${activeDescriptor.hiddenSections ? `[${activeDescriptor.hiddenSections.join(', ')}]` : '(none)'}`); + lines.push(` workspaceSubpaths: ${activeDescriptor.workspaceSubpaths ? `[${activeDescriptor.workspaceSubpaths.join(', ')}]` : '(none)'}`); + lines.push(` hideGenerateButton: ${activeDescriptor.hideGenerateButton ?? false}`); + lines.push(` requiredAgentId: ${activeDescriptor.requiredAgentId ?? '(none)'}`); + lines.push(` instructionFileFilter: ${activeDescriptor.instructionFileFilter ? `[${activeDescriptor.instructionFileFilter.join(', ')}]` : '(none)'}`); + } + lines.push(''); if (filter.includedUserFileRoots) { lines.push(`Filter includedUserFileRoots:`); for (const r of filter.includedUserFileRoots) { @@ -109,6 +126,19 @@ async function appendExternalProviderData(lines: string[], provider: IExternalCu if (item.description) { lines.push(` desc: ${item.description}`); } + if (item.groupKey) { + lines.push(` groupKey: ${item.groupKey}`); + } + if (item.badge) { + lines.push(` badge: ${item.badge}`); + } + if (item.status) { + lines.push(` status: ${item.status}${item.statusMessage ? ` (${item.statusMessage})` : ''}`); + } + if (item.enabled === false) { + lines.push(` enabled: false`); + } + lines.push(` scheme: ${item.uri.scheme}`); } } @@ -209,6 +239,11 @@ function appendWidgetState(lines: string[], state: IDebugWidgetState): void { lines.push(` extension: ${state.allItems.filter(i => i.storage === PromptsStorage.extension).length}`); lines.push(` plugin: ${state.allItems.filter(i => i.storage === PromptsStorage.plugin).length}`); + // Show each item with its groupKey and storage + for (const item of state.allItems) { + lines.push(` - ${item.name} [storage=${item.storage ?? '?'}, groupKey=${item.groupKey ?? '(none)'}]`); + } + lines.push(` displayEntries (after filterItems): ${state.displayEntries.length}`); const fileEntries = state.displayEntries.filter(e => e.type === 'file-item'); lines.push(` file items shown: ${fileEntries.length}`); diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts index 75cde4ff73ccd..4afa34724d807 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationListWidget.ts @@ -8,6 +8,7 @@ import * as DOM from '../../../../../base/browser/dom.js'; import { ActionBar } from '../../../../../base/browser/ui/actionbar/actionbar.js'; import { Checkbox } from '../../../../../base/browser/ui/toggle/toggle.js'; import { Disposable, DisposableStore, MutableDisposable } from '../../../../../base/common/lifecycle.js'; +import { onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { CancellationToken } from '../../../../../base/common/cancellation.js'; import { autorun } from '../../../../../base/common/observable.js'; @@ -1161,7 +1162,13 @@ export class AICustomizationListWidget extends Disposable { */ private async loadItems(): Promise { const section = this.currentSection; - const items = await this.fetchItemsForSection(section); + let items: IAICustomizationListItem[]; + try { + items = await this.fetchItemsForSection(section); + } catch (err) { + onUnexpectedError(err); + items = []; + } if (this.currentSection !== section) { return; // section changed while loading @@ -1226,25 +1233,38 @@ export class AICustomizationListWidget extends Disposable { /** * Fetches and filters items for a given section. - * Shared between `loadItems` (active section) and `computeItemCountForSection` (any section). + * Delegates to the provider path or core path based on the active harness. */ private async fetchItemsForSection(section: AICustomizationManagementSection): Promise { const promptType = sectionToPromptType(section); - - // When the active harness has an external item provider, delegate to it - // instead of querying promptsService and applying filters. - // When the harness also has a syncProvider, include local items with - // sync toggles alongside the remote items. const activeDescriptor = this.harnessService.getActiveDescriptor(); + if (activeDescriptor.itemProvider && promptType) { - const remoteItems = await this.fetchItemsFromProvider(activeDescriptor.itemProvider, promptType); - if (!activeDescriptor.syncProvider) { - return remoteItems; - } - const localItems = await this.fetchLocalSyncableItems(promptType, activeDescriptor.syncProvider); - return [...remoteItems, ...localItems]; + return this.fetchProviderItemsForSection(activeDescriptor, promptType); } + return this.fetchCoreItemsForSection(promptType); + } + + /** + * Fetches items from an external customization provider. + * When a syncProvider is present, blends remote items with local sync items. + */ + private async fetchProviderItemsForSection(descriptor: ReturnType, promptType: PromptsType): Promise { + const remoteItems = await this.fetchItemsFromProvider(descriptor.itemProvider!, promptType); + if (!descriptor.syncProvider) { + return remoteItems; + } + const localItems = await this.fetchLocalSyncableItems(promptType, descriptor.syncProvider); + return [...remoteItems, ...localItems]; + } + + /** + * Fetches items from the core promptsService with full filtering pipeline. + * This is the legacy path used when no external provider is active. + * TODO: Remove when provider API is the sole code path. + */ + private async fetchCoreItemsForSection(promptType: PromptsType): Promise { const items: IAICustomizationListItem[] = []; const disabledUris = this.promptsService.getDisabledPromptFiles(promptType); const extensionInfoByUri = new ResourceMap<{ id: ExtensionIdentifier; displayName?: string }>(); @@ -1612,22 +1632,69 @@ export class AICustomizationListWidget extends Disposable { return []; } + const workspaceFolders = this.workspaceContextService.getWorkspace().folders; + + // Build a URI→description lookup from promptsService for items the provider + // doesn't supply descriptions for (e.g. skills and instructions from ChatResource). + const descriptionsByUri = new ResourceMap(); + if (promptType === PromptsType.skill) { + const skills = await this.promptsService.findAgentSkills(CancellationToken.None); + for (const s of skills ?? []) { + if (s.description) { + descriptionsByUri.set(s.uri, s.description); + } + } + } + return allItems .filter(item => item.type === promptType) - .map((item: IExternalCustomizationItem) => ({ - id: item.uri.toString(), - uri: item.uri, - name: item.name, - filename: basename(item.uri), - description: item.description, - promptType, - disabled: item.enabled === false, - status: item.status, - statusMessage: item.statusMessage, - })) + .map((item: IExternalCustomizationItem) => { + const { storage, groupKey } = item.groupKey + ? { storage: undefined, groupKey: item.groupKey } + : this._inferStorageAndGroup(item.uri, workspaceFolders); + return { + id: item.uri.toString(), + uri: item.uri, + name: item.name, + filename: basename(item.uri), + description: item.description ?? descriptionsByUri.get(item.uri), + promptType, + disabled: item.enabled === false, + status: item.status, + statusMessage: item.statusMessage, + groupKey, + badge: item.badge, + badgeTooltip: item.badgeTooltip, + storage, + }; + }) .sort((a, b) => a.name.localeCompare(b.name)); } + /** + * Infers storage and groupKey from a URI for auto-grouping. + * + * - `file:` URIs under a workspace folder → storage `local` (Workspace group) + * - `file:` URIs elsewhere (e.g. `~/.copilot/`) → storage `user` (User group) + * - Non-file schemes (synthetic URIs, vscode-userdata:, etc.) → groupKey `builtin` (Built-in group) + */ + private _inferStorageAndGroup(uri: URI, workspaceFolders: readonly { uri: URI }[]): { storage?: PromptsStorage; groupKey?: string } { + // Non-file schemes are synthetic/built-in (includes vscode-userdata: for extension-contributed items) + if (uri.scheme !== Schemas.file) { + return { groupKey: BUILTIN_STORAGE }; + } + + // file: URI under a workspace folder = workspace (local) + for (const folder of workspaceFolders) { + if (isEqualOrParent(uri, folder.uri)) { + return { storage: PromptsStorage.local }; + } + } + + // file: URI elsewhere = user directory + return { storage: PromptsStorage.user }; + } + /** * Fetches local customization items and marks them as syncable, using * the sync provider to determine their current selection state. @@ -1678,120 +1745,46 @@ export class AICustomizationListWidget extends Disposable { /** * Filters items based on the current search query and builds grouped display entries. */ - private filterItems(): number { - let matchedItems: IAICustomizationListItem[]; - + /** + * Applies the search query to items, returning matched items with highlight info. + */ + private applySearchFilter(items: IAICustomizationListItem[]): IAICustomizationListItem[] { if (!this.searchQuery.trim()) { - matchedItems = this.allItems.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); - } else { - const query = this.searchQuery.toLowerCase(); - matchedItems = []; - - for (const item of this.allItems) { - // Compute matches against the formatted display name so highlight positions - // are correct even after .md stripping and title-casing. - const displayName = item.displayName ?? formatDisplayName(item.name); - const nameMatches = matchesContiguousSubString(query, displayName); - const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; - const filenameMatches = matchesContiguousSubString(query, item.filename); - const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - - if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { - matchedItems.push({ - ...item, - nameMatches: nameMatches || undefined, - descriptionMatches: descriptionMatches || undefined, - }); - } - } + return items.map(item => ({ ...item, nameMatches: undefined, descriptionMatches: undefined })); } - // When items come from an external provider, skip storage-based grouping - // and render a flat list. When a syncProvider is also present, show - // remote items first, then local items below with sync checkboxes. - // Synced local items sort to the top of the local group; unsynced - // items appear greyed out below them. - const activeDescriptor = this.harnessService.getActiveDescriptor(); - if (activeDescriptor.itemProvider) { - if (activeDescriptor.syncProvider) { - const remoteItems = matchedItems.filter(i => !i.syncable); - const localItems = matchedItems.filter(i => i.syncable); - const entries: IListEntry[] = []; + const query = this.searchQuery.toLowerCase(); + const matched: IAICustomizationListItem[] = []; - // Remote items first (flat, no group header) - for (const item of remoteItems.sort((a, b) => a.name.localeCompare(b.name))) { - entries.push({ type: 'file-item' as const, item }); - } + for (const item of items) { + const displayName = item.displayName ?? formatDisplayName(item.name); + const nameMatches = matchesContiguousSubString(query, displayName); + const descriptionMatches = item.description ? matchesContiguousSubString(query, item.description) : null; + const filenameMatches = matchesContiguousSubString(query, item.filename); + const badgeMatches = item.badge ? matchesContiguousSubString(query, item.badge) : null; - // Local items below with a group header, synced items first - if (localItems.length > 0) { - const syncedCount = localItems.filter(i => i.synced).length; - entries.push({ - type: 'group-header' as const, - id: 'group-sync-local', - groupKey: 'sync-local', - label: localize('localGroup', "Local"), - icon: Codicon.folder, - count: syncedCount, - isFirst: remoteItems.length === 0, - description: localize('localGroupDescription', "Local customizations available to sync to the remote agent."), - collapsed: false, - }); - // Sort: synced items first, then alphabetical within each group - const sorted = localItems.sort((a, b) => { - if (a.synced !== b.synced) { - return a.synced ? -1 : 1; - } - return a.name.localeCompare(b.name); - }); - for (const item of sorted) { - entries.push({ type: 'file-item' as const, item: item.synced ? item : { ...item, disabled: true } }); - } - } - - this.displayEntries = entries; - } else { - matchedItems.sort((a, b) => a.name.localeCompare(b.name)); - this.displayEntries = matchedItems.map(item => ({ type: 'file-item' as const, item })); + if (nameMatches || descriptionMatches || filenameMatches || badgeMatches) { + matched.push({ + ...item, + nameMatches: nameMatches || undefined, + descriptionMatches: descriptionMatches || undefined, + }); } - this.list.splice(0, this.list.length, this.displayEntries); - this.updateEmptyState(); - return matchedItems.length; } - // Group items by storage - const promptType = sectionToPromptType(this.currentSection); - const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); - const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = - this.currentSection === AICustomizationManagementSection.Instructions - ? [ - { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, - { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, - { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, - ] - : [ - { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, - { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, - { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, - { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, - { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, - { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, - ].filter(g => visibleSources.has(g.groupKey as PromptsStorage) || g.groupKey === 'agents'); - - for (const item of matchedItems) { - const key = item.groupKey ?? item.storage ?? PromptsStorage.local; - const group = groups.find(g => g.groupKey === key); - if (group) { - group.items.push(item); - } - } + return matched; + } + /** + * Builds grouped display entries from items assigned to groups. + * Empty groups are omitted. Collapsed groups show only their header. + */ + private buildGroupedEntries(groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[]): void { // Sort items within each group for (const group of groups) { group.items.sort((a, b) => a.name.localeCompare(b.name)); } - // Build display entries: group header + items (hidden if collapsed) this.displayEntries = []; let isFirstGroup = true; for (const group of groups) { @@ -1820,9 +1813,131 @@ export class AICustomizationListWidget extends Disposable { } } } + } + /** + * Commits the current displayEntries to the list and updates empty state. + */ + private commitDisplayEntries(): void { this.list.splice(0, this.list.length, this.displayEntries); this.updateEmptyState(); + } + + /** + * Filters and groups items from an external provider. + * When a syncProvider is present, shows remote items + local sync items. + * Otherwise, groups items by inferred storage/groupKey. + */ + private filterItemsForProvider(matchedItems: IAICustomizationListItem[]): void { + const activeDescriptor = this.harnessService.getActiveDescriptor(); + + if (activeDescriptor.syncProvider) { + // Sync layout: remote items flat, then local items with sync checkboxes + const remoteItems = matchedItems.filter(i => !i.syncable); + const localItems = matchedItems.filter(i => i.syncable); + const entries: IListEntry[] = []; + + for (const item of remoteItems.sort((a, b) => a.name.localeCompare(b.name))) { + entries.push({ type: 'file-item' as const, item }); + } + + if (localItems.length > 0) { + const syncedCount = localItems.filter(i => i.synced).length; + entries.push({ + type: 'group-header' as const, + id: 'group-sync-local', + groupKey: 'sync-local', + label: localize('localGroup', "Local"), + icon: Codicon.folder, + count: syncedCount, + isFirst: remoteItems.length === 0, + description: localize('localGroupDescription', "Local customizations available to sync to the remote agent."), + collapsed: false, + }); + const sorted = localItems.sort((a, b) => { + if (a.synced !== b.synced) { + return a.synced ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + for (const item of sorted) { + entries.push({ type: 'file-item' as const, item: item.synced ? item : { ...item, disabled: true } }); + } + } + + this.displayEntries = entries; + } else { + // Standard provider layout: group by inferred storage/groupKey + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + ]; + + for (const item of matchedItems) { + const key = item.groupKey ?? item.storage ?? PromptsStorage.local; + const group = groups.find(g => g.groupKey === key); + if (group) { + group.items.push(item); + } + } + + this.buildGroupedEntries(groups); + } + + this.commitDisplayEntries(); + } + + /** + * Filters and groups items from the core promptsService (static harness path). + * Instructions use semantic categories; other sections use storage-based groups. + */ + private filterItemsForCore(matchedItems: IAICustomizationListItem[]): void { + const promptType = sectionToPromptType(this.currentSection); + const visibleSources = new Set(this.workspaceService.getStorageSourceFilter(promptType).sources); + + const groups: { groupKey: string; label: string; icon: ThemeIcon; description: string; items: IAICustomizationListItem[] }[] = + this.currentSection === AICustomizationManagementSection.Instructions + ? [ + { groupKey: 'agent-instructions', label: localize('agentInstructionsGroup', "Agent Instructions"), icon: instructionsIcon, description: localize('agentInstructionsGroupDescription', "Instruction files automatically loaded for all agent interactions (e.g. AGENTS.md, CLAUDE.md, copilot-instructions.md)."), items: [] }, + { groupKey: 'context-instructions', label: localize('contextInstructionsGroup', "Included Based on Context"), icon: instructionsIcon, description: localize('contextInstructionsGroupDescription', "Instructions automatically loaded when matching files are part of the context."), items: [] }, + { groupKey: 'on-demand-instructions', label: localize('onDemandInstructionsGroup', "Loaded on Demand"), icon: instructionsIcon, description: localize('onDemandInstructionsGroupDescription', "Instructions loaded only when explicitly referenced."), items: [] }, + ] + : [ + { groupKey: PromptsStorage.local, label: localize('workspaceGroup', "Workspace"), icon: workspaceIcon, description: localize('workspaceGroupDescription', "Customizations stored as files in your project folder and shared with your team via version control."), items: [] }, + { groupKey: PromptsStorage.user, label: localize('userGroup', "User"), icon: userIcon, description: localize('userGroupDescription', "Customizations stored locally on your machine in a central location. Private to you and available across all projects."), items: [] }, + { groupKey: PromptsStorage.plugin, label: localize('pluginGroup', "Plugins"), icon: pluginIcon, description: localize('pluginGroupDescription', "Read-only customizations provided by installed plugins."), items: [] }, + { groupKey: PromptsStorage.extension, label: localize('extensionGroup', "Extensions"), icon: extensionIcon, description: localize('extensionGroupDescription', "Read-only customizations provided by installed extensions."), items: [] }, + { groupKey: BUILTIN_STORAGE, label: localize('builtinGroup', "Built-in"), icon: builtinIcon, description: localize('builtinGroupDescription', "Built-in customizations shipped with the application."), items: [] }, + { groupKey: 'agents', label: localize('agentsGroup', "Agents"), icon: agentIcon, description: localize('agentsGroupDescription', "Hooks defined in agent files."), items: [] }, + ].filter(g => g.groupKey === BUILTIN_STORAGE || g.groupKey === 'agents' || visibleSources.has(g.groupKey as PromptsStorage)); + + for (const item of matchedItems) { + const key = item.groupKey ?? item.storage ?? PromptsStorage.local; + const group = groups.find(g => g.groupKey === key); + if (group) { + group.items.push(item); + } + } + + this.buildGroupedEntries(groups); + this.commitDisplayEntries(); + } + + /** + * Filters items based on the current search query and builds grouped display entries. + */ + private filterItems(): number { + const matchedItems = this.applySearchFilter(this.allItems); + const activeDescriptor = this.harnessService.getActiveDescriptor(); + + if (activeDescriptor.itemProvider) { + this.filterItemsForProvider(matchedItems); + } else { + this.filterItemsForCore(matchedItems); + } + return matchedItems.length; } @@ -2004,7 +2119,7 @@ export class AICustomizationListWidget extends Disposable { this.promptsService, this.workspaceService, { allItems: this.allItems, displayEntries: this.displayEntries }, - activeDescriptor.itemProvider, + activeDescriptor, ); } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts index b5fb824333936..fda68173118d1 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.contribution.ts @@ -12,6 +12,7 @@ import { basename, dirname, isEqualOrParent } from '../../../../../base/common/r import { URI } from '../../../../../base/common/uri.js'; import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js'; import { localize, localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuRegistry, registerAction2 } from '../../../../../platform/actions/common/actions.js'; import { IClipboardService } from '../../../../../platform/clipboard/common/clipboardService.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; @@ -631,6 +632,35 @@ class AICustomizationManagementActionsContribution extends Disposable implements } })); + // Generate Debug Report + this._register(registerAction2(class extends Action2 { + constructor() { + super({ + id: AICustomizationManagementCommands.GenerateDebugReport, + title: localize2('generateDebugReport', "Generate Customization Debug Report"), + category: Categories.Developer, + precondition: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.has(`config.${ChatConfiguration.ChatCustomizationMenuEnabled}`)), + f1: true, + }); + } + + async run(accessor: ServicesAccessor): Promise { + const editorService = accessor.get(IEditorService); + // Open the customizations editor if not already open + const input = AICustomizationManagementEditorInput.getOrCreate(); + const pane = await editorService.openEditor(input, { pinned: true }); + if (!(pane instanceof AICustomizationManagementEditor)) { + return; + } + const report = await pane.generateDebugReport(); + await editorService.openEditor({ + resource: undefined, + contents: report, + languageId: 'plaintext', + }); + } + })); + } } diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts index b7edd605f2eba..bf4e3a9e74ce5 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.ts @@ -32,6 +32,7 @@ export const AICustomizationManagementCommands = { CreateNewSkill: 'aiCustomization.createNewSkill', CreateNewInstructions: 'aiCustomization.createNewInstructions', CreateNewPrompt: 'aiCustomization.createNewPrompt', + GenerateDebugReport: 'aiCustomization.generateDebugReport', } as const; /** diff --git a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts index b55537066e144..33027458d2c7c 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionCustomizationProvider.d.ts @@ -25,6 +25,8 @@ declare module 'vscode' { static readonly Prompt: ChatSessionCustomizationType; /** Hook customization (event-driven automation). */ static readonly Hook: ChatSessionCustomizationType; + /** Plugin customization (agent runtime plugins). */ + static readonly Plugins: ChatSessionCustomizationType; /** * The string identifier for this customization type. @@ -56,11 +58,11 @@ declare module 'vscode' { readonly iconId?: string; /** - * Customization types that this provider does **not** support. - * The corresponding sections will be hidden in the management UI - * when this provider is active. + * Customization types that this provider supports. + * Only the corresponding sections will be shown in the management UI + * when this provider is active. When omitted, all sections are shown. */ - readonly unsupportedTypes?: readonly ChatSessionCustomizationType[]; + readonly supportedTypes?: readonly ChatSessionCustomizationType[]; } /** From 02a92c1bc96543c7e807f09177923cd2f2805759 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Thu, 2 Apr 2026 17:11:31 -0400 Subject: [PATCH 27/31] agentHost: adopt improved AHP file edits (#307502) * agentHost: adopt improved AHP file edits Adopts https://github.com/microsoft/agent-host-protocol/pull/35 * comments --- .../common/agentHostFileSystemProvider.ts | 84 ++++++---- .../platform/agentHost/common/agentService.ts | 25 ++- .../agentHost/common/sessionDataService.ts | 18 ++- .../common/state/protocol/.ahp-version | 2 +- .../common/state/protocol/commands.ts | 142 +++++++++++++---- .../agentHost/common/state/protocol/errors.ts | 2 +- .../common/state/protocol/messages.ts | 11 +- .../agentHost/common/state/protocol/state.ts | 71 ++++++--- .../agentHost/common/state/sessionProtocol.ts | 18 ++- .../agentHost/common/state/sessionState.ts | 19 ++- .../electron-browser/agentHostService.ts | 23 ++- .../remoteAgentHostProtocolClient.ts | 110 ++++++++----- .../platform/agentHost/node/agentService.ts | 63 +++++++- .../agentHost/node/copilot/fileEditTracker.ts | 13 +- .../node/copilot/mapSessionEvents.ts | 16 +- .../agentHost/node/protocolServerHandler.ts | 28 +++- .../agentHost/node/sessionDatabase.ts | 41 ++++- .../agentHost/test/node/agentService.test.ts | 8 +- .../test/node/fileEditTracker.test.ts | 4 +- .../test/node/mapSessionEvents.test.ts | 9 +- .../test/node/protocolServerHandler.test.ts | 21 +-- .../test/node/sessionDatabase.test.ts | 16 ++ .../agentHost/agentHostEditingSession.ts | 147 +++++++++++++----- .../agentHost/agentHostSessionHandler.ts | 30 ++-- .../agentHost/loggingAgentConnection.ts | 26 +++- .../agentHost/stateToProgressAdapter.ts | 108 ++++++++----- .../agentHost/agentHostEditingSession.test.ts | 93 +++++------ .../stateToProgressAdapter.test.ts | 41 +++-- 28 files changed, 824 insertions(+), 365 deletions(-) diff --git a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts index 6e3b6e4a38d95..844d4c02c4177 100644 --- a/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts +++ b/src/vs/platform/agentHost/common/agentHostFileSystemProvider.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from '../../../base/common/buffer.js'; +import { decodeBase64, VSBuffer } from '../../../base/common/buffer.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { basename, dirname } from '../../../base/common/resources.js'; @@ -11,17 +11,20 @@ import { URI } from '../../../base/common/uri.js'; import { createFileSystemProviderError, FilePermission, FileSystemProviderCapabilities, FileSystemProviderErrorCode, FileType, IFileChange, IFileDeleteOptions, IFileOverwriteOptions, IFileSystemProvider, IFileWriteOptions, IStat } from '../../files/common/files.js'; import { fromAgentHostUri, toAgentHostUri } from './agentHostUri.js'; import { type IAgentConnection } from './agentService.js'; -import { IBrowseDirectoryResult, IDirectoryEntry, IFetchContentResult } from './state/protocol/commands.js'; +import { ContentEncoding, type IDirectoryEntry, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult } from './state/protocol/commands.js'; /** - * Minimal interface for browsing and fetching files from a remote endpoint. + * Interface for performing resource operations on a remote endpoint. * * Both {@link IAgentConnection} (client→server) and client-exposed * filesystems (server→client) satisfy this contract. */ export interface IRemoteFilesystemConnection { - browseDirectory(uri: URI): Promise; - fetchContent(uri: URI): Promise; + resourceList(uri: URI): Promise; + resourceRead(uri: URI): Promise; + resourceWrite(params: IResourceWriteParams): Promise; + resourceDelete(params: IResourceDeleteParams): Promise; + resourceMove(params: IResourceMoveParams): Promise; } /** @@ -39,23 +42,10 @@ export function agentHostRemotePath(uri: URI): string { return fromAgentHostUri(uri).path; } -// ---- Remote filesystem connection ------------------------------------------- - -/** - * Minimal interface for browsing and fetching files from a remote endpoint. - * - * Both {@link IAgentConnection} (client→server) and client-exposed - * filesystems (server→client) satisfy this contract. - */ -export interface IRemoteFilesystemConnection { - browseDirectory(uri: URI): Promise; - fetchContent(uri: URI): Promise; -} - // ---- Abstract base ---------------------------------------------------------- /** - * Read-only {@link IFileSystemProvider} that proxies filesystem operations + * {@link IFileSystemProvider} that proxies filesystem operations * through a {@link IRemoteFilesystemConnection}. * * URIs encode the original scheme and authority in the path so any remote @@ -67,9 +57,8 @@ export interface IRemoteFilesystemConnection { export abstract class AHPFileSystemProvider extends Disposable implements IFileSystemProvider { readonly capabilities = - FileSystemProviderCapabilities.Readonly | FileSystemProviderCapabilities.PathCaseSensitive | - FileSystemProviderCapabilities.FileReadWrite; // required for the file service to resolve directory contents + FileSystemProviderCapabilities.FileReadWrite; private readonly _onDidChangeCapabilities = this._register(new Emitter()); readonly onDidChangeCapabilities = this._onDidChangeCapabilities.event; @@ -137,7 +126,10 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS const connection = this._getConnection(resource.authority); try { const originalUri = this._decodeUri(resource); - const result = await connection.fetchContent(originalUri); + const result = await connection.resourceRead(originalUri); + if (result.encoding === ContentEncoding.Base64) { + return decodeBase64(result.data).buffer; + } return VSBuffer.fromString(result.data).buffer; } catch (err) { throw createFileSystemProviderError( @@ -147,20 +139,52 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS } } - async writeFile(_resource: URI, _content: Uint8Array, _opts: IFileWriteOptions): Promise { - throw createFileSystemProviderError('writeFile not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + async writeFile(resource: URI, content: Uint8Array, _opts: IFileWriteOptions): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + await connection.resourceWrite({ + uri: originalUri.toString(), + data: VSBuffer.wrap(content).toString(), + encoding: ContentEncoding.Utf8, + }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } } async mkdir(): Promise { throw createFileSystemProviderError('mkdir not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); } - async delete(_resource: URI, _opts: IFileDeleteOptions): Promise { - throw createFileSystemProviderError('delete not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + async delete(resource: URI, opts: IFileDeleteOptions): Promise { + const connection = this._getConnection(resource.authority); + try { + const originalUri = this._decodeUri(resource); + await connection.resourceDelete({ uri: originalUri.toString(), recursive: opts.recursive }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } } - async rename(_from: URI, _to: URI, _opts: IFileOverwriteOptions): Promise { - throw createFileSystemProviderError('rename not supported on remote filesystem', FileSystemProviderErrorCode.NoPermissions); + async rename(from: URI, to: URI, opts: IFileOverwriteOptions): Promise { + const connection = this._getConnection(from.authority); + try { + const originalFrom = this._decodeUri(from); + const originalTo = this._decodeUri(to); + await connection.resourceMove({ source: originalFrom.toString(), destination: originalTo.toString(), failIfExists: !opts.overwrite }); + } catch (err) { + throw createFileSystemProviderError( + err instanceof Error ? err.message : String(err), + FileSystemProviderErrorCode.NoPermissions, + ); + } } // ---- Internals ---------------------------------------------------------- @@ -177,7 +201,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS const connection = this._getConnection(authority); try { const originalUri = this._decodeUri(resource); - const result = await connection.browseDirectory(originalUri); + const result = await connection.resourceList(originalUri); return result.entries; } catch (err) { throw createFileSystemProviderError( @@ -191,7 +215,7 @@ export abstract class AHPFileSystemProvider extends Disposable implements IFileS // ---- Agent Host filesystem (client reads agent host files) ------------------ /** - * Read-only filesystem provider for accessing agent host files from the + * Filesystem provider for accessing agent host files from the * client side. Registered under the `vscode-agent-host` scheme. * * ``` diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 4fcbc7fde6b1f..9610ec1f9c717 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -9,7 +9,7 @@ import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ISyncedCustomization } from './agentPluginManager.js'; import type { IActionEnvelope, INotification, ISessionAction } from './state/sessionActions.js'; -import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot, IWriteFileParams, IWriteFileResult } from './state/sessionProtocol.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from './state/sessionProtocol.js'; import { AttachmentType, type ICustomizationRef, type IPendingMessage, type IToolCallResult, type PolicyState, type StringOrMarkdown } from './state/sessionState.js'; // IPC contract between the renderer and the agent host utility process. @@ -491,19 +491,34 @@ export interface IAgentService { * List the contents of a directory on the agent host's filesystem. * Used by the client to drive a remote folder picker before session creation. */ - browseDirectory(uri: URI): Promise; + resourceList(uri: URI): Promise; /** - * Fetch stored content by URI from the agent host (e.g. file edit snapshots, + * Read stored content by URI from the agent host (e.g. file edit snapshots, * or reading files from the remote filesystem). */ - fetchContent(uri: URI): Promise; + resourceRead(uri: URI): Promise; /** * Write content to a file on the agent host's filesystem. * Used for undo/redo operations on file edits. */ - writeFile(params: IWriteFileParams): Promise; + resourceWrite(params: IResourceWriteParams): Promise; + + /** + * Copy a resource from one URI to another on the agent host's filesystem. + */ + resourceCopy(params: IResourceCopyParams): Promise; + + /** + * Delete a resource at a URI on the agent host's filesystem. + */ + resourceDelete(params: IResourceDeleteParams): Promise; + + /** + * Move (rename) a resource from one URI to another on the agent host's filesystem. + */ + resourceMove(params: IResourceMoveParams): Promise; } /** diff --git a/src/vs/platform/agentHost/common/sessionDataService.ts b/src/vs/platform/agentHost/common/sessionDataService.ts index 4a1b2463ee36c..cc9469b5f00f3 100644 --- a/src/vs/platform/agentHost/common/sessionDataService.ts +++ b/src/vs/platform/agentHost/common/sessionDataService.ts @@ -6,6 +6,7 @@ import { IDisposable, IReference } from '../../../base/common/lifecycle.js'; import { URI } from '../../../base/common/uri.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import type { FileEditKind } from './state/sessionState.js'; export const ISessionDataService = createDecorator('sessionDataService'); @@ -20,8 +21,12 @@ export interface IFileEditRecord { turnId: string; /** The tool call that produced this edit. */ toolCallId: string; - /** Absolute file path that was edited. */ + /** Primary file path (after-path for edits/creates/renames, before-path for deletes). */ filePath: string; + /** The kind of file operation. */ + kind: FileEditKind; + /** For renames, the original file path before the move. */ + originalPath?: string; /** Number of lines added (informational, for diff metadata). */ addedLines: number | undefined; /** Number of lines removed (informational, for diff metadata). */ @@ -31,12 +36,15 @@ export interface IFileEditRecord { /** * The before/after content blobs for a single file edit. * Retrieved on demand via {@link ISessionDatabase.readFileEditContent}. + * + * For creates, `beforeContent` is absent. + * For deletes, `afterContent` is absent. */ export interface IFileEditContent { - /** File content before the edit (may be empty for newly created files). */ - beforeContent: Uint8Array; - /** File content after the edit. */ - afterContent: Uint8Array; + /** File content before the edit. Absent for file creations. */ + beforeContent?: Uint8Array; + /** File content after the edit. Absent for file deletions. */ + afterContent?: Uint8Array; } // ---- Session database --------------------------------------------------- diff --git a/src/vs/platform/agentHost/common/state/protocol/.ahp-version b/src/vs/platform/agentHost/common/state/protocol/.ahp-version index ddbe3aef6b34b..b2f37b431b132 100644 --- a/src/vs/platform/agentHost/common/state/protocol/.ahp-version +++ b/src/vs/platform/agentHost/common/state/protocol/.ahp-version @@ -1 +1 @@ -2743bf6 +b13578c diff --git a/src/vs/platform/agentHost/common/state/protocol/commands.ts b/src/vs/platform/agentHost/common/state/protocol/commands.ts index 889d4a883b87a..fd67388ee2124 100644 --- a/src/vs/platform/agentHost/common/state/protocol/commands.ts +++ b/src/vs/platform/agentHost/common/state/protocol/commands.ts @@ -237,7 +237,7 @@ export interface IListSessionsResult { items: ISessionSummary[]; } -// ─── fetchContent ──────────────────────────────────────────────────────────── +// ─── resourceRead ──────────────────────────────────────────────────────── /** * Encoding of fetched content data. @@ -250,7 +250,7 @@ export const enum ContentEncoding { } /** - * Fetches large content referenced by a `ContentRef` in the state tree. + * Reads the content of a resource by URI. * * Content references keep the state tree small by storing large data (images, * long tool outputs) by reference rather than inline. @@ -259,7 +259,7 @@ export const enum ContentEncoding { * use `utf-8` encoding. * * @category Commands - * @method fetchContent + * @method resourceRead * @direction Client → Server * @messageType Request * @version 1 @@ -268,7 +268,7 @@ export const enum ContentEncoding { * @example * ```jsonc * // Client → Server - * { "jsonrpc": "2.0", "id": 10, "method": "fetchContent", + * { "jsonrpc": "2.0", "id": 10, "method": "resourceRead", * "params": { "uri": "copilot://content/img-1" } } * * // Server → Client @@ -279,7 +279,7 @@ export const enum ContentEncoding { * }} * ``` */ -export interface IFetchContentParams { +export interface IResourceReadParams { /** Content URI from a `ContentRef` */ uri: string; /** Preferred encoding for the returned data (default: server-chosen) */ @@ -287,13 +287,13 @@ export interface IFetchContentParams { } /** - * Result of the `fetchContent` command. + * Result of the `resourceRead` command. * * The server SHOULD honor the `encoding` requested in the params. If the * server cannot provide the requested encoding, it MUST fall back to either * `base64` or `utf-8`. */ -export interface IFetchContentResult { +export interface IResourceReadResult { /** Content encoded as a string */ data: string; /** How `data` is encoded */ @@ -302,7 +302,7 @@ export interface IFetchContentResult { contentType?: string; } -// ─── writeFile ─────────────────────────────────────────────────────────────── +// ─── resourceWrite ─────────────────────────────────────────────────────────── /** * Writes content to a file on the server's filesystem. @@ -314,7 +314,7 @@ export interface IFetchContentResult { * overwritten unless `createOnly` is set. * * @category Commands - * @method writeFile + * @method resourceWrite * @direction Client → Server * @messageType Request * @version 1 @@ -324,7 +324,7 @@ export interface IFetchContentResult { * @example * ```jsonc * // Client → Server - * { "jsonrpc": "2.0", "id": 11, "method": "writeFile", + * { "jsonrpc": "2.0", "id": 11, "method": "resourceWrite", * "params": { "uri": "file:///workspace/hello.txt", "data": "SGVsbG8=", * "encoding": "base64", "contentType": "text/plain" } } * @@ -332,7 +332,7 @@ export interface IFetchContentResult { * { "jsonrpc": "2.0", "id": 11, "result": {} } * ``` */ -export interface IWriteFileParams { +export interface IResourceWriteParams { /** Target file URI on the server filesystem */ uri: URI; /** Content encoded as a string */ @@ -349,14 +349,14 @@ export interface IWriteFileParams { } /** - * Result of the `writeFile` command. + * Result of the `resourceWrite` command. * * An empty object on success. */ -export interface IWriteFileResult { +export interface IResourceWriteResult { } -// ─── browseDirectory ──────────────────────────────────────────────────────── +// ─── resourceList ──────────────────────────────────────────────────────── /** * Lists directory entries at a file URI on the server's filesystem. @@ -369,20 +369,20 @@ export interface IWriteFileResult { * server MUST return a JSON-RPC error. * * @category Commands - * @method browseDirectory + * @method resourceList * @direction Client → Server * @messageType Request * @version 1 * @throws `NotFound` (`-32008`) if the directory does not exist. * @throws `PermissionDenied` (`-32009`) if the client is not permitted to browse the directory. */ -export interface IBrowseDirectoryParams { +export interface IResourceListParams { /** Directory URI on the server filesystem */ uri: URI; } /** - * Directory entry returned by `browseDirectory`. + * Directory entry returned by `resourceList`. */ export interface IDirectoryEntry { /** Base name of the entry */ @@ -392,9 +392,9 @@ export interface IDirectoryEntry { } /** - * Result of the `browseDirectory` command. + * Result of the `resourceList` command. */ -export interface IBrowseDirectoryResult { +export interface IResourceListResult { /** Entries directly contained in the requested directory */ entries: IDirectoryEntry[]; } @@ -483,31 +483,109 @@ export interface IDispatchActionParams { action: IStateAction; } -// ─── browseDirectory ──────────────────────────────────────────────────── +// ─── resourceCopy ──────────────────────────────────────────────────────────── /** - * Lists the contents of a directory on the server. Used by clients to - * present directory pickers or file browsers. + * Copies a resource from one URI to another on the server's filesystem. + * + * If the destination already exists, it is overwritten unless `failIfExists` + * is set. * * @category Commands - * @method browseDirectory + * @method resourceCopy * @direction Client → Server * @messageType Request * @version 1 + * @throws `NotFound` (`-32008`) if the source does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to read the source or write to the destination. + * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. */ -export interface IBrowseDirectoryParams { - /** Directory path to browse. Omit to list the default/root directory. */ - directory?: string; +export interface IResourceCopyParams { + /** Source URI to copy from */ + source: URI; + /** Destination URI to copy to */ + destination: URI; + /** + * If `true`, the server MUST fail if the destination already exists instead + * of overwriting it. + */ + failIfExists?: boolean; } /** - * A single entry in a directory listing. + * Result of the `resourceCopy` command. + * + * An empty object on success. */ -export interface IBrowseDirectoryEntry { - /** Entry name (not a full path) */ - name: string; - /** Whether this entry is a directory */ - isDirectory: boolean; +export interface IResourceCopyResult { +} + +// ─── resourceDelete ────────────────────────────────────────────────────────── + +/** + * Deletes a resource at a URI on the server's filesystem. + * + * @category Commands + * @method resourceDelete + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the resource does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to delete the resource. + */ +export interface IResourceDeleteParams { + /** URI of the resource to delete */ + uri: URI; + /** + * If `true` and the target is a directory, delete it and all its contents + * recursively. If `false` (default), deleting a non-empty directory MUST fail. + */ + recursive?: boolean; +} + +/** + * Result of the `resourceDelete` command. + * + * An empty object on success. + */ +export interface IResourceDeleteResult { +} + +// ─── resourceMove ──────────────────────────────────────────────────────────── + +/** + * Moves (renames) a resource from one URI to another on the server's filesystem. + * + * If the destination already exists, it is overwritten unless `failIfExists` + * is set. + * + * @category Commands + * @method resourceMove + * @direction Client → Server + * @messageType Request + * @version 1 + * @throws `NotFound` (`-32008`) if the source does not exist. + * @throws `PermissionDenied` (`-32009`) if the client is not permitted to move the resource. + * @throws `AlreadyExists` (`-32010`) if `failIfExists` is set and the destination already exists. + */ +export interface IResourceMoveParams { + /** Source URI to move from */ + source: URI; + /** Destination URI to move to */ + destination: URI; + /** + * If `true`, the server MUST fail if the destination already exists instead + * of overwriting it. + */ + failIfExists?: boolean; +} + +/** + * Result of the `resourceMove` command. + * + * An empty object on success. + */ +export interface IResourceMoveResult { } // ─── authenticate ──────────────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/protocol/errors.ts b/src/vs/platform/agentHost/common/state/protocol/errors.ts index d22126e922298..bcf0e7947de3b 100644 --- a/src/vs/platform/agentHost/common/state/protocol/errors.ts +++ b/src/vs/platform/agentHost/common/state/protocol/errors.ts @@ -68,7 +68,7 @@ export const AhpErrorCodes = { PermissionDenied: -32009, /** * The target resource already exists and the operation does not allow - * overwriting (e.g. `writeFile` with `createOnly: true`). + * overwriting (e.g. `resourceWrite` with `createOnly: true`). */ AlreadyExists: -32010, } as const; diff --git a/src/vs/platform/agentHost/common/state/protocol/messages.ts b/src/vs/platform/agentHost/common/state/protocol/messages.ts index 829945a73c597..253893ab6619a 100644 --- a/src/vs/platform/agentHost/common/state/protocol/messages.ts +++ b/src/vs/platform/agentHost/common/state/protocol/messages.ts @@ -6,7 +6,7 @@ // allow-any-unicode-comment-file // DO NOT EDIT -- auto-generated by scripts/sync-agent-host-protocol.ts -import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IFetchContentParams, IFetchContentResult, IWriteFileParams, IWriteFileResult, IBrowseDirectoryParams, IBrowseDirectoryResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; +import type { IInitializeParams, IInitializeResult, IReconnectParams, IReconnectResult, ISubscribeParams, ISubscribeResult, ICreateSessionParams, IDisposeSessionParams, IListSessionsParams, IListSessionsResult, IResourceReadParams, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IResourceListParams, IResourceListResult, IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceMoveParams, IResourceMoveResult, IFetchTurnsParams, IFetchTurnsResult, IUnsubscribeParams, IDispatchActionParams, IAuthenticateParams, IAuthenticateResult } from './commands.js'; import type { IActionEnvelope } from './actions.js'; import type { IProtocolNotification } from './notifications.js'; @@ -63,9 +63,12 @@ export interface ICommandMap { 'createSession': { params: ICreateSessionParams; result: null }; 'disposeSession': { params: IDisposeSessionParams; result: null }; 'listSessions': { params: IListSessionsParams; result: IListSessionsResult }; - 'fetchContent': { params: IFetchContentParams; result: IFetchContentResult }; - 'writeFile': { params: IWriteFileParams; result: IWriteFileResult }; - 'browseDirectory': { params: IBrowseDirectoryParams; result: IBrowseDirectoryResult }; + 'resourceRead': { params: IResourceReadParams; result: IResourceReadResult }; + 'resourceWrite': { params: IResourceWriteParams; result: IResourceWriteResult }; + 'resourceList': { params: IResourceListParams; result: IResourceListResult }; + 'resourceCopy': { params: IResourceCopyParams; result: IResourceCopyResult }; + 'resourceDelete': { params: IResourceDeleteParams; result: IResourceDeleteResult }; + 'resourceMove': { params: IResourceMoveParams; result: IResourceMoveResult }; 'fetchTurns': { params: IFetchTurnsParams; result: IFetchTurnsResult }; 'authenticate': { params: IAuthenticateParams; result: IAuthenticateResult }; } diff --git a/src/vs/platform/agentHost/common/state/protocol/state.ts b/src/vs/platform/agentHost/common/state/protocol/state.ts index 6f27c6a0d2480..49cefab87d7af 100644 --- a/src/vs/platform/agentHost/common/state/protocol/state.ts +++ b/src/vs/platform/agentHost/common/state/protocol/state.ts @@ -454,20 +454,26 @@ export interface IMarkdownResponsePart { /** * A reference to large content stored outside the state tree. - * - * @category Response Parts */ export interface IContentRef { - /** Discriminant */ - kind: ResponsePartKind.ContentRef; /** Content URI */ - uri: string; + uri: URI; /** Approximate size in bytes */ sizeHint?: number; /** Content MIME type */ contentType?: string; } +/** + * A content part that's a reference to large content stored outside the state tree. + * + * @category Response Parts + */ +export interface IResourceReponsePart extends IContentRef { + /** Discriminant */ + kind: ResponsePartKind.ContentRef; +} + /** * A tool call represented as a response part. * @@ -501,7 +507,7 @@ export interface IReasoningResponsePart { /** * @category Response Parts */ -export type IResponsePart = IMarkdownResponsePart | IContentRef | IToolCallResponsePart | IReasoningResponsePart; +export type IResponsePart = IMarkdownResponsePart | IResourceReponsePart | IToolCallResponsePart | IReasoningResponsePart; // ─── Tool Call Types ───────────────────────────────────────────────────────── @@ -786,7 +792,8 @@ export interface IToolAnnotations { */ export const enum ToolResultContentType { Text = 'text', - Binary = 'binary', + EmbeddedResource = 'embeddedResource', + Resource = 'resource', FileEdit = 'fileEdit', } @@ -804,33 +811,55 @@ export interface IToolResultTextContent { } /** - * Base64-encoded binary content in a tool result. + * Base64-encoded binary content embedded in a tool result. * - * Mirrors MCP `ImageContent` but generalized to any binary content type. + * Mirrors MCP `EmbeddedResource` for inline binary data. * * @category Tool Result Content */ -export interface IToolResultBinaryContent { - type: ToolResultContentType.Binary; +export interface IToolResultEmbeddedResourceContent { + type: ToolResultContentType.EmbeddedResource; /** Base64-encoded data */ data: string; /** Content type (e.g. `"image/png"`, `"application/pdf"`) */ contentType: string; } +/** + * A reference to a resource stored outside the tool result. + * + * Wraps {@link IContentRef} for lazy-loading large results. + * + * @category Tool Result Content + */ +export interface IToolResultResourceContent extends IContentRef { + type: ToolResultContentType.Resource; +} + /** * Describes a file modification performed by a tool. * - * Clients can use the `beforeURI`/`afterURI` pair to render a diff view. + * Supports creates (only `after`), deletes (only `before`), renames/moves + * (different `uri` in `before` and `after`), and edits (same `uri`, different content). * * @category Tool Result Content */ export interface IToolResultFileEditContent { type: ToolResultContentType.FileEdit; - /** URI of the file content before the edit */ - beforeURI: URI; - /** URI of the file content after the edit */ - afterURI: URI; + /** The file state before the edit. Absent for file creations or for in-place file edits. */ + before?: { + /** URI of the file before the edit */ + uri: URI; + /** Reference to the file content before the edit */ + content: IContentRef; + }; + /** The file state after the edit. Absent for file deletions. */ + after?: { + /** URI of the file after the edit */ + uri: URI; + /** Reference to the file content after the edit */ + content: IContentRef; + }; /** Optional diff display metadata */ diff?: { /** Number of items added (e.g., lines for text files, cells for notebooks) */ @@ -844,16 +873,16 @@ export interface IToolResultFileEditContent { * Content block in a tool result. * * Mirrors the content blocks in MCP `CallToolResult.content`, plus - * `IContentRef` for lazy-loading large results and `IToolResultFileEditContent` - * for file edit diffs (AHP extensions). + * `IToolResultResourceContent` for lazy-loading large results and + * `IToolResultFileEditContent` for file edit diffs (AHP extensions). * * @category Tool Result Content */ export type IToolResultContent = | IToolResultTextContent - | IToolResultBinaryContent - | IToolResultFileEditContent - | IContentRef; + | IToolResultEmbeddedResourceContent + | IToolResultResourceContent + | IToolResultFileEditContent; // ─── Customization Types ───────────────────────────────────────────────────── diff --git a/src/vs/platform/agentHost/common/state/sessionProtocol.ts b/src/vs/platform/agentHost/common/state/sessionProtocol.ts index ba3a8c33ecb95..b093358ca6ae9 100644 --- a/src/vs/platform/agentHost/common/state/sessionProtocol.ts +++ b/src/vs/platform/agentHost/common/state/sessionProtocol.ts @@ -39,14 +39,10 @@ export type { // Command params and results export type { - IBrowseDirectoryParams, - IBrowseDirectoryResult, ICreateSessionParams, IDirectoryEntry, IDispatchActionParams, IDisposeSessionParams, - IFetchContentParams, - IFetchContentResult, IFetchTurnsParams, IFetchTurnsResult, IInitializeParams, @@ -57,10 +53,20 @@ export type { IReconnectReplayResult, IReconnectResult, IReconnectSnapshotResult, + IResourceCopyParams, + IResourceCopyResult, + IResourceDeleteParams, + IResourceDeleteResult, + IResourceListParams, + IResourceListResult, + IResourceMoveParams, + IResourceMoveResult, + IResourceReadParams, + IResourceReadResult, + IResourceWriteParams, + IResourceWriteResult, ISubscribeParams, IUnsubscribeParams, - IWriteFileParams, - IWriteFileResult, } from './protocol/commands.js'; export { ContentEncoding, ReconnectResultType } from './protocol/commands.js'; diff --git a/src/vs/platform/agentHost/common/state/sessionState.ts b/src/vs/platform/agentHost/common/state/sessionState.ts index 24f5b4a0d7218..6b9ddb22fcaf0 100644 --- a/src/vs/platform/agentHost/common/state/sessionState.ts +++ b/src/vs/platform/agentHost/common/state/sessionState.ts @@ -56,7 +56,7 @@ export { type IToolDefinition, type ICustomizationRef, type ISessionCustomization, - type IToolResultBinaryContent, + type IToolResultEmbeddedResourceContent as IToolResultBinaryContent, type IToolResultContent, type IToolResultFileEditContent, type IToolResultTextContent, @@ -80,6 +80,23 @@ export { TurnState, } from './protocol/state.js'; +// ---- File edit kind --------------------------------------------------------- + +/** + * The kind of file edit operation. Derived from the presence/absence of + * `before`/`after` in {@link IToolResultFileEditContent}. + */ +export const enum FileEditKind { + /** Content edit (same file URI, different content). */ + Edit = 'edit', + /** File creation (no before state). */ + Create = 'create', + /** File deletion (no after state). */ + Delete = 'delete', + /** File rename/move (different before and after URIs). */ + Rename = 'rename', +} + // ---- Well-known URIs -------------------------------------------------------- /** URI for the root state subscription. */ diff --git a/src/vs/platform/agentHost/electron-browser/agentHostService.ts b/src/vs/platform/agentHost/electron-browser/agentHostService.ts index 2f885e0b51e9c..d75cd8c6062dd 100644 --- a/src/vs/platform/agentHost/electron-browser/agentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/agentHostService.ts @@ -15,7 +15,7 @@ import { IConfigurationService } from '../../configuration/common/configuration. import { ILogService } from '../../log/common/log.js'; import { AgentHostEnabledSettingId, AgentHostIpcChannels, IAgentCreateSessionConfig, IAgentDescriptor, IAgentHostService, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; -import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot, IWriteFileParams, IWriteFileResult } from '../common/state/sessionProtocol.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../common/state/sessionProtocol.js'; import { revive } from '../../../base/common/marshalling.js'; import { URI } from '../../../base/common/uri.js'; @@ -120,14 +120,23 @@ class AgentHostServiceClient extends Disposable implements IAgentHostService { nextClientSeq(): number { return this._nextSeq++; } - browseDirectory(uri: URI): Promise { - return this._proxy.browseDirectory(uri); + resourceList(uri: URI): Promise { + return this._proxy.resourceList(uri); } - fetchContent(uri: URI): Promise { - return this._proxy.fetchContent(uri); + resourceRead(uri: URI): Promise { + return this._proxy.resourceRead(uri); } - writeFile(params: IWriteFileParams): Promise { - return this._proxy.writeFile(params); + resourceWrite(params: IResourceWriteParams): Promise { + return this._proxy.resourceWrite(params); + } + resourceCopy(params: IResourceCopyParams): Promise { + return this._proxy.resourceCopy(params); + } + resourceDelete(params: IResourceDeleteParams): Promise { + return this._proxy.resourceDelete(params); + } + resourceMove(params: IResourceMoveParams): Promise { + return this._proxy.resourceMove(params); } async restartAgentHost(): Promise { // Restart is handled by the main process side diff --git a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts index 30c7870bda439..cef610a4d23f4 100644 --- a/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/electron-browser/remoteAgentHostProtocolClient.ts @@ -14,7 +14,7 @@ import { hasKey } from '../../../base/common/types.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { ILogService } from '../../log/common/log.js'; -import { IFileService } from '../../files/common/files.js'; +import { FileSystemProviderErrorCode, IFileService, toFileSystemProviderErrorCode } from '../../files/common/files.js'; import { AgentSession, IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import { agentHostAuthority, fromAgentHostUri, toAgentHostUri } from '../common/agentHostUri.js'; import type { IClientNotificationMap, ICommandMap } from '../common/state/protocol/messages.js'; @@ -22,9 +22,10 @@ import type { IActionEnvelope, INotification, ISessionAction } from '../common/s import { PROTOCOL_VERSION } from '../common/state/sessionCapabilities.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, type IJsonRpcResponse, type IProtocolMessage, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { isClientTransport, type IProtocolTransport } from '../common/state/sessionTransport.js'; +import { AhpErrorCodes } from '../common/state/protocol/errors.js'; import { ContentEncoding } from '../common/state/protocol/commands.js'; import type { ISessionSummary } from '../common/state/sessionState.js'; -import { encodeBase64 } from '../../../base/common/buffer.js'; +import { decodeBase64, encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; /** * A protocol-level client for a single remote agent host connection. @@ -208,19 +209,31 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC /** * List the contents of a directory on the remote host's filesystem. */ - async browseDirectory(uri: URI): Promise { - return await this._sendRequest('browseDirectory', { uri: uri.toString() }); + async resourceList(uri: URI): Promise { + return await this._sendRequest('resourceList', { uri: uri.toString() }); } /** - * Fetch the content of a file on the remote host's filesystem. + * Read the content of a resource on the remote host. */ - async fetchContent(uri: URI): Promise { - return this._sendRequest('fetchContent', { uri: uri.toString() }); + async resourceRead(uri: URI): Promise { + return this._sendRequest('resourceRead', { uri: uri.toString() }); } - async writeFile(params: ICommandMap['writeFile']['params']): Promise { - return this._sendRequest('writeFile', params); + async resourceWrite(params: ICommandMap['resourceWrite']['params']): Promise { + return this._sendRequest('resourceWrite', params); + } + + async resourceCopy(params: ICommandMap['resourceCopy']['params']): Promise { + return this._sendRequest('resourceCopy', params); + } + + async resourceDelete(params: ICommandMap['resourceDelete']['params']): Promise { + return this._sendRequest('resourceDelete', params); + } + + async resourceMove(params: ICommandMap['resourceMove']['params']): Promise { + return this._sendRequest('resourceMove', params); } private _handleMessage(msg: IProtocolMessage): void { @@ -264,45 +277,64 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC } /** - * Handles reverse RPC requests from the server (e.g. browseDirectory, - * fetchContent). Reads from the local file service and sends a response. + * Handles reverse RPC requests from the server (e.g. resourceList, + * resourceRead). Reads from the local file service and sends a response. */ private _handleReverseRequest(id: number, method: string, params: unknown): void { const sendResult = (result: unknown) => { - const response: IJsonRpcResponse = { jsonrpc: '2.0', id, result }; - this._transport.send(response); + this._transport.send({ jsonrpc: '2.0', id, result }); + }; + const sendError = (err: unknown) => { + const fsCode = toFileSystemProviderErrorCode(err instanceof Error ? err : undefined); + let code = -32000; + switch (fsCode) { + case FileSystemProviderErrorCode.FileNotFound: code = AhpErrorCodes.NotFound; break; + case FileSystemProviderErrorCode.NoPermissions: code = AhpErrorCodes.PermissionDenied; break; + case FileSystemProviderErrorCode.FileExists: code = AhpErrorCodes.AlreadyExists; break; + } + this._transport.send({ jsonrpc: '2.0', id, error: { code, message: err instanceof Error ? err.message : String(err) } }); }; - const sendError = (message: string) => { - const response: IJsonRpcResponse = { jsonrpc: '2.0', id, error: { code: -32000, message } }; - this._transport.send(response); + const handle = (fn: () => Promise) => { + fn().then(sendResult, sendError); }; - const p = params as { uri?: string }; + const p = params as Record; switch (method) { - case 'browseDirectory': { - if (!p.uri) { sendError('Missing uri'); return; } - this._fileService.resolve(URI.parse(p.uri)).then(stat => { - const entries = (stat.children ?? []).map(c => ({ - name: c.name, - type: c.isDirectory ? 'directory' as const : 'file' as const, - })); - sendResult({ entries }); - }).catch(err => sendError(err instanceof Error ? err.message : String(err))); - return; - } - case 'fetchContent': { - if (!p.uri) { sendError('Missing uri'); return; } - this._fileService.readFile(URI.parse(p.uri)).then(content => { - sendResult({ - data: encodeBase64(content.value), - encoding: ContentEncoding.Base64, - }); - }).catch(err => sendError(err instanceof Error ? err.message : String(err))); - return; - } + case 'resourceList': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(async () => { + const stat = await this._fileService.resolve(URI.parse(p.uri as string)); + return { entries: (stat.children ?? []).map(c => ({ name: c.name, type: c.isDirectory ? 'directory' as const : 'file' as const })) }; + }); + case 'resourceRead': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(async () => { + const content = await this._fileService.readFile(URI.parse(p.uri as string)); + return { data: encodeBase64(content.value), encoding: ContentEncoding.Base64 }; + }); + case 'resourceWrite': + if (!p.uri || !p.data) { sendError(new Error('Missing uri or data')); return; } + return handle(async () => { + const writeUri = URI.parse(p.uri as string); + const buf = p.encoding === ContentEncoding.Base64 + ? decodeBase64(p.data as string) + : VSBuffer.fromString(p.data as string); + if (p.createOnly) { + await this._fileService.createFile(writeUri, buf, { overwrite: false }); + } else { + await this._fileService.writeFile(writeUri, buf); + } + return {}; + }); + case 'resourceDelete': + if (!p.uri) { sendError(new Error('Missing uri')); return; } + return handle(() => this._fileService.del(URI.parse(p.uri as string), { recursive: !!p.recursive }).then(() => ({}))); + case 'resourceMove': + if (!p.source || !p.destination) { sendError(new Error('Missing source or destination')); return; } + return handle(() => this._fileService.move(URI.parse(p.source as string), URI.parse(p.destination as string), !p.failIfExists).then(() => ({}))); default: this._logService.warn(`[RemoteAgentHostProtocol] Unhandled reverse request: ${method}`); - sendError(`Unknown method: ${method}`); + sendError(new Error(`Unknown method: ${method}`)); } } diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6614244c2d9df..6612067ee1799 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -14,7 +14,7 @@ import { ILogService } from '../../log/common/log.js'; import { AgentProvider, AgentSession, IAgent, IAgentCreateSessionConfig, IAgentDescriptor, IAgentMessageEvent, IAgentService, IAgentSessionMetadata, IAgentToolCompleteEvent, IAgentToolStartEvent, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../common/agentService.js'; import { ISessionDataService } from '../common/sessionDataService.js'; import { ActionType, IActionEnvelope, INotification, ISessionAction } from '../common/state/sessionActions.js'; -import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IBrowseDirectoryResult, type IDirectoryEntry, type IFetchContentResult, type IStateSnapshot, type IWriteFileParams, type IWriteFileResult } from '../common/state/sessionProtocol.js'; +import { AhpErrorCodes, AHP_SESSION_NOT_FOUND, ContentEncoding, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IDirectoryEntry, type IResourceCopyParams, type IResourceCopyResult, type IResourceDeleteParams, type IResourceDeleteResult, type IResourceListResult, type IResourceMoveParams, type IResourceMoveResult, type IResourceReadResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../common/state/sessionProtocol.js'; import { ResponsePartKind, SessionStatus, ToolCallConfirmationReason, ToolCallStatus, TurnState, type IResponsePart, type ISessionSummary, type IToolCallCompletedState, type ITurn } from '../common/state/sessionState.js'; import { AgentSideEffects } from './agentSideEffects.js'; import { ISessionDbUriFields, parseSessionDbUri } from './copilot/fileEditTracker.js'; @@ -264,7 +264,7 @@ export class AgentService extends Disposable implements IAgentService { this._sideEffects.handleAction(action); } - async browseDirectory(uri: URI): Promise { + async resourceList(uri: URI): Promise { let stat; try { stat = await this._fileService.resolve(uri); @@ -360,7 +360,7 @@ export class AgentService extends Disposable implements IAgentService { this._logService.info(`[AgentService] Restored session ${sessionStr} with ${turns.length} turns`); } - async fetchContent(uri: URI): Promise { + async resourceRead(uri: URI): Promise { // Handle session-db: URIs that reference file-edit content stored // in a per-session SQLite database. const dbFields = parseSessionDbUri(uri.toString()); @@ -380,7 +380,7 @@ export class AgentService extends Disposable implements IAgentService { } } - async writeFile(params: IWriteFileParams): Promise { + async resourceWrite(params: IResourceWriteParams): Promise { const fileUri = typeof params.uri === 'string' ? URI.parse(params.uri) : URI.revive(params.uri); let content: VSBuffer; if (params.encoding === ContentEncoding.Base64) { @@ -407,6 +407,56 @@ export class AgentService extends Disposable implements IAgentService { } } + async resourceCopy(params: IResourceCopyParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + try { + await this._fileService.copy(source, destination, !params.failIfExists); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); + } + } + + async resourceDelete(params: IResourceDeleteParams): Promise { + const fileUri = URI.parse(params.uri); + try { + await this._fileService.del(fileUri, { recursive: params.recursive }); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${fileUri.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Resource not found: ${fileUri.toString()}`); + } + } + + async resourceMove(params: IResourceMoveParams): Promise { + const source = URI.parse(params.source); + const destination = URI.parse(params.destination); + try { + await this._fileService.move(source, destination, !params.failIfExists); + return {}; + } catch (e) { + const code = toFileSystemProviderErrorCode(e as Error); + if (code === FileSystemProviderErrorCode.FileExists) { + throw new ProtocolError(AhpErrorCodes.AlreadyExists, `Destination already exists: ${destination.toString()}`); + } + if (code === FileSystemProviderErrorCode.NoPermissions) { + throw new ProtocolError(AhpErrorCodes.PermissionDenied, `Permission denied: ${source.toString()}`); + } + throw new ProtocolError(AhpErrorCodes.NotFound, `Source not found: ${source.toString()}`); + } + } + async shutdown(): Promise { this._logService.info('AgentService: shutting down all providers...'); const promises: Promise[] = []; @@ -514,7 +564,7 @@ export class AgentService extends Disposable implements IAgentService { return turns; } - private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { + private async _fetchSessionDbContent(fields: ISessionDbUriFields): Promise { const sessionUri = URI.parse(fields.sessionUri); const ref = this._sessionDataService.openDatabase(sessionUri); try { @@ -523,6 +573,9 @@ export class AgentService extends Disposable implements IAgentService { throw new ProtocolError(AhpErrorCodes.NotFound, `File edit not found: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); } const bytes = fields.part === 'before' ? content.beforeContent : content.afterContent; + if (!bytes) { + throw new ProtocolError(AhpErrorCodes.NotFound, `No ${fields.part} content for: toolCallId=${fields.toolCallId}, filePath=${fields.filePath}`); + } return { data: new TextDecoder().decode(bytes), encoding: ContentEncoding.Utf8, diff --git a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts index 7eee79b096e26..69b401e6069ac 100644 --- a/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts +++ b/src/vs/platform/agentHost/node/copilot/fileEditTracker.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IFileService } from '../../../files/common/files.js'; import { ILogService } from '../../../log/common/log.js'; import { ISessionDatabase } from '../../common/sessionDataService.js'; -import { ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; +import { FileEditKind, ToolResultContentType, type IToolResultFileEditContent } from '../../common/state/sessionState.js'; const SESSION_DB_SCHEME = 'session-db'; @@ -144,6 +144,7 @@ export class FileEditTracker { turnId, toolCallId, filePath, + kind: FileEditKind.Edit, beforeContent: beforeBytes, afterContent: afterBytes, addedLines: undefined, @@ -152,8 +153,14 @@ export class FileEditTracker { return { type: ToolResultContentType.FileEdit, - beforeURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before'), - afterURI: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after'), + before: { + uri: URI.file(filePath).toString(), + content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'before') }, + }, + after: { + uri: URI.file(filePath).toString(), + content: { uri: buildSessionDbUri(this._sessionUri, toolCallId, filePath, 'after') }, + }, }; } diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index f9bfbf1470d45..4a651512eb821 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -186,10 +186,22 @@ export async function mapSessionEvents( const edits = storedEdits?.get(d.toolCallId); if (edits) { for (const edit of edits) { + const beforeUri = edit.kind === 'rename' && edit.originalPath + ? URI.file(edit.originalPath).toString() + : URI.file(edit.filePath).toString(); + const afterUri = URI.file(edit.filePath).toString(); + const hasBefore = edit.kind !== 'create'; + const hasAfter = edit.kind !== 'delete'; content.push({ type: ToolResultContentType.FileEdit, - beforeURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before'), - afterURI: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after'), + before: hasBefore ? { + uri: beforeUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'before') }, + } : undefined, + after: hasAfter ? { + uri: afterUri, + content: { uri: buildSessionDbUri(sessionUriStr, edit.toolCallId, edit.filePath, 'after') }, + } : undefined, diff: (edit.addedLines !== undefined || edit.removedLines !== undefined) ? { added: edit.addedLines, removed: edit.removedLines } : undefined, diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 07f4fb3a3a042..311d823078540 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -237,8 +237,11 @@ export class ProtocolServerHandler extends Disposable { this._onDidChangeConnectionCount.fire(this._clients.size); disposables.add(this._clientFileSystemProvider.registerAuthority(params.clientId, { - browseDirectory: (uri) => this._sendReverseRequest(params.clientId, 'browseDirectory', { uri: uri.toString() }), - fetchContent: (uri) => this._sendReverseRequest(params.clientId, 'fetchContent', { uri: uri.toString() }), + resourceList: (uri) => this._sendReverseRequest(params.clientId, 'resourceList', { uri: uri.toString() }), + resourceRead: (uri) => this._sendReverseRequest(params.clientId, 'resourceRead', { uri: uri.toString() }), + resourceWrite: (params_) => this._sendReverseRequest(params.clientId, 'resourceWrite', params_), + resourceDelete: (params_) => this._sendReverseRequest(params.clientId, 'resourceDelete', params_), + resourceMove: (params_) => this._sendReverseRequest(params.clientId, 'resourceMove', params_), })); @@ -369,8 +372,8 @@ export class ProtocolServerHandler extends Disposable { await this._agentService.disposeSession(URI.parse(params.session)); return null; }, - writeFile: async (_client, params) => { - return this._agentService.writeFile(params); + resourceWrite: async (_client, params) => { + return this._agentService.resourceWrite(params); }, listSessions: async () => { const sessions = await this._agentService.listSessions(); @@ -407,11 +410,20 @@ export class ProtocolServerHandler extends Disposable { hasMore: startIndex > 0, }; }, - browseDirectory: async (_client, params) => { - return this._agentService.browseDirectory(URI.parse(params.uri)); + resourceList: async (_client, params) => { + return this._agentService.resourceList(URI.parse(params.uri)); }, - fetchContent: async (_client, params) => { - return this._agentService.fetchContent(URI.parse(params.uri)); + resourceRead: async (_client, params) => { + return this._agentService.resourceRead(URI.parse(params.uri)); + }, + resourceCopy: async (_client, params) => { + return this._agentService.resourceCopy(params); + }, + resourceDelete: async (_client, params) => { + return this._agentService.resourceDelete(params); + }, + resourceMove: async (_client, params) => { + return this._agentService.resourceMove(params); }, }; diff --git a/src/vs/platform/agentHost/node/sessionDatabase.ts b/src/vs/platform/agentHost/node/sessionDatabase.ts index 77b72f26a4a25..aab5aff8025c4 100644 --- a/src/vs/platform/agentHost/node/sessionDatabase.ts +++ b/src/vs/platform/agentHost/node/sessionDatabase.ts @@ -51,6 +51,29 @@ export const sessionDatabaseMigrations: readonly ISessionDatabaseMigration[] = [ value TEXT NOT NULL )`, }, + { + version: 3, + sql: [ + // Recreate file_edits with new columns: edit_type, original_path, + // and nullable before_content/after_content. + `CREATE TABLE file_edits_v3 ( + turn_id TEXT NOT NULL REFERENCES turns(id) ON DELETE CASCADE, + tool_call_id TEXT NOT NULL, + file_path TEXT NOT NULL, + edit_type TEXT NOT NULL DEFAULT 'edit', + original_path TEXT, + before_content BLOB, + after_content BLOB, + added_lines INTEGER, + removed_lines INTEGER, + PRIMARY KEY (tool_call_id, file_path) + )`, + `INSERT INTO file_edits_v3 (turn_id, tool_call_id, file_path, edit_type, before_content, after_content, added_lines, removed_lines) + SELECT turn_id, tool_call_id, file_path, 'edit', before_content, after_content, added_lines, removed_lines FROM file_edits`, + `DROP TABLE file_edits`, + `ALTER TABLE file_edits_v3 RENAME TO file_edits`, + ].join(';\n'), + }, ]; // ---- Promise wrappers around callback-based @vscode/sqlite3 API ----------- @@ -242,14 +265,16 @@ export class SessionDatabase implements ISessionDatabase { await dbRun( db, `INSERT OR REPLACE INTO file_edits - (turn_id, tool_call_id, file_path, before_content, after_content, added_lines, removed_lines) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + (turn_id, tool_call_id, file_path, edit_type, original_path, before_content, after_content, added_lines, removed_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ edit.turnId, edit.toolCallId, edit.filePath, - Buffer.from(edit.beforeContent), - Buffer.from(edit.afterContent), + edit.kind, + edit.originalPath ?? null, + edit.beforeContent ? Buffer.from(edit.beforeContent) : null, + edit.afterContent ? Buffer.from(edit.afterContent) : null, edit.addedLines ?? null, edit.removedLines ?? null, ], @@ -265,7 +290,7 @@ export class SessionDatabase implements ISessionDatabase { const placeholders = toolCallIds.map(() => '?').join(','); const rows = await dbAll( db, - `SELECT turn_id, tool_call_id, file_path, added_lines, removed_lines + `SELECT turn_id, tool_call_id, file_path, edit_type, original_path, added_lines, removed_lines FROM file_edits WHERE tool_call_id IN (${placeholders}) ORDER BY rowid`, @@ -275,6 +300,8 @@ export class SessionDatabase implements ISessionDatabase { turnId: row.turn_id as string, toolCallId: row.tool_call_id as string, filePath: row.file_path as string, + kind: (row.edit_type as IFileEditRecord['kind']) ?? 'edit', + originalPath: row.original_path as string | undefined ?? undefined, addedLines: row.added_lines as number | undefined ?? undefined, removedLines: row.removed_lines as number | undefined ?? undefined, })); @@ -294,8 +321,8 @@ export class SessionDatabase implements ISessionDatabase { return undefined; } return { - beforeContent: toUint8Array(row.before_content), - afterContent: toUint8Array(row.after_content), + beforeContent: row.before_content ? toUint8Array(row.before_content) : undefined, + afterContent: row.after_content ? toUint8Array(row.after_content) : undefined, }; }); } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index cec34d9738b00..3b41fa674cfa1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -375,20 +375,20 @@ suite('AgentService (node dispatcher)', () => { }); }); - // ---- browseDirectory ------------------------------------------------ + // ---- resourceList ------------------------------------------------ - suite('browseDirectory', () => { + suite('resourceList', () => { test('throws when the directory does not exist', async () => { await assert.rejects( - () => service.browseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })), + () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/nonexistent' })), /Directory not found/, ); }); test('throws when the target is not a directory', async () => { await assert.rejects( - () => service.browseDirectory(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })), + () => service.resourceList(URI.from({ scheme: Schemas.inMemory, path: '/testDir/file.txt' })), /Not a directory/, ); }); diff --git a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts index dec81f59f3e4e..8433372ab0f8d 100644 --- a/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts +++ b/src/vs/platform/agentHost/test/node/fileEditTracker.test.ts @@ -51,14 +51,14 @@ suite('FileEditTracker', () => { assert.strictEqual(fileEdit.type, ToolResultContentType.FileEdit); // URIs are parseable session-db: URIs - const beforeFields = parseSessionDbUri(fileEdit.beforeURI); + const beforeFields = parseSessionDbUri(fileEdit.before!.content.uri); assert.ok(beforeFields); assert.strictEqual(beforeFields.sessionUri, 'copilot:/test-session'); assert.strictEqual(beforeFields.toolCallId, 'tc-1'); assert.strictEqual(beforeFields.filePath, '/workspace/test.txt'); assert.strictEqual(beforeFields.part, 'before'); - const afterFields = parseSessionDbUri(fileEdit.afterURI); + const afterFields = parseSessionDbUri(fileEdit.after!.content.uri); assert.ok(afterFields); assert.strictEqual(afterFields.part, 'after'); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 034dbb4943550..3472a3019a717 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -7,7 +7,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; -import { ToolResultContentType } from '../../common/state/sessionState.js'; +import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { parseSessionDbUri } from '../../node/copilot/fileEditTracker.js'; import { mapSessionEvents, type ISessionEvent } from '../../node/copilot/mapSessionEvents.js'; @@ -102,6 +102,7 @@ suite('mapSessionEvents', () => { turnId: 'turn-1', toolCallId: 'tc-edit', filePath: '/workspace/file.ts', + kind: FileEditKind.Edit, beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), addedLines: 3, @@ -131,8 +132,8 @@ suite('mapSessionEvents', () => { assert.strictEqual(content[1].type, ToolResultContentType.FileEdit); // File edit URIs should be parseable - const fileEdit = content[1] as { beforeURI: string; afterURI: string; diff?: { added?: number; removed?: number } }; - const beforeFields = parseSessionDbUri(fileEdit.beforeURI); + const fileEdit = content[1] as { before: { uri: any; content: { uri: any } }; after: { uri: any; content: { uri: any } }; diff?: { added?: number; removed?: number } }; + const beforeFields = parseSessionDbUri(fileEdit.before.content.uri); assert.ok(beforeFields); assert.strictEqual(beforeFields.toolCallId, 'tc-edit'); assert.strictEqual(beforeFields.filePath, '/workspace/file.ts'); @@ -147,6 +148,7 @@ suite('mapSessionEvents', () => { turnId: 'turn-1', toolCallId: 'tc-multi', filePath: '/workspace/a.ts', + kind: FileEditKind.Edit, beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('a'), addedLines: undefined, @@ -156,6 +158,7 @@ suite('mapSessionEvents', () => { turnId: 'turn-1', toolCallId: 'tc-multi', filePath: '/workspace/b.ts', + kind: FileEditKind.Edit, beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('b'), addedLines: undefined, diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 5dc16e87966af..64a77cd26b53c 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -10,10 +10,10 @@ import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { NullLogService } from '../../../log/common/log.js'; import type { IAgentCreateSessionConfig, IAgentDescriptor, IAgentService, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata } from '../../common/agentService.js'; -import { IFetchContentResult } from '../../common/state/protocol/commands.js'; +import { IResourceReadResult } from '../../common/state/protocol/commands.js'; import { ActionType, type ISessionAction } from '../../common/state/sessionActions.js'; import { PROTOCOL_VERSION } from '../../common/state/sessionCapabilities.js'; -import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IBrowseDirectoryResult, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IStateSnapshot, type IWriteFileParams, type IWriteFileResult } from '../../common/state/sessionProtocol.js'; +import { isJsonRpcNotification, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, ProtocolError, type IAhpNotification, type IInitializeResult, type IProtocolMessage, type IReconnectResult, type IResourceListResult, type IResourceWriteParams, type IResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { SessionStatus, type ISessionSummary } from '../../common/state/sessionState.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; @@ -105,8 +105,8 @@ class MockAgentService implements IAgentService { async authenticate(_params: IAuthenticateParams): Promise { return { authenticated: true }; } async refreshModels(): Promise { } async listAgents(): Promise { return []; } - async writeFile(_params: IWriteFileParams): Promise { return {}; } - async browseDirectory(uri: URI): Promise { + async resourceWrite(_params: IResourceWriteParams): Promise { return {}; } + async resourceList(uri: URI): Promise { this.browsedUris.push(uri); const error = this.browseErrors.get(uri.toString()); if (error) { @@ -119,9 +119,12 @@ class MockAgentService implements IAgentService { ], }; } - async fetchContent(_uri: URI): Promise { + async resourceRead(_uri: URI): Promise { throw new Error('Not implemented'); } + async resourceCopy(): Promise<{}> { return {}; } + async resourceDelete(): Promise<{}> { return {}; } + async resourceMove(): Promise<{}> { return {}; } dispose(): void { this._onDidAction.dispose(); @@ -386,13 +389,13 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(URI.parse(result.defaultDirectory!).path, '/home/testuser'); }); - test('browseDirectory routes to side effect handler', async () => { + test('resourceList routes to side effect handler', async () => { const transport = connectClient('client-browse'); transport.sent.length = 0; const dirUri = URI.file('/home/user/project').toString(); const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri })); + transport.simulateMessage(request(2, 'resourceList', { uri: dirUri })); const resp = await responsePromise; assert.strictEqual(agentService.browsedUris.length, 1); @@ -407,14 +410,14 @@ suite('ProtocolServerHandler', () => { assert.strictEqual(result.entries[1].type, 'file'); }); - test('browseDirectory returns a JSON-RPC error when the target is invalid', async () => { + test('resourceList returns a JSON-RPC error when the target is invalid', async () => { const transport = connectClient('client-browse-error'); transport.sent.length = 0; const dirUri = URI.file('/missing').toString(); agentService.browseErrors.set(URI.file('/missing').toString(), new ProtocolError(JSON_RPC_INTERNAL_ERROR, `Directory not found: ${dirUri}`)); const responsePromise = waitForResponse(transport, 2); - transport.simulateMessage(request(2, 'browseDirectory', { uri: dirUri })); + transport.simulateMessage(request(2, 'resourceList', { uri: dirUri })); const resp = await responsePromise as { error?: { code: number; message: string } }; assert.ok(resp?.error); diff --git a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts index fbf66f35f7b02..23d26104eb73a 100644 --- a/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts +++ b/src/vs/platform/agentHost/test/node/sessionDatabase.test.ts @@ -7,6 +7,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; import { SessionDatabase, runMigrations, sessionDatabaseMigrations, type ISessionDatabaseMigration } from '../../node/sessionDatabase.js'; +import { FileEditKind } from '../../common/state/sessionState.js'; import type { Database } from '@vscode/sqlite3'; suite('SessionDatabase', () => { @@ -125,6 +126,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), @@ -136,7 +138,9 @@ suite('SessionDatabase', () => { assert.deepStrictEqual(edits, [{ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', + originalPath: undefined, addedLines: 5, removedLines: 2, }]); @@ -149,6 +153,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new TextEncoder().encode('a-before'), afterContent: new TextEncoder().encode('a-after'), @@ -158,6 +163,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/b.ts', beforeContent: new TextEncoder().encode('b-before'), afterContent: new TextEncoder().encode('b-after'), @@ -178,6 +184,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('hello'), @@ -187,6 +194,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-2', + kind: FileEditKind.Edit, filePath: '/workspace/b.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('world'), @@ -222,6 +230,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('v1'), afterContent: new TextEncoder().encode('v1-after'), @@ -231,6 +240,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('v2'), afterContent: new TextEncoder().encode('v2-after'), @@ -254,6 +264,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/file.ts', beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), @@ -281,6 +292,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-bin', + kind: FileEditKind.Edit, filePath: '/workspace/image.png', beforeContent: new Uint8Array(0), afterContent: binary, @@ -300,6 +312,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'auto-turn', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/x', beforeContent: new Uint8Array(0), afterContent: new Uint8Array(0), @@ -330,6 +343,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new TextEncoder().encode('before'), afterContent: new TextEncoder().encode('after'), @@ -353,6 +367,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-1', toolCallId: 'tc-1', + kind: FileEditKind.Edit, filePath: '/workspace/a.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('a'), @@ -362,6 +377,7 @@ suite('SessionDatabase', () => { await db.storeFileEdit({ turnId: 'turn-2', toolCallId: 'tc-2', + kind: FileEditKind.Edit, filePath: '/workspace/b.ts', beforeContent: new Uint8Array(0), afterContent: new TextEncoder().encode('b'), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts index d604f351efb69..8df04dc28b3d6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostEditingSession.ts @@ -19,8 +19,7 @@ import { IEditorWorkerService } from '../../../../../../editor/common/services/e import { ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { localize } from '../../../../../../nls.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; -import { ContentEncoding, IWriteFileParams } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; -import { getToolFileEdits, ToolCallStatus, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { FileEditKind, ToolCallStatus, type IToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { EditorActivation } from '../../../../../../platform/editor/common/editor.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -36,19 +35,11 @@ import { fileEditsToExternalEdits, type IToolCallFileEdit } from './stateToProgr // ---- Internal data model ---------------------------------------------------- -interface IAgentHostFileEdit { - readonly resource: URI; - readonly beforeContentUri: URI; - readonly afterContentUri: URI; - readonly undoStopId: string; - readonly diff?: { added?: number; removed?: number }; -} - interface IAgentHostCheckpoint { readonly requestId: string; /** Tool-call ID, or `undefined` for the sentinel checkpoint at request start. */ readonly undoStopId: string | undefined; - readonly edits: IAgentHostFileEdit[]; + readonly edits: IToolCallFileEdit[]; } // ---- Modified file entry ---------------------------------------------------- @@ -145,9 +136,6 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS private readonly _onDidDispose = this._register(new Emitter()); readonly onDidDispose: Event = this._onDidDispose.event; - private readonly _onDidRequestFileWrite = this._register(new Emitter()); - readonly onDidRequestFileWrite: Event = this._onDidRequestFileWrite.event; - private readonly _checkpoints: IAgentHostCheckpoint[] = []; private readonly _currentCheckpointIndex = observableValue(this, -1); private readonly _diffCache = new Map(); @@ -214,14 +202,15 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } const authority = this._connectionAuthority; - const protocolEdits = getToolFileEdits(tc); - const edits: IAgentHostFileEdit[] = fileEdits.map((edit: IToolCallFileEdit, i: number) => ({ + const edits: IToolCallFileEdit[] = fileEdits.map((edit: IToolCallFileEdit) => ({ + kind: edit.kind, resource: toAgentHostUri(edit.resource, authority), - beforeContentUri: toAgentHostUri(edit.beforeContentUri, authority), - afterContentUri: toAgentHostUri(edit.afterContentUri, authority), + originalResource: edit.originalResource ? toAgentHostUri(edit.originalResource, authority) : undefined, + beforeContentUri: edit.beforeContentUri ? toAgentHostUri(edit.beforeContentUri, authority) : undefined, + afterContentUri: edit.afterContentUri ? toAgentHostUri(edit.afterContentUri, authority) : undefined, undoStopId: edit.undoStopId, - diff: protocolEdits[i]?.diff, + diff: edit.diff, })); const checkpoint: IAgentHostCheckpoint = { @@ -244,11 +233,24 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS // Build progress parts for the file edit pills in the chat response const progressParts: IChatProgress[] = []; for (const edit of edits) { - progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); - progressParts.push({ kind: 'codeblockUri', uri: edit.resource, isEdit: true, undoStopId: tc.toolCallId }); - progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: false, isExternalEdit: true }); - progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: true, isExternalEdit: true }); - progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + // Emit workspace file edit progress for creates, deletes, and renames + if (edit.kind === FileEditKind.Create || edit.kind === FileEditKind.Delete || edit.kind === FileEditKind.Rename) { + progressParts.push({ + kind: 'workspaceEdit', + edits: [{ + oldResource: edit.originalResource ?? (edit.kind === FileEditKind.Delete ? edit.resource : undefined), + newResource: edit.kind === FileEditKind.Delete ? undefined : edit.resource, + }], + }); + } + // Emit code-block UI for content edits (and renames/creates with content) + if (edit.afterContentUri) { + progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + progressParts.push({ kind: 'codeblockUri', uri: edit.resource, isEdit: true, undoStopId: tc.toolCallId }); + progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: false, isExternalEdit: true }); + progressParts.push({ kind: 'textEdit', uri: edit.resource, edits: [], done: true, isExternalEdit: true }); + progressParts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + } } return progressParts; } @@ -378,6 +380,9 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } try { + if (!edit.afterContentUri) { + return VSBuffer.fromByteArray([]); + } const content = await this._fileService.readFile(edit.afterContentUri); return content.value; } catch (err) { @@ -697,7 +702,7 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS private _rebuildEntries(): void { const currentIdx = this._currentCheckpointIndex.get(); - const resourceMap = new Map(); + const resourceMap = new Map(); for (let i = 0; i <= currentIdx && i < this._checkpoints.length; i++) { const cp = this._checkpoints[i]; @@ -706,7 +711,9 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS const existing = resourceMap.get(key); if (existing) { // Update after-content to the latest, accumulate diff counts - existing.afterContentUri = edit.afterContentUri; + if (edit.afterContentUri) { + existing.afterContentUri = edit.afterContentUri; + } existing.requestId = cp.requestId; existing.added += edit.diff?.added ?? 0; existing.removed += edit.diff?.removed ?? 0; @@ -723,28 +730,90 @@ export class AgentHostEditingSession extends Disposable implements IChatEditingS } } - const entries = [...resourceMap.values()].map(v => - new AgentHostModifiedFileEntry(v.resource, v.beforeContentUri, v.requestId, v.added, v.removed) - ); + const entries = [...resourceMap.values()] + .filter(v => v.beforeContentUri && v.afterContentUri) + .map(v => + new AgentHostModifiedFileEntry(v.resource, v.beforeContentUri!, v.requestId, v.added, v.removed) + ); this._entriesObs.set(entries, undefined); } private async _writeCheckpointContent(checkpoint: IAgentHostCheckpoint, direction: 'before' | 'after'): Promise { - const writes = checkpoint.edits.map(async edit => { - const contentUri = direction === 'before' ? edit.beforeContentUri : edit.afterContentUri; + const ops = checkpoint.edits.map(async edit => { try { - const file = await this._fileService.readFile(contentUri); - this._onDidRequestFileWrite.fire({ - uri: edit.resource.toString(), - data: file.value.toString(), - encoding: ContentEncoding.Utf8, - }); + if (direction === 'before') { + // Undoing this edit + switch (edit.kind) { + case FileEditKind.Create: + // Undo create → delete the file + await this._fileService.del(edit.resource); + break; + case FileEditKind.Delete: + // Undo delete → recreate from before-snapshot + if (edit.beforeContentUri) { + const content = await this._fileService.readFile(edit.beforeContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + case FileEditKind.Rename: + // Undo rename → move back to original + if (edit.originalResource) { + await this._fileService.move(edit.resource, edit.originalResource, true); + } + // Also restore before-content if we have it + if (edit.beforeContentUri && edit.originalResource) { + const content = await this._fileService.readFile(edit.beforeContentUri); + await this._fileService.writeFile(edit.originalResource, content.value); + } + break; + case FileEditKind.Edit: + // Undo edit → write before-snapshot content + if (edit.beforeContentUri) { + const content = await this._fileService.readFile(edit.beforeContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + } + } else { + // Redoing this edit + switch (edit.kind) { + case FileEditKind.Create: + // Redo create → recreate from after-snapshot + if (edit.afterContentUri) { + const content = await this._fileService.readFile(edit.afterContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + case FileEditKind.Delete: + // Redo delete → delete the file again + await this._fileService.del(edit.resource); + break; + case FileEditKind.Rename: + // Redo rename → move from original to new + if (edit.originalResource) { + await this._fileService.move(edit.originalResource, edit.resource, true); + } + // Also apply after-content if we have it + if (edit.afterContentUri) { + const content = await this._fileService.readFile(edit.afterContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + case FileEditKind.Edit: + // Redo edit → write after-snapshot content + if (edit.afterContentUri) { + const content = await this._fileService.readFile(edit.afterContentUri); + await this._fileService.writeFile(edit.resource, content.value); + } + break; + } + } } catch (err) { - this._logService.warn(`[AgentHostEditingSession] Failed to fetch content for ${direction}`, contentUri.toString(), err); + this._logService.warn(`[AgentHostEditingSession] Failed to ${direction === 'before' ? 'undo' : 'redo'} ${edit.kind} for ${edit.resource.toString()}`, err); } }); - await Promise.all(writes); + await Promise.all(ops); } /** diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index 3dc1a2ef8abab..9e2f12e055f1f 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -181,8 +181,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private readonly _pendingMessageSubscriptions = this._register(new DisposableResourceMap()); /** Per-session subscription watching for server-initiated turns. */ private readonly _serverTurnWatchers = this._register(new DisposableResourceMap()); - /** Per-session writeFile listeners for agent host editing sessions. */ - private readonly _editingSessionListeners = this._register(new DisposableResourceMap()); /** Historical turns with file edits, pending hydration into the editing session. */ private readonly _pendingHistoryTurns = new ResourceMap(); /** Turn IDs dispatched by this client, used to distinguish server-originated turns. */ @@ -339,7 +337,6 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC this._sessionToBackend.delete(sessionResource); this._pendingMessageSubscriptions.deleteAndDispose(sessionResource); this._serverTurnWatchers.deleteAndDispose(sessionResource); - this._editingSessionListeners.deleteAndDispose(sessionResource); this._pendingHistoryTurns.delete(sessionResource); if (resolvedSession) { this._clientState.unsubscribe(resolvedSession.toString()); @@ -1255,24 +1252,15 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC return undefined; } - // Wire up the writeFile listener if not already done - if (!this._editingSessionListeners.has(sessionResource)) { - this._editingSessionListeners.set(sessionResource, editingSession.onDidRequestFileWrite(params => { - this._config.connection.writeFile(params).catch(err => { - this._logService.warn('[AgentHost] writeFile failed for undo/redo', err); - }); - })); - - // Hydrate from historical turns if this is the first time - // the editing session is accessed for this chat session. - const pendingTurns = this._pendingHistoryTurns.get(sessionResource); - if (pendingTurns) { - this._pendingHistoryTurns.delete(sessionResource); - for (const turn of pendingTurns) { - for (const rp of turn.responseParts) { - if (rp.kind === ResponsePartKind.ToolCall) { - editingSession.addToolCallEdits(turn.id, rp.toolCall); - } + // Hydrate from historical turns if this is the first time + // the editing session is accessed for this chat session. + const pendingTurns = this._pendingHistoryTurns.get(sessionResource); + if (pendingTurns) { + this._pendingHistoryTurns.delete(sessionResource); + for (const turn of pendingTurns) { + for (const rp of turn.responseParts) { + if (rp.kind === ResponsePartKind.ToolCall) { + editingSession.addToolCallEdits(turn.id, rp.toolCall); } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts index c33486b56a67d..7aa234364b9ac 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.ts @@ -9,7 +9,7 @@ import { URI, UriComponents } from '../../../../../../base/common/uri.js'; import { Registry } from '../../../../../../platform/registry/common/platform.js'; import { IAgentConnection, IAgentCreateSessionConfig, IAgentDescriptor, IAgentSessionMetadata, IAuthenticateParams, IAuthenticateResult, IResourceMetadata, AgentHostIpcLoggingSettingId } from '../../../../../../platform/agentHost/common/agentService.js'; import type { IActionEnvelope, INotification, ISessionAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; -import type { IBrowseDirectoryResult, IFetchContentResult, IStateSnapshot, IWriteFileParams, IWriteFileResult } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import type { IResourceCopyParams, IResourceCopyResult, IResourceDeleteParams, IResourceDeleteResult, IResourceListResult, IResourceMoveParams, IResourceMoveResult, IResourceReadResult, IResourceWriteParams, IResourceWriteResult, IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import { Extensions, IOutputChannel, IOutputChannelRegistry, IOutputService } from '../../../../../services/output/common/output.js'; import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; @@ -151,16 +151,28 @@ export class LoggingAgentConnection extends Disposable implements IAgentConnecti return this._inner.nextClientSeq(); } - async browseDirectory(uri: URI): Promise { - return this._logCall('browseDirectory', uri, () => this._inner.browseDirectory(uri)); + async resourceList(uri: URI): Promise { + return this._logCall('resourceList', uri, () => this._inner.resourceList(uri)); } - async fetchContent(uri: URI): Promise { - return this._logCall('fetchContent', uri, () => this._inner.fetchContent(uri)); + async resourceRead(uri: URI): Promise { + return this._logCall('resourceRead', uri, () => this._inner.resourceRead(uri)); } - async writeFile(params: IWriteFileParams): Promise { - return this._logCall('writeFile', params, () => this._inner.writeFile(params)); + async resourceWrite(params: IResourceWriteParams): Promise { + return this._logCall('resourceWrite', params, () => this._inner.resourceWrite(params)); + } + + async resourceCopy(params: IResourceCopyParams): Promise { + return this._logCall('resourceCopy', params, () => this._inner.resourceCopy(params)); + } + + async resourceDelete(params: IResourceDeleteParams): Promise { + return this._logCall('resourceDelete', params, () => this._inner.resourceDelete(params)); + } + + async resourceMove(params: IResourceMoveParams): Promise { + return this._logCall('resourceMove', params, () => this._inner.resourceMove(params)); } // ---- Public logging API for callers' catch blocks ----------------------- diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts index 69a2c1d7109cc..b4ed0a29e6729 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/stateToProgressAdapter.ts @@ -5,12 +5,13 @@ import { IMarkdownString, MarkdownString } from '../../../../../../base/common/htmlContent.js'; import { URI } from '../../../../../../base/common/uri.js'; -import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { ToolCallStatus, TurnState, ResponsePartKind, getToolFileEdits, getToolOutputText, type IActiveTurn, type ICompletedToolCall, type IToolCallState, type ITurn, FileEditKind } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { getToolKind, getToolLanguage } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { type IChatProgress, type IChatTerminalToolInvocationData, type IChatToolInputInvocationData, type IChatToolInvocationSerialized, ToolConfirmKind } from '../../../common/chatService/chatService.js'; import { type IChatSessionHistoryItem } from '../../../common/chatSessionsService.js'; import { ChatToolInvocation } from '../../../common/model/chatProgressTypes/chatToolInvocation.js'; import { type IToolConfirmationMessages, type IToolData, ToolDataSource, ToolInvocationPresentation } from '../../../common/tools/languageModelToolsService.js'; +import { isEqual } from '../../../../../../base/common/resources.js'; import { hasKey } from '../../../../../../base/common/types.js'; function getPtyTerminalData(meta: Record | undefined): { input?: string; output?: string } | undefined { @@ -183,18 +184,33 @@ function completedToolCallToEditParts(tc: ICompletedToolCall): IChatProgress[] { if (fileEdits.length === 0) { return []; } - const filePath = getFilePathFromToolInput(tc); - if (!filePath) { - return []; - } - const fileUri = URI.file(filePath); const parts: IChatProgress[] = []; - for (const _edit of fileEdits) { - parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); - parts.push({ kind: 'codeblockUri', uri: fileUri, isEdit: true, undoStopId: tc.toolCallId }); - parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: false, isExternalEdit: true }); - parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: true, isExternalEdit: true }); - parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + for (const edit of fileEdits) { + const fileUri = edit.after?.uri ? URI.parse(edit.after.uri) : edit.before?.uri ? URI.parse(edit.before.uri) : undefined; + if (!fileUri) { + continue; + } + // Emit workspace file edit progress for creates, deletes, and renames + const isCreate = !edit.before && !!edit.after; + const isDelete = !!edit.before && !edit.after; + const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); + if (isCreate || isDelete || isRename) { + parts.push({ + kind: 'workspaceEdit', + edits: [{ + oldResource: edit.before?.uri ? URI.parse(edit.before.uri) : undefined, + newResource: edit.after?.uri ? URI.parse(edit.after.uri) : undefined, + }], + }); + } + // Emit code-block UI for content edits (and renames with content changes) + if (edit.after?.content) { + parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + parts.push({ kind: 'codeblockUri', uri: fileUri, isEdit: true, undoStopId: tc.toolCallId }); + parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: false, isExternalEdit: true }); + parts.push({ kind: 'textEdit', uri: fileUri, edits: [], done: true, isExternalEdit: true }); + parts.push({ kind: 'markdownContent', content: new MarkdownString('\n````\n') }); + } } return parts; } @@ -283,14 +299,20 @@ export function toolCallStateToInvocation(tc: IToolCallState): ChatToolInvocatio * that should be routed through the editing session's external edits pipeline. */ export interface IToolCallFileEdit { - /** The real file URI on the remote (e.g., `file:///path/to/file`). */ + /** The kind of file operation. */ + readonly kind: FileEditKind; + /** The primary file URI (after-URI for edits/creates/renames, before-URI for deletes). */ readonly resource: URI; - /** URI to read the before-snapshot content from. */ - readonly beforeContentUri: URI; - /** URI to read the after-content from (real file on remote via agenthost:// scheme). */ - readonly afterContentUri: URI; + /** For renames, the original file URI before the move. */ + readonly originalResource?: URI; + /** URI to read the before-snapshot content from. Absent for creates. */ + readonly beforeContentUri?: URI; + /** URI to read the after-content from. Absent for deletes. */ + readonly afterContentUri?: URI; /** Undo stop ID for grouping edits. */ readonly undoStopId: string; + /** Optional diff display metadata. */ + readonly diff?: { added?: number; removed?: number }; } /** @@ -349,31 +371,35 @@ export function fileEditsToExternalEdits(tc: IToolCallState): IToolCallFileEdit[ } const result: IToolCallFileEdit[] = []; for (const edit of edits) { - const filePath = getFilePathFromToolInput(tc); - if (filePath) { - result.push({ - resource: URI.file(filePath), - beforeContentUri: URI.parse(edit.beforeURI), - afterContentUri: URI.parse(edit.afterURI), - undoStopId: tc.toolCallId, - }); + const isCreate = !edit.before && !!edit.after; + const isDelete = !!edit.before && !edit.after; + const isRename = !!edit.before && !!edit.after && !isEqual(URI.parse(edit.before.uri), URI.parse(edit.after.uri)); + + let kind: FileEditKind; + if (isCreate) { + kind = FileEditKind.Create; + } else if (isDelete) { + kind = FileEditKind.Delete; + } else if (isRename) { + kind = FileEditKind.Rename; + } else { + kind = FileEditKind.Edit; } - } - return result; -} -/** - * Extracts the file path from a tool call's input parameters. - * Edit tools store the file path in JSON parameters as `path`. - */ -export function getFilePathFromToolInput(tc: IToolCallState): string | undefined { - if (tc.status !== ToolCallStatus.Completed || !tc.toolInput) { - return undefined; - } - try { - const params = JSON.parse(tc.toolInput); - return typeof params.path === 'string' ? params.path : undefined; - } catch { - return undefined; + const resource = edit.after?.uri ? URI.parse(edit.after.uri) : edit.before?.uri ? URI.parse(edit.before.uri) : undefined; + if (!resource) { + continue; + } + + result.push({ + kind, + resource, + originalResource: isRename ? URI.parse(edit.before!.uri) : undefined, + beforeContentUri: edit.before?.content.uri ? URI.parse(edit.before.content.uri) : undefined, + afterContentUri: edit.after?.content.uri ? URI.parse(edit.after.content.uri) : undefined, + undoStopId: tc.toolCallId, + diff: edit.diff, + }); } + return result; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts index b4a0ffc155cf1..c760e13185c0c 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentHost/agentHostEditingSession.test.ts @@ -16,7 +16,6 @@ import { IEditorWorkerService } from '../../../../../../editor/common/services/e import { IResolvedTextEditorModel, ITextModelService } from '../../../../../../editor/common/services/resolverService.js'; import { toAgentHostUri } from '../../../../../../platform/agentHost/common/agentHostUri.js'; import { IToolCallState, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { ContentEncoding, IWriteFileParams } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { IToolCallCompletedState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IFileContent, IFileService } from '../../../../../../platform/files/common/files.js'; import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; @@ -70,8 +69,14 @@ function makeToolCall(opts: { confirmed: ToolCallConfirmationReason.NotNeeded, content: [{ type: ToolResultContentType.FileEdit, - beforeURI: opts.beforeURI, - afterURI: opts.afterURI, + before: { + uri: URI.file(opts.filePath).toString(), + content: { uri: opts.beforeURI }, + }, + after: { + uri: URI.file(opts.filePath).toString(), + content: { uri: opts.afterURI }, + }, diff: { added: opts.added ?? 0, removed: opts.removed ?? 0, @@ -90,6 +95,21 @@ function makeMockFileService(contentMap: Map): IFileService { } return { value: VSBuffer.fromString(data) } as IFileContent; } + override async writeFile(uri: URI, content: VSBuffer): Promise { + contentMap.set(uri.toString(), content.toString()); + return {}; + } + override async del(uri: URI) { + contentMap.delete(uri.toString()); + } + override async move(source: URI, target: URI): Promise { + const data = contentMap.get(source.toString()); + if (data !== undefined) { + contentMap.set(target.toString(), data); + contentMap.delete(source.toString()); + } + return {}; + } }; } @@ -305,56 +325,50 @@ suite('AgentHostEditingSession', () => { suite('undo/redo', () => { - test('undo fires writeFile with before-content and updates state', async () => { - const beforeUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); + test('undo writes before-content to file and updates state', async () => { + const beforeContentUri = toAgentHostUri(URI.parse('content://before-1'), 'local'); + const fileUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); const contentMap = new Map(); - contentMap.set(beforeUri.toString(), 'before-content'); + contentMap.set(beforeContentUri.toString(), 'before-content'); + contentMap.set(fileUri.toString(), 'current-content'); const session = createSession(store, contentMap); session.addToolCallEdits('req-1', makeToolCall({ toolCallId: 'tc-1', filePath: '/workspace/file.ts', - beforeURI: URI.file('/workspace/file.ts').toString(), + beforeURI: 'content://before-1', afterURI: 'content://after-1', })); - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.undoInteraction(); - assert.strictEqual(writes.length, 1); - assert.strictEqual(writes[0].data, 'before-content'); - assert.strictEqual(writes[0].encoding, ContentEncoding.Utf8); + assert.strictEqual(contentMap.get(fileUri.toString()), 'before-content'); assert.strictEqual(session.canUndo.get(), false); assert.strictEqual(session.canRedo.get(), true); assert.deepStrictEqual(session.entries.get(), []); }); - test('redo fires writeFile with after-content', async () => { - const beforeUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); - const afterUri = toAgentHostUri(URI.parse('content://after-1'), 'local'); + test('redo writes after-content to file', async () => { + const beforeContentUri = toAgentHostUri(URI.parse('content://before-1'), 'local'); + const afterContentUri = toAgentHostUri(URI.parse('content://after-1'), 'local'); + const fileUri = toAgentHostUri(URI.file('/workspace/file.ts'), 'local'); const contentMap = new Map(); - contentMap.set(beforeUri.toString(), 'before-content'); - contentMap.set(afterUri.toString(), 'after-content'); + contentMap.set(beforeContentUri.toString(), 'before-content'); + contentMap.set(afterContentUri.toString(), 'after-content'); + contentMap.set(fileUri.toString(), 'current-content'); const session = createSession(store, contentMap); session.addToolCallEdits('req-1', makeToolCall({ toolCallId: 'tc-1', filePath: '/workspace/file.ts', - beforeURI: URI.file('/workspace/file.ts').toString(), + beforeURI: 'content://before-1', afterURI: 'content://after-1', })); await session.undoInteraction(); - - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.redoInteraction(); - assert.strictEqual(writes.length, 1); - assert.strictEqual(writes[0].data, 'after-content'); + assert.strictEqual(contentMap.get(fileUri.toString()), 'after-content'); assert.strictEqual(session.canUndo.get(), true); assert.strictEqual(session.canRedo.get(), false); assert.strictEqual(session.entries.get().length, 1); @@ -363,12 +377,8 @@ suite('AgentHostEditingSession', () => { test('undo when nothing to undo is no-op', async () => { const session = createSession(store, new Map()); - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.undoInteraction(); - - assert.strictEqual(writes.length, 0); + // No assertions needed — just verifying no throw }); test('redo when nothing to redo is no-op', async () => { @@ -381,26 +391,22 @@ suite('AgentHostEditingSession', () => { afterURI: 'a', })); - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.redoInteraction(); - - assert.strictEqual(writes.length, 0); + // No assertions needed — just verifying no throw }); test('undo after multiple checkpoints removes entries correctly', async () => { const contentMap = new Map(); - contentMap.set(toAgentHostUri(URI.file('/workspace/a.ts'), 'local').toString(), 'a-before'); - contentMap.set(toAgentHostUri(URI.file('/workspace/b.ts'), 'local').toString(), 'b-before'); - contentMap.set(toAgentHostUri(URI.parse('content://after-a'), 'local').toString(), 'a-after'); - contentMap.set(toAgentHostUri(URI.parse('content://after-b'), 'local').toString(), 'b-after'); + contentMap.set(toAgentHostUri(URI.parse('content://before-a'), 'local').toString(), 'a-before'); + contentMap.set(toAgentHostUri(URI.parse('content://before-b'), 'local').toString(), 'b-before'); + contentMap.set(toAgentHostUri(URI.file('/workspace/a.ts'), 'local').toString(), 'a-current'); + contentMap.set(toAgentHostUri(URI.file('/workspace/b.ts'), 'local').toString(), 'b-current'); const session = createSession(store, contentMap); session.addToolCallEdits('req-1', makeToolCall({ toolCallId: 'tc-1', filePath: '/workspace/a.ts', - beforeURI: URI.file('/workspace/a.ts').toString(), + beforeURI: 'content://before-a', afterURI: 'content://after-a', added: 5, })); @@ -408,7 +414,7 @@ suite('AgentHostEditingSession', () => { session.addToolCallEdits('req-2', makeToolCall({ toolCallId: 'tc-2', filePath: '/workspace/b.ts', - beforeURI: URI.file('/workspace/b.ts').toString(), + beforeURI: 'content://before-b', afterURI: 'content://after-b', added: 3, })); @@ -769,13 +775,8 @@ suite('AgentHostEditingSession', () => { })); // Restore to before req-2 — should undo req-2's edits - const writes: IWriteFileParams[] = []; - store.add(session.onDidRequestFileWrite(p => writes.push(p))); - await session.restoreSnapshot('req-2', undefined); - // req-2 has a tool checkpoint whose before-content should be written - assert.ok(writes.length > 0); // Entries should only show req-1's edits assert.strictEqual(session.entries.get().length, 1); assert.strictEqual(session.entries.get()[0].lastModifyingRequestId, 'req-1'); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts index 64ee099bd7da3..5f249eb4466fa 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/stateToProgressAdapter.test.ts @@ -276,15 +276,21 @@ suite('stateToProgressAdapter', () => { toolInput: JSON.stringify({ path: '/home/user/file.ts' }), content: [{ type: ToolResultContentType.FileEdit, - beforeURI: 'agenthost-content:///session/snap/before', - afterURI: 'agenthost-content:///session/snap/after', + before: { + uri: URI.file('/home/user/file.ts').toString(), + content: { uri: 'agenthost-content:///session/snap/before' }, + }, + after: { + uri: URI.file('/home/user/file.ts').toString(), + content: { uri: 'agenthost-content:///session/snap/after' }, + }, }], }); assert.strictEqual(fileEdits.length, 1); assert.strictEqual(fileEdits[0].resource.fsPath.replace(/\\/g, '/'), '/home/user/file.ts'); - assert.strictEqual(fileEdits[0].beforeContentUri.toString(), URI.parse('agenthost-content:///session/snap/before').toString()); - assert.strictEqual(fileEdits[0].afterContentUri.toString(), URI.parse('agenthost-content:///session/snap/after').toString()); + assert.strictEqual(fileEdits[0].beforeContentUri?.toString(), URI.parse('agenthost-content:///session/snap/before').toString()); + assert.strictEqual(fileEdits[0].afterContentUri?.toString(), URI.parse('agenthost-content:///session/snap/after').toString()); assert.ok(fileEdits[0].undoStopId); }); @@ -324,7 +330,7 @@ suite('stateToProgressAdapter', () => { assert.strictEqual(fileEdits.length, 0); }); - test('returns empty file edits when toolInput has no path', () => { + test('returns empty file edits when FileEdit has no before or after', () => { const tc = createToolCallState({ status: ToolCallStatus.Running }); const invocation = toolCallStateToInvocation(tc); @@ -340,36 +346,39 @@ suite('stateToProgressAdapter', () => { toolInput: JSON.stringify({ content: 'no path field' }), content: [{ type: ToolResultContentType.FileEdit, - beforeURI: 'agenthost-content:///before', - afterURI: 'agenthost-content:///after', }], }); assert.strictEqual(fileEdits.length, 0); }); - test('returns empty file edits when toolInput is invalid JSON', () => { + test('returns file edit for create (only after present)', () => { const tc = createToolCallState({ status: ToolCallStatus.Running }); const invocation = toolCallStateToInvocation(tc); const fileEdits = finalizeToolInvocation(invocation, { status: ToolCallStatus.Completed, toolCallId: 'tc-1', - toolName: 'edit_file', - displayName: 'Edit File', - invocationMessage: 'Editing file...', + toolName: 'create_file', + displayName: 'Create File', + invocationMessage: 'Creating file...', confirmed: ToolCallConfirmationReason.NotNeeded, success: true, - pastTenseMessage: 'Edited', - toolInput: 'not json', + pastTenseMessage: 'Created file', content: [{ type: ToolResultContentType.FileEdit, - beforeURI: 'agenthost-content:///before', - afterURI: 'agenthost-content:///after', + after: { + uri: URI.file('/home/user/new-file.ts').toString(), + content: { uri: 'agenthost-content:///snap/after' }, + }, }], }); - assert.strictEqual(fileEdits.length, 0); + assert.strictEqual(fileEdits.length, 1); + assert.strictEqual(fileEdits[0].kind, 'create'); + assert.strictEqual(fileEdits[0].resource.fsPath.replace(/\\/g, '/'), '/home/user/new-file.ts'); + assert.strictEqual(fileEdits[0].beforeContentUri, undefined); + assert.ok(fileEdits[0].afterContentUri); }); }); From c2f1a6ea4345b291ecb05e430e9f1a61a75c0a38 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Thu, 2 Apr 2026 17:29:53 -0400 Subject: [PATCH 28/31] sessions: hide disabled chat input pickers (#307494) Hide disabled/new-session-only pickers in the sessions app instead of changing shared chat widgets. This keeps the fix scoped to sessions and intentionally uses CSS display:none for the shared active-session option-group dropdowns to avoid reaching down into lower-level workbench picker infrastructure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/browser/media/style.css | 7 ++ .../contrib/chat/browser/sessionTypePicker.ts | 7 +- .../test/browser/sessionTypePicker.test.ts | 116 ++++++++++++++++++ .../browser/branchPicker.ts | 11 +- .../browser/copilotChatSessionsActions.ts | 8 +- .../browser/isolationPicker.ts | 12 +- .../copilotChatSessions/browser/modePicker.ts | 8 +- .../browser/modelPicker.ts | 10 +- 8 files changed, 161 insertions(+), 18 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts diff --git a/src/vs/sessions/browser/media/style.css b/src/vs/sessions/browser/media/style.css index 14cd046fcd88c..2d59d94d388e9 100644 --- a/src/vs/sessions/browser/media/style.css +++ b/src/vs/sessions/browser/media/style.css @@ -116,6 +116,13 @@ box-sizing: border-box; } +/* Hide shared chat-session option-group pickers in the sessions app active chat UI. + * The sessions workbench provides its own new-session configuration controls and + * should not surface the shared workbench chat session pickers here. */ +.agent-sessions-workbench .interactive-session .chat-input-toolbars .chat-sessionPicker-container { + display: none; +} + /* ---- Modal Editor Block ---- */ .agent-sessions-workbench .monaco-modal-editor-block { diff --git a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts index 3f05db616851b..3425f9f2c4df8 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionTypePicker.ts @@ -114,7 +114,7 @@ export class SessionTypePicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -129,7 +129,10 @@ export class SessionTypePicker extends Disposable { labelSpan.textContent = modeLabel; const hasMultipleTypes = this._sessionTypes.length > 1; - this._slotElement?.classList.toggle('disabled', !hasMultipleTypes); + dom.setVisibility(hasMultipleTypes, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!hasMultipleTypes)); + this._triggerElement.tabIndex = hasMultipleTypes ? 0 : -1; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); } } diff --git a/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts new file mode 100644 index 0000000000000..af522c20d3372 --- /dev/null +++ b/src/vs/sessions/contrib/chat/test/browser/sessionTypePicker.test.ts @@ -0,0 +1,116 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { Codicon } from '../../../../../base/common/codicons.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { constObservable, observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { IActionWidgetService } from '../../../../../platform/actionWidget/browser/actionWidget.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IActiveSession, ISessionsManagementService } from '../../../sessions/browser/sessionsManagementService.js'; +import { ISessionType } from '../../../sessions/browser/sessionsProvider.js'; +import { SessionStatus } from '../../../sessions/common/sessionData.js'; +import { SessionTypePicker } from '../../browser/sessionTypePicker.js'; + +function createActiveSession(sessionType: string): IActiveSession { + const chat = { + resource: URI.parse(`test:///chat/${sessionType}`), + createdAt: new Date(), + title: constObservable('Chat'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Untitled), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + }; + + return { + sessionId: `provider:${sessionType}`, + resource: URI.parse(`test:///session/${sessionType}`), + providerId: 'provider', + sessionType, + icon: Codicon.copilot, + createdAt: new Date(), + workspace: constObservable(undefined), + title: constObservable('Session'), + updatedAt: constObservable(new Date()), + status: constObservable(SessionStatus.Untitled), + changes: constObservable([]), + modelId: constObservable(undefined), + mode: constObservable(undefined), + loading: constObservable(false), + isArchived: constObservable(false), + isRead: constObservable(true), + description: constObservable(undefined), + lastTurnEnd: constObservable(undefined), + gitHubInfo: constObservable(undefined), + chats: constObservable([chat]), + mainChat: chat, + activeChat: constObservable(chat), + }; +} + +suite('SessionTypePicker', () => { + + const disposables = new DisposableStore(); + let sessionTypes: ISessionType[]; + let activeSession: ReturnType>; + let instantiationService: TestInstantiationService; + + setup(() => { + sessionTypes = []; + activeSession = observableValue('activeSession', undefined); + instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IActionWidgetService, { isVisible: false, hide: () => { }, show: () => { } }); + instantiationService.stub(ISessionsManagementService, { + activeSession, + getSessionTypes: () => sessionTypes, + setSessionType: () => { + throw new Error('Not implemented'); + }, + }); + }); + + teardown(() => { + disposables.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('hides the picker when only one session type is available', () => { + sessionTypes = [{ id: 'copilotcli', label: 'Copilot CLI', icon: Codicon.copilot }]; + activeSession.set(createActiveSession('copilotcli'), undefined); + + const picker = disposables.add(instantiationService.createInstance(SessionTypePicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + assert.ok(slot); + assert.strictEqual(slot.style.display, 'none'); + }); + + test('shows the picker when multiple session types are available', () => { + sessionTypes = [ + { id: 'copilotcli', label: 'Copilot CLI', icon: Codicon.copilot }, + { id: 'copilot-cloud-agent', label: 'Cloud', icon: Codicon.cloud }, + ]; + activeSession.set(createActiveSession('copilotcli'), undefined); + + const picker = disposables.add(instantiationService.createInstance(SessionTypePicker)); + const container = document.createElement('div'); + picker.render(container); + + const slot = container.querySelector('.sessions-chat-picker-slot'); + assert.ok(slot); + assert.strictEqual(slot.style.display, ''); + }); +}); diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts index 26718af64dfca..d3a69f34fd246 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/branchPicker.ts @@ -129,7 +129,7 @@ export class BranchPicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } dom.clearNode(this._triggerElement); @@ -145,8 +145,11 @@ export class BranchPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - this._slotElement?.classList.toggle('disabled', isLoading || isDisabled); - this._triggerElement.setAttribute('aria-disabled', String(isLoading || isDisabled)); - this._triggerElement.tabIndex = (isLoading || isDisabled) ? -1 : 0; + const visible = !(isLoading || isDisabled); + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts index 4e66caae48981..47901aa1bc425 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -29,7 +29,7 @@ import { ISession } from '../../sessions/common/sessionData.js'; import { IAgentSessionsService } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js'; import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; import { COPILOT_CLI_SESSION_TYPE, COPILOT_CLOUD_SESSION_TYPE } from '../../sessions/browser/sessionTypes.js'; -import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext } from '../../../common/contextkeys.js'; +import { ActiveSessionHasGitRepositoryContext, ActiveSessionProviderIdContext, ActiveSessionTypeContext, ChatSessionProviderIdContext, IsNewChatSessionContext } from '../../../common/contextkeys.js'; import { IsolationPicker } from './isolationPicker.js'; import { BranchPicker } from './branchPicker.js'; import { ModePicker } from './modePicker.js'; @@ -56,6 +56,7 @@ registerAction2(class extends Action2 { group: 'navigation', order: 1, when: ContextKeyExpr.and( + IsNewChatSessionContext, IsActiveSessionCopilotChatCLI, ContextKeyExpr.equals('config.github.copilot.chat.cli.isolationOption.enabled', true), ), @@ -76,7 +77,10 @@ registerAction2(class extends Action2 { id: Menus.NewSessionRepositoryConfig, group: 'navigation', order: 2, - when: IsActiveSessionCopilotChatCLI, + when: ContextKeyExpr.and( + IsNewChatSessionContext, + IsActiveSessionCopilotChatCLI, + ), }], }); } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts index d9e255932c9de..474ce3da58995 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/isolationPicker.ts @@ -160,7 +160,7 @@ export class IsolationPicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -187,9 +187,11 @@ export class IsolationPicker extends Disposable { labelSpan.textContent = modeLabel; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - const isDisabled = !this._hasGitRepo; - this._slotElement?.classList.toggle('disabled', isDisabled); - this._triggerElement.setAttribute('aria-disabled', String(isDisabled)); - this._triggerElement.tabIndex = isDisabled ? -1 : 0; + const visible = this._isolationOptionEnabled && this._hasGitRepo; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts index cb0e7cd087cc0..49cf7550029e6 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modePicker.ts @@ -223,7 +223,7 @@ export class ModePicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -239,6 +239,10 @@ export class ModePicker extends Disposable { dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); const modes = this._getAvailableModes(); - this._slotElement?.classList.toggle('disabled', modes.length <= 1); + const visible = modes.length > 1; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } diff --git a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts index e29b55da61db6..82e8b93d376bb 100644 --- a/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts +++ b/src/vs/sessions/contrib/copilotChatSessions/browser/modelPicker.ts @@ -198,7 +198,7 @@ export class CloudModelPicker extends Disposable { } private _updateTriggerLabel(): void { - if (!this._triggerElement) { + if (!this._triggerElement || !this._slotElement) { return; } @@ -209,7 +209,11 @@ export class CloudModelPicker extends Disposable { labelSpan.textContent = label; dom.append(this._triggerElement, renderIcon(Codicon.chevronDown)); - this._slotElement?.classList.toggle('disabled', this._models.length === 0); - this._triggerElement.setAttribute('aria-disabled', String(this._models.length === 0)); + const visible = this._models.length > 0; + dom.setVisibility(visible, this._slotElement); + this._slotElement.classList.toggle('disabled', false); + this._triggerElement.setAttribute('aria-hidden', String(!visible)); + this._triggerElement.setAttribute('aria-disabled', String(!visible)); + this._triggerElement.tabIndex = visible ? 0 : -1; } } From 608936739ddbd7168f5112b02b628b3924495b68 Mon Sep 17 00:00:00 2001 From: dileepyavan <52841896+dileepyavan@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:50:56 -0700 Subject: [PATCH 29/31] Fix unsandboxed terminal sandbox wrapping for quoted shell execution (#307487) Fix terminal sandbox unsandboxed command wrapping --- .../commandLineSandboxRewriter.ts | 2 +- .../common/terminalSandboxService.ts | 18 ++++++++----- .../browser/terminalSandboxService.test.ts | 26 ++++++++++++++----- 3 files changed, 33 insertions(+), 13 deletions(-) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts index 625a9608bdca1..6b0dccf6065bc 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/commandLineRewriter/commandLineSandboxRewriter.ts @@ -20,7 +20,7 @@ export class CommandLineSandboxRewriter extends Disposable implements ICommandLi return undefined; } - const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution); + const wrappedCommand = this._sandboxService.wrapCommand(options.commandLine, options.requestUnsandboxedExecution, options.shell); return { rewritten: wrappedCommand.command, reasoning: wrappedCommand.requiresUnsandboxConfirmation ? 'Switched command to unsandboxed execution because the command includes a domain that is not in the sandbox allowlist' : 'Wrapped command for sandbox execution', diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts index e9b6004d0af10..321c9abc61c8c 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalSandboxService.ts @@ -106,7 +106,7 @@ export interface ITerminalSandboxService { isEnabled(): Promise; getOS(): Promise; checkForSandboxingPrereqs(forceRefresh?: boolean): Promise; - wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult; + wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult; getSandboxConfigPath(forceRefresh?: boolean): Promise; getTempDir(): URI | undefined; setNeedsForceUpdateConfigFile(): void; @@ -200,7 +200,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return this._os; } - public wrapCommand(command: string, requestUnsandboxedExecution?: boolean): ITerminalSandboxWrapResult { + public wrapCommand(command: string, requestUnsandboxedExecution?: boolean, shell?: string): ITerminalSandboxWrapResult { if (!this._sandboxConfigPath || !this._tempDir) { throw new Error('Sandbox config path or temp dir not initialized'); } @@ -208,7 +208,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb const blockedDomainResult = requestUnsandboxedExecution ? { blockedDomains: [], deniedDomains: [] } : this._getBlockedDomains(command); if (!requestUnsandboxedExecution && blockedDomainResult.blockedDomains.length > 0) { return { - command: this._wrapUnsandboxedCommand(command), + command: this._wrapUnsandboxedCommand(command, shell), isSandboxWrapped: false, blockedDomains: blockedDomainResult.blockedDomains, deniedDomains: blockedDomainResult.deniedDomains, @@ -219,7 +219,7 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb // If requestUnsandboxedExecution is true, need to ensure env variables set during sandbox still apply. if (requestUnsandboxedExecution) { return { - command: this._wrapUnsandboxedCommand(command), + command: this._wrapUnsandboxedCommand(command, shell), isSandboxWrapped: false, }; } @@ -453,8 +453,14 @@ export class TerminalSandboxService extends Disposable implements ITerminalSandb return `'${value.replace(/'/g, `'\\''`)}'`; } - private _wrapUnsandboxedCommand(command: string): string { - return this._tempDir?.path ? `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})` : command; + private _wrapUnsandboxedCommand(command: string, shell?: string): string { + if (!this._tempDir?.path) { + return command; + } + if (!shell) { + return `(TMPDIR="${this._tempDir.path}"; export TMPDIR; ${command})`; + } + return `env TMPDIR="${this._tempDir.path}" ${this._quoteShellArgument(shell)} -c ${this._quoteShellArgument(command)}`; } private _getBlockedDomains(command: string): { blockedDomains: string[]; deniedDomains: string[] } { 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 33c026438adee..2f0c346f9c73a 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 @@ -322,26 +322,40 @@ suite('TerminalSandboxService - network domains', () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test', true).command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test)`); + strictEqual(sandboxService.wrapCommand('echo test', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test'`); }); test('should preserve TMPDIR for piped unsandboxed commands', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - strictEqual(sandboxService.wrapCommand('echo test | cat', true).command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; echo test | cat)`); + strictEqual(sandboxService.wrapCommand('echo test | cat', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test | cat'`); + }); + + test('should preserve trailing backslashes for unsandboxed commands', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + strictEqual(sandboxService.wrapCommand('echo test \\', true, 'bash').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo test \\'`); + }); + + test('should use fish-compatible wrapping for unsandboxed commands', async () => { + const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); + await sandboxService.getSandboxConfigPath(); + + strictEqual(sandboxService.wrapCommand('echo test', true, 'fish').command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'fish' -c 'echo test'`); }); test('should switch to unsandboxed execution when a domain is not allowlisted', async () => { const sandboxService = store.add(instantiationService.createInstance(TerminalSandboxService)); await sandboxService.getSandboxConfigPath(); - const wrapResult = sandboxService.wrapCommand('curl https://example.com'); + const wrapResult = sandboxService.wrapCommand('curl https://example.com', false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Blocked domains should prevent sandbox wrapping'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains should require unsandbox confirmation'); deepStrictEqual(wrapResult.blockedDomains, ['example.com']); - strictEqual(wrapResult.command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; curl https://example.com)`); + strictEqual(wrapResult.command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'curl https://example.com'`); }); test('should allow exact allowlisted domains', async () => { @@ -523,12 +537,12 @@ suite('TerminalSandboxService - network domains', () => { await sandboxService.getSandboxConfigPath(); const command = 'echo $HOME $(curl eth0.me) `id`'; - const wrapResult = sandboxService.wrapCommand(command); + const wrapResult = sandboxService.wrapCommand(command, false, 'bash'); strictEqual(wrapResult.isSandboxWrapped, false, 'Commands with blocked domains inside substitutions should not stay sandboxed'); strictEqual(wrapResult.requiresUnsandboxConfirmation, true, 'Blocked domains inside substitutions should require confirmation'); deepStrictEqual(wrapResult.blockedDomains, ['eth0.me']); - strictEqual(wrapResult.command, `(TMPDIR="${sandboxService.getTempDir()?.path}"; export TMPDIR; ${command})`); + strictEqual(wrapResult.command, `env TMPDIR="${sandboxService.getTempDir()?.path}" 'bash' -c 'echo $HOME $(curl eth0.me) \`id\`'`); }); test('should escape single-quote breakout payloads in wrapped command argument', async () => { From f540be8c1abf14ef594d42dd3a99c50f48732a7b Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Thu, 2 Apr 2026 17:55:53 -0400 Subject: [PATCH 30/31] Add completion notifications and async prompt monitoring for background terminals (#307199) --- .../api/common/extHostTypeConverters.ts | 1 + .../chat/browser/widget/chatListRenderer.ts | 38 +++- .../chat/browser/widget/media/chat.css | 14 ++ .../chat/common/chatService/chatService.ts | 14 ++ .../common/chatService/chatServiceImpl.ts | 5 +- .../contrib/chat/common/model/chatModel.ts | 18 +- .../common/model/chatSessionOperationLog.ts | 2 + .../chat/common/model/chatViewModel.ts | 17 +- .../chat/common/participants/chatAgents.ts | 4 + .../common/chatService/chatService.test.ts | 56 +++++- .../browser/tools/monitoring/outputMonitor.ts | 168 +++++++++++++++- .../browser/tools/runInTerminalTool.ts | 180 +++++++++++++++--- .../terminalChatAgentToolsConfiguration.ts | 8 + ...scode.proposed.chatParticipantPrivate.d.ts | 8 + 14 files changed, 489 insertions(+), 44 deletions(-) diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 4b2dd1db8d0a5..1906d97f8ad47 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -3450,6 +3450,7 @@ export namespace ChatAgentRequest { parentRequestId: request.parentRequestId, hasHooksEnabled: request.hasHooksEnabled ?? false, hooks: request.hooks ? ChatRequestHooksConverter.to(request.hooks) : undefined, + isSystemInitiated: request.isSystemInitiated, }; if (!isProposedApiEnabled(extension, 'chatParticipantPrivate')) { diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts index 0bd1a6f70040e..5efce6f271d89 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatListRenderer.ts @@ -83,7 +83,7 @@ import { ChatMarkdownContentPart, codeblockHasClosingBackticks } from './chatCon 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 { ChatProgressContentPart, ChatProgressSubPart, ChatWorkingProgressContentPart } from './chatContentParts/chatProgressContentPart.js'; import { ChatPullRequestContentPart } from './chatContentParts/chatPullRequestContentPart.js'; import { ChatQuotaExceededPart } from './chatContentParts/chatQuotaExceededPart.js'; import { ChatCollapsibleListContentPart, ChatUsedReferencesListContentPart, CollapsibleListPool } from './chatContentParts/chatReferencesContentPart.js'; @@ -749,9 +749,11 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer('chat.editRequests') === 'hover'); templateData.requestHover.classList.toggle('checkpoints-enabled', checkpointEnabled); templateData.elementDisposables.add(dom.addStandardDisposableListener(templateData.rowContainer, dom.EventType.CLICK, (e) => { @@ -832,7 +834,7 @@ export class ChatListItemRenderer extends Disposable implements ITreeRenderer; diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index 6816cabd79d33..be9efdf281716 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -860,6 +860,8 @@ export class ChatService extends Disposable implements IChatService { attachedContext: options.attachedContext, modelId: options.userSelectedModelId, userSelectedTools: options.userSelectedTools?.get(), + isSystemInitiated: options.isSystemInitiated, + systemInitiatedLabel: options.systemInitiatedLabel, }); const deferred = new DeferredPromise(); @@ -1162,7 +1164,7 @@ export class ChatService extends Disposable implements IChatService { if (agentPart || (defaultAgent && !commandPart)) { const prepareChatAgentRequest = (agent: IChatAgentData, command?: IChatAgentCommand, enableCommandDetection?: boolean, chatRequest?: ChatRequestModel, isParticipantDetected?: boolean): IChatAgentRequest => { const initVariableData: IChatRequestVariableData = { variables: [] }; - request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, agent, command, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get()); + request = chatRequest ?? model.addRequest(parsedRequest, initVariableData, attempt, options?.modeInfo, agent, command, options?.confirmation, options?.locationData, options?.attachedContext, undefined, options?.userSelectedModelId, options?.userSelectedTools?.get(), undefined, options?.isSystemInitiated, options?.systemInitiatedLabel); let variableData: IChatRequestVariableData; let message: string; @@ -1206,6 +1208,7 @@ export class ChatService extends Disposable implements IChatService { editedFileEvents: request.editedFileEvents, hooks: collectedHooks, hasHooksEnabled: !!collectedHooks && Object.values(collectedHooks).some(arr => arr.length > 0), + isSystemInitiated: options?.isSystemInitiated, }; let isInitialTools = true; diff --git a/src/vs/workbench/contrib/chat/common/model/chatModel.ts b/src/vs/workbench/contrib/chat/common/model/chatModel.ts index 425b1e0fffc55..f368bbfd5de32 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatModel.ts @@ -126,6 +126,8 @@ export interface IChatRequestModel { setShouldBeBlocked(value: boolean): void; readonly modelId?: string; readonly userSelectedTools?: UserSelectedTools; + readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; } export interface ICodeBlockInfo { @@ -342,6 +344,8 @@ export interface IChatRequestModelParameters { restoredId?: string; editedFileEvents?: IChatAgentEditedFileEvent[]; userSelectedTools?: UserSelectedTools; + isSystemInitiated?: boolean; + systemInitiatedLabel?: string; } export class ChatRequestModel implements IChatRequestModel { @@ -354,6 +358,8 @@ export class ChatRequestModel implements IChatRequestModel { public readonly modelId?: string; public readonly modeInfo?: IChatRequestModeInfo; public readonly userSelectedTools?: UserSelectedTools; + public readonly isSystemInitiated?: boolean; + public readonly systemInitiatedLabel?: string; private readonly _shouldBeBlocked = observableValue(this, false); public get shouldBeBlocked(): IObservable { @@ -425,6 +431,8 @@ export class ChatRequestModel implements IChatRequestModel { this.id = params.restoredId ?? 'request_' + generateUuid(); this._editedFileEvents = params.editedFileEvents; this.userSelectedTools = params.userSelectedTools; + this.isSystemInitiated = params.isSystemInitiated; + this.systemInitiatedLabel = params.systemInitiatedLabel; } adoptTo(session: ChatModel) { @@ -1510,6 +1518,8 @@ export interface ISerializableChatRequestData extends ISerializableChatResponseD editedFileEvents?: IChatAgentEditedFileEvent[]; modelId?: string; modeInfo?: IChatRequestModeInfo; + isSystemInitiated?: boolean; + systemInitiatedLabel?: string; } export interface ISerializableMarkdownInfo { @@ -2375,6 +2385,8 @@ export class ChatModel extends Disposable implements IChatModel { editedFileEvents: raw.editedFileEvents, modelId: raw.modelId, modeInfo: raw.modeInfo, + isSystemInitiated: raw.isSystemInitiated, + systemInitiatedLabel: raw.systemInitiatedLabel, }); request.shouldBeRemovedOnSend = raw.isHidden ? { requestId: raw.requestId } : raw.shouldBeRemovedOnSend; // eslint-disable-next-line @typescript-eslint/no-explicit-any, local/code-no-any-casts @@ -2543,7 +2555,7 @@ export class ChatModel extends Disposable implements IChatModel { this._onDidChange.fire({ kind: 'setHidden' }); } - addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, modeInfo?: IChatRequestModeInfo, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean, modelId?: string, userSelectedTools?: UserSelectedTools, id?: string): ChatRequestModel { + addRequest(message: IParsedChatRequest, variableData: IChatRequestVariableData, attempt: number, modeInfo?: IChatRequestModeInfo, chatAgent?: IChatAgentData, slashCommand?: IChatAgentCommand, confirmation?: string, locationData?: IChatLocationData, attachments?: IChatRequestVariableEntry[], isCompleteAddedRequest?: boolean, modelId?: string, userSelectedTools?: UserSelectedTools, id?: string, isSystemInitiated?: boolean, systemInitiatedLabel?: string): ChatRequestModel { const editedFileEvents = [...this.currentEditedFileEvents.values()]; this.currentEditedFileEvents.clear(); const request = new ChatRequestModel({ @@ -2561,6 +2573,8 @@ export class ChatModel extends Disposable implements IChatModel { modelId, editedFileEvents: editedFileEvents.length ? editedFileEvents : undefined, userSelectedTools, + isSystemInitiated, + systemInitiatedLabel, }); request.response = new ChatResponseModel({ responseContent: [], @@ -2718,6 +2732,8 @@ export class ChatModel extends Disposable implements IChatModel { editedFileEvents: r.editedFileEvents, modelId: r.modelId, modeInfo: r.modeInfo, + isSystemInitiated: r.isSystemInitiated || undefined, + systemInitiatedLabel: r.systemInitiatedLabel, ...r.response?.toJSON(), }; }), diff --git a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts index df6f2f227a11d..c1227b9423be2 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatSessionOperationLog.ts @@ -151,6 +151,8 @@ const requestSchema = Adapt.object m.response?.codeCitations, objectsEqual), timeSpentWaiting: Adapt.v(m => m.response?.timestamp), // based on response timestamp modeInfo: Adapt.v(m => m.modeInfo, objectsEqual), + isSystemInitiated: Adapt.v(m => m.isSystemInitiated), + systemInitiatedLabel: Adapt.v(m => m.systemInitiatedLabel), }, { sealed: (o) => o.modelState?.value === ResponseModelState.Cancelled || o.modelState?.value === ResponseModelState.Failed || o.modelState?.value === ResponseModelState.Complete, }); diff --git a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts index e69e3d29079f6..84577df908ef9 100644 --- a/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts +++ b/src/vs/workbench/contrib/chat/common/model/chatViewModel.ts @@ -98,6 +98,8 @@ export interface IChatRequestViewModel { readonly timestamp: number; /** The kind of pending request, or undefined if not pending */ readonly pendingKind?: ChatRequestQueueKind; + readonly isSystemInitiated?: boolean; + readonly systemInitiatedLabel?: string; } export interface IChatResponseMarkdownRenderData { @@ -334,7 +336,12 @@ export class ChatViewModel extends Disposable implements IChatViewModel { } getItems(): (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] { - let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => !item.shouldBeRemovedOnSend || item.shouldBeRemovedOnSend.afterUndoStop); + let items: (IChatRequestViewModel | IChatResponseViewModel | IChatPendingDividerViewModel)[] = this._items.filter((item) => { + if (item.shouldBeRemovedOnSend && !item.shouldBeRemovedOnSend.afterUndoStop) { + return false; + } + return true; + }); if (this._options?.maxVisibleItems !== undefined && items.length > this._options.maxVisibleItems) { items = items.slice(-this._options.maxVisibleItems); } @@ -477,6 +484,14 @@ export class ChatRequestViewModel implements IChatRequestViewModel { return this._pendingKind; } + get isSystemInitiated() { + return this._model.isSystemInitiated; + } + + get systemInitiatedLabel() { + return this._model.systemInitiatedLabel; + } + constructor( private readonly _model: IChatRequestModel, private readonly _pendingKind?: ChatRequestQueueKind, diff --git a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts index 42b418e34b044..302ccc33a8cfa 100644 --- a/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts +++ b/src/vs/workbench/contrib/chat/common/participants/chatAgents.ts @@ -181,6 +181,10 @@ export interface IChatAgentRequest { */ parentRequestId?: string; + /** + * When true, this request was initiated by the system rather than the user. + */ + isSystemInitiated?: boolean; } export interface IChatQuestion { 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 564473138de35..ac68d4cde44f9 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 @@ -461,6 +461,60 @@ suite('ChatService', () => { await assertSnapshot(toSnapshotExportData(chatModel2)); }); + test('can serialize and deserialize implicit request flag', async () => { + let serializedChatData: ISerializableChatData; + + { + const testService = createChatService(); + const chatModel1Ref = testDisposables.add(startSessionModel(testService)); + const chatModel1 = chatModel1Ref.object; + + const response = await testService.sendRequest(chatModel1.sessionResource, 'test implicit request', { isSystemInitiated: true }); + ChatSendResult.assertSent(response); + await response.data.responseCompletePromise; + + assert.strictEqual(chatModel1.getRequests().length, 1); + assert.strictEqual(chatModel1.getRequests()[0].isSystemInitiated, true); + + serializedChatData = JSON.parse(JSON.stringify(chatModel1)); + assert.strictEqual(serializedChatData.requests.length, 1); + assert.strictEqual(serializedChatData.requests[0].isSystemInitiated, true); + } + + const testService2 = createChatService(); + const chatModel2Ref = testService2.loadSessionFromData(serializedChatData); + assert(chatModel2Ref); + testDisposables.add(chatModel2Ref); + const chatModel2 = chatModel2Ref.object; + + assert.strictEqual(chatModel2.getRequests().length, 1); + assert.strictEqual(chatModel2.getRequests()[0].isSystemInitiated, true); + }); + + test('acquireExistingSession keeps model alive for steering request after refs released', async () => { + const testService = createChatService(); + const modelRef = startSessionModel(testService); + const sessionResource = modelRef.object.sessionResource; + + // Acquire a keep-alive reference (what the fix does) + const keepAliveRef = testDisposables.add(testService.acquireExistingSession(sessionResource, 'test#keepAlive')!); + assert.ok(keepAliveRef, 'acquireExistingSession should return a reference'); + + // Release the original reference to simulate user navigating away + modelRef.dispose(); + await testService.waitForModelDisposals(); + + // Model should still be accessible because keepAliveRef holds it + const response = await testService.sendRequest(sessionResource, 'terminal completed', { + queue: ChatRequestQueueKind.Steering, + isSystemInitiated: true, + }); + assert.strictEqual(response.kind, 'queued'); + + // Clean up + keepAliveRef.dispose(); + }); + test('onDidDisposeSession', async () => { const testService = createChatService(); const modelRef = testService.startNewLocalSession(ChatAgentLocation.Chat); @@ -994,7 +1048,7 @@ function toSnapshotExportData(model: IChatModel) { ...exp, requests: exp.requests.map(r => { // Destructure properties after `vote` so we can insert `voteDownReason` in the correct position for snapshot compat - const { slashCommand, usedContext, contentReferences, codeCitations, timeSpentWaiting, ...rest } = r; + const { slashCommand, usedContext, contentReferences, codeCitations, timeSpentWaiting, isSystemInitiated: _isSystemInitiated, systemInitiatedLabel: _systemInitiatedLabel, ...rest } = r; return { ...rest, modelState: { diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts index a2357b7bd2fdf..22e43ee8a2c58 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/monitoring/outputMonitor.ts @@ -6,17 +6,19 @@ import type { IMarker as XtermMarker } from '@xterm/xterm'; import { IAction } from '../../../../../../../base/common/actions.js'; import { timeout, type MaybePromise } from '../../../../../../../base/common/async.js'; -import { CancellationToken } from '../../../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../../base/common/cancellation.js'; import { Emitter, Event } from '../../../../../../../base/common/event.js'; import { MarkdownString } from '../../../../../../../base/common/htmlContent.js'; -import { Disposable, MutableDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; +import { Disposable, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; import { isObject, isString } from '../../../../../../../base/common/types.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { localize } from '../../../../../../../nls.js'; import { IChatWidgetService } from '../../../../../chat/browser/chat.js'; import { ChatElicitationRequestPart } from '../../../../../chat/common/model/chatProgressTypes/chatElicitationRequestPart.js'; -import { ChatModel } from '../../../../../chat/common/model/chatModel.js'; +import { ChatModel, ChatRequestModel } from '../../../../../chat/common/model/chatModel.js'; import { ElicitationState, IChatService } from '../../../../../chat/common/chatService/chatService.js'; +import { ChatRequestTextPart } from '../../../../../chat/common/requestParser/chatParserTypes.js'; +import { OffsetRange } from '../../../../../../../editor/common/core/ranges/offsetRange.js'; import { ChatAgentLocation, ChatPermissionLevel } from '../../../../../chat/common/constants.js'; import { ChatMessageRole, getTextResponseFromStream, type ILanguageModelChatSelector, ILanguageModelsService } from '../../../../../chat/common/languageModels.js'; import { IToolInvocationContext } from '../../../../../chat/common/tools/languageModelToolsService.js'; @@ -111,6 +113,11 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { /** The chat session resource for this tool invocation, used to check permission level. */ private readonly _sessionResource: URI | undefined; + private _asyncMode = false; + private _command = ''; + private _invocationContext: IToolInvocationContext | undefined; + private _currentMonitoringCts: CancellationTokenSource | undefined; + constructor( private readonly _execution: IExecution, private readonly _pollFn: ((execution: IExecution, token: CancellationToken, taskService: ITaskService) => Promise) | undefined, @@ -128,10 +135,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { super(); this._sessionResource = invocationContext?.sessionResource; + this._command = command; + this._invocationContext = invocationContext; + this._register(toDisposable(() => this._currentMonitoringCts?.dispose())); // Start async to ensure listeners are set up timeout(0).then(() => { - this._startMonitoring(command, invocationContext, token); + this._currentMonitoringCts = new CancellationTokenSource(token); + this._startMonitoring(command, invocationContext, this._currentMonitoringCts.token); }); } @@ -162,6 +173,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { extended = true; this._state = OutputMonitorState.PollingForIdle; continue; + } else if (this._asyncMode) { + // In async mode, wait for new data instead of stopping on timeout + this._logService.trace('OutputMonitor: Async mode - timeout reached, waiting for new terminal data'); + extended = false; + await this._waitForNewData(token); + if (token.isCancellationRequested) { + break; + } + this._state = OutputMonitorState.PollingForIdle; + continue; } else { this._promptPart?.hide(); this._promptPart = undefined; @@ -177,6 +198,16 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._logService.trace('OutputMonitor: Idle handler -> continue polling'); this._state = OutputMonitorState.PollingForIdle; continue; + } else if (this._asyncMode) { + // In async mode, wait for new terminal data before monitoring again. + // This avoids expensive LLM calls while the terminal sits idle. + this._logService.trace('OutputMonitor: Async mode - waiting for new terminal data before next monitoring cycle'); + await this._waitForNewData(token); + if (token.isCancellationRequested) { + break; + } + this._state = OutputMonitorState.PollingForIdle; + continue; } else { this._logService.trace(`OutputMonitor: Idle handler -> stop polling (hasResources=${!!idleResult.resources}, outputLen=${idleResult.output?.length ?? 0})`); resources = idleResult.resources; @@ -216,6 +247,54 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } } + /** + * Continues monitoring in background mode with a new cancellation token. + * In background mode, the monitor re-polls for idle and handles prompts + * whenever new terminal data arrives, rather than stopping after the first + * idle detection. Resource cost is bounded because the monitor only wakes + * on new terminal data (via {@link _waitForNewData}) and each idle cycle + * is capped by the standard polling timeouts. + */ + continueMonitoringAsync(token: CancellationToken): void { + this._asyncMode = true; + // Cancel and dispose any in-progress monitoring run to avoid two concurrent loops + this._currentMonitoringCts?.dispose(); + this._currentMonitoringCts = new CancellationTokenSource(token); + this._state = OutputMonitorState.PollingForIdle; + this._startMonitoring(this._command, this._invocationContext, this._currentMonitoringCts.token); + } + + /** + * Waits for new terminal data or cancellation. Used in background mode + * to avoid polling and LLM calls while the terminal is quiet. + */ + private _waitForNewData(token: CancellationToken): Promise { + return new Promise(resolve => { + if (token.isCancellationRequested) { + resolve(); + return; + } + const cleanup = () => { + dataListener.dispose(); + tokenListener.dispose(); + disposedListener.dispose(); + }; + const dataListener = this._execution.instance.onData(() => { + cleanup(); + resolve(); + }); + const tokenListener = token.onCancellationRequested(() => { + cleanup(); + resolve(); + }); + // Resolve when the terminal instance is disposed to avoid waiting forever + const disposedListener = this._execution.instance.onDisposed(() => { + cleanup(); + resolve(); + }); + }); + } + private async _handleIdleState(token: CancellationToken): Promise<{ resources?: ILinkLocation[]; shouldContinuePolling: boolean; output?: string }> { const output = this._execution.getOutput(this._lastPromptMarker); @@ -277,6 +356,38 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { return { shouldContinuePolling: true }; } + // In async mode, skip the LLM-based prompt detection to avoid expensive calls + // on every idle cycle. Instead, use regex-based detection for input-required + // patterns (passwords, [Y/n], etc.) which were already detected in _waitForIdle + // but need elicitation UI shown here. + if (this._asyncMode) { + if (detectsInputRequiredPattern(output)) { + this._logService.trace('OutputMonitor: Async mode - input-required pattern detected, showing free-form input'); + const autoReply = this._configurationService.getValue(TerminalChatAgentToolsSettingId.AutoReplyToPrompts) || this._isAutopilotMode(); + if (!autoReply) { + const currentMarker = this._execution.instance.registerMarker(); + if (currentMarker) { + this._lastPromptMarker = currentMarker; + } + this._cleanupIdleInputListener(); + this._outputMonitorTelemetryCounters.inputToolFreeFormInputShownCount++; + const lastLine = output.trimEnd().split(/\r?\n/).pop() || ''; + const receivedTerminalInput = await this._requestFreeFormTerminalInput(token, this._execution, { + prompt: lastLine, + options: [], + detectedRequestForFreeFormInput: true + }); + if (receivedTerminalInput) { + this._logService.trace('OutputMonitor: Async mode - free-form input received, continue polling'); + await timeout(200); + return { shouldContinuePolling: true }; + } + } + } + this._cleanupIdleInputListener(); + return { shouldContinuePolling: false, output }; + } + this._logService.trace('OutputMonitor: Determining user input options via language model'); const confirmationPrompt = await this._determineUserInputOptions(this._execution, token); this._logService.trace(`OutputMonitor: Input options result: ${confirmationPrompt ? `prompt=${this._formatLastLineForLog(confirmationPrompt.prompt)}, options=${confirmationPrompt.options.length} ${this._formatOptionsForLog(confirmationPrompt.options)}, freeForm=${!!confirmationPrompt.detectedRequestForFreeFormInput}` : 'none'}`); @@ -395,7 +506,14 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { try { while (!token.isCancellationRequested && waited < maxWaitMs) { const waitTime = Math.min(currentInterval, maxWaitMs - waited); - await timeout(waitTime, token); + try { + await timeout(waitTime, token); + } catch (err) { + if (token.isCancellationRequested) { + return OutputMonitorState.Cancelled; + } + throw err; + } waited += waitTime; currentInterval = Math.min(currentInterval * 2, maxInterval); const currentOutput = execution.getOutput(); @@ -843,11 +961,42 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { if (!(chatModel instanceof ChatModel)) { throw new Error('No model'); } - const request = chatModel.getRequests().at(-1); + // In async mode the last request may be an implicit (hidden) steering request. + // Attach the elicitation to the last visible request so it renders in the UI. + const requests = chatModel.getRequests(); + let request: ChatRequestModel | undefined; + if (this._asyncMode) { + // In async mode the previous response is already complete. + // Create a new system-initiated request so the data model properly + // represents a finished response followed by a new request/response + // rather than reopening the completed response. + const message = localize('terminalPromptDetected', "Terminal is waiting for input"); + const parts = [new ChatRequestTextPart(new OffsetRange(0, message.length), { startColumn: 1, startLineNumber: 1, endColumn: 1, endLineNumber: 1 }, message)]; + request = chatModel.addRequest( + { text: message, parts }, + { variables: [] }, + 0, // attempt + undefined, // modeInfo + undefined, // chatAgent + undefined, // slashCommand + undefined, // confirmation + undefined, // locationData + undefined, // attachments + undefined, // isCompleteAddedRequest + undefined, // modelId + undefined, // userSelectedTools + undefined, // id + true, // isSystemInitiated + localize('backgroundTaskInputNeeded', "Background task `{0}` input needed", this._command), // systemInitiatedLabel + ); + } else { + request = requests.findLast(r => !r.isSystemInitiated) ?? requests.at(-1); + } if (!request) { throw new Error('No request'); } let part!: ChatElicitationRequestPart; + const asyncRequest = this._asyncMode ? request : undefined; const promise = new Promise(resolve => { const thePart = part = new ChatElicitationRequestPart( title, @@ -869,6 +1018,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } thePart.hide(); this._promptPart = undefined; + asyncRequest?.response?.complete(); return ElicitationState.Accepted; }, async () => { @@ -885,6 +1035,7 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { } thePart.hide(); this._promptPart = undefined; + asyncRequest?.response?.complete(); return ElicitationState.Rejected; }, undefined, // source @@ -896,7 +1047,10 @@ export class OutputMonitor extends Disposable implements IOutputMonitor { this._promptPart = thePart; }); - this._register(token.onCancellationRequested(() => part.hide())); + this._register(token.onCancellationRequested(() => { + part.hide(); + asyncRequest?.response?.complete(); + })); return { promise, part }; } 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 c39cd3ae75a50..a043fb68e8608 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -27,7 +27,10 @@ import { ICommandDetectionCapability, TerminalCapability } from '../../../../../ import { ITerminalLogService, ITerminalProfile } from '../../../../../../platform/terminal/common/terminal.js'; import { IRemoteAgentService } from '../../../../../services/remote/common/remoteAgentService.js'; import { TerminalToolConfirmationStorageKeys } from '../../../../chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.js'; -import { IChatService, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; +import { IChatService, ChatRequestQueueKind, type IChatTerminalToolInvocationData } from '../../../../chat/common/chatService/chatService.js'; +import { constObservable, type IObservable } from '../../../../../../base/common/observable.js'; +import type { IChatRequestModeInfo } from '../../../../chat/common/model/chatModel.js'; +import type { UserSelectedTools } from '../../../../chat/common/participants/chatAgents.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolConfirmationMessages, IStreamedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolInvocationPreparationContext, IToolInvocationStreamContext, IToolResult, ToolDataSource, ToolInvocationPresentation, ToolProgress } from '../../../../chat/common/tools/languageModelToolsService.js'; import { ITerminalChatService, ITerminalService, type ITerminalInstance } from '../../../../terminal/browser/terminal.js'; import { ITerminalProfileResolverService } from '../../../../terminal/common/terminal.js'; @@ -83,7 +86,7 @@ const TERMINAL_SANDBOX_DOCUMENTATION_URL = 'https://aka.ms/vscode-sandboxing'; const TOOL_REFERENCE_NAME = 'runInTerminal'; const LEGACY_TOOL_REFERENCE_FULL_NAMES = ['runCommands/runInTerminal']; -function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createPowerShellModelDescription(shell: string, isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const isWinPwsh = isWindowsPowerShell(shell); const parts = [ `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, @@ -133,7 +136,7 @@ function createPowerShellModelDescription(shell: string, isSandboxEnabled: boole '- Use Test-Path to check file/directory existence', '- Be specific with Select-Object properties to avoid excessive output', '- Avoid printing credentials unless absolutely required', - `- NEVER run Start-Sleep or similar wait commands. If you need to check on an async process, use ${TerminalToolId.GetTerminalOutput} instead`, + `- NEVER run Start-Sleep or similar wait commands.${backgroundNotifications ? ' You will be automatically notified on your next turn when async terminal commands complete or need input.' : ''} Use ${TerminalToolId.GetTerminalOutput} to check output before then`, ); return parts.join('\n'); @@ -165,7 +168,7 @@ function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDoma return lines; } -function createGenericDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createGenericDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { const parts = [` Command Execution: - Use && to chain simple commands on one line @@ -206,25 +209,25 @@ Best Practices: - Use find with -exec or xargs for file operations - Be specific with commands to avoid excessive output - Avoid printing credentials unless absolutely required -- NEVER run sleep or similar wait commands in a terminal. If you need to check on an async process, use ${TerminalToolId.GetTerminalOutput} instead`); +- NEVER run sleep or similar wait commands in a terminal.${backgroundNotifications ? ' You will be automatically notified on your next turn when async terminal commands complete or need input.' : ''} Use ${TerminalToolId.GetTerminalOutput} to check output before then`); return parts.join(''); } -function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createBashModelDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, networkDomains), + createGenericDescription(isSandboxEnabled, backgroundNotifications, networkDomains), '- Use [[ ]] for conditional tests instead of [ ]', '- Prefer $() over backticks for command substitution', '- Use set -e at start of complex commands to exit on errors' ].join('\n'); } -function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createZshModelDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent zsh terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, networkDomains), + createGenericDescription(isSandboxEnabled, backgroundNotifications, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use [[ ]] for conditional tests instead of [ ]', @@ -234,10 +237,10 @@ function createZshModelDescription(isSandboxEnabled: boolean, networkDomains?: I ].join('\n'); } -function createFishModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { +function createFishModelDescription(isSandboxEnabled: boolean, backgroundNotifications: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { return [ 'This tool allows you to execute shell commands in a persistent fish terminal session, preserving environment variables, working directory, and other context across multiple commands.', - createGenericDescription(isSandboxEnabled, networkDomains), + createGenericDescription(isSandboxEnabled, backgroundNotifications, networkDomains), '- Use type to check command type (builtin, function, alias)', '- Use jobs, fg, bg for job control', '- Use test expressions for conditionals (no [[ ]] syntax)', @@ -253,6 +256,7 @@ export async function createRunInTerminalToolData( ): Promise { const instantiationService = accessor.get(IInstantiationService); const terminalSandboxService = accessor.get(ITerminalSandboxService); + const configurationService = accessor.get(IConfigurationService); const profileFetcher = instantiationService.createInstance(TerminalProfileFetcher); const [shell, os, isSandboxEnabled] = await Promise.all([ @@ -262,16 +266,17 @@ export async function createRunInTerminalToolData( ]); const networkDomains = isSandboxEnabled ? terminalSandboxService.getResolvedNetworkDomains() : undefined; + const backgroundNotifications = configurationService.getValue(TerminalChatAgentToolsSettingId.BackgroundNotifications) === true; let modelDescription: string; if (shell && os && isPowerShell(shell, os)) { - modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, networkDomains); + modelDescription = createPowerShellModelDescription(shell, isSandboxEnabled, backgroundNotifications, networkDomains); } else if (shell && os && isZsh(shell, os)) { - modelDescription = createZshModelDescription(isSandboxEnabled, networkDomains); + modelDescription = createZshModelDescription(isSandboxEnabled, backgroundNotifications, networkDomains); } else if (shell && os && isFish(shell, os)) { - modelDescription = createFishModelDescription(isSandboxEnabled, networkDomains); + modelDescription = createFishModelDescription(isSandboxEnabled, backgroundNotifications, networkDomains); } else { - modelDescription = createBashModelDescription(isSandboxEnabled, networkDomains); + modelDescription = createBashModelDescription(isSandboxEnabled, backgroundNotifications, networkDomains); } const sharedProperties: IJSONSchemaMap = { @@ -304,7 +309,7 @@ export async function createRunInTerminalToolData( toolReferenceName: TOOL_REFERENCE_NAME, legacyToolReferenceFullNames: LEGACY_TOOL_REFERENCE_FULL_NAMES, displayName: localize('runInTerminalTool.displayName', 'Run in Terminal'), - modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion up to timeout; if still running, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID.`, + modelDescription: `${modelDescription}\n\nExecution mode:\n- mode='sync': wait for completion up to timeout; if still running, return with a terminal ID.\n- mode='async': wait for an initial idle/output signal, then return with terminal output snapshot and ID.${backgroundNotifications ? `\n\nAsync terminal notifications: When a command finishes in an async terminal, you will be automatically notified on your next turn with the exit code and terminal output. You will also be notified if the terminal needs input. Use ${TerminalToolId.GetTerminalOutput} to check output before then. Do NOT poll or sleep to wait for completion.` : `\n\nUse ${TerminalToolId.GetTerminalOutput} to check on async terminal output. Do NOT poll or sleep to wait for completion.`}`, userDescription: localize('runInTerminalTool.userDescription', 'Run commands in the terminal'), source: ToolDataSource.Internal, icon: Codicon.terminal, @@ -1078,7 +1083,9 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { let exitCode: number | undefined; let altBufferResult: IToolResult | undefined; let didTimeout = false; - let didMoveToBackground = executionOptions.persistentSession; + // Covers both terminals that start as background (persistentSession) and + // foreground terminals that later move to background (timeout/continue-in-bg). + let isBackgroundExecution = executionOptions.persistentSession; let timeoutPromise: CancelablePromise | undefined; let timeoutRacePromise: Promise<{ type: 'timeout' }> | undefined; let outputMonitor: OutputMonitor | undefined; @@ -1108,7 +1115,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (sessionId === terminalToolSessionId) { const execution = RunInTerminalTool._activeExecutions.get(termId); execution?.setBackground?.(); - didMoveToBackground = true; + isBackgroundExecution = true; // Resolve the race promise instead of cancelling - this allows the execution // to continue running so it can be awaited later continueInBackgroundResolve?.(); @@ -1144,7 +1151,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { : undefined; store.add(execution.strategy.onDidCreateStartMarker(startMarker => { if (!outputMonitor) { - outputMonitor = store.add(this._instantiationService.createInstance( + outputMonitor = this._instantiationService.createInstance( OutputMonitor, { instance: toolTerminal.instance, @@ -1155,7 +1162,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { invocation.context, token, command - )); + ); } })); @@ -1238,7 +1245,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); error = 'timeout'; didTimeout = true; - didMoveToBackground = true; + isBackgroundExecution = true; toolTerminal.isBackground = true; this._sessionTerminalAssociations.delete(chatSessionResource); await this._associateProcessIdWithSession(toolTerminal.instance, chatSessionResource, termId, toolTerminal.shellIntegrationQuality, true); @@ -1308,7 +1315,7 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { if (didTimeout && e instanceof CancellationError) { this._logService.debug(`RunInTerminalTool: Timeout reached, returning output collected so far`); error = 'timeout'; - didMoveToBackground = true; + isBackgroundExecution = true; toolTerminal.isBackground = true; this._sessionTerminalAssociations.delete(chatSessionResource); const timeoutOutput = getOutput(toolTerminal.instance, undefined); @@ -1338,17 +1345,25 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } } finally { timeoutPromise?.cancel(); - if (didMoveToBackground && executionPromise) { - // Execution moved to background - attach error handler since we won't await it + if (isBackgroundExecution && executionPromise) { + // Background terminal (started as bg or moved to bg) - attach error handler since we won't await it executionPromise.catch((e: unknown) => { if (!(e instanceof CancellationError)) { this._logService.error(`RunInTerminalTool: Background execution error`, e); } }); + // Register a listener to notify the agent when commands complete in this + // background terminal, and continue the output monitor for prompt-for-input detection + if (this._configurationService.getValue(TerminalChatAgentToolsSettingId.BackgroundNotifications)) { + this._registerCompletionNotification(toolTerminal.instance, termId, chatSessionResource, command, outputMonitor); + } else { + outputMonitor?.dispose(); + } } else { - // Foreground completed or error - clean up execution + // Foreground completed or error - clean up execution and output monitor RunInTerminalTool._activeExecutions.get(termId)?.dispose(); RunInTerminalTool._activeExecutions.delete(termId); + outputMonitor?.dispose(); } store.dispose(); const timingExecuteMs = Date.now() - timingStart; @@ -1392,12 +1407,15 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { } else if (didToolEditCommand) { resultText.push(`Note: The tool simplified the command to \`${command}\`, and this is the output of running that command instead:\n`); } - if (didMoveToBackground && !executionOptions.persistentSession) { + if (isBackgroundExecution && !executionOptions.persistentSession) { resultText.push(`Note: This terminal execution was moved to the background using the ID ${termId}\n`); } } if (didTimeout && timeoutValue !== undefined && timeoutValue > 0) { - resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}. Use ${TerminalToolId.GetTerminalOutput} to check its current output, ${TerminalToolId.SendToTerminal} to send further input, or ${TerminalToolId.KillTerminal} to stop it. Do NOT use sleep or manual polling to wait.\n\n`); + const notificationHint = this._configurationService.getValue(TerminalChatAgentToolsSettingId.BackgroundNotifications) + ? ' You will be automatically notified on your next turn when it completes.' + : ''; + resultText.push(`Note: Command timed out after ${timeoutValue}ms. The command may still be running in terminal ID ${termId}.${notificationHint} Use ${TerminalToolId.GetTerminalOutput} to check output before then, ${TerminalToolId.SendToTerminal} to send further input, or ${TerminalToolId.KillTerminal} to stop it. Do NOT use sleep or manual polling to wait.\n\n`); } const outputAnalyzerMessage = await this._getOutputAnalyzerMessage(exitCode, terminalResult, command, didSandboxWrapCommand); if (outputAnalyzerMessage) { @@ -1759,6 +1777,114 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { RunInTerminalTool._activeExecutions.delete(termId); } } + + /** + * Registers a listener for command completion on a background terminal. + * When a command finishes, sends a steering message to the chat session + * so the agent is notified on its next turn. + * + * If an output monitor is provided, it is continued in background mode + * to detect prompts-for-input while the terminal runs in the background. + * The output monitor is cancelled and disposed when a command finishes. + */ + private _registerCompletionNotification(terminalInstance: ITerminalInstance, termId: string, chatSessionResource: URI, commandName: string, outputMonitor?: OutputMonitor): void { + const commandDetection = terminalInstance.capabilities.get(TerminalCapability.CommandDetection); + if (!commandDetection) { + outputMonitor?.dispose(); + return; + } + + // Acquire a reference to the ChatModel so it stays alive while we wait + // for the background terminal to complete. Without this, the model can + // be disposed if the user navigates away, and sendRequest would throw. + const sessionRef = this._chatService.acquireExistingSession(chatSessionResource, 'RunInTerminalTool#completionNotification'); + if (!sessionRef) { + this._logService.warn(`RunInTerminalTool: Cannot register completion notification for terminal ${termId} - session already disposed`); + outputMonitor?.dispose(); + return; + } + + // Capture model/mode/tools from the last request so the steering message + // uses the same settings as the original conversation (not defaults). + const lastRequest = sessionRef.object.lastRequest; + const sendOptions: { userSelectedModelId?: string; modeInfo?: IChatRequestModeInfo; userSelectedTools?: IObservable } = {}; + if (lastRequest) { + sendOptions.userSelectedModelId = lastRequest.modelId; + sendOptions.modeInfo = lastRequest.modeInfo; + if (lastRequest.userSelectedTools) { + sendOptions.userSelectedTools = constObservable(lastRequest.userSelectedTools); + } + } + + // Continue the output monitor in background mode for prompt-for-input detection. + // The monitor wakes only on new terminal data (not on a fixed interval), so + // resource cost is proportional to actual terminal activity. + let bgCts: CancellationTokenSource | undefined; + if (outputMonitor) { + bgCts = new CancellationTokenSource(); + outputMonitor.continueMonitoringAsync(bgCts.token); + } + + const listener = commandDetection.onCommandFinished(command => { + const execution = RunInTerminalTool._activeExecutions.get(termId); + if (!execution) { + cleanup(); + return; + } + + // Dispose after first notification to avoid chatty repeated messages + // if the user runs additional commands via send_to_terminal. + cleanup(); + + const exitCode = command.exitCode; + const exitCodeText = exitCode !== undefined ? ` with exit code ${exitCode}` : ''; + const currentOutput = execution.getOutput(); + const message = `[Terminal ${termId} notification: command completed${exitCodeText}. Use send_to_terminal to send another command or kill_terminal to stop it.]\nTerminal output:\n${currentOutput}`; + + this._logService.debug(`RunInTerminalTool: Command completed in background terminal ${termId}, notifying chat session`); + + this._chatService.sendRequest(chatSessionResource, message, { + queue: ChatRequestQueueKind.Steering, + isSystemInitiated: true, + systemInitiatedLabel: localize('backgroundTaskCompleted', "Background task `{0}` completed", commandName), + ...sendOptions, + }).catch(e => { + this._logService.warn(`RunInTerminalTool: Failed to send completion notification for terminal ${termId}`, e); + }); + }); + + // Clean up all background resources when the terminal is disposed + // (e.g. user closes the terminal) to avoid leaking listeners and monitors. + const disposedListener = terminalInstance.onDisposed(() => { + cleanup(); + }); + + // When a checkpoint is restored, requests are removed from the model. + // Cancel the background notification and dispose the terminal so that + // background processes don't outlive the rolled-back session state. + const modelChangeListener = sessionRef.object.onDidChange(e => { + if (e.kind === 'removeRequest') { + this._logService.debug(`RunInTerminalTool: Request removed from session, cleaning up background terminal ${termId}`); + RunInTerminalTool._activeExecutions.get(termId)?.dispose(); + RunInTerminalTool._activeExecutions.delete(termId); + cleanup(); + terminalInstance.dispose(); + } + }); + + const cleanup = () => { + listener.dispose(); + disposedListener.dispose(); + modelChangeListener.dispose(); + bgCts?.dispose(); + outputMonitor?.dispose(); + sessionRef.dispose(); + }; + + this._register(listener); + this._register(disposedListener); + this._register(modelChangeListener); + } // #endregion } diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts index 7949d61554896..ea341825e4e25 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/common/terminalChatAgentToolsConfiguration.ts @@ -28,6 +28,7 @@ export const enum TerminalChatAgentToolsSettingId { PreventShellHistory = 'chat.tools.terminal.preventShellHistory', EnforceTimeoutFromModel = 'chat.tools.terminal.enforceTimeoutFromModel', DetachBackgroundProcesses = 'chat.tools.terminal.detachBackgroundProcesses', + BackgroundNotifications = 'chat.tools.terminal.backgroundNotifications', IdlePollInterval = 'chat.tools.terminal.idlePollInterval', TerminalProfileLinux = 'chat.tools.terminal.terminalProfile.linux', @@ -654,6 +655,13 @@ export const terminalChatAgentToolsConfiguration: IStringDictionary Date: Fri, 3 Apr 2026 03:44:23 +0530 Subject: [PATCH 31/31] Merge pull request #307250 from yogeshwaran-c/feat/coverage-minimap-indicators feat: show coverage indicators in minimap --- .../testing/browser/codeCoverageDecorations.ts | 17 ++++++++++++++--- .../workbench/contrib/testing/browser/theme.ts | 14 ++++++++++++++ .../contrib/testing/common/configuration.ts | 7 +++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts index 37fac8335cb35..a4cb032efa757 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts @@ -25,7 +25,7 @@ import { EditorOption } from '../../../../editor/common/config/editorOptions.js' import { Position } from '../../../../editor/common/core/position.js'; import { Range } from '../../../../editor/common/core/range.js'; import { IEditorContribution } from '../../../../editor/common/editorCommon.js'; -import { IModelDecorationOptions, InjectedTextCursorStops, InjectedTextOptions, ITextModel } from '../../../../editor/common/model.js'; +import { IModelDecorationOptions, InjectedTextCursorStops, InjectedTextOptions, ITextModel, MinimapPosition } from '../../../../editor/common/model.js'; import { localize, localize2 } from '../../../../nls.js'; import { Categories } from '../../../../platform/action/common/actionCommonCategories.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -39,6 +39,7 @@ import { KeybindingWeight } from '../../../../platform/keybinding/common/keybind import { ILogService } from '../../../../platform/log/common/log.js'; import { bindContextKey, observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IQuickInputButton, IQuickInputService, QuickPickInput } from '../../../../platform/quickinput/common/quickInput.js'; +import { themeColorFromId } from '../../../../platform/theme/common/themeService.js'; import { ActiveEditorContext } from '../../../common/contextkeys.js'; import { TEXT_FILE_EDITOR_ID } from '../../files/common/files.js'; import { getTestingConfiguration, TestingConfigKeys } from '../common/configuration.js'; @@ -52,6 +53,7 @@ import { TestingContextKeys } from '../common/testingContextKeys.js'; import * as coverUtils from './codeCoverageDisplayUtils.js'; import { testingCoverageMissingBranch, testingCoverageReport, testingFilterIcon, testingRerunIcon } from './icons.js'; import { ManagedTestCoverageBars } from './testCoverageBars.js'; +import { testingCoveredMinimapBackground, testingUncoveredMinimapBackground } from './theme.js'; const CLASS_HIT = 'coverage-deco-hit'; const CLASS_MISS = 'coverage-deco-miss'; @@ -130,10 +132,11 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri reader => this.hasInlineCoverageDetails.read(reader), )); + const minimapEnabled = observableConfigValue(TestingConfigKeys.CoverageMinimapEnabled, true, configurationService); this._register(autorun(reader => { const c = fileCoverage.read(reader); if (c) { - this.apply(editor.getModel()!, c.file, c.testId, coverage.showInline.read(reader)); + this.apply(editor.getModel()!, c.file, c.testId, coverage.showInline.read(reader), minimapEnabled.read(reader)); } else { this.clear(); } @@ -329,7 +332,7 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri return false; } - private async apply(model: ITextModel, coverage: FileCoverage, testId: TestId | undefined, showInlineByDefault: boolean) { + private async apply(model: ITextModel, coverage: FileCoverage, testId: TestId | undefined, showInlineByDefault: boolean, showMinimap: boolean) { const details = this.details = await this.loadDetails(coverage, testId, model); if (!details) { this.hasInlineCoverageDetails.set(false, undefined); @@ -353,6 +356,10 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri showIfCollapsed: showMissIndicator, // only avoid collapsing if we want to show the miss indicator description: 'coverage-gutter', lineNumberClassName: `coverage-deco-gutter ${cls}`, + minimap: showMinimap ? { + color: themeColorFromId(hits ? testingCoveredMinimapBackground : testingUncoveredMinimapBackground), + position: MinimapPosition.Gutter, + } : undefined, }; const applyHoverOptions = (target: IModelDecorationOptions) => { @@ -383,6 +390,10 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri showIfCollapsed: false, description: 'coverage-inline', lineNumberClassName: `coverage-deco-gutter ${cls}`, + minimap: showMinimap ? { + color: themeColorFromId(detail.count ? testingCoveredMinimapBackground : testingUncoveredMinimapBackground), + position: MinimapPosition.Gutter, + } : undefined, }; const applyHoverOptions = (target: IModelDecorationOptions) => { diff --git a/src/vs/workbench/contrib/testing/browser/theme.ts b/src/vs/workbench/contrib/testing/browser/theme.ts index d2caa0673bc8b..6765208b02c38 100644 --- a/src/vs/workbench/contrib/testing/browser/theme.ts +++ b/src/vs/workbench/contrib/testing/browser/theme.ts @@ -105,6 +105,20 @@ export const testingUncoveredGutterBackground = registerColor('testing.uncovered hcLight: chartsRed }, localize('testing.uncoveredGutterBackground', 'Gutter color of regions where code not covered.')); +export const testingCoveredMinimapBackground = registerColor('testing.coveredMinimapBackground', { + dark: transparent(diffInserted, 0.6), + light: transparent(diffInserted, 0.6), + hcDark: chartsGreen, + hcLight: chartsGreen +}, localize('testing.coveredMinimapBackground', 'Minimap color of regions where code was covered.')); + +export const testingUncoveredMinimapBackground = registerColor('testing.uncoveredMinimapBackground', { + dark: transparent(diffRemoved, 1.5), + light: transparent(diffRemoved, 1.5), + hcDark: chartsRed, + hcLight: chartsRed +}, localize('testing.uncoveredMinimapBackground', 'Minimap color of regions where code was not covered.')); + export const testingCoverCountBadgeBackground = registerColor('testing.coverCountBadgeBackground', badgeBackground, localize('testing.coverCountBadgeBackground', 'Background for the badge indicating execution count')); export const testingCoverCountBadgeForeground = registerColor('testing.coverCountBadgeForeground', badgeForeground, localize('testing.coverCountBadgeForeground', 'Foreground for the badge indicating execution count')); diff --git a/src/vs/workbench/contrib/testing/common/configuration.ts b/src/vs/workbench/contrib/testing/common/configuration.ts index 5843924ee7c0a..9c8c1f96b5764 100644 --- a/src/vs/workbench/contrib/testing/common/configuration.ts +++ b/src/vs/workbench/contrib/testing/common/configuration.ts @@ -25,6 +25,7 @@ export const enum TestingConfigKeys { ShowCoverageInExplorer = 'testing.showCoverageInExplorer', CoverageBarThresholds = 'testing.coverageBarThresholds', CoverageToolbarEnabled = 'testing.coverageToolbarEnabled', + CoverageMinimapEnabled = 'testing.coverageMinimapEnabled', ResultsViewLayout = 'testing.resultsView.layout', } @@ -197,6 +198,11 @@ export const testingConfiguration: IConfigurationNode = { type: 'boolean', default: false, // todo@connor4312: disabled by default until UI sync }, + [TestingConfigKeys.CoverageMinimapEnabled]: { + description: localize('testing.coverageMinimapEnabled', 'Controls whether coverage indicators are shown in the minimap.'), + type: 'boolean', + default: true, + }, [TestingConfigKeys.ResultsViewLayout]: { description: localize('testing.resultsView.layout', 'Controls the layout of the Test Results view.'), enum: [ @@ -246,6 +252,7 @@ export interface ITestingConfiguration { [TestingConfigKeys.ShowCoverageInExplorer]: boolean; [TestingConfigKeys.CoverageBarThresholds]: ITestingCoverageBarThresholds; [TestingConfigKeys.CoverageToolbarEnabled]: boolean; + [TestingConfigKeys.CoverageMinimapEnabled]: boolean; [TestingConfigKeys.ResultsViewLayout]: TestingResultsViewLayout; }