From 5bed6754c7393af5a8d2444f11709d2a315e5b04 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Tue, 3 Mar 2026 16:32:04 +0100 Subject: [PATCH 01/23] fix ctrl+w keybinding --- .../sessions/browser/sessionsViewPane.ts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 953bd0b26f985..4ed04cbc113e3 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -325,10 +325,27 @@ KeybindingsRegistry.registerKeybindingRule({ primary: KeyMod.CtrlCmd | KeyCode.KeyN, }); +const CLOSE_SESSION_COMMAND_ID = 'agentSession.close'; +registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { + constructor() { + super({ + id: CLOSE_SESSION_COMMAND_ID, + title: localize2('closeSession', "Close Session"), + f1: true, + precondition: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), + category: SessionsCategories.Sessions, + }); + } + override async run(accessor: ServicesAccessor) { + const sessionsService = accessor.get(ISessionsManagementService); + await sessionsService.openNewSessionView(); + } +}); + // Register Cmd+W / Ctrl+W to open new session when the current session is non-empty, // mirroring how Cmd+W closes the active editor in the normal workbench. KeybindingsRegistry.registerKeybindingRule({ - id: ACTION_ID_NEW_CHAT, + id: CLOSE_SESSION_COMMAND_ID, weight: KeybindingWeight.WorkbenchContrib + 1, when: ContextKeyExpr.and(IsNewChatSessionContext.negate(), EditorsVisibleContext.negate()), primary: KeyMod.CtrlCmd | KeyCode.KeyW, From 450aae82d85466e48f84393eb1bb2c44b8a22183 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:44:16 +0100 Subject: [PATCH 02/23] Update src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 4ed04cbc113e3..dbe1402b6a154 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -326,7 +326,7 @@ KeybindingsRegistry.registerKeybindingRule({ }); const CLOSE_SESSION_COMMAND_ID = 'agentSession.close'; -registerAction2(class RefreshAgentSessionsViewerAction extends Action2 { +registerAction2(class CloseSessionAction extends Action2 { constructor() { super({ id: CLOSE_SESSION_COMMAND_ID, From 5bf6c54c2216f8a1679a4428fbaafbdbbf466f37 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 4 Mar 2026 10:45:12 +0100 Subject: [PATCH 03/23] Update src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index dbe1402b6a154..41865aac2fbb3 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -342,7 +342,7 @@ registerAction2(class CloseSessionAction extends Action2 { } }); -// Register Cmd+W / Ctrl+W to open new session when the current session is non-empty, +// Register Cmd+W / Ctrl+W to close the current session and navigate to the new-session view, // mirroring how Cmd+W closes the active editor in the normal workbench. KeybindingsRegistry.registerKeybindingRule({ id: CLOSE_SESSION_COMMAND_ID, From 843f795ede4dc5ba7c4040eaf815f116b7180d47 Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Wed, 4 Mar 2026 11:24:19 +0100 Subject: [PATCH 04/23] towards schema based prompt file validation (#299067) --- .../promptSyntax/newPromptFileActions.ts | 2 +- .../promptToolsCodeLensProvider.ts | 4 +- .../contrib/chat/common/chatModes.ts | 3 +- .../PromptHeaderDefinitionProvider.ts | 2 +- .../languageProviders/promptCodeActions.ts | 3 +- .../promptDocumentSemanticTokensProvider.ts | 2 +- .../languageProviders/promptFileAttributes.ts | 436 ++++++++++++++++++ .../promptHeaderAutocompletion.ts | 72 +-- .../languageProviders/promptHovers.ts | 8 +- .../languageProviders/promptValidator.ts | 9 +- .../common/promptSyntax/promptFileParser.ts | 14 - .../service/promptsServiceImpl.ts | 2 +- 12 files changed, 467 insertions(+), 90 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts index 54a379abecc8b..a5c04f02c1eef 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/newPromptFileActions.ts @@ -27,7 +27,7 @@ import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js' import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js'; import { getCleanPromptName, SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js'; import { PromptsStorage } from '../../common/promptSyntax/service/promptsService.js'; -import { getTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { getTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; /** * Options to override the default folder-picker and editor-open behaviour diff --git a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts index e231b63d6d7f8..599946c97bf45 100644 --- a/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts +++ b/src/vs/workbench/contrib/chat/browser/promptSyntax/promptToolsCodeLensProvider.ts @@ -20,9 +20,9 @@ import { registerEditorFeature } from '../../../../../editor/common/editorFeatur import { PromptFileRewriter } from './promptFileRewriter.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { IEditorModel } from '../../../../../editor/common/editorCommon.js'; -import { isTarget, parseCommaSeparatedList, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; -import { getTarget, isVSCodeOrDefaultTarget } from '../../common/promptSyntax/languageProviders/promptValidator.js'; +import { parseCommaSeparatedList, PromptHeaderAttributes } from '../../common/promptSyntax/promptFileParser.js'; import { isBoolean } from '../../../../../base/common/types.js'; +import { getTarget, isTarget, isVSCodeOrDefaultTarget } from '../../common/promptSyntax/languageProviders/promptFileAttributes.js'; class PromptToolsCodeLensProvider extends Disposable implements CodeLensProvider { diff --git a/src/vs/workbench/contrib/chat/common/chatModes.ts b/src/vs/workbench/contrib/chat/common/chatModes.ts index 8b0e548eaf2ed..c13b64519ee4f 100644 --- a/src/vs/workbench/contrib/chat/common/chatModes.ts +++ b/src/vs/workbench/contrib/chat/common/chatModes.ts @@ -19,13 +19,14 @@ import { IStorageService, StorageScope, StorageTarget } from '../../../../platfo import { IChatAgentService } from './participants/chatAgents.js'; import { ChatContextKeys } from './actions/chatContextKeys.js'; import { ChatConfiguration, ChatModeKind } from './constants.js'; -import { IHandOff, isTarget } from './promptSyntax/promptFileParser.js'; +import { IHandOff } from './promptSyntax/promptFileParser.js'; import { ExtensionAgentSourceType, IAgentSource, ICustomAgent, ICustomAgentVisibility, IPromptsService, isCustomAgentVisibility, PromptsStorage } from './promptSyntax/service/promptsService.js'; import { Target } from './promptSyntax/promptTypes.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { hash } from '../../../../base/common/hash.js'; import { isString } from '../../../../base/common/types.js'; +import { isTarget } from './promptSyntax/languageProviders/promptFileAttributes.js'; export const IChatModeService = createDecorator('chatModeService'); export interface IChatModeService { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts index 8de8c06dfba12..b991d1d4db579 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/PromptHeaderDefinitionProvider.ts @@ -9,8 +9,8 @@ import { Range } from '../../../../../../editor/common/core/range.js'; import { Definition, DefinitionProvider } from '../../../../../../editor/common/languages.js'; import { ITextModel } from '../../../../../../editor/common/model.js'; import { IChatModeService } from '../../chatModes.js'; -import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { PromptHeaderAttributes } from '../promptFileParser.js'; +import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; export class PromptHeaderDefinitionProvider implements DefinitionProvider { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts index 704d5cd620815..eba207145d155 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptCodeActions.ts @@ -16,9 +16,10 @@ import { Selection } from '../../../../../../editor/common/core/selection.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; -import { getTarget, isVSCodeOrDefaultTarget, MARKERS_OWNER_ID } from './promptValidator.js'; +import { MARKERS_OWNER_ID } from './promptValidator.js'; import { IMarkerData, IMarkerService } from '../../../../../../platform/markers/common/markers.js'; import { CodeActionKind } from '../../../../../../editor/contrib/codeAction/common/types.js'; +import { getTarget, isVSCodeOrDefaultTarget } from './promptFileAttributes.js'; export class PromptCodeActionProvider implements CodeActionProvider { /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts index 3fdf4aa385e58..8b5ffb7ae41e8 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptDocumentSemanticTokensProvider.ts @@ -8,7 +8,7 @@ import { DocumentSemanticTokensProvider, ProviderResult, SemanticTokens, Semanti import { ITextModel } from '../../../../../../editor/common/model.js'; import { getPromptsTypeForLanguageId } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { getTarget, isVSCodeOrDefaultTarget } from './promptValidator.js'; +import { getTarget, isVSCodeOrDefaultTarget } from './promptFileAttributes.js'; export class PromptDocumentSemanticTokensProvider implements DocumentSemanticTokensProvider { /** diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts new file mode 100644 index 0000000000000..2e99a13df8223 --- /dev/null +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptFileAttributes.ts @@ -0,0 +1,436 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { dirname } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; +import { SpecedToolAliases } from '../../tools/languageModelToolsService.js'; +import { CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder } from '../config/promptFileLocations.js'; +import { PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { PromptsType, Target } from '../promptTypes.js'; + +export namespace GithubPromptHeaderAttributes { + export const mcpServers = 'mcp-servers'; + export const github = 'github'; +} + +export namespace ClaudeHeaderAttributes { + export const disallowedTools = 'disallowedTools'; +} + +export function isTarget(value: unknown): value is Target { + return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined; +} + + +interface IAttributeDefinition { + readonly type: string; + readonly description: string; + readonly defaults?: readonly string[]; + readonly items?: readonly { name: string; description?: string }[]; + readonly enums?: readonly { name: string; description?: string }[]; +} + +const booleanAttributeEnumValues: readonly IValueEntry[] = [ + { name: 'true' }, + { name: 'false' } +]; + +const targetAttributeEnumValues: readonly IValueEntry[] = [ + { name: 'vscode' }, + { name: 'github-copilot' }, +]; + +// Attribute metadata for prompt files (`*.prompt.md`). +export const promptFileAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.prompt.name', 'The name of the prompt. This is also the name of the slash command that will run this prompt.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.prompt.description', 'The description of the reusable prompt, what it does and when to use it.'), + }, + [PromptHeaderAttributes.argumentHint]: { + type: 'scalar', + description: localize('promptHeader.prompt.argumentHint', 'The argument-hint describes what inputs the prompt expects or supports.'), + }, + [PromptHeaderAttributes.model]: { + type: 'scalar | sequence', + description: localize('promptHeader.prompt.model', 'The model to use in this prompt. Can also be a list of models. The first available model will be used.'), + }, + [PromptHeaderAttributes.tools]: { + type: 'scalar | sequence', + description: localize('promptHeader.prompt.tools', 'The tools to use in this prompt.'), + defaults: ['[]', '[\'search\', \'edit\', \'web\']'], + }, + [PromptHeaderAttributes.agent]: { + type: 'scalar', + description: localize('promptHeader.prompt.agent.description', 'The agent to use when running this prompt.'), + }, + [PromptHeaderAttributes.mode]: { + type: 'scalar', + description: localize('promptHeader.prompt.agent.description', 'The agent to use when running this prompt.'), + }, +}; + +// Attribute metadata for instructions files (`*.instructions.md`). +export const instructionAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.instructions.name', 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), + }, + [PromptHeaderAttributes.applyTo]: { + type: 'scalar', + description: localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: `**/*.ts`, `**/*.js`, `client/**`'), + defaults: [ + '\'**\'', + '\'**/*.ts, **/*.js\'', + '\'**/*.php\'', + '\'**/*.py\'' + ], + }, + [PromptHeaderAttributes.excludeAgent]: { + type: 'scalar | sequence', + description: localize('promptHeader.instructions.excludeAgent', 'One or more agents to exclude from using this instruction file.'), + }, +}; + +// Attribute metadata for custom agent files (`*.agent.md`). +export const customAgentAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.agent.name', 'The name of the agent as shown in the UI.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.agent.description', 'The description of the custom agent, what it does and when to use it.'), + }, + [PromptHeaderAttributes.argumentHint]: { + type: 'scalar', + description: localize('promptHeader.agent.argumentHint', 'The argument-hint describes what inputs the custom agent expects or supports.'), + }, + [PromptHeaderAttributes.model]: { + type: 'scalar | sequence', + description: localize('promptHeader.agent.model', 'Specify the model that runs this custom agent. Can also be a list of models. The first available model will be used.'), + }, + [PromptHeaderAttributes.tools]: { + type: 'scalar | sequence', + description: localize('promptHeader.agent.tools', 'The set of tools that the custom agent has access to.'), + defaults: ['[]', '[search, edit, web]'], + }, + [PromptHeaderAttributes.handOffs]: { + type: 'sequence', + description: localize('promptHeader.agent.handoffs', 'Possible handoff actions when the agent has completed its task.'), + }, + [PromptHeaderAttributes.target]: { + type: 'scalar', + description: localize('promptHeader.agent.target', 'The target to which the header attributes like tools apply to. Possible values are `github-copilot` and `vscode`.'), + enums: targetAttributeEnumValues, + }, + [PromptHeaderAttributes.infer]: { + type: 'scalar', + description: localize('promptHeader.agent.infer', 'Controls visibility of the agent.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.agents]: { + type: 'sequence', + description: localize('promptHeader.agent.agents', 'One or more agents that this agent can use as subagents. Use \'*\' to specify all available agents.'), + defaults: ['["*"]'], + }, + [PromptHeaderAttributes.userInvocable]: { + type: 'scalar', + description: localize('promptHeader.agent.userInvocable', 'Whether the agent can be selected and invoked by users in the UI.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.userInvokable]: { + type: 'scalar', + description: localize('promptHeader.agent.userInvokable', 'Deprecated. Use user-invocable instead.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.disableModelInvocation]: { + type: 'scalar', + description: localize('promptHeader.agent.disableModelInvocation', 'If true, prevents the agent from being invoked as a subagent.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.advancedOptions]: { + type: 'map', + description: localize('promptHeader.agent.advancedOptions', 'Advanced options for custom agent behavior.'), + }, + [GithubPromptHeaderAttributes.github]: { + type: 'map', + description: localize('promptHeader.agent.github', 'GitHub-specific configuration for the agent, such as token permissions.'), + }, +}; + +// Attribute metadata for skill files (`SKILL.md`). +export const skillAttributes: Record = { + [PromptHeaderAttributes.name]: { + type: 'scalar', + description: localize('promptHeader.skill.name', 'The name of the skill.'), + }, + [PromptHeaderAttributes.description]: { + type: 'scalar', + description: localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'), + }, + [PromptHeaderAttributes.argumentHint]: { + type: 'scalar', + description: localize('promptHeader.skill.argumentHint', 'Hint shown during autocomplete to indicate expected arguments. Example: [issue-number] or [filename] [format]'), + }, + [PromptHeaderAttributes.userInvocable]: { + type: 'scalar', + description: localize('promptHeader.skill.userInvocable', 'Set to false to hide from the / menu. Use for background knowledge users should not invoke directly. Default: true.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.userInvokable]: { + type: 'scalar', + description: localize('promptHeader.skill.userInvokable', 'Deprecated. Use user-invocable instead.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.disableModelInvocation]: { + type: 'scalar', + description: localize('promptHeader.skill.disableModelInvocation', 'Set to true to prevent the agent from automatically loading this skill. Use for workflows you want to trigger manually with /name. Default: false.'), + enums: booleanAttributeEnumValues, + }, + [PromptHeaderAttributes.license]: { + type: 'scalar | map', + description: localize('promptHeader.skill.license', 'License information for the skill.'), + }, + [PromptHeaderAttributes.compatibility]: { + type: 'scalar | map', + description: localize('promptHeader.skill.compatibility', 'Compatibility metadata for environments or runtimes.'), + }, + [PromptHeaderAttributes.metadata]: { + type: 'map', + description: localize('promptHeader.skill.metadata', 'Additional metadata for the skill.'), + }, +}; + +const allAttributeNames: Record = { + [PromptsType.prompt]: Object.keys(promptFileAttributes), + [PromptsType.instructions]: Object.keys(instructionAttributes), + [PromptsType.agent]: Object.keys(customAgentAttributes), + [PromptsType.skill]: Object.keys(skillAttributes), + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter +}; +const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, GithubPromptHeaderAttributes.github, PromptHeaderAttributes.infer]; +const recommendedAttributeNames: Record = { + [PromptsType.prompt]: allAttributeNames[PromptsType.prompt].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.instructions]: allAttributeNames[PromptsType.instructions].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.agent]: allAttributeNames[PromptsType.agent].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.skill]: allAttributeNames[PromptsType.skill].filter(name => !isNonRecommendedAttribute(name)), + [PromptsType.hook]: [], // hooks are JSON files, not markdown with YAML frontmatter +}; + +export function getValidAttributeNames(promptType: PromptsType, includeNonRecommended: boolean, target: Target): string[] { + if (target === Target.Claude) { + if (promptType === PromptsType.instructions) { + return Object.keys(claudeRulesAttributes); + } + return Object.keys(claudeAgentAttributes); + } else if (target === Target.GitHubCopilot) { + if (promptType === PromptsType.agent) { + return githubCopilotAgentAttributeNames; + } + } + return includeNonRecommended ? allAttributeNames[promptType] : recommendedAttributeNames[promptType]; +} + +export function isNonRecommendedAttribute(attributeName: string): boolean { + return attributeName === PromptHeaderAttributes.advancedOptions || attributeName === PromptHeaderAttributes.excludeAgent || attributeName === PromptHeaderAttributes.mode || attributeName === PromptHeaderAttributes.infer || attributeName === PromptHeaderAttributes.userInvokable; +} + +export function getAttributeDefinition(attributeName: string, promptType: PromptsType, target: Target): IAttributeDefinition | undefined { + switch (promptType) { + case PromptsType.instructions: + if (target === Target.Claude) { + return claudeRulesAttributes[attributeName]; + } + return instructionAttributes[attributeName]; + case PromptsType.skill: + return skillAttributes[attributeName]; + case PromptsType.agent: + if (target === Target.Claude) { + return claudeAgentAttributes[attributeName]; + } + return customAgentAttributes[attributeName]; + case PromptsType.prompt: + return promptFileAttributes[attributeName]; + default: + return undefined; + } +} + +// The list of tools known to be used by GitHub Copilot custom agents +export const knownGithubCopilotTools = [ + { name: SpecedToolAliases.execute, description: localize('githubCopilot.execute', 'Execute commands') }, + { name: SpecedToolAliases.read, description: localize('githubCopilot.read', 'Read files') }, + { name: SpecedToolAliases.edit, description: localize('githubCopilot.edit', 'Edit files') }, + { name: SpecedToolAliases.search, description: localize('githubCopilot.search', 'Search files') }, + { name: SpecedToolAliases.agent, description: localize('githubCopilot.agent', 'Use subagents') }, +]; + +export interface IValueEntry { + readonly name: string; + readonly description?: string; +} + +export const knownClaudeTools = [ + { name: 'Bash', description: localize('claude.bash', 'Execute shell commands'), toolEquivalent: [SpecedToolAliases.execute] }, + { name: 'Edit', description: localize('claude.edit', 'Make targeted file edits'), toolEquivalent: ['edit/editNotebook', 'edit/editFiles'] }, + { name: 'Glob', description: localize('claude.glob', 'Find files by pattern'), toolEquivalent: ['search/fileSearch'] }, + { name: 'Grep', description: localize('claude.grep', 'Search file contents with regex'), toolEquivalent: ['search/textSearch'] }, + { name: 'Read', description: localize('claude.read', 'Read file contents'), toolEquivalent: ['read/readFile', 'read/getNotebookSummary'] }, + { name: 'Write', description: localize('claude.write', 'Create/overwrite files'), toolEquivalent: ['edit/createDirectory', 'edit/createFile', 'edit/createJupyterNotebook'] }, + { name: 'WebFetch', description: localize('claude.webFetch', 'Fetch URL content'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'WebSearch', description: localize('claude.webSearch', 'Perform web searches'), toolEquivalent: [SpecedToolAliases.web] }, + { name: 'Task', description: localize('claude.task', 'Run subagents for complex tasks'), toolEquivalent: [SpecedToolAliases.agent] }, + { name: 'Skill', description: localize('claude.skill', 'Execute skills'), toolEquivalent: [] }, + { name: 'LSP', description: localize('claude.lsp', 'Code intelligence (requires plugin)'), toolEquivalent: [] }, + { name: 'NotebookEdit', description: localize('claude.notebookEdit', 'Modify Jupyter notebooks'), toolEquivalent: ['edit/editNotebook'] }, + { name: 'AskUserQuestion', description: localize('claude.askUserQuestion', 'Ask multiple-choice questions'), toolEquivalent: ['vscode/askQuestions'] }, + { name: 'MCPSearch', description: localize('claude.mcpSearch', 'Searches for MCP tools when tool search is enabled'), toolEquivalent: [] } +]; + +export const knownClaudeModels = [ + { name: 'sonnet', description: localize('claude.sonnet', 'Latest Claude Sonnet'), modelEquivalent: 'Claude Sonnet 4.5 (copilot)' }, + { name: 'opus', description: localize('claude.opus', 'Latest Claude Opus'), modelEquivalent: 'Claude Opus 4.6 (copilot)' }, + { name: 'haiku', description: localize('claude.haiku', 'Latest Claude Haiku, fast for simple tasks'), modelEquivalent: 'Claude Haiku 4.5 (copilot)' }, + { name: 'inherit', description: localize('claude.inherit', 'Inherit model from parent agent or prompt'), modelEquivalent: undefined }, +]; + +export function mapClaudeModels(claudeModelNames: readonly string[]): readonly string[] { + const result = []; + for (const name of claudeModelNames) { + const claudeModel = knownClaudeModels.find(model => model.name === name); + if (claudeModel && claudeModel.modelEquivalent) { + result.push(claudeModel.modelEquivalent); + } + } + return result; +} + +/** + * Maps Claude tool names to their VS Code tool equivalents. + */ +export function mapClaudeTools(claudeToolNames: readonly string[]): string[] { + const result: string[] = []; + for (const name of claudeToolNames) { + const claudeTool = knownClaudeTools.find(tool => tool.name === name); + if (claudeTool) { + result.push(...claudeTool.toolEquivalent); + } + } + return result; +} + +export const claudeAgentAttributes: Record = { + 'name': { + type: 'scalar', + description: localize('attribute.name', "Unique identifier using lowercase letters and hyphens (required)"), + }, + 'description': { + type: 'scalar', + description: localize('attribute.description', "When to delegate to this subagent (required)"), + }, + 'tools': { + type: 'sequence', + description: localize('attribute.tools', "Array of tools the subagent can use. Inherits all tools if omitted"), + defaults: ['Read, Edit, Bash'], + items: knownClaudeTools + }, + 'disallowedTools': { + type: 'sequence', + description: localize('attribute.disallowedTools', "Tools to deny, removed from inherited or specified list"), + defaults: ['Write, Edit, Bash'], + items: knownClaudeTools + }, + 'model': { + type: 'scalar', + description: localize('attribute.model', "Model to use: sonnet, opus, haiku, or inherit. Defaults to inherit."), + defaults: ['sonnet', 'opus', 'haiku', 'inherit'], + enums: knownClaudeModels + }, + 'permissionMode': { + type: 'scalar', + description: localize('attribute.permissionMode', "Permission mode: default, acceptEdits, dontAsk, bypassPermissions, or plan."), + defaults: ['default', 'acceptEdits', 'dontAsk', 'bypassPermissions', 'plan'], + enums: [ + { name: 'default', description: localize('claude.permissionMode.default', 'Standard behavior: prompts for permission on first use of each tool.') }, + { name: 'acceptEdits', description: localize('claude.permissionMode.acceptEdits', 'Automatically accepts file edit permissions for the session.') }, + { name: 'plan', description: localize('claude.permissionMode.plan', 'Plan Mode: Claude can analyze but not modify files or execute commands.') }, + { name: 'delegate', description: localize('claude.permissionMode.delegate', 'Coordination-only mode for agent team leads. Only available when an agent team is active.') }, + { name: 'dontAsk', description: localize('claude.permissionMode.dontAsk', 'Auto-denies tools unless pre-approved via /permissions or permissions.allow rules.') }, + { name: 'bypassPermissions', description: localize('claude.permissionMode.bypassPermissions', 'Skips all permission prompts (requires safe environment like containers).') } + ] + }, + 'skills': { + type: 'sequence', + description: localize('attribute.skills', "Skills to load into the subagent's context at startup."), + }, + 'mcpServers': { + type: 'sequence', + description: localize('attribute.mcpServers', "MCP servers available to this subagent."), + }, + 'hooks': { + type: 'object', + description: localize('attribute.hooks', "Lifecycle hooks scoped to this subagent."), + }, + 'memory': { + type: 'scalar', + description: localize('attribute.memory', "Persistent memory scope: user, project, or local. Enables cross-session learning."), + defaults: ['user', 'project', 'local'], + enums: [ + { name: 'user', description: localize('claude.memory.user', "Remember learnings across all projects.") }, + { name: 'project', description: localize('claude.memory.project', "The subagent's knowledge is project-specific and shareable via version control.") }, + { name: 'local', description: localize('claude.memory.local', "The subagent's knowledge is project-specific but should not be checked into version control.") } + ] + } +}; + +/** + * Attributes supported in Claude rules files (`.claude/rules/*.md`). + * Claude rules use `paths` instead of `applyTo` for glob patterns. + */ +export const claudeRulesAttributes: Record = { + 'description': { + type: 'scalar', + description: localize('attribute.rules.description', "A description of what this rule covers, used to provide context about when it applies."), + }, + 'paths': { + type: 'sequence', + description: localize('attribute.rules.paths', "Array of glob patterns that describe for which files the rule applies. Based on these patterns, the file is automatically included in the prompt when the context contains a file that matches.\nExample: `['src/**/*.ts', 'test/**']`"), + }, +}; + +export function isVSCodeOrDefaultTarget(target: Target): boolean { + return target === Target.VSCode || target === Target.Undefined; +} + +export function getTarget(promptType: PromptsType, header: PromptHeader | URI): Target { + const uri = header instanceof URI ? header : header.uri; + if (promptType === PromptsType.agent) { + const parentDir = dirname(uri); + if (parentDir.path.endsWith(`/${CLAUDE_AGENTS_SOURCE_FOLDER}`)) { + return Target.Claude; + } + if (!(header instanceof URI)) { + const target = header.target; + if (target === Target.GitHubCopilot || target === Target.VSCode) { + return target; + } + } + return Target.Undefined; + } else if (promptType === PromptsType.instructions) { + if (isInClaudeRulesFolder(uri)) { + return Target.Claude; + } + } + return Target.Undefined; +} diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts index 66f81749bf583..14009b90d4498 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHeaderAutocompletion.ts @@ -15,12 +15,11 @@ import { IChatModeService } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; import { Iterable } from '../../../../../../base/common/iterator.js'; -import { ClaudeHeaderAttributes, ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, getTarget, getValidAttributeNames, claudeAgentAttributes, claudeRulesAttributes, knownClaudeTools, knownGithubCopilotTools, IValueEntry } from './promptValidator.js'; +import { ISequenceValue, IValue, parseCommaSeparatedList, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { getAttributeDefinition, getTarget, getValidAttributeNames, knownClaudeTools, knownGithubCopilotTools, IValueEntry, ClaudeHeaderAttributes, } from './promptFileAttributes.js'; import { localize } from '../../../../../../nls.js'; import { formatArrayValue, getQuotePreference } from '../utils/promptEditHelper.js'; - export class PromptHeaderAutocompletion implements CompletionItemProvider { /** * Debug display name for this provider. @@ -129,7 +128,7 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { for (const attribute of attributesToPropose) { const item: CompletionItem = { label: attribute, - documentation: getAttributeDescription(attribute, promptType, target), + documentation: getAttributeDefinition(attribute, promptType, target)?.description, kind: CompletionItemKind.Property, insertText: getInsertText(attribute), insertTextRules: CompletionItemInsertTextRule.InsertAsSnippet, @@ -233,29 +232,15 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return { suggestions }; } - private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): IValueEntry[] { - if (target === Target.Claude) { - const attributeDesc = promptType === PromptsType.instructions ? claudeRulesAttributes[attribute] : claudeAgentAttributes[attribute]; - if (attributeDesc) { - if (attributeDesc.enums) { - return attributeDesc.enums; - } else if (attributeDesc.defaults) { - return attributeDesc.defaults.map(value => ({ name: value })); - } - } - return []; + private getValueSuggestions(promptType: PromptsType, attribute: string, target: Target): readonly IValueEntry[] { + const attributeDesc = getAttributeDefinition(attribute, promptType, target); + if (attributeDesc?.enums) { + return attributeDesc.enums; + } + if (attributeDesc?.defaults) { + return attributeDesc.defaults.map(value => ({ name: value })); } switch (attribute) { - case PromptHeaderAttributes.applyTo: - if (promptType === PromptsType.instructions) { - return [ - { name: `'**'` }, - { name: `'**/*.ts, **/*.js'` }, - { name: `'**/*.php'` }, - { name: `'**/*.py'` } - ]; - } - break; case PromptHeaderAttributes.agent: case PromptHeaderAttributes.mode: if (promptType === PromptsType.prompt) { @@ -268,47 +253,12 @@ export class PromptHeaderAutocompletion implements CompletionItemProvider { return suggestions; } break; - case PromptHeaderAttributes.target: - if (promptType === PromptsType.agent) { - return [{ name: 'vscode' }, { name: 'github-copilot' }]; - } - break; - case PromptHeaderAttributes.tools: - if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { - return [ - { name: '[]' }, - { name: `['search', 'edit', 'web']` } - ]; - } - break; case PromptHeaderAttributes.model: if (promptType === PromptsType.prompt || promptType === PromptsType.agent) { return this.getModelNames(promptType === PromptsType.agent); } break; - case PromptHeaderAttributes.infer: - if (promptType === PromptsType.agent) { - return [ - { name: 'true' }, - { name: 'false' } - ]; - } - break; - case PromptHeaderAttributes.agents: - if (promptType === PromptsType.agent) { - return [{ name: '["*"]' }]; - } - break; - case PromptHeaderAttributes.userInvocable: - if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return [{ name: 'true' }, { name: 'false' }]; - } - break; - case PromptHeaderAttributes.disableModelInvocation: - if (promptType === PromptsType.agent || promptType === PromptsType.skill) { - return [{ name: 'true' }, { name: 'false' }]; - } - break; + } return []; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts index c223dc3145188..273ceef3be047 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptHovers.ts @@ -15,8 +15,8 @@ import { ILanguageModelToolsService, isToolSet, IToolSet } from '../../tools/lan import { IChatModeService, isBuiltinChatMode } from '../../chatModes.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; import { IPromptsService } from '../service/promptsService.js'; -import { ClaudeHeaderAttributes, IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; -import { getAttributeDescription, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptValidator.js'; +import { IHeaderAttribute, parseCommaSeparatedList, PromptBody, PromptHeader, PromptHeaderAttributes } from '../promptFileParser.js'; +import { ClaudeHeaderAttributes, getAttributeDefinition, getTarget, isVSCodeOrDefaultTarget, knownClaudeModels, knownClaudeTools } from './promptFileAttributes.js'; export class PromptHoverProvider implements HoverProvider { /** @@ -73,7 +73,7 @@ export class PromptHoverProvider implements HoverProvider { private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader, target: Target): Promise { for (const attribute of header.attributes) { if (attribute.range.containsPosition(position)) { - const description = getAttributeDescription(attribute.key, promptType, target); + const description = getAttributeDefinition(attribute.key, promptType, target)?.description; if (description) { switch (attribute.key) { case PromptHeaderAttributes.model: @@ -233,7 +233,7 @@ export class PromptHoverProvider implements HoverProvider { } private getHandsOffHover(attribute: IHeaderAttribute, position: Position, target: Target): Hover | undefined { - const handoffsBaseMessage = getAttributeDescription(PromptHeaderAttributes.handOffs, PromptsType.agent, target)!; + const handoffsBaseMessage = getAttributeDefinition(PromptHeaderAttributes.handOffs, PromptsType.agent, target)?.description!; if (!isVSCodeOrDefaultTarget(target)) { return this.createHover(handoffsBaseMessage + '\n\n' + localize('promptHeader.agent.handoffs.githubCopilot', 'Note: This attribute is not used in GitHub Copilot or Claude targets.'), attribute.range); } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts index 838dfadb33c39..569e024d3c866 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/languageProviders/promptValidator.ts @@ -16,17 +16,19 @@ import { ChatModeKind } from '../../constants.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../languageModels.js'; import { ILanguageModelToolsService, SpecedToolAliases } from '../../tools/languageModelToolsService.js'; import { getPromptsTypeForLanguageId, PromptsType, Target } from '../promptTypes.js'; -import { GithubPromptHeaderAttributes, ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, PromptHeaderAttributes, IValue } from '../promptFileParser.js'; +import { ISequenceValue, IHeaderAttribute, IScalarValue, parseCommaSeparatedList, ParsedPromptFile, PromptHeader, IValue, PromptHeaderAttributes } from '../promptFileParser.js'; import { Disposable, DisposableStore, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { Delayer } from '../../../../../../base/common/async.js'; import { ResourceMap } from '../../../../../../base/common/map.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { IPromptsService } from '../service/promptsService.js'; import { ILabelService } from '../../../../../../platform/label/common/label.js'; -import { AGENTS_SOURCE_FOLDER, isInClaudeAgentsFolder, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; +import { AGENTS_SOURCE_FOLDER, CLAUDE_AGENTS_SOURCE_FOLDER, isInClaudeRulesFolder, LEGACY_MODE_FILE_EXTENSION } from '../config/promptFileLocations.js'; import { Lazy } from '../../../../../../base/common/lazy.js'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; +import { dirname } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { GithubPromptHeaderAttributes } from './promptFileAttributes.js'; export const MARKERS_OWNER_ID = 'prompts-diagnostics-provider'; @@ -1022,7 +1024,8 @@ export function isVSCodeOrDefaultTarget(target: Target): boolean { export function getTarget(promptType: PromptsType, header: PromptHeader | URI): Target { const uri = header instanceof URI ? header : header.uri; if (promptType === PromptsType.agent) { - if (isInClaudeAgentsFolder(uri)) { + const parentDir = dirname(uri); + if (parentDir.path.endsWith(`/${CLAUDE_AGENTS_SOURCE_FOLDER}`)) { return Target.Claude; } if (!(header instanceof URI)) { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts index 42ed43f03000c..3d04a7cfec313 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser.ts @@ -10,7 +10,6 @@ import { URI } from '../../../../../base/common/uri.js'; import { parse, YamlNode, YamlParseError } from '../../../../../base/common/yaml.js'; import { Range } from '../../../../../editor/common/core/range.js'; import { PositionOffsetTransformer } from '../../../../../editor/common/core/text/positionToOffsetImpl.js'; -import { Target } from './promptTypes.js'; export class PromptFileParser { constructor() { @@ -87,19 +86,6 @@ export namespace PromptHeaderAttributes { export const disableModelInvocation = 'disable-model-invocation'; } -export namespace GithubPromptHeaderAttributes { - export const mcpServers = 'mcp-servers'; - export const github = 'github'; -} - -export namespace ClaudeHeaderAttributes { - export const disallowedTools = 'disallowedTools'; -} - -export function isTarget(value: unknown): value is Target { - return value === Target.VSCode || value === Target.GitHubCopilot || value === Target.Claude || value === Target.Undefined; -} - export class PromptHeader { private _parsed: ParsedHeader | undefined; diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 05f8827bd0fca..75acb1fe94bc1 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -41,7 +41,7 @@ import { HookType } from '../hookTypes.js'; import { HookSourceFormat, getHookSourceFormat, parseHooksFromFile } from '../hookCompatibility.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; import { IPathService } from '../../../../../services/path/common/pathService.js'; -import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptValidator.js'; +import { getTarget, mapClaudeModels, mapClaudeTools } from '../languageProviders/promptFileAttributes.js'; import { StopWatch } from '../../../../../../base/common/stopwatch.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; import { getCanonicalPluginCommandId, IAgentPlugin, IAgentPluginService } from '../../plugins/agentPluginService.js'; From 780451d291df8d1dc7d2681fb2a0ca095c3f1375 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 4 Mar 2026 11:40:32 +0100 Subject: [PATCH 05/23] Inline chat affordance fixes (#299169) * Add ESC to dismiss inline chat editor affordance Adds a new action bound to Escape that hides the editor affordance without collapsing the selection. Fixes https://github.com/Microsoft/vscode/issues/297994 * fix orphaned separators when toolbar items are hidden Fixes https://github.com/microsoft/vscode/issues/298659 * Add tests for InlineChatAffordance telemetry events * undo instruct-changes --- src/vs/base/common/actions.ts | 18 +++ src/vs/platform/actions/browser/toolbar.ts | 3 +- .../browser/inlineChat.contribution.ts | 1 + .../inlineChat/browser/inlineChatActions.ts | 22 ++- .../browser/inlineChatAffordance.ts | 20 ++- .../contrib/inlineChat/common/inlineChat.ts | 1 + .../test/browser/inlineChatAffordance.test.ts | 153 ++++++++++++++++++ 7 files changed, 214 insertions(+), 4 deletions(-) create mode 100644 src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts diff --git a/src/vs/base/common/actions.ts b/src/vs/base/common/actions.ts index 6d3e3f2b3db58..18641db33f93e 100644 --- a/src/vs/base/common/actions.ts +++ b/src/vs/base/common/actions.ts @@ -220,6 +220,24 @@ export class Separator implements IAction { return out; } + /** + * Removes leading, trailing, and consecutive duplicate separators in-place and returns the actions. + */ + public static clean(actions: IAction[]): IAction[] { + while (actions.length > 0 && actions[0].id === Separator.ID) { + actions.shift(); + } + while (actions.length > 0 && actions[actions.length - 1].id === Separator.ID) { + actions.pop(); + } + for (let i = actions.length - 2; i >= 0; i--) { + if (actions[i].id === Separator.ID && actions[i + 1].id === Separator.ID) { + actions.splice(i + 1, 1); + } + } + return actions; + } + static readonly ID = 'vs.actions.separator'; readonly id: string = Separator.ID; diff --git a/src/vs/platform/actions/browser/toolbar.ts b/src/vs/platform/actions/browser/toolbar.ts index 9304d12db48a8..e44cdb4eae07e 100644 --- a/src/vs/platform/actions/browser/toolbar.ts +++ b/src/vs/platform/actions/browser/toolbar.ts @@ -184,7 +184,8 @@ export class WorkbenchToolBar extends ToolBar { // coalesce turns Array into IAction[] coalesceInPlace(primary); coalesceInPlace(extraSecondary); - super.setActions(primary, Separator.join(extraSecondary, secondary)); + + super.setActions(Separator.clean(primary), Separator.join(extraSecondary, secondary)); // add context menu for toggle and configure keybinding actions if (toggleActions.length > 0 || primary.length > 0) { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts index 2220f7d147462..85237c141009b 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChat.contribution.ts @@ -96,6 +96,7 @@ registerAction2(InlineChatActions.SubmitInlineChatInputAction); registerAction2(InlineChatActions.QueueInChatAction); registerAction2(InlineChatActions.HideInlineChatInputAction); registerAction2(InlineChatActions.FixDiagnosticsAction); +registerAction2(InlineChatActions.DismissEditorAffordanceAction); const workbenchContributionsRegistry = Registry.as(WorkbenchExtensions.Workbench); diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index ec5ac5e2167b7..d282737cbbc15 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -11,7 +11,7 @@ import { EmbeddedDiffEditorWidget } from '../../../../editor/browser/widget/diff import { EmbeddedCodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/embeddedCodeEditorWidget.js'; import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; import { InlineChatController, InlineChatRunOptions } from './inlineChatController.js'; -import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_PENDING_CONFIRMATION, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED } from '../common/inlineChat.js'; +import { ACTION_ACCEPT_CHANGES, ACTION_ASK_IN_CHAT, CTX_INLINE_CHAT_FOCUSED, CTX_INLINE_CHAT_VISIBLE, CTX_INLINE_CHAT_OUTER_CURSOR_POSITION, CTX_INLINE_CHAT_POSSIBLE, ACTION_START, CTX_INLINE_CHAT_V2_ENABLED, CTX_INLINE_CHAT_V1_ENABLED, CTX_HOVER_MODE, CTX_INLINE_CHAT_INPUT_HAS_TEXT, CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT, CTX_INLINE_CHAT_INPUT_WIDGET_FOCUSED, CTX_INLINE_CHAT_PENDING_CONFIRMATION, InlineChatConfigKeys, CTX_FIX_DIAGNOSTICS_ENABLED, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE } from '../common/inlineChat.js'; import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/browser/chatEditing/chatEditingEditorContextKeys.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; @@ -506,6 +506,26 @@ export class AskInChatAction extends EditorAction2 { } } +export class DismissEditorAffordanceAction extends EditorAction2 { + + constructor() { + super({ + id: 'inlineChat.dismissEditorAffordance', + title: localize2('dismissAffordance', "Dismiss Editor Affordance"), + precondition: ContextKeyExpr.and(CTX_INLINE_CHAT_AFFORDANCE_VISIBLE, ContextKeyExpr.equals('config.inlineChat.affordance', 'editor')), + keybinding: { + when: EditorContextKeys.editorTextFocus, + weight: KeybindingWeight.EditorContrib, + primary: KeyCode.Escape, + } + }); + } + + override runEditorCommand(_accessor: ServicesAccessor, editor: ICodeEditor): void { + InlineChatController.get(editor)?.inputOverlayWidget.dismiss(); + } +} + export class QueueInChatAction extends AbstractInlineChatAction { diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts index 4e3cf4c660003..961c1943e746a 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatAffordance.ts @@ -9,7 +9,7 @@ import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; import { observableCodeEditor } from '../../../../editor/browser/observableCodeEditor.js'; import { ScrollType } from '../../../../editor/common/editorCommon.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; -import { InlineChatConfigKeys } from '../common/inlineChat.js'; +import { InlineChatConfigKeys, CTX_INLINE_CHAT_AFFORDANCE_VISIBLE } from '../common/inlineChat.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { observableConfigValue } from '../../../../platform/observable/common/platformObservableUtils.js'; import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js'; @@ -24,6 +24,7 @@ import { CodeActionController } from '../../../../editor/contrib/codeAction/brow import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { Event } from '../../../../base/common/event.js'; +import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; type InlineChatAffordanceEvent = { mode: string; @@ -45,6 +46,7 @@ export class InlineChatAffordance extends Disposable { readonly #inputWidget: InlineChatInputWidget; readonly #instantiationService: IInstantiationService; readonly #menuData = observableValue<{ rect: DOMRect; above: boolean; lineNumber: number; placeholder: string } | undefined>(this, undefined); + readonly #selectionData = observableValue(this, undefined); constructor( editor: ICodeEditor, @@ -54,6 +56,7 @@ export class InlineChatAffordance extends Disposable { @IChatEntitlementService chatEntiteldService: IChatEntitlementService, @IInlineChatSessionService inlineChatSessionService: IInlineChatSessionService, @ITelemetryService telemetryService: ITelemetryService, + @IContextKeyService contextKeyService: IContextKeyService, ) { super(); this.#editor = editor; @@ -64,7 +67,10 @@ export class InlineChatAffordance extends Disposable { const affordance = observableConfigValue<'off' | 'gutter' | 'editor'>(InlineChatConfigKeys.Affordance, 'off', configurationService); const debouncedSelection = debouncedObservable(editorObs.cursorSelection, 500); - const selectionData = observableValue(this, undefined); + const selectionData = this.#selectionData; + + const ctxAffordanceVisible = CTX_INLINE_CHAT_AFFORDANCE_VISIBLE.bindTo(contextKeyService); + this._store.add({ dispose: () => ctxAffordanceVisible.reset() }); let explicitSelection = false; let affordanceId: string | undefined; @@ -114,6 +120,12 @@ export class InlineChatAffordance extends Disposable { selectionData.set(undefined, undefined); })); + this._store.add(autorun(r => { + const sel = selectionData.read(r); + const mode = affordance.read(r); + ctxAffordanceVisible.set(sel !== undefined && (mode === 'editor' || mode === 'gutter')); + })); + const gutterAffordance = this._store.add(this.#instantiationService.createInstance( InlineChatGutterAffordance, editorObs, @@ -167,6 +179,10 @@ export class InlineChatAffordance extends Disposable { })); } + dismiss(): void { + this.#selectionData.set(undefined, undefined); + } + async showMenuAtSelection(placeholder: string): Promise { assertType(this.#editor.hasModel()); diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 99e23c1d69672..69fd6f6674c14 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -130,6 +130,7 @@ export const CTX_INLINE_CHAT_REQUEST_IN_PROGRESS = new RawContextKey('i export const CTX_INLINE_CHAT_RESPONSE_TYPE = new RawContextKey('inlineChatResponseType', InlineChatResponseType.None, localize('inlineChatResponseTypes', "What type was the responses have been receieved, nothing yet, just messages, or messaged and local edits")); export const CTX_INLINE_CHAT_FILE_BELONGS_TO_CHAT = new RawContextKey('inlineChatFileBelongsToChat', false, localize('inlineChatFileBelongsToChat', "Whether the current file belongs to a chat editing session")); export const CTX_INLINE_CHAT_PENDING_CONFIRMATION = new RawContextKey('inlineChatPendingConfirmation', false, localize('inlineChatPendingConfirmation', "Whether an inline chat request is pending user confirmation")); +export const CTX_INLINE_CHAT_AFFORDANCE_VISIBLE = new RawContextKey('inlineChatAffordanceVisible', false, localize('inlineChatAffordanceVisible', "Whether an inline chat affordance widget is visible")); export const CTX_INLINE_CHAT_V1_ENABLED = ContextKeyExpr.or( ContextKeyExpr.and(NOTEBOOK_IS_ACTIVE_EDITOR, CTX_INLINE_CHAT_HAS_NOTEBOOK_INLINE) diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts new file mode 100644 index 0000000000000..85719bf152cf6 --- /dev/null +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatAffordance.test.ts @@ -0,0 +1,153 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { Selection } from '../../../../../editor/common/core/selection.js'; +import { CursorChangeReason } from '../../../../../editor/common/cursorEvents.js'; +import { CursorState } from '../../../../../editor/common/cursorCommon.js'; +import { createTextModel } from '../../../../../editor/test/common/testTextModel.js'; +import { instantiateTestCodeEditor, ITestCodeEditor } from '../../../../../editor/test/browser/testCodeEditor.js'; +import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { IInlineChatSessionService } from '../../browser/inlineChatSessionService.js'; +import { Event } from '../../../../../base/common/event.js'; +import { InlineChatAffordance } from '../../browser/inlineChatAffordance.js'; +import { InlineChatInputWidget } from '../../browser/inlineChatOverlayWidget.js'; +import { runWithFakedTimers } from '../../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { timeout } from '../../../../../base/common/async.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ITextModel } from '../../../../../editor/common/model.js'; +import { InlineChatConfigKeys } from '../../common/inlineChat.js'; +import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js'; +import { IConfigurationChangeEvent, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { mock } from '../../../../../base/test/common/mock.js'; + +function createMockInputWidget(): InlineChatInputWidget { + return new class extends mock() { + override readonly position = observableValue('test.position', null); + override show() { } + override hide() { } + override dispose() { } + }; +} + +suite('InlineChatAffordance - Telemetry', () => { + + const store = new DisposableStore(); + let editor: ITestCodeEditor; + let model: ITextModel; + let instantiationService: TestInstantiationService; + let configurationService: TestConfigurationService; + let telemetryEvents: { eventName: string; data: Record }[]; + + setup(() => { + telemetryEvents = []; + + instantiationService = workbenchInstantiationService({ + configurationService: () => new TestConfigurationService({ + [InlineChatConfigKeys.Affordance]: 'editor', + }), + }, store); + + configurationService = instantiationService.get(IConfigurationService) as TestConfigurationService; + + instantiationService.stub(ITelemetryService, new class extends mock() { + override publicLog2(eventName: string, data?: Record) { + telemetryEvents.push({ eventName, data: data ?? {} }); + } + }); + + instantiationService.stub(IInlineChatSessionService, new class extends mock() { + override readonly onWillStartSession = Event.None; + override readonly onDidChangeSessions = Event.None; + override getSessionByTextModel() { return undefined; } + override getSessionBySessionUri() { return undefined; } + }); + + model = store.add(createTextModel('hello world\nfoo bar\nbaz qux')); + editor = store.add(instantiateTestCodeEditor(instantiationService, model)); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + function setExplicitSelection(sel: Selection): void { + editor.getViewModel()!.setCursorStates( + 'test', + CursorChangeReason.Explicit, + [CursorState.fromModelSelection(sel)] + ); + } + + test('shown event includes mode "editor"', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 6)); + await timeout(600); + + const shown = telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown'); + assert.strictEqual(shown.length, 1); + assert.strictEqual(shown[0].data.mode, 'editor'); + assert.ok(typeof shown[0].data.id === 'string'); + assert.strictEqual(shown[0].data.commandId, ''); + })); + + test('shown event does NOT fire when mode is off', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + configurationService.setUserConfiguration(InlineChatConfigKeys.Affordance, 'off'); + configurationService.onDidChangeConfigurationEmitter.fire(new class extends mock() { + override affectsConfiguration(key: string) { return key === InlineChatConfigKeys.Affordance; } + }); + + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 6)); + await timeout(600); + + assert.strictEqual(telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown').length, 0); + })); + + test('shown event does NOT fire for whitespace-only selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + model.setValue(' \nhello'); + + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 4)); + await timeout(600); + + assert.strictEqual(telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown').length, 0); + })); + + test('shown event does NOT fire for empty selection', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 1)); + await timeout(600); + + assert.strictEqual(telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown').length, 0); + })); + + test('each selection gets a unique affordanceId', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + store.add(instantiationService.createInstance(InlineChatAffordance, editor, createMockInputWidget())); + + setExplicitSelection(new Selection(1, 1, 1, 6)); + await timeout(600); + + // Clear selection, then make a new one + setExplicitSelection(new Selection(2, 1, 2, 1)); + await timeout(100); + setExplicitSelection(new Selection(2, 1, 2, 4)); + await timeout(600); + + const shown = telemetryEvents.filter(e => e.eventName === 'inlineChatAffordance/shown'); + assert.strictEqual(shown.length, 2); + assert.notStrictEqual(shown[0].data.id, shown[1].data.id); + })); +}); From 0ac17b9c3ba3371296d13e8cd15c0d2a0275e801 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 4 Mar 2026 10:30:31 +0000 Subject: [PATCH 06/23] 2026 theme: update misc UI component styles --- extensions/theme-2026/themes/2026-dark.json | 1 + extensions/theme-2026/themes/2026-light.json | 6 + extensions/theme-2026/themes/styles.css | 103 ------------------ src/vs/base/browser/ui/menu/menu.ts | 2 + .../contrib/find/browser/findWidget.css | 2 +- src/vs/editor/contrib/hover/browser/hover.css | 4 +- .../parts/editor/media/breadcrumbscontrol.css | 4 + .../preferences/browser/media/keybindings.css | 1 + 8 files changed, 18 insertions(+), 105 deletions(-) diff --git a/extensions/theme-2026/themes/2026-dark.json b/extensions/theme-2026/themes/2026-dark.json index 032cdd12cf34a..4645a08fa61b4 100644 --- a/extensions/theme-2026/themes/2026-dark.json +++ b/extensions/theme-2026/themes/2026-dark.json @@ -10,6 +10,7 @@ "descriptionForeground": "#8C8C8C", "icon.foreground": "#8C8C8C", "focusBorder": "#3994BCB3", + "contrastBorder": "#333536", "textBlockQuote.background": "#242526", "textBlockQuote.border": "#2A2B2CFF", "textCodeBlock.background": "#242526", diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index 13dcdef5c3e6a..a3a4951704d5d 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -10,6 +10,7 @@ "descriptionForeground": "#606060", "icon.foreground": "#606060", "focusBorder": "#0069CCFF", + "contrastBorder": "#F0F1F2FF", "textBlockQuote.background": "#EAEAEA", "textBlockQuote.border": "#F0F1F2FF", "textCodeBlock.background": "#EAEAEA", @@ -135,6 +136,10 @@ "editorBracketMatch.background": "#0069CC40", "editorBracketMatch.border": "#F0F1F2FF", "editorWidget.background": "#FAFAFD", + "editorWidget.border": "#F0F1F2FF", + "editorWidget.foreground": "#202020", + "editorSuggestWidget.background": "#FAFAFD", + "editorSuggestWidget.border": "#F0F1F2FF", "editorWidget.border": "#EEEEF1", "editorWidget.foreground": "#202020", "editorSuggestWidget.background": "#FAFAFD", @@ -143,6 +148,7 @@ "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", "editorHoverWidget.background": "#FAFAFD", + "editorHoverWidget.border": "#F0F1F2FF", "editorHoverWidget.border": "#EEEEF1", "peekView.border": "#0069CC", "peekViewEditor.background": "#FAFAFD", diff --git a/extensions/theme-2026/themes/styles.css b/extensions/theme-2026/themes/styles.css index 050f706aa1e79..d76e07491ed4a 100644 --- a/extensions/theme-2026/themes/styles.css +++ b/extensions/theme-2026/themes/styles.css @@ -22,27 +22,6 @@ } /* Quick Input (Command Palette) */ -.monaco-workbench.vs-dark .quick-input-widget { - border: 1px solid var(--vscode-menu-border) !important; -} - -.monaco-workbench .quick-input-widget .quick-input-header, -.monaco-workbench .quick-input-widget .quick-input-list, -.monaco-workbench .quick-input-widget .quick-input-titlebar, -.monaco-workbench .quick-input-widget .quick-input-title, -.monaco-workbench .quick-input-widget .quick-input-description, -.monaco-workbench .quick-input-widget .quick-input-filter, -.monaco-workbench .quick-input-widget .quick-input-action, -.monaco-workbench .quick-input-widget .quick-input-message, -.monaco-workbench .quick-input-widget .monaco-list, -.monaco-workbench .quick-input-widget .monaco-list-row:not(:has(.quick-input-list-separator-border)) { - border-color: transparent !important; - outline: none !important; -} - -.monaco-workbench .quick-input-widget .quick-input-list .monaco-list-rows { - background: transparent !important; -} .monaco-workbench .quick-input-list .quick-input-list-entry .quick-input-list-separator { height: 16px; @@ -67,82 +46,6 @@ padding: 0; } -.monaco-workbench .quick-input-widget .monaco-list-rows { - background: transparent !important; -} - -.monaco-workbench .quick-input-widget .monaco-inputbox { - background: transparent !important; -} - -.monaco-workbench .quick-input-widget .quick-input-filter .monaco-inputbox { - background: color-mix(in srgb, var(--vscode-input-background) 60%, transparent) !important; -} - -/* Chat Widget */ - -.monaco-workbench .interactive-session .chat-question-carousel-container { - border-radius: var(--radius-lg); -} - -.monaco-workbench .interactive-session .interactive-input-part .chat-editor-container .interactive-input-editor .monaco-editor, -.monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { - border-radius: var(--radius-lg) var(--radius-lg) 0 0; -} - -.monaco-workbench .interactive-input-part:has(.chat-editing-session > .chat-editing-session-container) .chat-input-container { - border-radius: 0 0 var(--radius-lg) var(--radius-lg); -} - -.monaco-workbench.vs .interactive-session .chat-input-container { - box-shadow: inset var(--shadow-sm); -} -.monaco-workbench .part.panel .interactive-session, -.monaco-workbench .part.auxiliarybar .interactive-session { - position: relative; -} - -.monaco-workbench .interactive-session .chat-editing-session .chat-editing-session-container { - background-color: transparent !important; -} - -/* Notifications */ - -.monaco-workbench .notifications-list-container .monaco-list-rows { - background: transparent !important; -} - -/* Context Menus */ -.monaco-workbench .action-widget .action-widget-action-bar { - background: transparent; -} - -/* Suggest Widget */ -.monaco-workbench.vs-dark .monaco-editor .suggest-widget { - border: 1px solid var(--vscode-editorWidget-border); -} - -/* Dialog */ -.monaco-workbench .monaco-dialog-box { - border: 1px solid var(--vscode-dialog-border); -} - -/* Peek View */ -.monaco-workbench .monaco-editor .peekview-widget .head, -.monaco-workbench .monaco-editor .peekview-widget .body { - background: transparent !important; -} - -.monaco-workbench .defineKeybindingWidget { - border: 1px solid var(--vscode-editorWidget-border); -} - -/* Chat Editor Overlay */ -.monaco-workbench.vs-dark .chat-editor-overlay-widget, -.monaco-workbench.vs-dark .chat-diff-change-content-widget { - border: 1px solid var(--vscode-editorWidget-border); -} - /* Settings */ .monaco-workbench .settings-editor > .settings-header > .search-container > .search-container-widgets > .settings-count-widget { border-radius: var(--radius-sm); @@ -151,12 +54,6 @@ border: 1px solid color-mix(in srgb, var(--vscode-descriptionForeground) 50%, transparent) !important; } -/* Breadcrumbs */ - -.monaco-workbench.vs .breadcrumbs-control { - border-bottom: 1px solid var(--vscode-editorWidget-border); -} - /* Input Boxes */ .monaco-inputbox .monaco-action-bar .action-item .codicon, .monaco-workbench .search-container .input-box, diff --git a/src/vs/base/browser/ui/menu/menu.ts b/src/vs/base/browser/ui/menu/menu.ts index 0b6afe0e2eb59..500b0614adeaf 100644 --- a/src/vs/base/browser/ui/menu/menu.ts +++ b/src/vs/base/browser/ui/menu/menu.ts @@ -1240,6 +1240,8 @@ ${formatRule(Codicon.menuSubmenu)} animation: fadeIn 0.083s linear; -webkit-app-region: no-drag; box-shadow: var(--vscode-shadow-lg${style.shadowColor ? `, 0 0 12px ${style.shadowColor}` : ''}); + border-radius: var(--vscode-cornerRadius-large); + overflow: hidden; } .context-view.monaco-menu-container :focus, diff --git a/src/vs/editor/contrib/find/browser/findWidget.css b/src/vs/editor/contrib/find/browser/findWidget.css index 42e63375f2c83..62c6056c1d98d 100644 --- a/src/vs/editor/contrib/find/browser/findWidget.css +++ b/src/vs/editor/contrib/find/browser/findWidget.css @@ -7,7 +7,7 @@ .monaco-editor .find-widget { position: absolute; z-index: 35; - height: 33px; + height: 34px; overflow: hidden; line-height: 19px; transition: transform 200ms linear; diff --git a/src/vs/editor/contrib/hover/browser/hover.css b/src/vs/editor/contrib/hover/browser/hover.css index 5817cd9a3c52f..d9d64ffc21624 100644 --- a/src/vs/editor/contrib/hover/browser/hover.css +++ b/src/vs/editor/contrib/hover/browser/hover.css @@ -15,7 +15,8 @@ .monaco-editor .monaco-resizable-hover > .monaco-hover { border: none; - border-radius: unset; + border-radius: inherit; + overflow: hidden; } .monaco-editor .monaco-hover { @@ -35,6 +36,7 @@ } .monaco-editor .monaco-hover .hover-row { + border-radius: var(--vscode-cornerRadius-large); display: flex; } diff --git a/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css b/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css index 4e4e923ad9b7e..a80a925d97ad7 100644 --- a/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css +++ b/src/vs/workbench/browser/parts/editor/media/breadcrumbscontrol.css @@ -3,6 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +.monaco-workbench.vs .breadcrumbs-control { + border-bottom: 1px solid var(--vscode-editorWidget-border); +} + .monaco-workbench .part.editor > .content .editor-group-container .breadcrumbs-control.hidden { display: none; } diff --git a/src/vs/workbench/contrib/preferences/browser/media/keybindings.css b/src/vs/workbench/contrib/preferences/browser/media/keybindings.css index 75905e2e6d444..482f819ee58b3 100644 --- a/src/vs/workbench/contrib/preferences/browser/media/keybindings.css +++ b/src/vs/workbench/contrib/preferences/browser/media/keybindings.css @@ -8,6 +8,7 @@ border-radius: var(--vscode-cornerRadius-large); position: absolute; box-shadow: var(--vscode-shadow-lg); + border: 1px solid var(--vscode-editorWidget-border); } .defineKeybindingWidget .message { From 033cffbdaea378f11dc724b37d5c06f848688c95 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 4 Mar 2026 10:53:17 +0000 Subject: [PATCH 07/23] 2026 theme: refine editor widget styles and remove redundant properties --- extensions/theme-2026/themes/2026-light.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/extensions/theme-2026/themes/2026-light.json b/extensions/theme-2026/themes/2026-light.json index a3a4951704d5d..f5a8730719e23 100644 --- a/extensions/theme-2026/themes/2026-light.json +++ b/extensions/theme-2026/themes/2026-light.json @@ -140,16 +140,11 @@ "editorWidget.foreground": "#202020", "editorSuggestWidget.background": "#FAFAFD", "editorSuggestWidget.border": "#F0F1F2FF", - "editorWidget.border": "#EEEEF1", - "editorWidget.foreground": "#202020", - "editorSuggestWidget.background": "#FAFAFD", - "editorSuggestWidget.border": "#EEEEF1", "editorSuggestWidget.foreground": "#202020", "editorSuggestWidget.highlightForeground": "#0069CC", "editorSuggestWidget.selectedBackground": "#0069CC26", "editorHoverWidget.background": "#FAFAFD", "editorHoverWidget.border": "#F0F1F2FF", - "editorHoverWidget.border": "#EEEEF1", "peekView.border": "#0069CC", "peekViewEditor.background": "#FAFAFD", "peekViewEditor.matchHighlightBackground": "#0069CC33", From 34659c0ef113dc827fc5b1f190621adb09ae3f7b Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 12:51:00 +0100 Subject: [PATCH 08/23] Revert "sessions - improve session hover title rendering and persistence" (#299168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "sessions - improve session hover title rendering and persistence (#29…" This reverts commit ff7ffa542f145bbd0bad82c38ed1ba1c54f72ec3. --- .../sessions/contrib/sessions/browser/sessionsViewPane.ts | 2 +- .../chat/browser/agentSessions/agentSessionsControl.ts | 2 +- .../chat/browser/agentSessions/agentSessionsViewer.ts | 6 ++---- .../componentFixtures/agentSessionsViewer.fixture.ts | 2 +- 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 9a9f060ad7003..41865aac2fbb3 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -143,7 +143,7 @@ export class AgenticSessionsViewPane extends ViewPane { source: 'agentSessionsViewPane', filter: sessionsFilter, overrideStyles: this.getLocationBasedColors().listOverrideStyles, - useSimpleHover: true, + disableHover: true, showIsolationIcon: true, enableApprovalRow: true, getHoverPosition: () => this.getSessionHoverPosition(), diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts index 57ca104e997a4..6f09474087b49 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsControl.ts @@ -44,7 +44,7 @@ export interface IAgentSessionsControlOptions extends IAgentSessionsSorterOption readonly overrideStyles: IStyleOverride; readonly filter: IAgentSessionsFilter; readonly source: string; - readonly useSimpleHover?: boolean; + readonly disableHover?: boolean; readonly showIsolationIcon?: boolean; readonly enableApprovalRow?: boolean; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 9e8462abd004b..5efe135838114 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -87,7 +87,7 @@ interface IAgentSessionItemTemplate { } export interface IAgentSessionRendererOptions { - readonly useSimpleHover?: boolean; + readonly disableHover?: boolean; readonly showIsolationIcon?: boolean; getHoverPosition(): HoverPosition; } @@ -402,9 +402,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre } private renderHover(session: ITreeNode, template: IAgentSessionItemTemplate): void { - if (this.options.useSimpleHover) { - const title = renderAsPlaintext(new MarkdownString(session.element.label)); - template.elementDisposable.add(this.hoverService.setupDelayedHover(template.element, { content: title, position: { hoverPosition: this.options.getHoverPosition() } }, { groupId: 'agent.sessions' })); + if (this.options.disableHover) { return; } diff --git a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts index 90ff4a6f730d3..f88f88719f43f 100644 --- a/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts +++ b/src/vs/workbench/test/browser/componentFixtures/agentSessionsViewer.fixture.ts @@ -68,7 +68,7 @@ function wrapAsTreeNode(element: T): ITreeNode { } const rendererOptions: IAgentSessionRendererOptions = { - useSimpleHover: true, + disableHover: true, getHoverPosition: () => HoverPosition.BELOW, }; From d724d414562a3690dc83f9a95a8e622c4bd740d4 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 4 Mar 2026 12:58:44 +0100 Subject: [PATCH 09/23] send workspace data to extension host behind a flag (#299179) --- .../browser/workspaceFolderManagement.ts | 4 ++- .../electron-browser/sessions.main.ts | 32 +++++++++++-------- .../browser/workspaceContextService.ts | 13 ++++---- 3 files changed, 28 insertions(+), 21 deletions(-) diff --git a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts index ecbd59a521321..3bdba7d96a678 100644 --- a/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts +++ b/src/vs/sessions/contrib/workspace/browser/workspaceFolderManagement.ts @@ -15,10 +15,12 @@ import { URI } from '../../../../base/common/uri.js'; import { autorun } from '../../../../base/common/observable.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { getGitHubRemoteFileDisplayName } from '../../fileTreeView/browser/githubFileSystemProvider.js'; +import { Queue } from '../../../../base/common/async.js'; export class WorkspaceFolderManagementContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.workspaceFolderManagement'; + private queue = this._register(new Queue()); constructor( @ISessionsManagementService private readonly sessionManagementService: ISessionsManagementService, @@ -30,7 +32,7 @@ export class WorkspaceFolderManagementContribution extends Disposable implements super(); this._register(autorun(reader => { const activeSession = this.sessionManagementService.activeSession.read(reader); - this.updateWorkspaceFoldersForSession(activeSession); + this.queue.queue(() => this.updateWorkspaceFoldersForSession(activeSession)); })); } diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index 23f71e9d66a26..3a5ed7dff30e0 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -15,7 +15,7 @@ import { INativeWorkbenchEnvironmentService, NativeWorkbenchEnvironmentService } import { ServiceCollection } from '../../platform/instantiation/common/serviceCollection.js'; import { ILoggerService, ILogService, LogLevel } from '../../platform/log/common/log.js'; import { NativeWorkbenchStorageService } from '../../workbench/services/storage/electron-browser/storageService.js'; -import { IWorkspaceContextService, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier, reviveIdentifier } from '../../platform/workspace/common/workspace.js'; +import { IWorkspaceContextService, isSingleFolderWorkspaceIdentifier, isWorkspaceIdentifier, IAnyWorkspaceIdentifier, reviveIdentifier, IWorkspaceIdentifier } from '../../platform/workspace/common/workspace.js'; import { IWorkbenchConfigurationService } from '../../workbench/services/configuration/common/configuration.js'; import { IStorageService } from '../../platform/storage/common/storage.js'; import { Disposable } from '../../base/common/lifecycle.js'; @@ -67,6 +67,7 @@ import { NativeMenubarControl } from '../../workbench/electron-browser/parts/tit import { IWorkspaceEditingService } from '../../workbench/services/workspaces/common/workspaceEditing.js'; import { ConfigurationService } from '../services/configuration/browser/configurationService.js'; import { SessionsWorkspaceContextService } from '../services/workspace/browser/workspaceContextService.js'; +import { getWorkspaceIdentifier } from '../../workbench/services/workspaces/browser/workspaces.js'; export class SessionsMain extends Disposable { @@ -291,21 +292,21 @@ export class SessionsMain extends Disposable { // // !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - // Workspace - const workspaceContextService = new SessionsWorkspaceContextService(uriIdentityService.extUri.joinPath(uriIdentityService.extUri.dirname(userDataProfilesService.profilesHome), 'agent-sessions.code-workspace'), uriIdentityService); - serviceCollection.set(IWorkspaceContextService, workspaceContextService); - serviceCollection.set(IWorkspaceEditingService, workspaceContextService); + const workspaceIdentifier = getWorkspaceIdentifier(uriIdentityService.extUri.joinPath(uriIdentityService.extUri.dirname(userDataProfilesService.profilesHome), 'agent-sessions.code-workspace')); - const [configurationService, storageService] = await Promise.all([ - this.createConfigurationService(userDataProfileService, fileService, logService, policyService).then(service => { + const [{ configurationService, workspaceContextService }, storageService] = await Promise.all([ + this.createWorkspaceAndConfigurationService(workspaceIdentifier, userDataProfileService, uriIdentityService, fileService, logService, policyService).then(services => { // Configuration - serviceCollection.set(IWorkbenchConfigurationService, service); + serviceCollection.set(IWorkbenchConfigurationService, services.configurationService); + // Workspace + serviceCollection.set(IWorkspaceContextService, services.workspaceContextService); + serviceCollection.set(IWorkspaceEditingService, services.workspaceContextService); - return service; + return services; }), - this.createStorageService(workspaceContextService.getWorkspace(), environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { + this.createStorageService(workspaceIdentifier, environmentService, userDataProfileService, userDataProfilesService, mainProcessService).then(service => { // Storage serviceCollection.set(IStorageService, service); @@ -343,20 +344,23 @@ export class SessionsMain extends Disposable { return { serviceCollection, logService, storageService, configurationService }; } - private async createConfigurationService( + private async createWorkspaceAndConfigurationService( + workspaceIdentifier: IWorkspaceIdentifier, userDataProfileService: IUserDataProfileService, + uriIdentityService: IUriIdentityService, fileService: FileService, logService: ILogService, policyService: IPolicyService - ): Promise { + ): Promise<{ configurationService: ConfigurationService; workspaceContextService: SessionsWorkspaceContextService }> { const configurationService = new ConfigurationService(userDataProfileService.currentProfile.settingsResource, fileService, policyService, logService); try { await configurationService.initialize(); - return configurationService; } catch (error) { onUnexpectedError(error); - return configurationService; } + + const workspaceContextService = new SessionsWorkspaceContextService(workspaceIdentifier, uriIdentityService, configurationService); + return { configurationService, workspaceContextService }; } private async createStorageService(workspace: IAnyWorkspaceIdentifier, environmentService: INativeWorkbenchEnvironmentService, userDataProfileService: IUserDataProfileService, userDataProfilesService: IUserDataProfilesService, mainProcessService: IMainProcessService): Promise { diff --git a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts index 58b4dfb588923..9d8b6763daba3 100644 --- a/src/vs/sessions/services/workspace/browser/workspaceContextService.ts +++ b/src/vs/sessions/services/workspace/browser/workspaceContextService.ts @@ -11,8 +11,9 @@ import { IUriIdentityService } from '../../../../platform/uriIdentity/common/uri import { Workspace, WorkspaceFolder, IWorkspace, IWorkspaceContextService, IWorkspaceFoldersChangeEvent, IWorkspaceFoldersWillChangeEvent, IWorkspaceIdentifier, ISingleFolderWorkspaceIdentifier, IWorkspaceFolder, WorkbenchState } from '../../../../platform/workspace/common/workspace.js'; import { IWorkspaceFolderCreationData } from '../../../../platform/workspaces/common/workspaces.js'; import { getWorkspaceIdentifier } from '../../../../workbench/services/workspaces/browser/workspaces.js'; -import { IDidEnterWorkspaceEvent, IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; +import { IWorkspaceEditingService } from '../../../../workbench/services/workspaces/common/workspaceEditing.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; +import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; export class SessionsWorkspaceContextService extends Disposable implements IWorkspaceContextService, IWorkspaceEditingService { @@ -20,7 +21,7 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork readonly onDidChangeWorkbenchState = Event.None; readonly onDidChangeWorkspaceName = Event.None; - readonly onDidEnterWorkspace = Event.None as Event; + readonly onDidEnterWorkspace = Event.None; private readonly _onWillChangeWorkspaceFolders = new Emitter(); readonly onWillChangeWorkspaceFolders = this._onWillChangeWorkspaceFolders.event; @@ -32,11 +33,11 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork private readonly _updateFoldersQueue = this._register(new Queue()); constructor( - sessionsWorkspaceUri: URI, - private readonly uriIdentityService: IUriIdentityService + workspaceIdentifier: IWorkspaceIdentifier, + private readonly uriIdentityService: IUriIdentityService, + private readonly configurationService: IConfigurationService, ) { super(); - const workspaceIdentifier = getWorkspaceIdentifier(sessionsWorkspaceUri); this.workspace = new Workspace(workspaceIdentifier.id, [], false, workspaceIdentifier.configPath, uri => uriIdentityService.extUri.ignorePathCasing(uri)); } @@ -53,7 +54,7 @@ export class SessionsWorkspaceContextService extends Disposable implements IWork } hasWorkspaceData(): boolean { - return false; + return this.configurationService.getValue('sessions.workspace.sendWorkspaceDataToExtHost') === true; } getWorkspaceFolder(resource: URI): IWorkspaceFolder | null { From 0be0030e0edb344cbb7fbafddb0039e8663824a3 Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 4 Mar 2026 13:06:00 +0100 Subject: [PATCH 10/23] Support multi line terminal command approvals with ellipsis and aligned approve button --- .../agentSessions/agentSessionsViewer.ts | 33 ++++-- .../media/agentsessionsviewer.css | 3 +- .../agentSessionsViewer.fixture.ts | 110 ++++++++++++++++++ 3 files changed, 137 insertions(+), 9 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 9e8462abd004b..74140b20870d1 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -96,7 +96,14 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre static readonly TEMPLATE_ID = 'agent-session'; - static readonly APPROVAL_ROW_HEIGHT = 40; + static readonly APPROVAL_ROW_MAX_LINES = 3; + private static readonly _APPROVAL_ROW_LINE_HEIGHT = 18; + private static readonly _APPROVAL_ROW_OVERHEAD = 14; // 4px margin-top + 4px padding-top + 4px padding-bottom + 2px border + + static getApprovalRowHeight(label: string): number { + const lineCount = Math.min(label.split('\n').length, AgentSessionRenderer.APPROVAL_ROW_MAX_LINES); + return lineCount * AgentSessionRenderer._APPROVAL_ROW_LINE_HEIGHT + AgentSessionRenderer._APPROVAL_ROW_OVERHEAD; + } readonly templateId = AgentSessionRenderer.TEMPLATE_ID; @@ -459,13 +466,24 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre template.approvalRow.classList.toggle('visible', visible); if (info) { - // Render as a syntax-highlighted code block - const codeblockContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label); - this.renderMarkdownOrText(codeblockContent, template.approvalLabel, buttonStore); + // Render up to 3 lines, each as a separate code block so CSS can truncate per-line + const lines = info.label.split('\n'); + const maxLines = AgentSessionRenderer.APPROVAL_ROW_MAX_LINES; + const visibleLines = lines.slice(0, maxLines); + if (lines.length > maxLines) { + visibleLines[maxLines - 1] = `${visibleLines[maxLines - 1]} \u2026`; + } + const langId = info.languageId ?? 'json'; + const labelContent = new MarkdownString(); + for (const line of visibleLines) { + labelContent.appendCodeblock(langId, line); + } + this.renderMarkdownOrText(labelContent, template.approvalLabel, buttonStore); // Hover with full content as a code block + const fullContent = new MarkdownString().appendCodeblock(info.languageId ?? 'json', info.label); buttonStore.add(this.hoverService.setupDelayedHover(template.approvalLabel, { - content: codeblockContent, + content: fullContent, style: HoverStyle.Pointer, position: { hoverPosition: HoverPosition.BELOW }, })); @@ -607,8 +625,9 @@ export class AgentSessionsListDelegate implements IListVirtualDelegate { + const resource = URI.parse('vscode-chat-session://local/approval-1line'); + const approvalModel = createMockApprovalModel(resource, { + label: 'npm install --save express@latest', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Install express', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow2Lines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-2lines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'cd /workspace/project\nnpm install', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Setup project dependencies', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 3 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow3Lines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-3lines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'cd /workspace/project\nnpm install\nnpm run build', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Build the project', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow4Lines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-4lines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'cd /workspace/project\nnpm install\nnpm run build\nnpm run test -- --coverage', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Build and test project', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), + + ApprovalRow3LongLines: defineComponentFixture({ + render: (ctx) => { + const resource = URI.parse('vscode-chat-session://local/approval-3longlines'); + const approvalModel = createMockApprovalModel(resource, { + label: 'RUSTFLAGS="-C target-cpu=native -C opt-level=3" cargo build --release --target x86_64-unknown-linux-gnu\nfind ./target/release -name "*.so" -exec strip --strip-unneeded {} \\; && tar czf release-bundle.tar.gz -C target/release .\ncurl -X POST https://deploy.internal.example.com/api/v2/artifacts/upload --header "Authorization: Bearer $DEPLOY_TOKEN" --form "bundle=@release-bundle.tar.gz"', + languageId: 'sh', + confirm: () => { }, + }); + renderSessionItem(ctx, createMockSession({ + resource, + label: 'Build and deploy native release', + status: AgentSessionStatus.InProgress, + providerType: AgentSessionProviders.Local, + timing: { + created: now - 2 * 60 * 1000, + lastRequestStarted: now - 60 * 1000, + lastRequestEnded: undefined, + }, + }), approvalModel); + }, + }), }); From 3674f5ad58f12d2fa12c50c9a21633dc20cc3e6b Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:09:28 +0100 Subject: [PATCH 11/23] Add optional codeSelection property and refactor feedback comment (#299005) * feat: add optional codeSelection property to feedback comment and agent feedback variable entries * refactor: remove unused range property and model service from feedback comment renderer --- .../browser/agentFeedbackAttachment.ts | 1 + .../browser/agentFeedbackHover.ts | 23 ++++++------------- .../common/attachments/chatVariableEntries.ts | 1 + 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts index e45f96e488d06..04e66cf8dd099 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackAttachment.ts @@ -74,6 +74,7 @@ export class AgentFeedbackAttachmentContribution extends Disposable { text: f.text, resourceUri: f.resourceUri, range: f.range, + codeSelection: this._snippetCache.get(f.id), })), value, }; diff --git a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts index 9b38ad3c63bb8..ea4414455e5db 100644 --- a/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts +++ b/src/vs/sessions/contrib/agentFeedback/browser/agentFeedbackHover.ts @@ -15,10 +15,8 @@ import { MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; import { basename } from '../../../../base/common/path.js'; import { ThemeIcon } from '../../../../base/common/themables.js'; -import { IRange } from '../../../../editor/common/core/range.js'; import { URI } from '../../../../base/common/uri.js'; import { ILanguageService } from '../../../../editor/common/languages/language.js'; -import { IModelService } from '../../../../editor/common/services/model.js'; import { localize } from '../../../../nls.js'; import { FileKind } from '../../../../platform/files/common/files.js'; import { IHoverService } from '../../../../platform/hover/browser/hover.js'; @@ -44,7 +42,7 @@ interface IFeedbackCommentElement { readonly id: string; readonly text: string; readonly resourceUri: URI; - readonly range: IRange; + readonly codeSelection?: string; } type FeedbackTreeElement = IFeedbackFileElement | IFeedbackCommentElement; @@ -151,7 +149,6 @@ class FeedbackCommentRenderer implements ITreeRenderer; } From f7d4b70acba251ecfffb0b05b21c152f730b1920 Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:12:11 +0100 Subject: [PATCH 12/23] Update src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../contrib/chat/browser/agentSessions/agentSessionsViewer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts index 74140b20870d1..235ef27d6839e 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentSessionsViewer.ts @@ -101,7 +101,7 @@ export class AgentSessionRenderer extends Disposable implements ICompressibleTre private static readonly _APPROVAL_ROW_OVERHEAD = 14; // 4px margin-top + 4px padding-top + 4px padding-bottom + 2px border static getApprovalRowHeight(label: string): number { - const lineCount = Math.min(label.split('\n').length, AgentSessionRenderer.APPROVAL_ROW_MAX_LINES); + const lineCount = Math.min(label.split(/\r?\n/).length, AgentSessionRenderer.APPROVAL_ROW_MAX_LINES); return lineCount * AgentSessionRenderer._APPROVAL_ROW_LINE_HEIGHT + AgentSessionRenderer._APPROVAL_ROW_OVERHEAD; } From 1722624c4ace5469c3c6e68304d7a85efcdfd67e Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:17:09 +0100 Subject: [PATCH 13/23] Git - expose `rebase()` though the extension API (#299181) --- extensions/git/src/api/api1.ts | 4 ++++ extensions/git/src/api/git.d.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/extensions/git/src/api/api1.ts b/extensions/git/src/api/api1.ts index e5820c0ded74a..d65c75bbf01e1 100644 --- a/extensions/git/src/api/api1.ts +++ b/extensions/git/src/api/api1.ts @@ -320,6 +320,10 @@ export class ApiRepository implements Repository { return this.#repository.mergeAbort(); } + rebase(branch: string): Promise { + return this.#repository.rebase(branch); + } + createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise { return this.#repository.createStash(options?.message, options?.includeUntracked, options?.staged); } diff --git a/extensions/git/src/api/git.d.ts b/extensions/git/src/api/git.d.ts index 122134c2c8b57..84560e038f414 100644 --- a/extensions/git/src/api/git.d.ts +++ b/extensions/git/src/api/git.d.ts @@ -315,6 +315,7 @@ export interface Repository { commit(message: string, opts?: CommitOptions): Promise; merge(ref: string): Promise; mergeAbort(): Promise; + rebase(branch: string): Promise; createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise; applyStash(index?: number): Promise; From c1184bc26164d2ebe0500dac6c1994e3302e5f03 Mon Sep 17 00:00:00 2001 From: Johannes Rieken Date: Wed, 4 Mar 2026 13:20:02 +0100 Subject: [PATCH 14/23] Remove holdToSpeech feature for inline chat (#299182) Fixes https://github.com/microsoft/vscode/issues/297811 --- src/vs/sessions/sessions.desktop.main.ts | 1 - .../inlineChat/browser/inlineChatActions.ts | 16 +--- .../contrib/inlineChat/common/inlineChat.ts | 6 -- .../inlineChat.contribution.ts | 11 --- .../electron-browser/inlineChatActions.ts | 83 ------------------- src/vs/workbench/workbench.desktop.main.ts | 2 +- 6 files changed, 2 insertions(+), 117 deletions(-) delete mode 100644 src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts delete mode 100644 src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts diff --git a/src/vs/sessions/sessions.desktop.main.ts b/src/vs/sessions/sessions.desktop.main.ts index 17d622826eb3d..9ede9e80baaf1 100644 --- a/src/vs/sessions/sessions.desktop.main.ts +++ b/src/vs/sessions/sessions.desktop.main.ts @@ -177,7 +177,6 @@ import '../workbench/contrib/remoteTunnel/electron-browser/remoteTunnel.contribu // Chat import '../workbench/contrib/chat/electron-browser/chat.contribution.js'; -//import '../workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.js'; import './contrib/agentFeedback/browser/agentFeedback.contribution.js'; // Encryption diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts index d282737cbbc15..8829e4bb9127c 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatActions.ts @@ -16,7 +16,7 @@ import { ctxHasEditorModification, ctxHasRequestInProgress } from '../../chat/br import { localize, localize2 } from '../../../../nls.js'; import { Action2, IAction2Options, MenuId, MenuRegistry } from '../../../../platform/actions/common/actions.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; +import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { IEditorService } from '../../../services/editor/common/editorService.js'; import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js'; @@ -37,16 +37,6 @@ CommandsRegistry.registerCommandAlias('interactive.acceptChanges', ACTION_ACCEPT export const START_INLINE_CHAT = registerIcon('start-inline-chat', Codicon.sparkle, localize('startInlineChat', 'Icon which spawns the inline chat from the editor toolbar.')); -// some gymnastics to enable hold for speech without moving the StartSessionAction into the electron-layer - -export interface IHoldForSpeech { - (accessor: ServicesAccessor, controller: InlineChatController, source: Action2): void; -} -let _holdForSpeech: IHoldForSpeech | undefined = undefined; -export function setHoldForSpeech(holdForSpeech: IHoldForSpeech) { - _holdForSpeech = holdForSpeech; -} - const inlineChatContextKey = ContextKeyExpr.and( ContextKeyExpr.or(CTX_INLINE_CHAT_V1_ENABLED, CTX_INLINE_CHAT_V2_ENABLED), CTX_INLINE_CHAT_POSSIBLE, @@ -114,10 +104,6 @@ export class StartSessionAction extends Action2 { return; } - if (_holdForSpeech) { - accessor.get(IInstantiationService).invokeFunction(_holdForSpeech, ctrl, this); - } - let options: InlineChatRunOptions | undefined; const arg = args[0]; if (arg && InlineChatRunOptions.isInlineChatRunOptions(arg)) { diff --git a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts index 69fd6f6674c14..6331855f6a019 100644 --- a/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts +++ b/src/vs/workbench/contrib/inlineChat/common/inlineChat.ts @@ -15,7 +15,6 @@ import { NOTEBOOK_IS_ACTIVE_EDITOR } from '../../notebook/common/notebookContext export const enum InlineChatConfigKeys { FinishOnType = 'inlineChat.finishOnType', - HoldToSpeech = 'inlineChat.holdToSpeech', /** @deprecated do not read on client */ EnableV2 = 'inlineChat.enableV2', notebookAgent = 'inlineChat.notebookAgent', @@ -33,11 +32,6 @@ Registry.as(Extensions.Configuration).registerConfigurat default: false, type: 'boolean' }, - [InlineChatConfigKeys.HoldToSpeech]: { - description: localize('holdToSpeech', "Whether holding the inline chat keybinding will automatically enable speech recognition."), - default: true, - type: 'boolean' - }, [InlineChatConfigKeys.EnableV2]: { description: localize('enableV2', "Whether to use the next version of inline chat."), default: false, diff --git a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts b/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts deleted file mode 100644 index 99549752c8ab5..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChat.contribution.ts +++ /dev/null @@ -1,11 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { HoldToSpeak } from './inlineChatActions.js'; - -// start and hold for voice - -registerAction2(HoldToSpeak); diff --git a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts b/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts deleted file mode 100644 index 0a9c91d1859df..0000000000000 --- a/src/vs/workbench/contrib/inlineChat/electron-browser/inlineChatActions.ts +++ /dev/null @@ -1,83 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { ICodeEditor } from '../../../../editor/browser/editorBrowser.js'; -import { ContextKeyExpr } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; -import { InlineChatController } from '../browser/inlineChatController.js'; -import { AbstractInlineChatAction, setHoldForSpeech } from '../browser/inlineChatActions.js'; -import { disposableTimeout } from '../../../../base/common/async.js'; -import { EditorContextKeys } from '../../../../editor/common/editorContextKeys.js'; -import { ICommandService } from '../../../../platform/commands/common/commands.js'; -import { IKeybindingService } from '../../../../platform/keybinding/common/keybinding.js'; -import { StartVoiceChatAction, StopListeningAction, VOICE_KEY_HOLD_THRESHOLD } from '../../chat/electron-browser/actions/voiceChatActions.js'; -import { IChatExecuteActionContext } from '../../chat/browser/actions/chatExecuteActions.js'; -import { CTX_INLINE_CHAT_VISIBLE, InlineChatConfigKeys } from '../common/inlineChat.js'; -import { HasSpeechProvider, ISpeechService } from '../../speech/common/speechService.js'; -import { localize2 } from '../../../../nls.js'; -import { Action2 } from '../../../../platform/actions/common/actions.js'; -import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; -import { EditorAction2 } from '../../../../editor/browser/editorExtensions.js'; - -export class HoldToSpeak extends EditorAction2 { - - constructor() { - super({ - id: 'inlineChat.holdForSpeech', - category: AbstractInlineChatAction.category, - precondition: ContextKeyExpr.and(HasSpeechProvider, CTX_INLINE_CHAT_VISIBLE), - title: localize2('holdForSpeech', "Hold for Speech"), - keybinding: { - when: EditorContextKeys.textInputFocus, - weight: KeybindingWeight.WorkbenchContrib, - primary: KeyMod.CtrlCmd | KeyCode.KeyI, - }, - }); - } - - override runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor, ..._args: unknown[]) { - const ctrl = InlineChatController.get(editor); - if (ctrl) { - holdForSpeech(accessor, ctrl, this); - } - } -} - -function holdForSpeech(accessor: ServicesAccessor, ctrl: InlineChatController, action: Action2): void { - - const configService = accessor.get(IConfigurationService); - const speechService = accessor.get(ISpeechService); - const keybindingService = accessor.get(IKeybindingService); - const commandService = accessor.get(ICommandService); - - // enabled or possible? - if (!configService.getValue(InlineChatConfigKeys.HoldToSpeech || !speechService.hasSpeechProvider)) { - return; - } - - const holdMode = keybindingService.enableKeybindingHoldMode(action.desc.id); - if (!holdMode) { - return; - } - let listening = false; - const handle = disposableTimeout(() => { - // start VOICE input - commandService.executeCommand(StartVoiceChatAction.ID, { voice: { disableTimeout: true } } satisfies IChatExecuteActionContext); - listening = true; - }, VOICE_KEY_HOLD_THRESHOLD); - - holdMode.finally(() => { - if (listening) { - commandService.executeCommand(StopListeningAction.ID).finally(() => { - ctrl.widget.chatWidget.acceptInput(); - }); - } - handle.dispose(); - }); -} - -// make this accessible to the chat actions from the browser layer -setHoldForSpeech(holdForSpeech); diff --git a/src/vs/workbench/workbench.desktop.main.ts b/src/vs/workbench/workbench.desktop.main.ts index 8291f38e8d17d..68821267b8613 100644 --- a/src/vs/workbench/workbench.desktop.main.ts +++ b/src/vs/workbench/workbench.desktop.main.ts @@ -178,7 +178,7 @@ import './contrib/remoteTunnel/electron-browser/remoteTunnel.contribution.js'; // Chat import './contrib/chat/electron-browser/chat.contribution.js'; -import './contrib/inlineChat/electron-browser/inlineChat.contribution.js'; + // Encryption import './contrib/encryption/electron-browser/encryption.contribution.js'; From 856ea291a5701ed17952fa79b17aad2f96f3b283 Mon Sep 17 00:00:00 2001 From: Alex Ross <38270282+alexr00@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:50:35 +0100 Subject: [PATCH 15/23] No need to throw when an element with the same ID comes in (#299154) * No need to throw when an element with the same ID comes in Fixes microsoft/vscode-pull-request-github#8073 * Fix tests --- extensions/vscode-api-tests/package.json | 5 + .../src/singlefolder-tests/tree.test.ts | 124 ++++++++++++++++-- .../workbench/api/common/extHostTreeViews.ts | 9 +- .../api/test/browser/extHostTreeViews.test.ts | 51 ++++++- 4 files changed, 169 insertions(+), 20 deletions(-) diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 58ec85b1450d0..96ad4d41c5ace 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -146,6 +146,11 @@ "id": "test.treeId", "name": "test-tree", "when": "never" + }, + { + "id": "test.treeSwitchUpdate", + "name": "test-tree-switch-update", + "when": "never" } ] }, diff --git a/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts b/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts index a9d9bc5aa345a..02259dc98c274 100644 --- a/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts +++ b/extensions/vscode-api-tests/src/singlefolder-tests/tree.test.ts @@ -98,10 +98,11 @@ suite('vscode API - tree', () => { await provider.resolveNextRequest(); const [firstResult, secondResult] = await Promise.all([revealFirst, revealSecond]); - const error = firstResult.error ?? secondResult.error; - if (error && /Element with id .+ is already registered/.test(error.message)) { - assert.fail(error.message); - } + // Two concurrent root fetches race: the stale one gets invalidated and + // its reveal fails with "Cannot resolve". The other succeeds. + const errors = [firstResult.error, secondResult.error].filter((e): e is Error => !!e); + assert.strictEqual(errors.length, 1, 'Exactly one reveal should fail from the stale fetch'); + assert.ok(/Cannot resolve tree item/.test(errors[0].message), `Expected "Cannot resolve" error but got: ${errors[0].message}`); }); test('TreeView - element already registered after rapid root refresh', async function () { @@ -206,10 +207,113 @@ suite('vscode API - tree', () => { provider.resolveRequestWithElement(1, provider.getElement2()); const [firstResult, secondResult] = await Promise.all([firstReveal, secondReveal]); - const error = firstResult.error ?? secondResult.error; - if (error && /Element with id .+ is already registered/.test(error.message)) { - assert.fail(error.message); + const errors = [firstResult.error, secondResult.error].filter((e): e is Error => !!e); + assert.strictEqual(errors.length, 1, 'Exactly one reveal should fail from the stale fetch'); + assert.ok(/Cannot resolve tree item/.test(errors[0].message), `Expected "Cannot resolve" error but got: ${errors[0].message}`); + }); + + test('TreeView - element already registered during switch and update', async function () { + this.timeout(60_000); + + // This test reproduces a race condition where the tree is being "switched to" + // (via reveal, which triggers getChildren) while simultaneously the tree data + // is being updated with a new element being added. Both operations trigger + // concurrent getChildren calls. The first resolves with the old set of elements, + // the second resolves with a new set that includes a new element. If both try + // to register elements with the same ID, the error is thrown. + + type TreeElement = { readonly kind: 'leaf'; readonly instance: number }; + + class SwitchAndUpdateTreeDataProvider implements vscode.TreeDataProvider { + private readonly changeEmitter = new vscode.EventEmitter(); + private readonly requestEmitter = new vscode.EventEmitter(); + private readonly pendingRequests: DeferredPromise[] = []; + private readonly existingOld: TreeElement = { kind: 'leaf', instance: 1 }; + private readonly existingNew: TreeElement = { kind: 'leaf', instance: 2 }; + private readonly addedElement: TreeElement = { kind: 'leaf', instance: 3 }; + + readonly onDidChangeTreeData = this.changeEmitter.event; + + getChildren(element?: TreeElement): Thenable { + if (!element) { + const deferred = new DeferredPromise(); + this.pendingRequests.push(deferred); + this.requestEmitter.fire(this.pendingRequests.length); + return deferred.p; + } + return Promise.resolve([]); + } + + getTreeItem(element: TreeElement): vscode.TreeItem { + if (element === this.addedElement) { + const item = new vscode.TreeItem('added', vscode.TreeItemCollapsibleState.None); + item.id = 'added-elem'; + return item; + } + const item = new vscode.TreeItem('existing', vscode.TreeItemCollapsibleState.None); + item.id = 'existing-elem'; + return item; + } + + getParent(): TreeElement | undefined { + return undefined; + } + + async waitForRequestCount(count: number): Promise { + while (this.pendingRequests.length < count) { + await asPromise(this.requestEmitter.event); + } + } + + resolveRequestAt(index: number, elements: TreeElement[]): void { + const request = this.pendingRequests[index]; + if (request) { + request.complete(elements); + } + } + + getExistingOld(): TreeElement { return this.existingOld; } + getExistingNew(): TreeElement { return this.existingNew; } + getAddedElement(): TreeElement { return this.addedElement; } + + dispose(): void { + this.changeEmitter.dispose(); + this.requestEmitter.dispose(); + while (this.pendingRequests.length) { + this.pendingRequests.shift()!.complete([]); + } + } } + + const provider = new SwitchAndUpdateTreeDataProvider(); + disposables.push(provider); + + const treeView = vscode.window.createTreeView('test.treeSwitchUpdate', { treeDataProvider: provider }); + disposables.push(treeView); + + // Two concurrent reveals simulate the tree being "switched to" while also + // being updated: both trigger getChildren calls on the ext host directly. + const revealFirst = (treeView.reveal(provider.getExistingOld(), { expand: true }) + .then(() => ({ error: undefined as Error | undefined })) as Promise<{ error: Error | undefined }>) + .catch(error => ({ error })); + const revealSecond = (treeView.reveal(provider.getExistingNew(), { expand: true }) + .then(() => ({ error: undefined as Error | undefined })) as Promise<{ error: Error | undefined }>) + .catch(error => ({ error })); + + // Wait for both getChildren calls to be pending + await provider.waitForRequestCount(2); + + // Resolve first request with old data (just the existing element, old instance) + provider.resolveRequestAt(0, [provider.getExistingOld()]); + await delay(0); + + // Resolve second request with new data: different instance of existing + added element + provider.resolveRequestAt(1, [provider.getExistingNew(), provider.getAddedElement()]); + + const [firstResult, secondResult] = await Promise.all([revealFirst, revealSecond]); + const errors = [firstResult.error, secondResult.error].filter((e): e is Error => !!e); + assert.strictEqual(errors.length, 1, 'Exactly one reveal should fail from the stale fetch'); + assert.ok(/Cannot resolve tree item/.test(errors[0].message), `Expected "Cannot resolve" error but got: ${errors[0].message}`); }); test('TreeView - element already registered after refresh', async function () { @@ -345,9 +449,7 @@ suite('vscode API - tree', () => { await provider.resolveChildRequestAt(0, [staleChild]); const [firstResult, secondResult] = await Promise.all([firstReveal, secondReveal]); - const error = firstResult.error ?? secondResult.error; - if (error && /Element with id .+ is already registered/.test(error.message)) { - assert.fail(error.message); - } + assert.strictEqual(firstResult.error, undefined, `First reveal should not fail: ${firstResult.error?.message}`); + assert.strictEqual(secondResult.error, undefined, `Second reveal should not fail: ${secondResult.error?.message}`); }); }); diff --git a/src/vs/workbench/api/common/extHostTreeViews.ts b/src/vs/workbench/api/common/extHostTreeViews.ts index fce5e9d47db5a..c0d05cbb48633 100644 --- a/src/vs/workbench/api/common/extHostTreeViews.ts +++ b/src/vs/workbench/api/common/extHostTreeViews.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { localize } from '../../../nls.js'; import type * as vscode from 'vscode'; import { basename } from '../../../base/common/resources.js'; import { URI } from '../../../base/common/uri.js'; @@ -876,10 +875,14 @@ class ExtHostTreeView extends Disposable { if (duplicateHandle) { const existingElement = this._elements.get(duplicateHandle); if (existingElement) { + const existingNode = this._nodes.get(existingElement); if (existingElement !== element) { - throw new Error(localize('treeView.duplicateElement', 'Element with id {0} is already registered', extTreeItem.id)); + // A different element object was registered with the same ID. + // This can happen during concurrent tree operations (e.g., tree + // being switched to while data is updated). Clean up the stale + // element reference before re-registering with the new one. + this._nodes.delete(existingElement); } - const existingNode = this._nodes.get(existingElement); if (existingNode) { const newNode = this._createTreeNode(element, extTreeItem, parentNode); this._updateNodeCache(element, newNode, existingNode, parentNode); diff --git a/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts b/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts index 751a4ff73f9f7..2ca97fac8a80a 100644 --- a/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTreeViews.test.ts @@ -204,7 +204,7 @@ suite('ExtHostTreeView', function () { }); }); - test('error is thrown if id is not unique', (done) => { + test('duplicate id across siblings is handled gracefully', (done) => { tree['a'] = { 'aa': {}, }; @@ -212,7 +212,6 @@ suite('ExtHostTreeView', function () { 'aa': {}, 'ba': {} }; - let caughtExpectedError = false; store.add(target.onRefresh.event(() => { testObject.$getChildren('testNodeWithIdTreeProvider') .then(elements => { @@ -220,14 +219,54 @@ suite('ExtHostTreeView', function () { assert.deepStrictEqual(actuals, ['1/a', '1/b']); return testObject.$getChildren('testNodeWithIdTreeProvider', ['1/a']) .then(() => testObject.$getChildren('testNodeWithIdTreeProvider', ['1/b'])) - .then(() => assert.fail('Should fail with duplicate id')) - .catch(() => caughtExpectedError = true) - .finally(() => caughtExpectedError ? done() : assert.fail('Expected duplicate id error not thrown.')); - }); + .then(elements => { + // Children of 'b' should include both 'aa' and 'ba' + const children = unBatchChildren(elements)?.map(e => e.handle); + assert.deepStrictEqual(children, ['1/aa', '1/ba']); + done(); + }); + }).catch(done); })); onDidChangeTreeNode.fire(undefined); }); + test('different element instances with same id are replaced gracefully', async () => { + // Simulates the race condition: two concurrent getChildren calls return + // different element objects that map to the same tree item ID. The second + // call should replace the first's registration without error. + let callCount = 0; + const element1 = { key: 'x' }; + const element2 = { key: 'x' }; + + const treeView = testObject.createTreeView('testRaceProvider', { + treeDataProvider: { + getChildren: (): { key: string }[] => { + callCount++; + // Return a different object instance each time + return callCount === 1 ? [element1] : [element2]; + }, + getTreeItem: (element: { key: string }): TreeItem => { + return { label: { label: element.key }, id: 'same-id', collapsibleState: TreeItemCollapsibleState.None }; + }, + onDidChangeTreeData: onDidChangeTreeNode.event, + } + }, extensionsDescription); + + store.add(treeView); + + // First fetch — registers element1 with id 'same-id' + const first = await testObject.$getChildren('testRaceProvider'); + const firstChildren = unBatchChildren(first); + assert.strictEqual(firstChildren?.length, 1); + assert.strictEqual(firstChildren![0].handle, '1/same-id'); + + // Second fetch — different element instance, same id. Should not throw. + const second = await testObject.$getChildren('testRaceProvider'); + const secondChildren = unBatchChildren(second); + assert.strictEqual(secondChildren?.length, 1); + assert.strictEqual(secondChildren![0].handle, '1/same-id'); + }); + test('refresh root', function (done) { store.add(target.onRefresh.event(actuals => { assert.strictEqual(undefined, actuals); From c0782402b7918cac5fe4d685a5bb2aac4c82419d Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:56:02 +0100 Subject: [PATCH 16/23] Sessions - hide the apply change action (#299188) --- .../browser/applyChangesToParentRepo.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts index 60bbbed676900..1c02efa68e675 100644 --- a/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts +++ b/src/vs/sessions/contrib/applyCommitsToParentRepo/browser/applyChangesToParentRepo.ts @@ -68,8 +68,9 @@ class ApplyChangesToParentRepoAction extends Action2 { group: 'navigation', order: 2, when: ContextKeyExpr.and( + ContextKeyExpr.false(), IsSessionsWindowContext, - hasWorktreeAndRepositoryContextKey, + hasWorktreeAndRepositoryContextKey ), }, ], From c5e1a4bdeac36fd9544a828f097bec6f414502f3 Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 4 Mar 2026 14:08:00 +0100 Subject: [PATCH 17/23] sessions - disconnect inactive pickers when switching local/cloud target (#299189) * fix: update session handling in NewChatWidget to disconnect inactive pickers * fix: update repoPicker session handling in NewChatWidget --- .../sessions/contrib/chat/browser/newChatViewPane.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index fd5104e8dc5ee..1a649c0634ec6 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -344,15 +344,17 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { private _setNewSession(session: INewSession): void { this._newSession.value = session; - // Wire pickers to the new session + // Wire pickers to the new session and disconnect inactive ones const target = this._targetPicker.selectedTarget; if (target === AgentSessionProviders.Background) { this._folderPicker.setNewSession(session); this._isolationModePicker.setNewSession(session); this._branchPicker.setNewSession(session); - } - - if (target === AgentSessionProviders.Cloud) { + this._repoPicker.setNewSession(undefined); + } else { + this._folderPicker.setNewSession(undefined); + this._isolationModePicker.setNewSession(undefined); + this._branchPicker.setNewSession(undefined); this._repoPicker.setNewSession(session); } From cf8f3944b5ffc024b3e2e93a3389ce28816916b9 Mon Sep 17 00:00:00 2001 From: mrleemurray Date: Wed, 4 Mar 2026 13:22:47 +0000 Subject: [PATCH 18/23] update: bump @vscode/codicons version to 0.0.45-12 in package.json and package-lock.json --- package-lock.json | 8 ++++---- package.json | 2 +- remote/web/package-lock.json | 8 ++++---- remote/web/package.json | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 03536ba036085..9728ddebc18ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-12", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", @@ -2983,9 +2983,9 @@ ] }, "node_modules/@vscode/codicons": { - "version": "0.0.45-11", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-11.tgz", - "integrity": "sha512-fLjx4i7pfSYJJzzmQ6tZnshWWSLYUfg8Ru6xNRBWRSFj8yZkuuXEZGMxju4mt/tuu8Y/gjhEGmIVmVC16fg+yQ==", + "version": "0.0.45-12", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-12.tgz", + "integrity": "sha512-omdtI6hEzpa901Q1s53ndM2vp3ROIVFFCGdz8I6hl4DZ/eKQzEdGYlY09Lnxfh+r9PfSDoyafChGIMIXmNnsRQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/component-explorer": { diff --git a/package.json b/package.json index 2cbaf453e1653..91ffe79555c79 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@microsoft/1ds-post-js": "^3.2.13", "@parcel/watcher": "^2.5.6", "@types/semver": "^7.5.8", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-12", "@vscode/deviceid": "^0.1.1", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/native-watchdog": "^1.4.6", diff --git a/remote/web/package-lock.json b/remote/web/package-lock.json index 6767fc211f02b..0cd6aee9fb79f 100644 --- a/remote/web/package-lock.json +++ b/remote/web/package-lock.json @@ -10,7 +10,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-12", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", @@ -73,9 +73,9 @@ "integrity": "sha512-n1VPsljTSkthsAFYdiWfC+DKzK2WwcRp83Y1YAqdX552BstvsDjft9YXppjUzp11BPsapDoO1LDgrDB0XVsfNQ==" }, "node_modules/@vscode/codicons": { - "version": "0.0.45-11", - "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-11.tgz", - "integrity": "sha512-fLjx4i7pfSYJJzzmQ6tZnshWWSLYUfg8Ru6xNRBWRSFj8yZkuuXEZGMxju4mt/tuu8Y/gjhEGmIVmVC16fg+yQ==", + "version": "0.0.45-12", + "resolved": "https://registry.npmjs.org/@vscode/codicons/-/codicons-0.0.45-12.tgz", + "integrity": "sha512-omdtI6hEzpa901Q1s53ndM2vp3ROIVFFCGdz8I6hl4DZ/eKQzEdGYlY09Lnxfh+r9PfSDoyafChGIMIXmNnsRQ==", "license": "CC-BY-4.0" }, "node_modules/@vscode/iconv-lite-umd": { diff --git a/remote/web/package.json b/remote/web/package.json index 29fb739234012..a641d6346c079 100644 --- a/remote/web/package.json +++ b/remote/web/package.json @@ -5,7 +5,7 @@ "dependencies": { "@microsoft/1ds-core-js": "^3.2.13", "@microsoft/1ds-post-js": "^3.2.13", - "@vscode/codicons": "^0.0.45-11", + "@vscode/codicons": "^0.0.45-12", "@vscode/iconv-lite-umd": "0.7.1", "@vscode/tree-sitter-wasm": "^0.3.0", "@vscode/vscode-languagedetection": "1.0.23", From 284bd98ce3c1b582db4010624bc04da868cfd138 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 5 Mar 2026 00:38:47 +1100 Subject: [PATCH 19/23] Add support for custom chat agents in the API (#298227) * Add support for custom chat agents in the API - Introduced `chatCustomAgents` proposal in extensions API. - Implemented methods to handle custom agents in `MainThreadChatAgents2`. - Added `ICustomAgentDto` interface and related functionality in extHost. - Created new type definitions for custom agents in `vscode.proposed.chatCustomAgents.d.ts`. * Filter custom agents by visibility before pushing to the proxy * Refactor onDidChangeCustomAgents to use direct event listener * Update custom agent tools property to allow undefined values * Add chatCustomAgents to enabledApiProposals in package.json * update * update * support skills * support instructions * update * update --------- Co-authored-by: Martin Aeschlimann --- extensions/vscode-api-tests/package.json | 1 + .../api/browser/mainThreadChatAgents2.ts | 50 ++++++++++++++++++- .../workbench/api/common/extHost.api.impl.ts | 24 +++++++++ .../workbench/api/common/extHost.protocol.ts | 15 ++++++ .../api/common/extHostChatAgents2.ts | 40 ++++++++++++++- .../promptSyntax/service/promptsService.ts | 10 ++++ .../service/promptsServiceImpl.ts | 10 ++++ .../service/mockPromptsService.ts | 9 ++-- .../vscode.proposed.chatPromptFiles.d.ts | 33 ++++++++++++ 9 files changed, 187 insertions(+), 5 deletions(-) diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index 96ad4d41c5ace..e5c6ce5f767a9 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -9,6 +9,7 @@ "authSession", "environmentPower", "chatParticipantPrivate", + "chatPromptFiles", "chatProvider", "contribStatusBarItems", "contribViewsRemote", diff --git a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts index bf0ba0049b04c..9459b611a98b3 100644 --- a/src/vs/workbench/api/browser/mainThreadChatAgents2.ts +++ b/src/vs/workbench/api/browser/mainThreadChatAgents2.ts @@ -40,7 +40,7 @@ import { ILanguageModelToolsService } from '../../contrib/chat/common/tools/lang import { IExtHostContext, extHostNamedCustomer } from '../../services/extensions/common/extHostCustomers.js'; import { IExtensionService } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; +import { ExtHostChatAgentsShape2, ExtHostContext, IChatNotebookEditDto, IChatParticipantMetadata, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IDynamicChatAgentProps, IExtensionChatAgentMetadata, IInstructionDto, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from '../common/extHost.protocol.js'; import { NotebookDto } from './mainThreadNotebookDto.js'; interface AgentData { @@ -153,6 +153,24 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA // Push the initial active session if there is already a focused widget this._acceptActiveChatSession(this._chatWidgetService.lastFocusedWidget); + + // Push custom agents to ext host + void this._pushCustomAgents(); + this._register(this._promptsService.onDidChangeCustomAgents(() => { + void this._pushCustomAgents(); + })); + + // Push instructions to ext host + void this._pushInstructions(); + this._register(this._promptsService.onDidChangeInstructions(() => { + void this._pushInstructions(); + })); + + // Push skills to ext host + void this._pushSkills(); + this._register(this._promptsService.onDidChangeSkills(() => { + void this._pushSkills(); + })); } private _acceptActiveChatSession(widget: IChatWidget | undefined): void { @@ -161,6 +179,36 @@ export class MainThreadChatAgents2 extends Disposable implements MainThreadChatA this._proxy.$acceptActiveChatSession(isLocal ? sessionResource : undefined); } + private async _pushCustomAgents(): Promise { + try { + const customAgents = await this._promptsService.getCustomAgents(CancellationToken.None); + const dtos: ICustomAgentDto[] = customAgents.map(agent => ({ uri: agent.uri })); + this._proxy.$acceptCustomAgents(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push custom agents to extension host', error); + } + } + + private async _pushInstructions(): Promise { + try { + const instructions = await this._promptsService.getInstructionFiles(CancellationToken.None); + const dtos: IInstructionDto[] = instructions.map(instruction => ({ uri: instruction.uri })); + this._proxy.$acceptInstructions(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push instructions to extension host', error); + } + } + + private async _pushSkills(): Promise { + try { + const skills = await this._promptsService.findAgentSkills(CancellationToken.None) ?? []; + const dtos: ISkillDto[] = skills.map(skill => ({ uri: skill.uri })); + this._proxy.$acceptSkills(dtos); + } catch (error) { + this._logService.error('[chat] Failed to push skills to extension host', error); + } + } + $unregisterAgent(handle: number): void { this._agents.deleteAndDispose(handle); } diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a5dfeb3c1d6c0..37251c6f6722a 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1677,6 +1677,30 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'chatDebug'); return extHostChatDebug.registerChatDebugLogProvider(provider); }, + get customAgents() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.customAgents as readonly vscode.ChatResource[]; + }, + onDidChangeCustomAgents: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeCustomAgents(listener, thisArgs, disposables); + }, + get instructions() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.instructions as readonly vscode.ChatResource[]; + }, + onDidChangeInstructions: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeInstructions(listener, thisArgs, disposables); + }, + get skills() { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.skills as readonly vscode.ChatResource[]; + }, + onDidChangeSkills: (listener, thisArgs?, disposables?) => { + checkProposedApiEnabled(extension, 'chatPromptFiles'); + return extHostChatAgents2.onDidChangeSkills(listener, thisArgs, disposables); + }, }; // namespace: lm diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 0f5821f2662b2..0bd60212242d7 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1614,6 +1614,21 @@ export interface ExtHostChatAgentsShape2 { $setRequestTools(requestId: string, tools: UserSelectedTools): void; $setYieldRequested(requestId: string, value: boolean): void; $acceptActiveChatSession(sessionResource: UriComponents | undefined): void; + $acceptCustomAgents(agents: ICustomAgentDto[]): void; + $acceptInstructions(instructions: IInstructionDto[]): void; + $acceptSkills(skills: ISkillDto[]): void; +} + +export interface ICustomAgentDto { + uri: UriComponents; +} + +export interface IInstructionDto { + uri: UriComponents; +} + +export interface ISkillDto { + uri: UriComponents; } export interface IChatParticipantMetadata { participant: string; diff --git a/src/vs/workbench/api/common/extHostChatAgents2.ts b/src/vs/workbench/api/common/extHostChatAgents2.ts index 7c5acd5cf8868..5edee47784fee 100644 --- a/src/vs/workbench/api/common/extHostChatAgents2.ts +++ b/src/vs/workbench/api/common/extHostChatAgents2.ts @@ -27,7 +27,7 @@ import { LocalChatSessionUri } from '../../contrib/chat/common/model/chatUri.js' import { ChatAgentLocation } from '../../contrib/chat/common/constants.js'; import { checkProposedApiEnabled, isProposedApiEnabled } from '../../services/extensions/common/extensions.js'; import { Dto } from '../../services/extensions/common/proxyIdentifier.js'; -import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentProgressShape, IChatProgressDto, IChatSessionContextDto, IExtensionChatAgentMetadata, IMainContext, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; +import { ExtHostChatAgentsShape2, IChatAgentCompletionItem, IChatAgentHistoryEntryDto, IChatAgentProgressShape, IChatProgressDto, IChatSessionContextDto, ICustomAgentDto, IExtensionChatAgentMetadata, IInstructionDto, IMainContext, ISkillDto, MainContext, MainThreadChatAgentsShape2 } from './extHost.protocol.js'; import { CommandsConverter, ExtHostCommands } from './extHostCommands.js'; import { ExtHostDiagnostics } from './extHostDiagnostics.js'; import { ExtHostDocuments } from './extHostDocuments.js'; @@ -487,6 +487,17 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS private readonly _onDidDisposeChatSession = this._register(new Emitter()); readonly onDidDisposeChatSession = this._onDidDisposeChatSession.event; + private readonly _onDidChangeCustomAgents = this._register(new Emitter()); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; + private readonly _onDidChangeInstructions = this._register(new Emitter()); + readonly onDidChangeInstructions = this._onDidChangeInstructions.event; + private readonly _onDidChangeSkills = this._register(new Emitter()); + readonly onDidChangeSkills = this._onDidChangeSkills.event; + + private _customAgents: vscode.ChatResource[] = []; + private _instructions: vscode.ChatResource[] = []; + private _skills: vscode.ChatResource[] = []; + private _activeChatPanelSessionResource: URI | undefined; private readonly _onDidChangeActiveChatPanelSessionResource = this._register(new Emitter()); @@ -496,6 +507,33 @@ export class ExtHostChatAgents2 extends Disposable implements ExtHostChatAgentsS return this._activeChatPanelSessionResource; } + get customAgents(): readonly vscode.ChatResource[] { + return this._customAgents; + } + + get instructions(): readonly vscode.ChatResource[] { + return this._instructions; + } + + get skills(): readonly vscode.ChatResource[] { + return this._skills; + } + + $acceptCustomAgents(agents: ICustomAgentDto[]): void { + this._customAgents = agents.map(a => Object.freeze({ uri: URI.revive(a.uri) })); + this._onDidChangeCustomAgents.fire(); + } + + $acceptInstructions(instructions: IInstructionDto[]): void { + this._instructions = instructions.map(i => Object.freeze({ uri: URI.revive(i.uri) })); + this._onDidChangeInstructions.fire(); + } + + $acceptSkills(skills: ISkillDto[]): void { + this._skills = skills.map(s => Object.freeze({ uri: URI.revive(s.uri) })); + this._onDidChangeSkills.fire(); + } + constructor( mainContext: IMainContext, private readonly _logService: ILogService, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index 66d01e1d7afc1..b4de6bbc2e4c9 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -417,6 +417,11 @@ export interface IPromptsService extends IDisposable { */ readonly onDidChangeCustomAgents: Event; + /** + * Event that is triggered when the list of instruction files changes. + */ + readonly onDidChangeInstructions: Event; + /** * Finds all available custom agents * @param sessionResource Optional session resource to scope debug logging to a specific session. @@ -483,6 +488,11 @@ export interface IPromptsService extends IDisposable { */ findAgentSkills(token: CancellationToken, sessionResource?: URI): Promise; + /** + * Event that is triggered when the list of skills changes. + */ + readonly onDidChangeSkills: Event; + /** * Gets detailed discovery information for a prompt type. * This includes all files found and their load/skip status with reasons. diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 75acb1fe94bc1..c628f10fcf7ff 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -152,6 +152,7 @@ export class PromptsService extends Disposable implements IPromptsService { private readonly _contributedWhenKeys = new Set(); private readonly _contributedWhenClauses = new Map(); private readonly _onDidContributedWhenChange = this._register(new Emitter()); + private readonly _onDidChangeInstructions = this._register(new Emitter()); private readonly _onDidPluginPromptFilesChange = this._register(new Emitter()); private readonly _onDidPluginHooksChange = this._register(new Emitter()); private _pluginPromptFilesByType = new Map(); @@ -417,6 +418,7 @@ export class PromptsService extends Disposable implements IPromptsService { this.cachedCustomAgents.refresh(); } else if (type === PromptsType.instructions) { this.cachedFileLocations[PromptsType.instructions] = undefined; + this._onDidChangeInstructions.fire(); } else if (type === PromptsType.prompt) { this.cachedFileLocations[PromptsType.prompt] = undefined; this.cachedSlashCommands.refresh(); @@ -644,6 +646,14 @@ export class PromptsService extends Disposable implements IPromptsService { return this.cachedCustomAgents.onDidChange; } + public get onDidChangeInstructions(): Event { + return Event.any( + this.getFileLocatorEvent(PromptsType.instructions), + this._onDidContributedWhenChange.event, + this._onDidChangeInstructions.event, + ); + } + public async getCustomAgents(token: CancellationToken, sessionResource?: URI): Promise { const sw = StopWatch.create(); const result = await this.cachedCustomAgents.get(token); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts index e01bc0089f014..131aafd39829a 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/mockPromptsService.ts @@ -18,8 +18,8 @@ export class MockPromptsService implements IPromptsService { _serviceBrand: undefined; - private readonly _onDidChangeCustomChatModes = new Emitter(); - readonly onDidChangeCustomAgents = this._onDidChangeCustomChatModes.event; + private readonly _onDidChangeCustomAgents = new Emitter(); + readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; private readonly _onDidLogDiscovery = new Emitter(); readonly onDidLogDiscovery: Event = this._onDidLogDiscovery.event; @@ -28,7 +28,7 @@ export class MockPromptsService implements IPromptsService { setCustomModes(modes: ICustomAgent[]): void { this._customModes = modes; - this._onDidChangeCustomChatModes.fire(); + this._onDidChangeCustomAgents.fire(); } async getCustomAgents(token: CancellationToken, sessionResource?: URI): Promise { @@ -72,4 +72,7 @@ export class MockPromptsService implements IPromptsService { getHooks(_token: CancellationToken, _sessionResource?: URI): Promise { throw new Error('Method not implemented.'); } getInstructionFiles(_token: CancellationToken, _sessionResource?: URI): Promise { throw new Error('Method not implemented.'); } dispose(): void { } + onDidChangeInstructions: Event = Event.None; + onDidChangePromptFiles: Event = Event.None; + onDidChangeSkills: Event = Event.None; } diff --git a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts index e683d6ce6005b..898b0cfbe27ef 100644 --- a/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts +++ b/src/vscode-dts/vscode.proposed.chatPromptFiles.d.ts @@ -103,6 +103,39 @@ declare module 'vscode' { // #region Chat Provider Registration export namespace chat { + /** + * An event that fires when the list of {@link customAgents custom agents} changes. + */ + export const onDidChangeCustomAgents: Event; + + /** + * The list of currently available custom agents. These are `.agent.md` files + * from all sources (workspace, user, and extension-provided). + */ + export const customAgents: readonly ChatResource[]; + + /** + * An event that fires when the list of {@link instructions instructions} changes. + */ + export const onDidChangeInstructions: Event; + + /** + * The list of currently available instructions. These are `.instructions.md` files + * from all sources (workspace, user, and extension-provided). + */ + export const instructions: readonly ChatResource[]; + + /** + * An event that fires when the list of {@link skills skills} changes. + */ + export const onDidChangeSkills: Event; + + /** + * The list of currently available skills. These are `SKILL.md` files + * from all sources (workspace, user, and extension-provided). + */ + export const skills: readonly ChatResource[]; + /** * Register a provider for custom agents. * @param provider The custom agent provider. From 7d47fd19043b9c168c5b7560c95417587d14356c Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 4 Mar 2026 15:57:15 +0100 Subject: [PATCH 20/23] Sessions - enable branch name generation (#299202) --- .../contrib/configuration/browser/configuration.contribution.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index 843068b74d642..0effc9b662a5c 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -27,6 +27,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'files.autoSave': 'afterDelay', 'git.autofetch': true, + 'git.branchRandomName.enable': true, 'git.detectWorktrees': false, 'git.showProgress': false, From 8a03516dd47f01b0fe0124ef9054cca31b743839 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Wed, 4 Mar 2026 16:04:28 +0100 Subject: [PATCH 21/23] sessions - focus chat input after attaching context (#299203) --- src/vs/sessions/contrib/chat/browser/newChatViewPane.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts index 1a649c0634ec6..8f78d2590daef 100644 --- a/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts +++ b/src/vs/sessions/contrib/chat/browser/newChatViewPane.ts @@ -245,7 +245,10 @@ class NewChatWidget extends Disposable implements IHistoryNavigationWidget { })); // Update input state when attachments or model change - this._register(this._contextAttachments.onDidChangeContext(() => this._updateDraftState())); + this._register(this._contextAttachments.onDidChangeContext(() => { + this._updateDraftState(); + this._focusEditor(); + })); this._register(autorun(reader => { this._currentLanguageModel.read(reader); this._updateDraftState(); From 75a2f31cdad3579a757044b31b1a3ad7644528bb Mon Sep 17 00:00:00 2001 From: BeniBenj Date: Wed, 4 Mar 2026 16:17:21 +0100 Subject: [PATCH 22/23] components for AI Customization shortcuts widget --- .../browser/aiCustomizationShortcutsWidget.ts | 137 +++++++++ .../customizationsToolbar.contribution.ts | 8 +- .../browser/media/customizationsToolbar.css | 251 ++++++++-------- .../sessions/browser/sessionsViewPane.ts | 121 +------- .../aiCustomizationShortcutsWidget.fixture.ts | 267 ++++++++++++++++++ 5 files changed, 541 insertions(+), 243 deletions(-) create mode 100644 src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts create mode 100644 src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts diff --git a/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts new file mode 100644 index 0000000000000..c4e89d70e17a7 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/browser/aiCustomizationShortcutsWidget.ts @@ -0,0 +1,137 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import '../../../browser/media/sidebarActionButton.css'; +import './media/customizationsToolbar.css'; +import * as DOM from '../../../../base/browser/dom.js'; +import { Codicon } from '../../../../base/common/codicons.js'; +import { Disposable, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { autorun } from '../../../../base/common/observable.js'; +import { ThemeIcon } from '../../../../base/common/themables.js'; +import { localize } from '../../../../nls.js'; +import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; +import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; +import { Button } from '../../../../base/browser/ui/button/button.js'; +import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { Menus } from '../../../browser/menus.js'; +import { getCustomizationTotalCount } from './customizationCounts.js'; + +const $ = DOM.$; + +const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; + +export interface IAICustomizationShortcutsWidgetOptions { + readonly onDidToggleCollapse?: () => void; +} + +export class AICustomizationShortcutsWidget extends Disposable { + + constructor( + container: HTMLElement, + options: IAICustomizationShortcutsWidgetOptions | undefined, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IStorageService private readonly storageService: IStorageService, + @IPromptsService private readonly promptsService: IPromptsService, + @IMcpService private readonly mcpService: IMcpService, + @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, + @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, + ) { + super(); + + this._render(container, options); + } + + private _render(parent: HTMLElement, options: IAICustomizationShortcutsWidgetOptions | undefined): void { + // Get initial collapsed state + const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); + + const container = DOM.append(parent, $('.ai-customization-toolbar')); + if (isCollapsed) { + container.classList.add('collapsed'); + } + + // Header (clickable to toggle) + const header = DOM.append(container, $('.ai-customization-header')); + header.classList.toggle('collapsed', isCollapsed); + + const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); + const headerButton = this._register(new Button(headerButtonContainer, { + ...defaultButtonStyles, + secondary: true, + title: false, + supportIcons: true, + buttonSecondaryBackground: 'transparent', + buttonSecondaryHoverBackground: undefined, + buttonSecondaryForeground: undefined, + buttonSecondaryBorder: undefined, + })); + headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); + headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); + headerButton.label = localize('customizations', "CUSTOMIZATIONS"); + + const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); + const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); + const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); + chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Toolbar container + const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); + + this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { + hiddenItemStrategy: HiddenItemStrategy.NoHide, + toolbarOptions: { primaryGroup: () => true }, + telemetrySource: 'sidebarCustomizations', + })); + + let updateCountRequestId = 0; + const updateHeaderTotalCount = async () => { + const requestId = ++updateCountRequestId; + const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService); + if (requestId !== updateCountRequestId) { + return; + } + + headerTotalCount.classList.toggle('hidden', totalCount === 0); + headerTotalCount.textContent = `${totalCount}`; + }; + + this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); + this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); + this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); + this._register(autorun(reader => { + this.mcpService.servers.read(reader); + updateHeaderTotalCount(); + })); + this._register(autorun(reader => { + this.workspaceService.activeProjectRoot.read(reader); + updateHeaderTotalCount(); + })); + updateHeaderTotalCount(); + + // Toggle collapse on header click + const transitionListener = this._register(new MutableDisposable()); + const toggleCollapse = () => { + const collapsed = container.classList.toggle('collapsed'); + header.classList.toggle('collapsed', collapsed); + this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); + headerButton.element.setAttribute('aria-expanded', String(!collapsed)); + chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); + chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); + + // Re-layout after the transition + transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => { + transitionListener.clear(); + options?.onDidToggleCollapse?.(); + }); + }; + + this._register(headerButton.onDidClick(() => toggleCollapse())); + } +} diff --git a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts index afbbd572247ec..ba97537a9b820 100644 --- a/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts +++ b/src/vs/sessions/contrib/sessions/browser/customizationsToolbar.contribution.ts @@ -34,7 +34,7 @@ import { getSourceCounts, getSourceCountsTotal } from './customizationCounts.js' import { IEditorService, MODAL_GROUP } from '../../../../workbench/services/editor/common/editorService.js'; import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; -interface ICustomizationItemConfig { +export interface ICustomizationItemConfig { readonly id: string; readonly label: string; readonly icon: ThemeIcon; @@ -43,7 +43,7 @@ interface ICustomizationItemConfig { readonly isMcp?: boolean; } -const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ +export const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ { id: 'sessions.customization.agents', label: localize('agents', "Agents"), @@ -92,7 +92,7 @@ const CUSTOMIZATION_ITEMS: ICustomizationItemConfig[] = [ * Custom ActionViewItem for each customization link in the toolbar. * Renders icon + label + source count badges, matching the sidebar footer style. */ -class CustomizationLinkViewItem extends ActionViewItem { +export class CustomizationLinkViewItem extends ActionViewItem { private readonly _viewItemDisposables: DisposableStore; private _button: Button | undefined; @@ -199,7 +199,7 @@ class CustomizationLinkViewItem extends ActionViewItem { // --- Register actions and view items --- // -class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { +export class CustomizationsToolbarContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsCustomizationsToolbar'; diff --git a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css index d671775dbd57c..bc08fc25eb54f 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css +++ b/src/vs/sessions/contrib/sessions/browser/media/customizationsToolbar.css @@ -2,132 +2,129 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/* AI Customization section - pinned to bottom */ +.ai-customization-toolbar { + display: flex; + flex-direction: column; + flex-shrink: 0; + border-top: 1px solid var(--vscode-widget-border); + padding: 6px; +} + +/* Make the toolbar, action bar, and items fill full width and stack vertically */ +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { + width: 100%; +} + +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { + display: flex; + flex-direction: column; + width: 100%; +} + +.ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { + width: 100%; + max-width: 100%; +} + +.ai-customization-toolbar .customization-link-widget { + width: 100%; +} + +/* Customization header - clickable for collapse */ +.ai-customization-toolbar .ai-customization-header { + display: flex; + align-items: center; + -webkit-user-select: none; + user-select: none; +} + +.ai-customization-toolbar .ai-customization-header:not(.collapsed) { + margin-bottom: 4px; +} + +.ai-customization-toolbar .ai-customization-chevron { + flex-shrink: 0; + opacity: 0; +} + +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { + opacity: 0.7; +} + +.ai-customization-toolbar .ai-customization-header-total { + display: none; + opacity: 0.7; + font-size: 11px; + line-height: 1; +} + +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { + display: inline; +} + +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, +.ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, +.ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { + display: none; +} + +/* Button container - fills available space */ +.ai-customization-toolbar .customization-link-button-container { + overflow: hidden; + min-width: 0; + flex: 1; +} + +/* Button needs relative positioning for counts overlay */ +.ai-customization-toolbar .customization-link-button { + position: relative; +} + +/* Counts - floating right inside the button */ +.ai-customization-toolbar .customization-link-counts { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + gap: 6px; +} + +.ai-customization-toolbar .customization-link-counts.hidden { + display: none; +} + +.ai-customization-toolbar .source-count-badge { + display: flex; + align-items: center; + gap: 2px; +} + +.ai-customization-toolbar .source-count-icon { + font-size: 12px; + opacity: 0.6; +} + +.ai-customization-toolbar .source-count-num { + font-size: 11px; + color: var(--vscode-descriptionForeground); + opacity: 0.8; +} + +/* Collapsed state */ +.ai-customization-toolbar .ai-customization-toolbar-content { + max-height: 500px; + overflow: hidden; + transition: max-height 0.2s ease-out; + padding-bottom: 2px; +} -.agent-sessions-viewpane { - - /* AI Customization section - pinned to bottom */ - .ai-customization-toolbar { - display: flex; - flex-direction: column; - flex-shrink: 0; - border-top: 1px solid var(--vscode-widget-border); - padding: 6px; - } - - /* Make the toolbar, action bar, and items fill full width and stack vertically */ - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-toolbar, - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar { - width: 100%; - } - - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .actions-container { - display: flex; - flex-direction: column; - width: 100%; - } - - .ai-customization-toolbar .ai-customization-toolbar-content .monaco-action-bar .action-item { - width: 100%; - max-width: 100%; - } - - .ai-customization-toolbar .customization-link-widget { - width: 100%; - } - - /* Customization header - clickable for collapse */ - .ai-customization-toolbar .ai-customization-header { - display: flex; - align-items: center; - -webkit-user-select: none; - user-select: none; - } - - .ai-customization-toolbar .ai-customization-header:not(.collapsed) { - margin-bottom: 4px; - } - - .ai-customization-toolbar .ai-customization-chevron { - flex-shrink: 0; - opacity: 0; - } - - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:hover .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button:focus-within .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-chevron, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-chevron { - opacity: 0.7; - } - - .ai-customization-toolbar .ai-customization-header-total { - display: none; - opacity: 0.7; - font-size: 11px; - line-height: 1; - } - - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:not(:hover):not(:focus-within) .ai-customization-header-total:not(.hidden) { - display: inline; - } - - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:hover .ai-customization-header-total, - .ai-customization-toolbar .ai-customization-header.collapsed .customization-link-button:focus-within .ai-customization-header-total, - .ai-customization-toolbar .ai-customization-header:not(.collapsed) .customization-link-button .ai-customization-header-total { - display: none; - } - - /* Button container - fills available space */ - .ai-customization-toolbar .customization-link-button-container { - overflow: hidden; - min-width: 0; - flex: 1; - } - - /* Button needs relative positioning for counts overlay */ - .ai-customization-toolbar .customization-link-button { - position: relative; - } - - /* Counts - floating right inside the button */ - .ai-customization-toolbar .customization-link-counts { - position: absolute; - right: 8px; - top: 50%; - transform: translateY(-50%); - display: flex; - align-items: center; - gap: 6px; - } - - .ai-customization-toolbar .customization-link-counts.hidden { - display: none; - } - - .ai-customization-toolbar .source-count-badge { - display: flex; - align-items: center; - gap: 2px; - } - - .ai-customization-toolbar .source-count-icon { - font-size: 12px; - opacity: 0.6; - } - - .ai-customization-toolbar .source-count-num { - font-size: 11px; - color: var(--vscode-descriptionForeground); - opacity: 0.8; - } - - /* Collapsed state */ - .ai-customization-toolbar .ai-customization-toolbar-content { - max-height: 500px; - overflow: hidden; - transition: max-height 0.2s ease-out; - } - - .ai-customization-toolbar.collapsed .ai-customization-toolbar-content { - max-height: 0; - } +.ai-customization-toolbar.collapsed .ai-customization-toolbar-content { + max-height: 0; } diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts index 41865aac2fbb3..fbec41877203d 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsViewPane.ts @@ -3,15 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import '../../../browser/media/sidebarActionButton.css'; -import './media/customizationsToolbar.css'; import './media/sessionsViewPane.css'; import * as DOM from '../../../../base/browser/dom.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { KeyCode, KeyMod } from '../../../../base/common/keyCodes.js'; -import { MutableDisposable } from '../../../../base/common/lifecycle.js'; import { autorun } from '../../../../base/common/observable.js'; -import { ThemeIcon } from '../../../../base/common/themables.js'; import { ContextKeyExpr, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; import { EditorsVisibleContext } from '../../../../workbench/common/contextkeys.js'; import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js'; @@ -29,9 +25,6 @@ import { localize, localize2 } from '../../../../nls.js'; import { AgentSessionsControl } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsControl.js'; import { AgentSessionsFilter, AgentSessionsGrouping } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsFilter.js'; import { AgentSessionProviders } from '../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; -import { IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; -import { IMcpService } from '../../../../workbench/contrib/mcp/common/mcpTypes.js'; -import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; import { ISessionsManagementService, IsNewChatSessionContext } from './sessionsManagementService.js'; import { Action2, ISubmenuItem, MenuId, MenuRegistry, registerAction2 } from '../../../../platform/actions/common/actions.js'; import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js'; @@ -40,26 +33,19 @@ import { Button } from '../../../../base/browser/ui/button/button.js'; import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultStyles.js'; import { KeybindingsRegistry, KeybindingWeight } from '../../../../platform/keybinding/common/keybindingsRegistry.js'; import { ACTION_ID_NEW_CHAT } from '../../../../workbench/contrib/chat/browser/actions/chatActions.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { IWorkspaceContextService } from '../../../../platform/workspace/common/workspace.js'; import { IViewsService } from '../../../../workbench/services/views/common/viewsService.js'; -import { HiddenItemStrategy, MenuWorkbenchToolBar } from '../../../../platform/actions/browser/toolbar.js'; -import { Menus } from '../../../browser/menus.js'; -import { getCustomizationTotalCount } from './customizationCounts.js'; +import { AICustomizationShortcutsWidget } from './aiCustomizationShortcutsWidget.js'; import { IHostService } from '../../../../workbench/services/host/browser/host.js'; const $ = DOM.$; export const SessionsViewId = 'agentic.workbench.view.sessionsView'; const SessionsViewFilterSubMenu = new MenuId('AgentSessionsViewFilterSubMenu'); -const CUSTOMIZATIONS_COLLAPSED_KEY = 'agentSessions.customizationsCollapsed'; - export class AgenticSessionsViewPane extends ViewPane { private viewPaneContainer: HTMLElement | undefined; private sessionsControlContainer: HTMLElement | undefined; sessionsControl: AgentSessionsControl | undefined; - private aiCustomizationContainer: HTMLElement | undefined; constructor( options: IViewPaneOptions, @@ -73,13 +59,8 @@ export class AgenticSessionsViewPane extends ViewPane { @IThemeService themeService: IThemeService, @IHoverService hoverService: IHoverService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, - @IStorageService private readonly storageService: IStorageService, - @IPromptsService private readonly promptsService: IPromptsService, - @IMcpService private readonly mcpService: IMcpService, - @IWorkspaceContextService private readonly workspaceContextService: IWorkspaceContextService, @ISessionsManagementService private readonly activeSessionService: ISessionsManagementService, @IHostService private readonly hostService: IHostService, - @IAICustomizationWorkspaceService private readonly workspaceService: IAICustomizationWorkspaceService, ) { super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, hoverService); } @@ -180,8 +161,14 @@ export class AgenticSessionsViewPane extends ViewPane { })); // AI Customization toolbar (bottom, fixed height) - this.aiCustomizationContainer = DOM.append(sessionsContainer, $('div')); - this.createAICustomizationShortcuts(this.aiCustomizationContainer); + this._register(this.instantiationService.createInstance(AICustomizationShortcutsWidget, sessionsContainer, { + onDidToggleCollapse: () => { + if (this.viewPaneContainer) { + const { offsetHeight, offsetWidth } = this.viewPaneContainer; + this.layoutBody(offsetHeight, offsetWidth); + } + }, + })); } private restoreLastSelectedSession(): void { @@ -191,96 +178,6 @@ export class AgenticSessionsViewPane extends ViewPane { } } - private createAICustomizationShortcuts(container: HTMLElement): void { - // Get initial collapsed state - const isCollapsed = this.storageService.getBoolean(CUSTOMIZATIONS_COLLAPSED_KEY, StorageScope.PROFILE, false); - - container.classList.add('ai-customization-toolbar'); - if (isCollapsed) { - container.classList.add('collapsed'); - } - - // Header (clickable to toggle) - const header = DOM.append(container, $('.ai-customization-header')); - header.classList.toggle('collapsed', isCollapsed); - - const headerButtonContainer = DOM.append(header, $('.customization-link-button-container')); - const headerButton = this._register(new Button(headerButtonContainer, { - ...defaultButtonStyles, - secondary: true, - title: false, - supportIcons: true, - buttonSecondaryBackground: 'transparent', - buttonSecondaryHoverBackground: undefined, - buttonSecondaryForeground: undefined, - buttonSecondaryBorder: undefined, - })); - headerButton.element.classList.add('customization-link-button', 'sidebar-action-button'); - headerButton.element.setAttribute('aria-expanded', String(!isCollapsed)); - headerButton.label = localize('customizations', "CUSTOMIZATIONS"); - - const chevronContainer = DOM.append(headerButton.element, $('span.customization-link-counts')); - const chevron = DOM.append(chevronContainer, $('.ai-customization-chevron')); - const headerTotalCount = DOM.append(chevronContainer, $('span.ai-customization-header-total.hidden')); - chevron.classList.add(...ThemeIcon.asClassNameArray(isCollapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - // Toolbar container - const toolbarContainer = DOM.append(container, $('.ai-customization-toolbar-content.sidebar-action-list')); - - this._register(this.instantiationService.createInstance(MenuWorkbenchToolBar, toolbarContainer, Menus.SidebarCustomizations, { - hiddenItemStrategy: HiddenItemStrategy.NoHide, - toolbarOptions: { primaryGroup: () => true }, - telemetrySource: 'sidebarCustomizations', - })); - - let updateCountRequestId = 0; - const updateHeaderTotalCount = async () => { - const requestId = ++updateCountRequestId; - const totalCount = await getCustomizationTotalCount(this.promptsService, this.mcpService, this.workspaceService, this.workspaceContextService); - if (requestId !== updateCountRequestId) { - return; - } - - headerTotalCount.classList.toggle('hidden', totalCount === 0); - headerTotalCount.textContent = `${totalCount}`; - }; - - this._register(this.promptsService.onDidChangeCustomAgents(() => updateHeaderTotalCount())); - this._register(this.promptsService.onDidChangeSlashCommands(() => updateHeaderTotalCount())); - this._register(this.workspaceContextService.onDidChangeWorkspaceFolders(() => updateHeaderTotalCount())); - this._register(autorun(reader => { - this.mcpService.servers.read(reader); - updateHeaderTotalCount(); - })); - this._register(autorun(reader => { - this.workspaceService.activeProjectRoot.read(reader); - updateHeaderTotalCount(); - })); - updateHeaderTotalCount(); - - // Toggle collapse on header click - const transitionListener = this._register(new MutableDisposable()); - const toggleCollapse = () => { - const collapsed = container.classList.toggle('collapsed'); - header.classList.toggle('collapsed', collapsed); - this.storageService.store(CUSTOMIZATIONS_COLLAPSED_KEY, collapsed, StorageScope.PROFILE, StorageTarget.USER); - headerButton.element.setAttribute('aria-expanded', String(!collapsed)); - chevron.classList.remove(...ThemeIcon.asClassNameArray(Codicon.chevronRight), ...ThemeIcon.asClassNameArray(Codicon.chevronDown)); - chevron.classList.add(...ThemeIcon.asClassNameArray(collapsed ? Codicon.chevronRight : Codicon.chevronDown)); - - // Re-layout after the transition so sessions control gets the right height - transitionListener.value = DOM.addDisposableListener(toolbarContainer, 'transitionend', () => { - transitionListener.clear(); - if (this.viewPaneContainer) { - const { offsetHeight, offsetWidth } = this.viewPaneContainer; - this.layoutBody(offsetHeight, offsetWidth); - } - }); - }; - - this._register(headerButton.onDidClick(() => toggleCollapse())); - } - private getSessionHoverPosition(): HoverPosition { const viewLocation = this.viewDescriptorService.getViewLocationById(this.id); const sideBarPosition = this.layoutService.getSideBarPosition(); diff --git a/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts new file mode 100644 index 0000000000000..71d5b630d1149 --- /dev/null +++ b/src/vs/sessions/contrib/sessions/test/browser/aiCustomizationShortcutsWidget.fixture.ts @@ -0,0 +1,267 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { toAction } from '../../../../../base/common/actions.js'; +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { DisposableStore } from '../../../../../base/common/lifecycle.js'; +import { observableValue } from '../../../../../base/common/observable.js'; +import { URI } from '../../../../../base/common/uri.js'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { IActionViewItemFactory, IActionViewItemService } from '../../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenu, IMenuActionOptions, IMenuService, isIMenuItem, MenuId, MenuItemAction, MenuRegistry, SubmenuItemAction } from '../../../../../platform/actions/common/actions.js'; +import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; +import { IFileService } from '../../../../../platform/files/common/files.js'; +import { IWorkspace, IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js'; +import { IPromptsService, PromptsStorage } from '../../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js'; +import { PromptsType } from '../../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js'; +import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js'; +import { IMcpServer, IMcpService } from '../../../../../workbench/contrib/mcp/common/mcpTypes.js'; +import { IAICustomizationWorkspaceService, IStorageSourceFilter } from '../../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js'; +import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup, registerWorkbenchServices } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; +import { AICustomizationShortcutsWidget } from '../../browser/aiCustomizationShortcutsWidget.js'; +import { CUSTOMIZATION_ITEMS, CustomizationLinkViewItem } from '../../browser/customizationsToolbar.contribution.js'; +import { IActiveSessionItem, ISessionsManagementService } from '../../browser/sessionsManagementService.js'; +import { Menus } from '../../../../browser/menus.js'; + +// Ensure color registrations are loaded +import '../../../../common/theme.js'; +import '../../../../../platform/theme/common/colors/inputColors.js'; + +// ============================================================================ +// One-time menu item registration (module-level). +// MenuRegistry.appendMenuItem does not throw on duplicates, unlike registerAction2 +// which registers global commands and throws on the second call. +// ============================================================================ + +const menuRegistrations = new DisposableStore(); +for (const [index, config] of CUSTOMIZATION_ITEMS.entries()) { + menuRegistrations.add(MenuRegistry.appendMenuItem(Menus.SidebarCustomizations, { + command: { id: config.id, title: config.label }, + group: 'navigation', + order: index + 1, + })); +} + +// ============================================================================ +// FixtureMenuService — reads from MenuRegistry without context-key filtering +// (MockContextKeyService.contextMatchesRules always returns false, which hides +// every item when using the real MenuService.) +// ============================================================================ + +class FixtureMenuService implements IMenuService { + declare readonly _serviceBrand: undefined; + + createMenu(id: MenuId): IMenu { + return { + onDidChange: Event.None, + dispose: () => { }, + getActions: () => { + const items = MenuRegistry.getMenuItems(id).filter(isIMenuItem); + items.sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const actions = items.map(item => { + const title = typeof item.command.title === 'string' ? item.command.title : item.command.title.value; + return toAction({ id: item.command.id, label: title, run: () => { } }); + }); + return actions.length ? [['navigation', actions as unknown as (MenuItemAction | SubmenuItemAction)[]]] : []; + }, + }; + } + + getMenuActions(_id: MenuId, _contextKeyService: unknown, _options?: IMenuActionOptions) { return []; } + getMenuContexts() { return new Set(); } + resetHiddenStates() { } +} + +// ============================================================================ +// Minimal IActionViewItemService that supports register/lookUp +// ============================================================================ + +class FixtureActionViewItemService implements IActionViewItemService { + declare _serviceBrand: undefined; + + private readonly _providers = new Map(); + private readonly _onDidChange = new Emitter(); + readonly onDidChange = this._onDidChange.event; + + register(menu: MenuId, commandId: string | MenuId, provider: IActionViewItemFactory): { dispose(): void } { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + this._providers.set(key, provider); + return { dispose: () => { this._providers.delete(key); } }; + } + + lookUp(menu: MenuId, commandId: string | MenuId): IActionViewItemFactory | undefined { + const key = `${menu.id}/${commandId instanceof MenuId ? commandId.id : commandId}`; + return this._providers.get(key); + } +} + +// ============================================================================ +// Mock helpers +// ============================================================================ + +const defaultFilter: IStorageSourceFilter = { + sources: [PromptsStorage.local, PromptsStorage.user, PromptsStorage.extension], +}; + +function createMockPromptsService(): IPromptsService { + return createMockPromptsServiceWithCounts(); +} + +interface ICustomizationCounts { + readonly agents?: number; + readonly skills?: number; + readonly instructions?: number; + readonly prompts?: number; + readonly hooks?: number; +} + +function createMockPromptsServiceWithCounts(counts?: ICustomizationCounts): IPromptsService { + const fakeUri = (prefix: string, i: number) => URI.parse(`file:///mock/${prefix}-${i}.md`); + const fakeItem = (prefix: string, i: number) => ({ uri: fakeUri(prefix, i), storage: PromptsStorage.local }); + + const agents = Array.from({ length: counts?.agents ?? 0 }, (_, i) => ({ + uri: fakeUri('agent', i), + source: { storage: PromptsStorage.local }, + })); + const skills = Array.from({ length: counts?.skills ?? 0 }, (_, i) => fakeItem('skill', i)); + const prompts = Array.from({ length: counts?.prompts ?? 0 }, (_, i) => ({ + promptPath: { uri: fakeUri('prompt', i), storage: PromptsStorage.local, type: PromptsType.prompt }, + })); + const instructions = Array.from({ length: counts?.instructions ?? 0 }, (_, i) => fakeItem('instructions', i)); + const hooks = Array.from({ length: counts?.hooks ?? 0 }, (_, i) => fakeItem('hook', i)); + + return new class extends mock() { + override readonly onDidChangeCustomAgents = Event.None; + override readonly onDidChangeSlashCommands = Event.None; + override async getCustomAgents() { return agents as never[]; } + override async findAgentSkills() { return skills as never[]; } + override async getPromptSlashCommands() { return prompts as never[]; } + override async listPromptFiles(type: PromptsType) { + return (type === PromptsType.hook ? hooks : instructions) as never[]; + } + override async listAgentInstructions() { return [] as never[]; } + }(); +} + +function createMockMcpService(serverCount: number = 0): IMcpService { + const MockServer = mock(); + const servers = observableValue('mockMcpServers', Array.from({ length: serverCount }, () => new MockServer())); + return new class extends mock() { + override readonly servers = servers; + }(); +} + +function createMockWorkspaceService(): IAICustomizationWorkspaceService { + const activeProjectRoot = observableValue('mockActiveProjectRoot', undefined); + return new class extends mock() { + override readonly activeProjectRoot = activeProjectRoot; + override getActiveProjectRoot() { return undefined; } + override getStorageSourceFilter() { return defaultFilter; } + }(); +} + +function createMockWorkspaceContextService(): IWorkspaceContextService { + return new class extends mock() { + override readonly onDidChangeWorkspaceFolders = Event.None; + override getWorkspace(): IWorkspace { return { id: 'test', folders: [] }; } + }(); +} + +// ============================================================================ +// Render helper +// ============================================================================ + +function renderWidget(ctx: ComponentFixtureContext, options?: { mcpServerCount?: number; collapsed?: boolean; counts?: ICustomizationCounts }): void { + ctx.container.style.width = '300px'; + ctx.container.style.backgroundColor = 'var(--vscode-sideBar-background)'; + + const actionViewItemService = new FixtureActionViewItemService(); + + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, + additionalServices: (reg) => { + // Register overrides BEFORE registerWorkbenchServices so they take priority + reg.defineInstance(IMenuService, new FixtureMenuService()); + reg.defineInstance(IActionViewItemService, actionViewItemService); + registerWorkbenchServices(reg); + // Services needed by AICustomizationShortcutsWidget + reg.defineInstance(IPromptsService, options?.counts ? createMockPromptsServiceWithCounts(options.counts) : createMockPromptsService()); + reg.defineInstance(IMcpService, createMockMcpService(options?.mcpServerCount ?? 0)); + reg.defineInstance(IAICustomizationWorkspaceService, createMockWorkspaceService()); + reg.defineInstance(IWorkspaceContextService, createMockWorkspaceContextService()); + // Additional services needed by CustomizationLinkViewItem + reg.defineInstance(ILanguageModelsService, new class extends mock() { + override readonly onDidChangeLanguageModels = Event.None; + }()); + reg.defineInstance(ISessionsManagementService, new class extends mock() { + override readonly activeSession = observableValue('activeSession', undefined); + }()); + reg.defineInstance(IFileService, new class extends mock() { + override readonly onDidFilesChange = Event.None; + }()); + }, + }); + + // Register view item factories from the real CustomizationLinkViewItem (per-render, instance-scoped) + for (const config of CUSTOMIZATION_ITEMS) { + ctx.disposableStore.add(actionViewItemService.register(Menus.SidebarCustomizations, config.id, (action, options) => { + return instantiationService.createInstance(CustomizationLinkViewItem, action, options, config); + })); + } + + // Override storage to set initial collapsed state + if (options?.collapsed) { + const storageService = instantiationService.get(IStorageService); + instantiationService.set(IStorageService, new class extends mock() { + override getBoolean(key: string, scope: StorageScope, fallbackValue?: boolean) { + if (key === 'agentSessions.customizationsCollapsed') { + return true; + } + return storageService.getBoolean(key, scope, fallbackValue!); + } + override store() { } + }()); + } + + // Create the widget (uses FixtureMenuService → reads MenuRegistry items registered above) + ctx.disposableStore.add( + instantiationService.createInstance(AICustomizationShortcutsWidget, ctx.container, undefined) + ); +} + +// ============================================================================ +// Fixtures +// ============================================================================ + +export default defineThemedFixtureGroup({ path: 'sessions/' }, { + + Expanded: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx), + }), + + Collapsed: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { collapsed: true }), + }), + + WithMcpServers: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { mcpServerCount: 3 }), + }), + + CollapsedWithMcpServers: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { mcpServerCount: 3, collapsed: true }), + }), + + WithCounts: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => renderWidget(ctx, { + mcpServerCount: 2, + counts: { agents: 2, skills: 30, instructions: 16, prompts: 17, hooks: 4 }, + }), + }), +}); From a4e35e0d6992b16f5eaac751457641cd9bc4099b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 4 Mar 2026 07:20:21 -0800 Subject: [PATCH 23/23] chat: add support for agent plugin sources (#299081) * chat: add support for agent plugin sources - Adds support for agent plugins to reference sources as specified in PLUGIN_SOURCES.md, enabling installation from GitHub, npm, pip, and other package registries - Integrates source parsing and validation into the plugin installation service and repository service - Adds comprehensive test coverage for plugin source handling and installation from various sources - Creates PLUGIN_SOURCES.md documentation describing how to specify plugin source configurations (Commit message generated by Copilot) * comments * windows fixes and fault handling * fix tests --- extensions/git/src/commands.ts | 14 +- extensions/git/src/git.ts | 11 +- .../agentPluginEditor/agentPluginEditor.ts | 7 +- .../agentPluginEditor/agentPluginItems.ts | 3 +- .../browser/agentPluginRepositoryService.ts | 180 +++++- .../contrib/chat/browser/agentPluginsView.ts | 7 +- .../chat/browser/pluginInstallService.ts | 270 +++++++- .../plugins/agentPluginRepositoryService.ts | 24 +- .../chat/common/plugins/agentPluginService.ts | 2 + .../common/plugins/agentPluginServiceImpl.ts | 1 + .../plugins/pluginMarketplaceService.ts | 280 +++++++- .../agentPluginRepositoryService.test.ts | 48 +- .../plugins/pluginInstallService.test.ts | 611 ++++++++++++++++++ .../plugins/pluginMarketplaceService.test.ts | 152 ++++- .../service/promptsService.test.ts | 3 +- .../common/discovery/pluginMcpDiscovery.ts | 3 +- 16 files changed, 1571 insertions(+), 45 deletions(-) create mode 100644 src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts diff --git a/extensions/git/src/commands.ts b/extensions/git/src/commands.ts index 6489802682126..d3eae80b5e193 100644 --- a/extensions/git/src/commands.ts +++ b/extensions/git/src/commands.ts @@ -1040,19 +1040,29 @@ export class CommandCenter { } @command('_git.cloneRepository') - async cloneRepository(url: string, parentPath: string): Promise { + async cloneRepository(url: string, localPath: string, ref?: string): Promise { const opts = { location: ProgressLocation.Notification, title: l10n.t('Cloning git repository "{0}"...', url), cancellable: true }; + const parentPath = path.dirname(localPath); + const targetName = path.basename(localPath); + await window.withProgress( opts, - (progress, token) => this.model.git.clone(url, { parentPath, progress }, token) + (progress, token) => this.model.git.clone(url, { parentPath, targetName, progress, ref }, token) ); } + @command('_git.checkout') + async checkoutRepository(repositoryPath: string, treeish: string, detached?: boolean): Promise { + const dotGit = await this.git.getRepositoryDotGit(repositoryPath); + const repo = new GitRepository(this.git, repositoryPath, undefined, dotGit, this.logger); + await repo.checkout(treeish, [], detached ? { detached: true } : {}); + } + @command('_git.pull') async pullRepository(repositoryPath: string): Promise { const dotGit = await this.git.getRepositoryDotGit(repositoryPath); diff --git a/extensions/git/src/git.ts b/extensions/git/src/git.ts index 5f7d1100f709c..1bd20a42c548f 100644 --- a/extensions/git/src/git.ts +++ b/extensions/git/src/git.ts @@ -378,6 +378,7 @@ const STASH_FORMAT = '%H%n%P%n%gd%n%gs%n%at%n%ct'; export interface ICloneOptions { readonly parentPath: string; + readonly targetName?: string; readonly progress: Progress<{ increment: number }>; readonly recursive?: boolean; readonly ref?: string; @@ -433,14 +434,16 @@ export class Git { } async clone(url: string, options: ICloneOptions, cancellationToken?: CancellationToken): Promise { - const baseFolderName = decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; + const baseFolderName = options.targetName || decodeURI(url).replace(/[\/]+$/, '').replace(/^.*[\/\\]/, '').replace(/\.git$/, '') || 'repository'; let folderName = baseFolderName; let folderPath = path.join(options.parentPath, folderName); let count = 1; - while (count < 20 && await new Promise(c => exists(folderPath, c))) { - folderName = `${baseFolderName}-${count++}`; - folderPath = path.join(options.parentPath, folderName); + if (!options.targetName) { + while (count < 20 && await new Promise(c => exists(folderPath, c))) { + folderName = `${baseFolderName}-${count++}`; + folderPath = path.join(options.parentPath, folderName); + } } await mkdirp(options.parentPath); diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index df8124325ee48..bca23294bf622 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -12,7 +12,7 @@ import { CancellationToken, CancellationTokenSource } from '../../../../../base/ import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js'; import { Schemas, matchesScheme } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename, dirname, joinPath } from '../../../../../base/common/resources.js'; +import { dirname, joinPath } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; import { TokenizationRegistry } from '../../../../../editor/common/languages.js'; @@ -202,6 +202,7 @@ export class AgentPluginEditor extends EditorPane { description: item.description, version: '', source: item.source, + sourceDescriptor: item.sourceDescriptor, marketplace: item.marketplace, marketplaceReference: item.marketplaceReference, marketplaceType: item.marketplaceType, @@ -222,6 +223,7 @@ export class AgentPluginEditor extends EditorPane { name: item.name, description: mp.description, source: mp.source, + sourceDescriptor: mp.sourceDescriptor, marketplace: mp.marketplace, marketplaceReference: mp.marketplaceReference, marketplaceType: mp.marketplaceType, @@ -267,7 +269,7 @@ export class AgentPluginEditor extends EditorPane { } private installedPluginToItem(plugin: IAgentPlugin): IInstalledPluginItem { - const name = basename(plugin.uri); + const name = plugin.label; const description = plugin.fromMarketplace?.description ?? this.labelService.getUriLabel(dirname(plugin.uri), { relative: true }); const marketplace = plugin.fromMarketplace?.marketplace; return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; @@ -517,6 +519,7 @@ class InstallPluginEditorAction extends Action { description: this.item.description, version: '', source: this.item.source, + sourceDescriptor: this.item.sourceDescriptor, marketplace: this.item.marketplace, marketplaceReference: this.item.marketplaceReference, marketplaceType: this.item.marketplaceType, diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts index 20ec5ed400985..9f1b8f8e97cd6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginItems.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../../base/common/uri.js'; import type { IAgentPlugin } from '../../common/plugins/agentPluginService.js'; -import type { IMarketplaceReference, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; +import type { IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from '../../common/plugins/pluginMarketplaceService.js'; export const enum AgentPluginItemKind { Installed = 'installed', @@ -25,6 +25,7 @@ export interface IMarketplacePluginItem { readonly name: string; readonly description: string; readonly source: string; + readonly sourceDescriptor: IPluginSourceDescriptor; readonly marketplace: string; readonly marketplaceReference: IMarketplaceReference; readonly marketplaceType: MarketplaceType; diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts index 7b2244345e865..41abb16b8f8a6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginRepositoryService.ts @@ -18,7 +18,7 @@ import { IProgressService, ProgressLocation } from '../../../../platform/progres import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import type { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../common/plugins/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, IMarketplaceReference, MarketplaceReferenceKind, MarketplaceType } from '../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceReferenceKind, MarketplaceType, PluginSourceKind } from '../common/plugins/pluginMarketplaceService.js'; const MARKETPLACE_INDEX_STORAGE_KEY = 'chat.plugins.marketplaces.index.v1'; @@ -176,7 +176,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi this._storageService.store(MARKETPLACE_INDEX_STORAGE_KEY, JSON.stringify(serialized), StorageScope.APPLICATION, StorageTarget.MACHINE); } - private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string): Promise { + private async _cloneRepository(repoDir: URI, cloneUrl: string, progressTitle: string, failureLabel: string, ref?: string): Promise { try { await this._progressService.withProgress( { @@ -186,7 +186,7 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi }, async () => { await this._fileService.createFolder(dirname(repoDir)); - await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, dirname(repoDir).fsPath); + await this._commandService.executeCommand('_git.cloneRepository', cloneUrl, repoDir.fsPath, ref); } ); } catch (err) { @@ -212,4 +212,178 @@ export class AgentPluginRepositoryService implements IAgentPluginRepositoryServi } return pluginDir; } + + getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI { + switch (sourceDescriptor.kind) { + case PluginSourceKind.RelativePath: + throw new Error('Use getPluginInstallUri() for relative-path sources'); + case PluginSourceKind.GitHub: { + const [owner, repo] = sourceDescriptor.repo.split('/'); + return joinPath(this._cacheRoot, 'github.com', owner, repo, ...this._getSourceRevisionCacheSuffix(sourceDescriptor)); + } + case PluginSourceKind.GitUrl: { + const segments = this._gitUrlCacheSegments(sourceDescriptor.url, sourceDescriptor.ref, sourceDescriptor.sha); + return joinPath(this._cacheRoot, ...segments); + } + case PluginSourceKind.Npm: + return joinPath(this._cacheRoot, 'npm', sanitizePackageName(sourceDescriptor.package), 'node_modules', sourceDescriptor.package); + case PluginSourceKind.Pip: + return joinPath(this._cacheRoot, 'pip', sanitizePackageName(sourceDescriptor.package)); + } + } + + async ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise { + const descriptor = plugin.sourceDescriptor; + switch (descriptor.kind) { + case PluginSourceKind.RelativePath: + return this.ensureRepository(plugin.marketplaceReference, options); + case PluginSourceKind.GitHub: { + const cloneUrl = `https://github.com/${descriptor.repo}.git`; + const repoDir = this.getPluginSourceInstallUri(descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (repoExists) { + await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.repo); + return repoDir; + } + const progressTitle = options?.progressTitle ?? localize('cloningPluginSource', "Cloning plugin source '{0}'...", descriptor.repo); + const failureLabel = options?.failureLabel ?? descriptor.repo; + await this._cloneRepository(repoDir, cloneUrl, progressTitle, failureLabel, descriptor.ref); + await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel); + return repoDir; + } + case PluginSourceKind.GitUrl: { + const repoDir = this.getPluginSourceInstallUri(descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (repoExists) { + await this._checkoutPluginSourceRevision(repoDir, descriptor, options?.failureLabel ?? descriptor.url); + return repoDir; + } + const progressTitle = options?.progressTitle ?? localize('cloningPluginSourceUrl', "Cloning plugin source '{0}'...", descriptor.url); + const failureLabel = options?.failureLabel ?? descriptor.url; + await this._cloneRepository(repoDir, descriptor.url, progressTitle, failureLabel, descriptor.ref); + await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel); + return repoDir; + } + case PluginSourceKind.Npm: { + // npm/pip install directories are managed by the install service. + // Return the expected install URI without performing installation. + return joinPath(this._cacheRoot, 'npm', sanitizePackageName(descriptor.package)); + } + case PluginSourceKind.Pip: { + return joinPath(this._cacheRoot, 'pip', sanitizePackageName(descriptor.package)); + } + } + } + + async updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise { + const descriptor = plugin.sourceDescriptor; + if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) { + return; + } + + const repoDir = this.getPluginSourceInstallUri(descriptor); + const repoExists = await this._fileService.exists(repoDir); + if (!repoExists) { + this._logService.warn(`[AgentPluginRepositoryService] Cannot update plugin '${options?.pluginName ?? plugin.name}': source repository not cloned`); + return; + } + + const updateLabel = options?.pluginName ?? plugin.name; + const failureLabel = options?.failureLabel ?? updateLabel; + + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: localize('updatingPluginSource', "Updating plugin '{0}'...", updateLabel), + cancellable: false, + }, + async () => { + await this._commandService.executeCommand('git.openRepository', repoDir.fsPath); + if (descriptor.sha) { + await this._commandService.executeCommand('git.fetch', repoDir.fsPath); + } else { + await this._commandService.executeCommand('_git.pull', repoDir.fsPath); + } + await this._checkoutPluginSourceRevision(repoDir, descriptor, failureLabel); + } + ); + } catch (err) { + this._logService.error(`[AgentPluginRepositoryService] Failed to update plugin source ${updateLabel}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pullPluginSourceFailed', "Failed to update plugin '{0}': {1}", failureLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + } + } + + private _gitUrlCacheSegments(url: string, ref?: string, sha?: string): string[] { + try { + const parsed = URI.parse(url); + const authority = (parsed.authority || 'unknown').replace(/[\\/:*?"<>|]/g, '_').toLowerCase(); + const pathPart = parsed.path.replace(/^\/+/, '').replace(/\.git$/i, '').replace(/\/+$/g, ''); + const segments = pathPart.split('/').map(s => s.replace(/[\\/:*?"<>|]/g, '_')); + return [authority, ...segments, ...this._getSourceRevisionCacheSuffix(ref, sha)]; + } catch { + return ['git', url.replace(/[\\/:*?"<>|]/g, '_'), ...this._getSourceRevisionCacheSuffix(ref, sha)]; + } + } + + private _getSourceRevisionCacheSuffix(descriptorOrRef: IPluginSourceDescriptor | string | undefined, sha?: string): string[] { + if (typeof descriptorOrRef === 'object' && descriptorOrRef) { + if (descriptorOrRef.kind === PluginSourceKind.GitHub || descriptorOrRef.kind === PluginSourceKind.GitUrl) { + return this._getSourceRevisionCacheSuffix(descriptorOrRef.ref, descriptorOrRef.sha); + } + return []; + } + + const ref = descriptorOrRef; + if (sha) { + return [`sha_${sanitizePackageName(sha)}`]; + } + if (ref) { + return [`ref_${sanitizePackageName(ref)}`]; + } + return []; + } + + private async _checkoutPluginSourceRevision(repoDir: URI, descriptor: IPluginSourceDescriptor, failureLabel: string): Promise { + if (descriptor.kind !== PluginSourceKind.GitHub && descriptor.kind !== PluginSourceKind.GitUrl) { + return; + } + + if (!descriptor.sha && !descriptor.ref) { + return; + } + + try { + if (descriptor.sha) { + await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.sha, true); + return; + } + + await this._commandService.executeCommand('_git.checkout', repoDir.fsPath, descriptor.ref); + } catch (err) { + this._logService.error(`[AgentPluginRepositoryService] Failed to checkout plugin source revision for ${failureLabel}:`, err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('checkoutPluginSourceFailed', "Failed to checkout plugin '{0}' to requested revision: {1}", failureLabel, err?.message ?? String(err)), + actions: { + primary: [new Action('showGitOutput', localize('showGitOutput', "Show Git Output"), undefined, true, () => { + this._commandService.executeCommand('git.showOutput'); + })], + }, + }); + throw err; + } + } +} + +function sanitizePackageName(name: string): string { + return name.replace(/[\\/:*?"<>|]/g, '_'); } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts index 129cbad928f6d..0033b0888efc0 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginsView.ts @@ -17,7 +17,7 @@ import { Disposable, DisposableStore, disposeIfDisposable, IDisposable, isDispos import { ThemeIcon } from '../../../../base/common/themables.js'; import { autorun } from '../../../../base/common/observable.js'; import { IPagedModel, PagedModel } from '../../../../base/common/paging.js'; -import { basename, dirname, joinPath } from '../../../../base/common/resources.js'; +import { dirname, joinPath } from '../../../../base/common/resources.js'; import { URI } from '../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../nls.js'; import { Action2, MenuId, registerAction2 } from '../../../../platform/actions/common/actions.js'; @@ -56,7 +56,7 @@ export const InstalledAgentPluginsViewId = 'workbench.views.agentPlugins.install //#region Item model function installedPluginToItem(plugin: IAgentPlugin, labelService: ILabelService): IInstalledPluginItem { - const name = basename(plugin.uri); + const name = plugin.label; const description = plugin.fromMarketplace?.description ?? labelService.getUriLabel(dirname(plugin.uri), { relative: true }); const marketplace = plugin.fromMarketplace?.marketplace; return { kind: AgentPluginItemKind.Installed, name, description, marketplace, plugin }; @@ -68,6 +68,7 @@ function marketplacePluginToItem(plugin: IMarketplacePlugin): IMarketplacePlugin name: plugin.name, description: plugin.description, source: plugin.source, + sourceDescriptor: plugin.sourceDescriptor, marketplace: plugin.marketplace, marketplaceReference: plugin.marketplaceReference, marketplaceType: plugin.marketplaceType, @@ -95,6 +96,7 @@ class InstallPluginAction extends Action { description: this.item.description, version: '', source: this.item.source, + sourceDescriptor: this.item.sourceDescriptor, marketplace: this.item.marketplace, marketplaceReference: this.item.marketplaceReference, marketplaceType: this.item.marketplaceType, @@ -518,6 +520,7 @@ export class AgentPluginsListView extends AbstractExtensionsListView { + switch (plugin.sourceDescriptor.kind) { + case PluginSourceKind.RelativePath: + return this._installRelativePathPlugin(plugin); + case PluginSourceKind.GitHub: + case PluginSourceKind.GitUrl: + return this._installGitPlugin(plugin); + case PluginSourceKind.Npm: + return this._installNpmPlugin(plugin, plugin.sourceDescriptor); + case PluginSourceKind.Pip: + return this._installPipPlugin(plugin, plugin.sourceDescriptor); + } + } + + async updatePlugin(plugin: IMarketplacePlugin): Promise { + switch (plugin.sourceDescriptor.kind) { + case PluginSourceKind.RelativePath: + return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, { + pluginName: plugin.name, + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + case PluginSourceKind.GitHub: + case PluginSourceKind.GitUrl: + return this._pluginRepositoryService.updatePluginSource(plugin, { + pluginName: plugin.name, + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + case PluginSourceKind.Npm: + return this._installNpmPlugin(plugin, plugin.sourceDescriptor); + case PluginSourceKind.Pip: + return this._installPipPlugin(plugin, plugin.sourceDescriptor); + } + } + + getPluginInstallUri(plugin: IMarketplacePlugin): URI { + if (plugin.sourceDescriptor.kind === PluginSourceKind.RelativePath) { + return this._pluginRepositoryService.getPluginInstallUri(plugin); + } + return this._pluginRepositoryService.getPluginSourceInstallUri(plugin.sourceDescriptor); + } + + // --- Relative-path source (existing git-based flow) ----------------------- + + private async _installRelativePathPlugin(plugin: IMarketplacePlugin): Promise { try { await this._pluginRepositoryService.ensureRepository(plugin.marketplaceReference, { progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), @@ -55,15 +112,212 @@ export class PluginInstallService implements IPluginInstallService { this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); } - async updatePlugin(plugin: IMarketplacePlugin): Promise { - return this._pluginRepositoryService.pullRepository(plugin.marketplaceReference, { - pluginName: plugin.name, - failureLabel: plugin.name, - marketplaceType: plugin.marketplaceType, + // --- GitHub / Git URL source (independent clone) -------------------------- + + private async _installGitPlugin(plugin: IMarketplacePlugin): Promise { + let pluginDir: URI; + try { + pluginDir = await this._pluginRepositoryService.ensurePluginSource(plugin, { + progressTitle: localize('installingPlugin', "Installing plugin '{0}'...", plugin.name), + failureLabel: plugin.name, + marketplaceType: plugin.marketplaceType, + }); + } catch { + return; + } + + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pluginSourceNotFound', "Plugin source '{0}' not found after cloning.", getPluginSourceLabel(plugin.sourceDescriptor)), + }); + return; + } + + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); + } + + // --- npm source ----------------------------------------------------------- + + private async _installNpmPlugin(plugin: IMarketplacePlugin, source: INpmPluginSource): Promise { + const packageSpec = source.version ? `${source.package}@${source.version}` : source.package; + const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin); + const args = ['npm', 'install', '--prefix', installDir.fsPath, packageSpec]; + if (source.registry) { + args.push('--registry', source.registry); + } + const command = this._formatShellCommand(args); + + const confirmed = await this._confirmTerminalCommand(plugin.name, command); + if (!confirmed) { + return; + } + + const { success, terminal } = await this._runTerminalCommand( + command, + localize('installingNpmPlugin', "Installing npm plugin '{0}'...", plugin.name), + ); + if (!success) { + return; + } + + const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(source); + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('npmPluginNotFound', "npm package '{0}' was not found after installation.", source.package), + }); + return; + } + + terminal?.dispose(); + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); + } + + // --- pip source ----------------------------------------------------------- + + private async _installPipPlugin(plugin: IMarketplacePlugin, source: IPipPluginSource): Promise { + const packageSpec = source.version ? `${source.package}==${source.version}` : source.package; + const installDir = await this._pluginRepositoryService.ensurePluginSource(plugin); + const args = ['pip', 'install', '--target', installDir.fsPath, packageSpec]; + if (source.registry) { + args.push('--index-url', source.registry); + } + const command = this._formatShellCommand(args); + + const confirmed = await this._confirmTerminalCommand(plugin.name, command); + if (!confirmed) { + return; + } + + const { success, terminal } = await this._runTerminalCommand( + command, + localize('installingPipPlugin', "Installing pip plugin '{0}'...", plugin.name), + ); + if (!success) { + return; + } + + const pluginDir = this._pluginRepositoryService.getPluginSourceInstallUri(source); + const pluginExists = await this._fileService.exists(pluginDir); + if (!pluginExists) { + this._notificationService.notify({ + severity: Severity.Error, + message: localize('pipPluginNotFound', "pip package '{0}' was not found after installation.", source.package), + }); + return; + } + + terminal?.dispose(); + this._pluginMarketplaceService.addInstalledPlugin(pluginDir, plugin); + } + + // --- Helpers -------------------------------------------------------------- + + private async _confirmTerminalCommand(pluginName: string, command: string): Promise { + const { confirmed } = await this._dialogService.confirm({ + type: 'question', + message: localize('confirmPluginInstall', "Install Plugin '{0}'?", pluginName), + detail: localize('confirmPluginInstallDetail', "This will run the following command in a terminal:\n\n{0}", command), + primaryButton: localize({ key: 'confirmInstall', comment: ['&& denotes a mnemonic'] }, "&&Install"), }); + return confirmed; } - getPluginInstallUri(plugin: IMarketplacePlugin): URI { - return this._pluginRepositoryService.getPluginInstallUri(plugin); + private async _runTerminalCommand(command: string, progressTitle: string) { + let terminal: ITerminalInstance | undefined; + try { + await this._progressService.withProgress( + { + location: ProgressLocation.Notification, + title: progressTitle, + cancellable: false, + }, + async () => { + terminal = await this._terminalService.createTerminal({ + config: { + name: localize('pluginInstallTerminal', "Plugin Install"), + forceShellIntegration: true, + isTransient: true, + isFeatureTerminal: true, + }, + }); + + await terminal.processReady; + this._terminalService.setActiveInstance(terminal); + + const commandResultPromise = this._waitForTerminalCommandCompletion(terminal); + await terminal.runCommand(command, true); + const exitCode = await commandResultPromise; + if (exitCode !== 0) { + throw new Error(localize('terminalCommandExitCode', "Command exited with code {0}", exitCode)); + } + } + ); + return { success: true, terminal }; + } catch (err) { + this._logService.error('[PluginInstallService] Terminal command failed:', err); + this._notificationService.notify({ + severity: Severity.Error, + message: localize('terminalCommandFailed', "Plugin installation command failed: {0}", err?.message ?? String(err)), + }); + return { success: false, terminal }; + } + } + + private _waitForTerminalCommandCompletion(terminal: ITerminalInstance): Promise { + return new Promise(resolve => { + const disposables = new DisposableStore(); + let isResolved = false; + + const resolveAndDispose = (exitCode: number | undefined): void => { + if (isResolved) { + return; + } + isResolved = true; + disposables.dispose(); + resolve(exitCode); + }; + + const attachCommandFinishedListener = (): void => { + const commandDetection = terminal.capabilities.get(TerminalCapability.CommandDetection); + if (!commandDetection) { + return; + } + + disposables.add(commandDetection.onCommandFinished((command: ITerminalCommand) => { + resolveAndDispose(command.exitCode ?? 0); + })); + }; + + attachCommandFinishedListener(); + disposables.add(terminal.capabilities.onDidAddCommandDetectionCapability(() => attachCommandFinishedListener())); + + const timeoutHandle: CancelablePromise = timeout(120_000); + disposables.add(toDisposable(() => timeoutHandle.cancel())); + void timeoutHandle.then(() => { + if (isResolved) { + return; + } + this._logService.warn('[PluginInstallService] Terminal command completion timed out'); + resolveAndDispose(undefined); + }); + }); + } + + private _formatShellCommand(args: readonly string[]): string { + const [command, ...rest] = args; + return [command, ...rest.map(arg => this._shellEscapeArg(arg))].join(' '); + } + + private _shellEscapeArg(value: string): string { + if (isWindows) { + // PowerShell: use double quotes, escape backticks, dollar signs, and double quotes + return `"${value.replace(/[`$"]/g, '`$&')}"`; + } + // POSIX shells: use single quotes, escape by ending quote, adding escaped quote, reopening + return `'${value.replace(/'/g, `'\\''`)}'`; } } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts index 3f9a9bffda545..cfb76de1c1d75 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginRepositoryService.ts @@ -5,7 +5,7 @@ import { URI } from '../../../../../base/common/uri.js'; import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; -import { IMarketplacePlugin, IMarketplaceReference, MarketplaceType } from './pluginMarketplaceService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginSourceDescriptor, MarketplaceType } from './pluginMarketplaceService.js'; export const IAgentPluginRepositoryService = createDecorator('agentPluginRepositoryService'); @@ -61,4 +61,26 @@ export interface IAgentPluginRepositoryService { * Pulls latest changes for a cloned marketplace repository. */ pullRepository(marketplace: IMarketplaceReference, options?: IPullRepositoryOptions): Promise; + + /** + * Returns the local install URI for a plugin based on its + * {@link IPluginSourceDescriptor}. For non-relative-path sources + * (github, url, npm, pip), this computes a cache location independent + * of the marketplace repository. + */ + getPluginSourceInstallUri(sourceDescriptor: IPluginSourceDescriptor): URI; + + /** + * Ensures the plugin source is available locally. For github/url sources + * this clones the repository into the cache. For npm/pip sources this is + * a no-op (installation via terminal is handled by the install service). + */ + ensurePluginSource(plugin: IMarketplacePlugin, options?: IEnsureRepositoryOptions): Promise; + + /** + * Updates a plugin source that is stored outside the marketplace repository. + * For github/url sources this pulls latest changes and reapplies pinned + * ref/sha checkout. For npm/pip sources this is a no-op. + */ + updatePluginSource(plugin: IMarketplacePlugin, options?: IPullRepositoryOptions): Promise; } diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts index bbc65030621a6..4efdce7078e56 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginService.ts @@ -44,6 +44,8 @@ export interface IAgentPluginMcpServerDefinition { export interface IAgentPlugin { readonly uri: URI; + /** Human-readable display name for the plugin. */ + readonly label: string; readonly enabled: IObservable; setEnabled(enabled: boolean): void; /** Removes this plugin from its discovery source (config or installed storage). */ diff --git a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts index bfd8c768e5e6f..722ff6da5769d 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/agentPluginServiceImpl.ts @@ -416,6 +416,7 @@ export abstract class AbstractAgentPluginDiscovery extends Disposable implements const plugin: PluginEntry = { uri, + label: fromMarketplace?.name ?? basename(uri), enabled, setEnabled: setEnabledCallback, remove: removeCallback, diff --git a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts index 11a8db5482303..dbd6e9b7b179b 100644 --- a/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts +++ b/src/vs/workbench/contrib/chat/common/plugins/pluginMarketplaceService.ts @@ -45,12 +45,64 @@ export interface IMarketplaceReference { readonly localRepositoryUri?: URI; } +export const enum PluginSourceKind { + RelativePath = 'relativePath', + GitHub = 'github', + GitUrl = 'url', + Npm = 'npm', + Pip = 'pip', +} + +export interface IRelativePathPluginSource { + readonly kind: PluginSourceKind.RelativePath; + /** Resolved relative path within the marketplace repository. */ + readonly path: string; +} + +export interface IGitHubPluginSource { + readonly kind: PluginSourceKind.GitHub; + readonly repo: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface IGitUrlPluginSource { + readonly kind: PluginSourceKind.GitUrl; + /** Full git repository URL (must end with .git). */ + readonly url: string; + readonly ref?: string; + readonly sha?: string; +} + +export interface INpmPluginSource { + readonly kind: PluginSourceKind.Npm; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export interface IPipPluginSource { + readonly kind: PluginSourceKind.Pip; + readonly package: string; + readonly version?: string; + readonly registry?: string; +} + +export type IPluginSourceDescriptor = + | IRelativePathPluginSource + | IGitHubPluginSource + | IGitUrlPluginSource + | INpmPluginSource + | IPipPluginSource; + export interface IMarketplacePlugin { readonly name: string; readonly description: string; readonly version: string; - /** Subdirectory within the repository where the plugin lives. */ + /** Subdirectory within the repository where the plugin lives (for relative-path sources). */ readonly source: string; + /** Structured source descriptor indicating how the plugin should be fetched/installed. */ + readonly sourceDescriptor: IPluginSourceDescriptor; /** Marketplace label shown in UI and plugin provenance. */ readonly marketplace: string; /** Canonical reference for clone/update/install location resolution. */ @@ -60,6 +112,18 @@ export interface IMarketplacePlugin { readonly readmeUri?: URI; } +/** Raw JSON shape of a remote plugin source object in marketplace.json. */ +interface IJsonPluginSource { + readonly source: string; + readonly repo?: string; + readonly url?: string; + readonly package?: string; + readonly ref?: string; + readonly sha?: string; + readonly version?: string; + readonly registry?: string; +} + interface IMarketplaceJson { readonly metadata?: { readonly pluginRoot?: string; @@ -68,7 +132,7 @@ interface IMarketplaceJson { readonly name?: string; readonly description?: string; readonly version?: string; - readonly source?: string; + readonly source?: string | IJsonPluginSource; }[]; } @@ -118,6 +182,23 @@ interface IStoredInstalledPlugin { readonly enabled: boolean; } +/** + * Ensures that an {@link IMarketplacePlugin} loaded from storage has a + * {@link IMarketplacePlugin.sourceDescriptor sourceDescriptor}. Plugins + * persisted before the sourceDescriptor field was introduced will only + * have the legacy `source` string — this function synthesises a + * {@link PluginSourceKind.RelativePath} descriptor from it. + */ +function ensureSourceDescriptor(plugin: IMarketplacePlugin): IMarketplacePlugin { + if (plugin.sourceDescriptor) { + return plugin; + } + return { + ...plugin, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: plugin.source }, + }; +} + const installedPluginsMemento = observableMemento({ defaultValue: [], key: 'chat.plugins.installed.v1', @@ -151,7 +232,12 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke installedPluginsMemento(StorageScope.APPLICATION, StorageTarget.MACHINE, _storageService) ); - this.installedPlugins = this._installedPluginsStore.map(s => revive(s)); + this.installedPlugins = this._installedPluginsStore.map(s => + (revive(s) as readonly IMarketplaceInstalledPlugin[]).map(e => ({ + ...e, + plugin: ensureSourceDescriptor(e.plugin), + })) + ); this.onDidChangeMarketplaces = Event.filter( _configurationService.onDidChangeConfiguration, @@ -213,21 +299,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke continue; } const plugins = json.plugins - .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + .filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } => typeof p.name === 'string' && !!p.name ) .flatMap(p => { - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${repo}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, { + pluginName: p.name, + logService: this._logService, + logPrefix: `[PluginMarketplaceService]`, + }); + if (!sourceDescriptor) { return []; } + const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : ''; + return [{ name: p.name, description: p.description ?? '', version: p.version ?? '', source, + sourceDescriptor, marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType: def.type, @@ -293,7 +385,7 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke continue; } - const plugins = entry.plugins.map(plugin => ({ + const plugins = entry.plugins.map(plugin => ensureSourceDescriptor({ ...plugin, marketplace: reference.displayLabel, marketplaceReference: reference, @@ -344,9 +436,11 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke addInstalledPlugin(pluginUri: URI, plugin: IMarketplacePlugin): void { const current = this.installedPlugins.get(); if (current.some(e => isEqual(e.pluginUri, pluginUri))) { - return; + // Still update to trigger watchers to re-check, something might have happened that we want to know about + this._installedPluginsStore.set([...current], undefined); + } else { + this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); } - this._installedPluginsStore.set([...current, { pluginUri, plugin, enabled: true }], undefined); } removeInstalledPlugin(pluginUri: URI): void { @@ -391,21 +485,27 @@ export class PluginMarketplaceService extends Disposable implements IPluginMarke } return json.plugins - .filter((p): p is { name: string; description?: string; version?: string; source?: string } => + .filter((p): p is { name: string; description?: string; version?: string; source?: string | IJsonPluginSource } => typeof p.name === 'string' && !!p.name ) .flatMap(p => { - const source = resolvePluginSource(json.metadata?.pluginRoot, p.source ?? ''); - if (source === undefined) { - this._logService.warn(`[PluginMarketplaceService] Skipping plugin '${p.name}' in ${reference.rawValue}: invalid source path '${p.source ?? ''}' with pluginRoot '${json.metadata?.pluginRoot ?? ''}'`); + const sourceDescriptor = parsePluginSource(p.source, json.metadata?.pluginRoot, { + pluginName: p.name, + logService: this._logService, + logPrefix: `[PluginMarketplaceService]`, + }); + if (!sourceDescriptor) { return []; } + const source = sourceDescriptor.kind === PluginSourceKind.RelativePath ? sourceDescriptor.path : ''; + return [{ name: p.name, description: p.description ?? '', version: p.version ?? '', source, + sourceDescriptor, marketplace: reference.displayLabel, marketplaceReference: reference, marketplaceType: def.type, @@ -597,6 +697,158 @@ function resolvePluginSource(pluginRoot: string | undefined, source: string): st return relativePath(repoRoot, resolvedUri) ?? undefined; } +/** + * Parse a raw `source` field from marketplace.json into a structured + * {@link IPluginSourceDescriptor}. Accepts either a relative-path string + * or a JSON object with a `source` discriminant indicating the kind. + */ +export function parsePluginSource( + rawSource: string | IJsonPluginSource | undefined, + pluginRoot: string | undefined, + logContext: { pluginName: string; logService: ILogService; logPrefix: string }, +): IPluginSourceDescriptor | undefined { + if (rawSource === undefined || rawSource === null) { + // Treat missing source the same as empty string → pluginRoot or repo root. + const resolved = resolvePluginSource(pluginRoot, ''); + if (resolved === undefined) { + return undefined; + } + return { kind: PluginSourceKind.RelativePath, path: resolved }; + } + + // String source → legacy relative-path behaviour. + if (typeof rawSource === 'string') { + const resolved = resolvePluginSource(pluginRoot, rawSource); + if (resolved === undefined) { + return undefined; + } + return { kind: PluginSourceKind.RelativePath, path: resolved }; + } + + // Object source → discriminated by `rawSource.source`. + if (typeof rawSource !== 'object' || typeof rawSource.source !== 'string') { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': source object is missing a 'source' discriminant`); + return undefined; + } + + switch (rawSource.source) { + case 'github': { + if (typeof rawSource.repo !== 'string' || !rawSource.repo) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source is missing required 'repo' field`); + return undefined; + } + if (!isValidGitHubRepo(rawSource.repo)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source repo must be in 'owner/repo' format`); + return undefined; + } + if (!isOptionalString(rawSource.ref)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'ref' must be a string when provided`); + return undefined; + } + if (!isOptionalGitSha(rawSource.sha)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': github source 'sha' must be a full 40-character commit hash when provided`); + return undefined; + } + return { + kind: PluginSourceKind.GitHub, + repo: rawSource.repo, + ref: rawSource.ref, + sha: rawSource.sha, + }; + } + case 'url': { + if (typeof rawSource.url !== 'string' || !rawSource.url) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source is missing required 'url' field`); + return undefined; + } + if (!rawSource.url.toLowerCase().endsWith('.git')) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source must end with '.git'`); + return undefined; + } + if (!isOptionalString(rawSource.ref)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'ref' must be a string when provided`); + return undefined; + } + if (!isOptionalGitSha(rawSource.sha)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': url source 'sha' must be a full 40-character commit hash when provided`); + return undefined; + } + return { + kind: PluginSourceKind.GitUrl, + url: rawSource.url, + ref: rawSource.ref, + sha: rawSource.sha, + }; + } + case 'npm': { + if (typeof rawSource.package !== 'string' || !rawSource.package) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source is missing required 'package' field`); + return undefined; + } + if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': npm source 'version' and 'registry' must be strings when provided`); + return undefined; + } + return { + kind: PluginSourceKind.Npm, + package: rawSource.package, + version: rawSource.version, + registry: rawSource.registry, + }; + } + case 'pip': { + if (typeof rawSource.package !== 'string' || !rawSource.package) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source is missing required 'package' field`); + return undefined; + } + if (!isOptionalString(rawSource.version) || !isOptionalString(rawSource.registry)) { + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': pip source 'version' and 'registry' must be strings when provided`); + return undefined; + } + return { + kind: PluginSourceKind.Pip, + package: rawSource.package, + version: rawSource.version, + registry: rawSource.registry, + }; + } + default: + logContext.logService.warn(`${logContext.logPrefix} Skipping plugin '${logContext.pluginName}': unknown source kind '${rawSource.source}'`); + return undefined; + } +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || typeof value === 'string'; +} + +function isOptionalGitSha(value: unknown): value is string | undefined { + return value === undefined || (typeof value === 'string' && /^[0-9a-fA-F]{40}$/.test(value)); +} + +function isValidGitHubRepo(repo: string): boolean { + return /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo); +} + +/** + * Returns a human-readable label for a plugin source descriptor, + * suitable for error messages and UI display. + */ +export function getPluginSourceLabel(descriptor: IPluginSourceDescriptor): string { + switch (descriptor.kind) { + case PluginSourceKind.RelativePath: + return descriptor.path || '.'; + case PluginSourceKind.GitHub: + return descriptor.repo; + case PluginSourceKind.GitUrl: + return descriptor.url; + case PluginSourceKind.Npm: + return descriptor.version ? `${descriptor.package}@${descriptor.version}` : descriptor.package; + case PluginSourceKind.Pip: + return descriptor.version ? `${descriptor.package}==${descriptor.version}` : descriptor.package; + } +} + function getMarketplaceReadmeUri(repo: string, source: string): URI { const normalizedSource = source.trim().replace(/^\.?\/+|\/+$/g, ''); const readmePath = normalizedSource ? `${normalizedSource}/README.md` : 'README.md'; diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts index d5b0366678f07..0aad10fc12286 100644 --- a/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/agentPluginRepositoryService.test.ts @@ -15,7 +15,7 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; import { IStorageService, InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; import { AgentPluginRepositoryService } from '../../../browser/agentPluginRepositoryService.js'; -import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference } from '../../../common/plugins/pluginMarketplaceService.js'; +import { IMarketplacePlugin, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; suite('AgentPluginRepositoryService', () => { const store = ensureNoDisposablesAreLeakedInTestSuite(); @@ -32,6 +32,7 @@ suite('AgentPluginRepositoryService', () => { description: '', version: '', source, + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: source }, marketplace: marketplaceReference.displayLabel, marketplaceReference, marketplaceType: MarketplaceType.Copilot, @@ -40,7 +41,7 @@ suite('AgentPluginRepositoryService', () => { function createService( onExists?: (resource: URI) => Promise, - onExecuteCommand?: (id: string) => void, + onExecuteCommand?: (id: string, ...args: unknown[]) => void, ): AgentPluginRepositoryService { const instantiationService = store.add(new TestInstantiationService()); @@ -53,8 +54,8 @@ suite('AgentPluginRepositoryService', () => { } as unknown as IProgressService; instantiationService.stub(ICommandService, { - executeCommand: async (id: string) => { - onExecuteCommand?.(id); + executeCommand: async (id: string, ...args: unknown[]) => { + onExecuteCommand?.(id, ...args); return undefined; }, } as unknown as ICommandService); @@ -170,4 +171,43 @@ suite('AgentPluginRepositoryService', () => { assert.strictEqual(uri.path, '/tmp/marketplace-repo'); assert.strictEqual(commandInvocationCount, 0); }); + + test('builds revision-aware install URI for github plugin sources', () => { + const service = createService(); + const uri = service.getPluginSourceInstallUri({ + kind: PluginSourceKind.GitHub, + repo: 'owner/repo', + ref: 'release/v1', + }); + + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/owner/repo/ref_release_v1'); + }); + + test('updates git plugin source by pulling and checking out requested revision', async () => { + const commands: string[] = []; + const service = createService(async () => true, (id: string) => { + commands.push(id); + }); + + await service.updatePluginSource({ + name: 'my-plugin', + description: '', + version: '', + source: '', + sourceDescriptor: { + kind: PluginSourceKind.GitHub, + repo: 'owner/repo', + sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', + }, + marketplace: 'owner/repo', + marketplaceReference: parseMarketplaceReference('owner/repo')!, + marketplaceType: MarketplaceType.Copilot, + }, { + pluginName: 'my-plugin', + failureLabel: 'my-plugin', + marketplaceType: MarketplaceType.Copilot, + }); + + assert.deepStrictEqual(commands, ['git.openRepository', 'git.fetch', '_git.checkout']); + }); }); diff --git a/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts new file mode 100644 index 0000000000000..7a55baa369a43 --- /dev/null +++ b/src/vs/workbench/contrib/chat/test/browser/plugins/pluginInstallService.test.ts @@ -0,0 +1,611 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { IFileService } from '../../../../../../platform/files/common/files.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; +import { INotificationService } from '../../../../../../platform/notification/common/notification.js'; +import { IProgressService } from '../../../../../../platform/progress/common/progress.js'; +import { ITerminalService } from '../../../../terminal/browser/terminal.js'; +import { PluginInstallService } from '../../../browser/pluginInstallService.js'; +import { IAgentPluginRepositoryService, IEnsureRepositoryOptions, IPullRepositoryOptions } from '../../../common/plugins/agentPluginRepositoryService.js'; +import { IMarketplacePlugin, IMarketplaceReference, IPluginMarketplaceService, IPluginSourceDescriptor, MarketplaceType, parseMarketplaceReference, PluginSourceKind } from '../../../common/plugins/pluginMarketplaceService.js'; + +suite('PluginInstallService', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + // --- Factory helpers ------------------------------------------------------- + + function makeMarketplaceRef(marketplace: string): IMarketplaceReference { + const ref = parseMarketplaceReference(marketplace); + assert.ok(ref); + return ref!; + } + + function createPlugin(overrides: Partial & { sourceDescriptor: IPluginSourceDescriptor }): IMarketplacePlugin { + return { + name: overrides.name ?? 'test-plugin', + description: overrides.description ?? '', + version: overrides.version ?? '', + source: overrides.source ?? '', + sourceDescriptor: overrides.sourceDescriptor, + marketplace: overrides.marketplace ?? 'microsoft/vscode', + marketplaceReference: overrides.marketplaceReference ?? makeMarketplaceRef('microsoft/vscode'), + marketplaceType: overrides.marketplaceType ?? MarketplaceType.Copilot, + readmeUri: overrides.readmeUri, + }; + } + + // --- Mock tracking types --------------------------------------------------- + + interface MockState { + notifications: { severity: number; message: string }[]; + addedPlugins: { uri: string; plugin: IMarketplacePlugin }[]; + dialogConfirmResult: boolean; + fileExistsResult: boolean | ((uri: URI) => Promise); + ensureRepositoryResult: URI; + ensurePluginSourceResult: URI; + /** Plugin source install URI, per kind */ + pluginSourceInstallUris: Map; + /** The commands that were sent to the terminal */ + terminalCommands: string[]; + /** Simulated exit code from terminal */ + terminalExitCode: number; + /** Whether the terminal resolves the command completion at all */ + terminalCompletes: boolean; + pullRepositoryCalls: { marketplace: IMarketplaceReference; options?: IPullRepositoryOptions }[]; + updatePluginSourceCalls: { plugin: IMarketplacePlugin; options?: IPullRepositoryOptions }[]; + } + + function createDefaults(): MockState { + return { + notifications: [], + addedPlugins: [], + dialogConfirmResult: true, + fileExistsResult: true, + ensureRepositoryResult: URI.file('/cache/agentPlugins/github.com/microsoft/vscode'), + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-package'), + pluginSourceInstallUris: new Map(), + terminalCommands: [], + terminalExitCode: 0, + terminalCompletes: true, + pullRepositoryCalls: [], + updatePluginSourceCalls: [], + }; + } + + function createService(stateOverrides?: Partial): { service: PluginInstallService; state: MockState } { + const state: MockState = { ...createDefaults(), ...stateOverrides }; + const instantiationService = store.add(new TestInstantiationService()); + + // IFileService + instantiationService.stub(IFileService, { + exists: async (resource: URI) => { + if (typeof state.fileExistsResult === 'function') { + return state.fileExistsResult(resource); + } + return state.fileExistsResult; + }, + } as unknown as IFileService); + + // INotificationService + instantiationService.stub(INotificationService, { + notify: (notification: { severity: number; message: string }) => { + state.notifications.push({ severity: notification.severity, message: notification.message }); + return undefined; + }, + } as unknown as INotificationService); + + // IDialogService + instantiationService.stub(IDialogService, { + confirm: async () => ({ confirmed: state.dialogConfirmResult }), + } as unknown as IDialogService); + + // ITerminalService — the mock coordinates runCommand and onCommandFinished + // so the command ID matches, just like a real terminal would. + instantiationService.stub(ITerminalService, { + createTerminal: async () => { + let finishedCallback: ((cmd: { id: string; exitCode: number }) => void) | undefined; + return { + processReady: Promise.resolve(), + dispose: () => { }, + runCommand: (command: string, _addNewLine?: boolean) => { + state.terminalCommands.push(command); + // Simulate command completing after runCommand is called + if (finishedCallback) { + finishedCallback({ id: 'command', exitCode: state.terminalExitCode }); + } + }, + capabilities: { + get: () => state.terminalCompletes ? { + onCommandFinished: (callback: (cmd: { id: string; exitCode: number }) => void) => { + finishedCallback = callback; + return { dispose() { } }; + }, + } : undefined, + onDidAddCommandDetectionCapability: () => ({ dispose() { } }), + }, + }; + }, + setActiveInstance: () => { }, + } as unknown as ITerminalService); + + // IProgressService + instantiationService.stub(IProgressService, { + withProgress: async (_options: unknown, callback: (...args: unknown[]) => Promise) => callback(), + } as unknown as IProgressService); + + // ILogService + instantiationService.stub(ILogService, new NullLogService()); + + // IAgentPluginRepositoryService + instantiationService.stub(IAgentPluginRepositoryService, { + getPluginInstallUri: (plugin: IMarketplacePlugin) => { + return URI.joinPath(state.ensureRepositoryResult, plugin.source); + }, + getRepositoryUri: () => state.ensureRepositoryResult, + ensureRepository: async (_marketplace: IMarketplaceReference, _options?: IEnsureRepositoryOptions) => { + return state.ensureRepositoryResult; + }, + pullRepository: async (marketplace: IMarketplaceReference, options?: IPullRepositoryOptions) => { + state.pullRepositoryCalls.push({ marketplace, options }); + }, + getPluginSourceInstallUri: (descriptor: IPluginSourceDescriptor) => { + const key = descriptor.kind; + return state.pluginSourceInstallUris.get(key) ?? URI.file(`/cache/agentPlugins/${key}/default`); + }, + ensurePluginSource: async () => state.ensurePluginSourceResult, + updatePluginSource: async (plugin: IMarketplacePlugin, options?: IPullRepositoryOptions) => { + state.updatePluginSourceCalls.push({ plugin, options }); + }, + } as unknown as IAgentPluginRepositoryService); + + // IPluginMarketplaceService + instantiationService.stub(IPluginMarketplaceService, { + addInstalledPlugin: (uri: URI, plugin: IMarketplacePlugin) => { + state.addedPlugins.push({ uri: uri.toString(), plugin }); + }, + } as unknown as IPluginMarketplaceService); + + const service = instantiationService.createInstance(PluginInstallService); + return { service, state }; + } + + // ========================================================================= + // getPluginInstallUri + // ========================================================================= + + suite('getPluginInstallUri', () => { + + test('delegates to getPluginInstallUri for relative-path plugins', () => { + const { service } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, '/cache/agentPlugins/github.com/microsoft/vscode/plugins/myPlugin'); + }); + + test('delegates to getPluginSourceInstallUri for npm plugins', () => { + const npmUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['npm', npmUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, npmUri.path); + }); + + test('delegates to getPluginSourceInstallUri for pip plugins', () => { + const pipUri = URI.file('/cache/agentPlugins/pip/my-pkg'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['pip', pipUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, pipUri.path); + }); + + test('delegates to getPluginSourceInstallUri for github plugins', () => { + const ghUri = URI.file('/cache/agentPlugins/github.com/owner/repo'); + const { service } = createService({ + pluginSourceInstallUris: new Map([['github', ghUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + const uri = service.getPluginInstallUri(plugin); + assert.strictEqual(uri.path, ghUri.path); + }); + }); + + // ========================================================================= + // installPlugin — relative path + // ========================================================================= + + suite('installPlugin — relative path', () => { + + test('installs a relative-path plugin when directory exists', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.ok(state.addedPlugins[0].uri.includes('plugins/myPlugin')); + assert.strictEqual(state.notifications.length, 0); + }); + + test('notifies error when plugin directory does not exist', async () => { + const { service, state } = createService({ fileExistsResult: false }); + const plugin = createPlugin({ + source: 'plugins/missing', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/missing' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + + test('does not install when ensureRepository throws', async () => { + const { state } = createService(); + // Override ensureRepository to throw + const instantiationService = store.add(new TestInstantiationService()); + const repoService = { + ensureRepository: async () => { throw new Error('clone failed'); }, + getPluginInstallUri: () => URI.file('/x'), + getPluginSourceInstallUri: () => URI.file('/x'), + }; + instantiationService.stub(IAgentPluginRepositoryService, repoService as unknown as IAgentPluginRepositoryService); + instantiationService.stub(IFileService, { exists: async () => true } as unknown as IFileService); + instantiationService.stub(INotificationService, { notify: (n: { severity: number; message: string }) => { state.notifications.push(n); } } as unknown as INotificationService); + instantiationService.stub(IDialogService, { confirm: async () => ({ confirmed: true }) } as unknown as IDialogService); + instantiationService.stub(ITerminalService, {} as unknown as ITerminalService); + instantiationService.stub(IProgressService, { withProgress: async (_o: unknown, cb: () => Promise) => cb() } as unknown as IProgressService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IPluginMarketplaceService, { addInstalledPlugin: () => { } } as unknown as IPluginMarketplaceService); + const svc = instantiationService.createInstance(PluginInstallService); + + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + await svc.installPlugin(plugin); + + // Should return without installing or crashing + assert.strictEqual(state.addedPlugins.length, 0); + }); + }); + + // ========================================================================= + // installPlugin — GitHub / GitUrl + // ========================================================================= + + suite('installPlugin — git sources', () => { + + test('installs a GitHub plugin when source exists after clone', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('installs a GitUrl plugin when source exists after clone', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/example.com/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('notifies error when cloned directory does not exist', async () => { + const { service, state } = createService({ + fileExistsResult: false, + ensurePluginSourceResult: URI.file('/cache/agentPlugins/github.com/owner/repo'), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + }); + + // ========================================================================= + // installPlugin — npm + // ========================================================================= + + suite('installPlugin — npm', () => { + + test('runs npm install and registers plugin on success', async () => { + const npmInstallUri = URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg'); + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', npmInstallUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('npm')); + assert.ok(state.terminalCommands[0].includes('install')); + assert.ok(state.terminalCommands[0].includes('my-pkg')); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('includes version in npm install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', version: '1.2.3' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('my-pkg@1.2.3')); + }); + + test('includes registry in npm install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg', registry: 'https://custom.registry.com' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('--registry')); + assert.ok(state.terminalCommands[0].includes('https://custom.registry.com')); + }); + + test('does not install when user declines confirmation', async () => { + const { service, state } = createService({ dialogConfirmResult: false }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('notifies error when npm package directory not found after install', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + // exists returns true for ensurePluginSource but false for the final check + fileExistsResult: false, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + + test('notifies error when terminal command fails with non-zero exit code', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + terminalExitCode: 1, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('failed')); + }); + }); + + // ========================================================================= + // installPlugin — pip + // ========================================================================= + + suite('installPlugin — pip', () => { + + test('runs pip install and registers plugin on success', async () => { + const pipInstallUri = URI.file('/cache/agentPlugins/pip/my-pkg'); + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', pipInstallUri]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('pip')); + assert.ok(state.terminalCommands[0].includes('install')); + assert.ok(state.terminalCommands[0].includes('my-pkg')); + assert.strictEqual(state.addedPlugins.length, 1); + assert.strictEqual(state.notifications.length, 0); + }); + + test('includes version with == syntax in pip install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', version: '2.0.0' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('my-pkg==2.0.0')); + }); + + test('includes registry with --index-url in pip install command', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg', registry: 'https://pypi.custom.com/simple' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('--index-url')); + assert.ok(state.terminalCommands[0].includes('https://pypi.custom.com/simple')); + }); + + test('does not install when user declines confirmation', async () => { + const { service, state } = createService({ dialogConfirmResult: false }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 0); + assert.strictEqual(state.addedPlugins.length, 0); + }); + + test('notifies error when pip package directory not found after install', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + fileExistsResult: false, + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.installPlugin(plugin); + + assert.strictEqual(state.addedPlugins.length, 0); + assert.strictEqual(state.notifications.length, 1); + assert.ok(state.notifications[0].message.includes('not found')); + }); + }); + + // ========================================================================= + // updatePlugin + // ========================================================================= + + suite('updatePlugin', () => { + + test('calls pullRepository for relative-path plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + source: 'plugins/myPlugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/myPlugin' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.pullRepositoryCalls.length, 1); + assert.strictEqual(state.updatePluginSourceCalls.length, 0); + }); + + test('calls updatePluginSource for GitHub plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitHub, repo: 'owner/repo' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + assert.strictEqual(state.pullRepositoryCalls.length, 0); + }); + + test('calls updatePluginSource for GitUrl plugins', async () => { + const { service, state } = createService(); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.updatePluginSourceCalls.length, 1); + assert.strictEqual(state.pullRepositoryCalls.length, 0); + }); + + test('re-installs for npm plugin updates', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/npm/my-pkg'), + pluginSourceInstallUris: new Map([['npm', URI.file('/cache/agentPlugins/npm/my-pkg/node_modules/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Npm, package: 'my-pkg' }, + }); + + await service.updatePlugin(plugin); + + // npm update goes through the same install flow + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('npm')); + }); + + test('re-installs for pip plugin updates', async () => { + const { service, state } = createService({ + ensurePluginSourceResult: URI.file('/cache/agentPlugins/pip/my-pkg'), + pluginSourceInstallUris: new Map([['pip', URI.file('/cache/agentPlugins/pip/my-pkg')]]), + }); + const plugin = createPlugin({ + sourceDescriptor: { kind: PluginSourceKind.Pip, package: 'my-pkg' }, + }); + + await service.updatePlugin(plugin); + + assert.strictEqual(state.terminalCommands.length, 1); + assert.ok(state.terminalCommands[0].includes('pip')); + }); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts index 46bb4fbcb3f25..da94ecb5615b8 100644 --- a/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/plugins/pluginMarketplaceService.test.ts @@ -15,7 +15,7 @@ import { IStorageService, InMemoryStorageService } from '../../../../../../platf import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IAgentPluginRepositoryService } from '../../../common/plugins/agentPluginRepositoryService.js'; import { ChatConfiguration } from '../../../common/constants.js'; -import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, parseMarketplaceReference, parseMarketplaceReferences } from '../../../common/plugins/pluginMarketplaceService.js'; +import { MarketplaceReferenceKind, MarketplaceType, PluginMarketplaceService, PluginSourceKind, getPluginSourceLabel, parseMarketplaceReference, parseMarketplaceReferences, parsePluginSource } from '../../../common/plugins/pluginMarketplaceService.js'; suite('PluginMarketplaceService', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -142,6 +142,7 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { description: 'A test plugin', version: '2.0.0', source: 'plugins/my-plugin', + sourceDescriptor: { kind: PluginSourceKind.RelativePath, path: 'plugins/my-plugin' } as const, marketplace: marketplaceRef.displayLabel, marketplaceReference: marketplaceRef, marketplaceType: MarketplaceType.Copilot, @@ -165,3 +166,152 @@ suite('PluginMarketplaceService - getMarketplacePluginMetadata', () => { assert.strictEqual(result, undefined); }); }); + +suite('parsePluginSource', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + const logContext = { + pluginName: 'test', + logService: new NullLogService(), + logPrefix: '[test]', + }; + + test('parses string source as RelativePath', () => { + const result = parsePluginSource('./my-plugin', undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'my-plugin' }); + }); + + test('parses string source with pluginRoot', () => { + const result = parsePluginSource('sub', 'plugins', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'plugins/sub' }); + }); + + test('parses undefined source as RelativePath using pluginRoot', () => { + const result = parsePluginSource(undefined, 'root', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'root' }); + }); + + test('parses empty string source as RelativePath using pluginRoot', () => { + const result = parsePluginSource('', 'base', logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.RelativePath, path: 'base' }); + }); + + test('returns undefined for empty source without pluginRoot', () => { + assert.strictEqual(parsePluginSource('', undefined, logContext), undefined); + }); + + test('parses github object source', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: undefined, sha: undefined }); + }); + + test('parses github object source with ref and sha', () => { + const result = parsePluginSource({ source: 'github', repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitHub, repo: 'owner/repo', ref: 'v2.0.0', sha: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }); + }); + + test('returns undefined for github source missing repo', () => { + assert.strictEqual(parsePluginSource({ source: 'github' }, undefined, logContext), undefined); + }); + + test('returns undefined for github source with invalid repo format', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner' }, undefined, logContext), undefined); + }); + + test('returns undefined for github source with invalid sha', () => { + assert.strictEqual(parsePluginSource({ source: 'github', repo: 'owner/repo', sha: 'abc123' }, undefined, logContext), undefined); + }); + + test('parses url object source', () => { + const result = parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin.git' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.GitUrl, url: 'https://gitlab.com/team/plugin.git', ref: undefined, sha: undefined }); + }); + + test('returns undefined for url source missing url field', () => { + assert.strictEqual(parsePluginSource({ source: 'url' }, undefined, logContext), undefined); + }); + + test('returns undefined for url source not ending in .git', () => { + assert.strictEqual(parsePluginSource({ source: 'url', url: 'https://gitlab.com/team/plugin' }, undefined, logContext), undefined); + }); + + test('parses npm object source', () => { + const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: undefined, registry: undefined }); + }); + + test('parses npm object source with version and registry', () => { + const result = parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Npm, package: '@acme/claude-plugin', version: '2.1.0', registry: 'https://npm.example.com' }); + }); + + test('returns undefined for npm source missing package', () => { + assert.strictEqual(parsePluginSource({ source: 'npm' }, undefined, logContext), undefined); + }); + + test('returns undefined for npm source with non-string version', () => { + assert.strictEqual(parsePluginSource({ source: 'npm', package: '@acme/claude-plugin', version: 123 } as never, undefined, logContext), undefined); + }); + + test('parses pip object source', () => { + const result = parsePluginSource({ source: 'pip', package: 'my-plugin' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: undefined, registry: undefined }); + }); + + test('parses pip object source with version and registry', () => { + const result = parsePluginSource({ source: 'pip', package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }, undefined, logContext); + assert.deepStrictEqual(result, { kind: PluginSourceKind.Pip, package: 'my-plugin', version: '1.0.0', registry: 'https://pypi.example.com' }); + }); + + test('returns undefined for pip source missing package', () => { + assert.strictEqual(parsePluginSource({ source: 'pip' }, undefined, logContext), undefined); + }); + + test('returns undefined for pip source with non-string registry', () => { + assert.strictEqual(parsePluginSource({ source: 'pip', package: 'my-plugin', registry: 42 } as never, undefined, logContext), undefined); + }); + + test('returns undefined for unknown source kind', () => { + assert.strictEqual(parsePluginSource({ source: 'unknown' }, undefined, logContext), undefined); + }); + + test('returns undefined for object source without source discriminant', () => { + assert.strictEqual(parsePluginSource({ package: 'test' } as never, undefined, logContext), undefined); + }); +}); + +suite('getPluginSourceLabel', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('formats relative path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: 'plugins/foo' }), 'plugins/foo'); + }); + + test('formats empty relative path', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.RelativePath, path: '' }), '.'); + }); + + test('formats github source', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitHub, repo: 'owner/repo' }), 'owner/repo'); + }); + + test('formats url source', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.GitUrl, url: 'https://example.com/repo.git' }), 'https://example.com/repo.git'); + }); + + test('formats npm source without version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin' }), '@acme/plugin'); + }); + + test('formats npm source with version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Npm, package: '@acme/plugin', version: '1.0.0' }), '@acme/plugin@1.0.0'); + }); + + test('formats pip source without version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin' }), 'my-plugin'); + }); + + test('formats pip source with version', () => { + assert.strictEqual(getPluginSourceLabel({ kind: PluginSourceKind.Pip, package: 'my-plugin', version: '2.0' }), 'my-plugin==2.0'); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 6130e821b9939..7d51bb6782f55 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -11,7 +11,7 @@ import { match } from '../../../../../../../base/common/glob.js'; import { ResourceSet } from '../../../../../../../base/common/map.js'; import { Schemas } from '../../../../../../../base/common/network.js'; import { ISettableObservable, observableValue } from '../../../../../../../base/common/observable.js'; -import { relativePath } from '../../../../../../../base/common/resources.js'; +import { basename, relativePath } from '../../../../../../../base/common/resources.js'; import { URI } from '../../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../../base/test/common/utils.js'; import { Range } from '../../../../../../../editor/common/core/range.js'; @@ -3505,6 +3505,7 @@ suite('PromptsService', () => { return { plugin: { uri: URI.file(path), + label: basename(URI.file(path)), enabled, setEnabled: () => { }, remove: () => { }, diff --git a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts index 904987c9e347c..a173a33b7d240 100644 --- a/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts +++ b/src/vs/workbench/contrib/mcp/common/discovery/pluginMcpDiscovery.ts @@ -8,7 +8,6 @@ import { Disposable, DisposableResourceMap } from '../../../../../base/common/li import { ResourceSet } from '../../../../../base/common/map.js'; import { Schemas } from '../../../../../base/common/network.js'; import { autorun } from '../../../../../base/common/observable.js'; -import { basename } from '../../../../../base/common/resources.js'; import { isDefined } from '../../../../../base/common/types.js'; import { URI } from '../../../../../base/common/uri.js'; import { ConfigurationTarget } from '../../../../../platform/configuration/common/configuration.js'; @@ -61,7 +60,7 @@ export class PluginMcpDiscovery extends Disposable implements IMcpDiscovery { const collectionId = `plugin.${plugin.uri}`; return this._mcpRegistry.registerCollection({ id: collectionId, - label: `${basename(plugin.uri)} (Agent Plugin)`, + label: `${plugin.label} (Agent Plugin)`, remoteAuthority: plugin.uri.scheme === Schemas.vscodeRemote ? plugin.uri.authority : null, configTarget: ConfigurationTarget.USER, scope: StorageScope.PROFILE,