Enable slash commands in chat session participants (#268792)

* Enable slash commands in chat session participants

* Copilot suggestions good
This commit is contained in:
Rob Lourens
2025-09-28 19:19:18 -07:00
committed by GitHub
parent 887fc4d185
commit 0a311727de
8 changed files with 89 additions and 35 deletions
+2 -1
View File
@@ -101,7 +101,8 @@
"src/vs/workbench/api/test/browser/extHostDocumentData.test.perf-data.ts": true,
"src/vs/base/test/node/uri.test.data.txt": true,
"src/vs/editor/test/node/diffing/fixtures/**": true,
"build/loader.min": true
"build/loader.min": true,
"**/*.snap": true,
},
// --- TypeScript ---
@@ -150,7 +150,7 @@ const chatParticipantExtensionPoint = extensionsRegistry.ExtensionsRegistry.regi
required: ['name'],
properties: {
name: {
description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or * `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."),
description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."),
type: 'string'
},
description: {
@@ -70,6 +70,30 @@ const extensionPoint = ExtensionsRegistry.registerExtensionPoint<IChatSessionsEx
type: 'boolean'
}
}
},
commands: {
markdownDescription: localize('chatCommandsDescription', "Commands available for this chat session, which the user can invoke with a `/`."),
type: 'array',
items: {
additionalProperties: false,
type: 'object',
defaultSnippets: [{ body: { name: '', description: '' } }],
required: ['name'],
properties: {
name: {
description: localize('chatCommand', "A short name by which this command is referred to in the UI, e.g. `fix` or `explain` for commands that fix an issue or explain code. The name should be unique among the commands provided by this participant."),
type: 'string'
},
description: {
description: localize('chatCommandDescription', "A description of this command."),
type: 'string'
},
when: {
description: localize('chatCommandWhen', "A condition which must be true to enable this command."),
type: 'string'
},
}
}
}
},
required: ['type', 'name', 'displayName', 'description'],
@@ -145,6 +169,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
when: contribution.when,
capabilities: contribution.capabilities,
extensionDescription: ext.description,
commands: contribution.commands
};
this._register(this.registerContribution(c));
}
@@ -359,7 +384,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
isDefault: false,
isCore: false,
isDynamic: true,
slashCommands: [],
slashCommands: contribution.commands ?? [],
locations: [ChatAgentLocation.Chat],
modes: [ChatModeKind.Agent, ChatModeKind.Ask],
disambiguation: [],
@@ -565,4 +590,3 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ
}
registerSingleton(IChatSessionsService, ChatSessionsService, InstantiationType.Delayed);
@@ -6,6 +6,8 @@
import * as dom from '../../../../base/browser/dom.js';
import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js';
import { Button } from '../../../../base/browser/ui/button/button.js';
import { IHoverOptions } from '../../../../base/browser/ui/hover/hover.js';
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
import { IListRenderer, IListVirtualDelegate } from '../../../../base/browser/ui/list/list.js';
import { ITreeContextMenuEvent, ITreeElement } from '../../../../base/browser/ui/tree/tree.js';
import { disposableTimeout, RunOnceScheduler, timeout } from '../../../../base/common/async.js';
@@ -35,12 +37,8 @@ import { ICommandService } from '../../../../platform/commands/common/commands.j
import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js';
import { ContextKeyExpr, IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js';
import { IContextMenuService } from '../../../../platform/contextview/browser/contextView.js';
import { IHoverService, WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js';
import { IHoverOptions } from '../../../../base/browser/ui/hover/hover.js';
import { HoverPosition } from '../../../../base/browser/ui/hover/hoverWidget.js';
import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js';
import { ViewContainerLocation } from '../../../common/views.js';
import { ITextResourceEditorInput } from '../../../../platform/editor/common/editor.js';
import { IHoverService, WorkbenchHoverDelegate } from '../../../../platform/hover/browser/hover.js';
import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js';
import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js';
import { WorkbenchList, WorkbenchObjectTree } from '../../../../platform/list/browser/listService.js';
@@ -54,11 +52,13 @@ import { IThemeService } from '../../../../platform/theme/common/themeService.js
import { IWorkspaceContextService, WorkbenchState } from '../../../../platform/workspace/common/workspace.js';
import { EditorResourceAccessor } from '../../../../workbench/common/editor.js';
import { IEditorService } from '../../../../workbench/services/editor/common/editorService.js';
import { ViewContainerLocation } from '../../../common/views.js';
import { IChatEntitlementService } from '../../../services/chat/common/chatEntitlementService.js';
import { IWorkbenchLayoutService, Position } from '../../../services/layout/browser/layoutService.js';
import { IViewsService } from '../../../services/views/common/viewsService.js';
import { checkModeOption } from '../common/chat.js';
import { IChatAgentCommand, IChatAgentData, IChatAgentService } from '../common/chatAgents.js';
import { ChatContextKeys, ChatContextKeyExprs } from '../common/chatContextKeys.js';
import { ChatContextKeyExprs, ChatContextKeys } from '../common/chatContextKeys.js';
import { applyingChatEditsFailedContextKey, decidedChatEditingResourceContextKey, hasAppliedChatEditsContextKey, hasUndecidedChatEditingResourceContextKey, IChatEditingService, IChatEditingSession, inChatEditingSessionContextKey, ModifiedFileEntryState } from '../common/chatEditingService.js';
import { IChatLayoutService } from '../common/chatLayoutService.js';
import { IChatModel, IChatResponseModel } from '../common/chatModel.js';
@@ -404,7 +404,12 @@ export class ChatWidget extends Disposable implements IChatWidget {
return { text: '', parts: [] };
}
this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser).parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, { selectedAgent: this._lastSelectedAgent, mode: this.input.currentModeKind });
this.parsedChatRequest = this.instantiationService.createInstance(ChatRequestParser)
.parseChatRequest(this.viewModel!.sessionId, this.getInput(), this.location, {
selectedAgent: this._lastSelectedAgent,
mode: this.input.currentModeKind,
forcedAgent: this._lockedAgentId ? this.chatAgentService.getAgent(this._lockedAgentId) : undefined
});
this._onDidChangeParsedInput.fire();
}
@@ -285,34 +285,15 @@ class AgentCompletions extends Disposable {
const range = computeCompletionRanges(model, position, /\/\w*/g);
if (!range) {
return null;
}
const parsedRequest = widget.parsedInput.parts;
const usedAgentIdx = parsedRequest.findIndex((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart);
if (usedAgentIdx < 0) {
return;
}
const usedOtherCommand = parsedRequest.find(p => p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashPromptPart);
if (usedOtherCommand) {
const usedAgent = this.getCurrentAgentForWidget(widget);
if (!usedAgent || usedAgent.command) {
// Only one allowed
return;
}
for (const partAfterAgent of parsedRequest.slice(usedAgentIdx + 1)) {
// Could allow text after 'position'
if (!(partAfterAgent instanceof ChatRequestTextPart) || !partAfterAgent.text.trim().match(/^(\/\w*)?$/)) {
// No text allowed between agent and subcommand
return;
}
}
if (widget.lockedAgentId) {
return null;
}
const usedAgent = parsedRequest[usedAgentIdx] as ChatRequestAgentPart;
return {
suggestions: usedAgent.agent.slashCommands.map((c, i): CompletionItem => {
const withSlash = `/${c.name}`;
@@ -530,6 +511,40 @@ class AgentCompletions extends Disposable {
}));
}
private getCurrentAgentForWidget(widget: IChatWidget): { agent: IChatAgentData; command?: string } | undefined {
if (widget.lockedAgentId) {
const usedAgent = this.chatAgentService.getAgent(widget.lockedAgentId);
return usedAgent && { agent: usedAgent };
}
const parsedRequest = widget.parsedInput.parts;
const usedAgentIdx = parsedRequest.findIndex((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart);
if (usedAgentIdx < 0) {
return;
}
const usedAgent = parsedRequest[usedAgentIdx] as ChatRequestAgentPart;
const usedOtherCommand = parsedRequest.find(p => p instanceof ChatRequestAgentSubcommandPart || p instanceof ChatRequestSlashPromptPart);
if (usedOtherCommand) {
// Only one allowed
return {
agent: usedAgent.agent,
command: usedOtherCommand instanceof ChatRequestAgentSubcommandPart ? usedOtherCommand.command.name : undefined
};
}
for (const partAfterAgent of parsedRequest.slice(usedAgentIdx + 1)) {
// Could allow text after 'position'
if (!(partAfterAgent instanceof ChatRequestTextPart) || !partAfterAgent.text.trim().match(/^(\/\w*)?$/)) {
// No text allowed between agent and subcommand
return;
}
}
return { agent: usedAgent.agent };
}
private getAgentCompletionDetails(agent: IChatAgentData): { label: string; isDupe: boolean } {
const isAllowed = this.chatAgentNameService.getAgentNameRestriction(agent);
const agentLabel = `${chatAgentLeader}${isAllowed ? agent.name : getFullyQualifiedId(agent)}`;
@@ -11,7 +11,6 @@ export interface IRawChatCommandContribution {
sampleRequest?: string;
isSticky?: boolean;
when?: string;
defaultImplicitVariables?: string[];
disambiguation?: { category: string; categoryName?: string /** Deprecated */; description: string; examples: string[] }[];
}
@@ -22,6 +22,8 @@ export interface IChatParserContext {
/** Used only as a disambiguator, when the query references an agent that has a duplicate with the same name. */
selectedAgent?: IChatAgentData;
mode?: ChatModeKind;
/** Parse as this agent, even when it does not appear in the query text */
forcedAgent?: IChatAgentData;
}
export class ChatRequestParser {
@@ -204,9 +206,10 @@ export class ChatRequestParser {
const slashRange = new OffsetRange(offset, offset + full.length);
const slashEditorRange = new Range(position.lineNumber, position.column, position.lineNumber, position.column + full.length);
const usedAgent = parts.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart);
const usedAgent = parts.find((p): p is ChatRequestAgentPart => p instanceof ChatRequestAgentPart)?.agent ??
(context?.forcedAgent ? context.forcedAgent : undefined);
if (usedAgent) {
const subCommand = usedAgent.agent.slashCommands.find(c => c.name === command);
const subCommand = usedAgent.slashCommands.find(c => c.name === command);
if (subCommand) {
// Valid agent subcommand
return new ChatRequestAgentSubcommandPart(slashRange, slashEditorRange, subCommand);
@@ -21,6 +21,12 @@ export const enum ChatSessionStatus {
InProgress = 2
}
export interface IChatSessionCommandContribution {
name: string;
description: string;
when?: string;
}
export interface IChatSessionsExtensionPoint {
readonly type: string;
readonly name: string;
@@ -32,6 +38,7 @@ export interface IChatSessionsExtensionPoint {
supportsFileAttachments?: boolean;
supportsToolAttachments?: boolean;
};
readonly commands?: IChatSessionCommandContribution[];
}
export interface IChatSessionItem {
id: string;