Path: blob/main/src/vs/sessions/contrib/chat/browser/slashCommands.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { CancellationToken } from '../../../../base/common/cancellation.js';6import { Disposable } from '../../../../base/common/lifecycle.js';7import { themeColorFromId } from '../../../../base/common/themables.js';8import { CodeEditorWidget } from '../../../../editor/browser/widget/codeEditor/codeEditorWidget.js';9import { ICodeEditorService } from '../../../../editor/browser/services/codeEditorService.js';10import { CompletionContext, CompletionItem, CompletionItemKind } from '../../../../editor/common/languages.js';11import { ITextModel } from '../../../../editor/common/model.js';12import { IDecorationOptions } from '../../../../editor/common/editorCommon.js';13import { Position } from '../../../../editor/common/core/position.js';14import { Range } from '../../../../editor/common/core/range.js';15import { getWordAtText } from '../../../../editor/common/core/wordHelper.js';16import { ILanguageFeaturesService } from '../../../../editor/common/services/languageFeatures.js';17import { CommandsRegistry, ICommandService } from '../../../../platform/commands/common/commands.js';18import { IThemeService } from '../../../../platform/theme/common/themeService.js';19import { inputPlaceholderForeground } from '../../../../platform/theme/common/colorRegistry.js';20import { localize } from '../../../../nls.js';21import { chatSlashCommandBackground, chatSlashCommandForeground } from '../../../../workbench/contrib/chat/common/widget/chatColors.js';22import { AICustomizationManagementCommands, AICustomizationManagementSection } from '../../../../workbench/contrib/chat/browser/aiCustomization/aiCustomizationManagement.js';23import { IAICustomizationWorkspaceService } from '../../../../workbench/contrib/chat/common/aiCustomizationWorkspaceService.js';24import { IChatPromptSlashCommand, IPromptsService } from '../../../../workbench/contrib/chat/common/promptSyntax/service/promptsService.js';25import { PromptsType } from '../../../../workbench/contrib/chat/common/promptSyntax/promptTypes.js';2627/**28* Static command ID used by completion items to trigger immediate slash command execution,29* mirroring the pattern of core's `ChatSubmitAction` for `executeImmediately` commands.30*/31export const SESSIONS_EXECUTE_SLASH_COMMAND_ID = 'sessions.chat.executeSlashCommand';3233CommandsRegistry.registerCommand(SESSIONS_EXECUTE_SLASH_COMMAND_ID, (_, handler: SlashCommandHandler, slashCommandStr: string) => {34handler.tryExecuteSlashCommand(slashCommandStr);35handler.clearInput();36});3738/**39* Minimal slash command descriptor for the sessions new-chat widget.40* Self-contained copy of the essential fields from core's `IChatSlashData`41* to avoid a direct dependency on the workbench chat slash command service.42*/43interface ISessionsSlashCommandData {44readonly command: string;45readonly detail: string;46readonly sortText?: string;47readonly executeImmediately?: boolean;48readonly execute: (args: string) => void;49}5051/**52* Manages slash commands for the sessions new-chat input widget — registration,53* autocompletion, decorations (syntax highlighting + placeholder text), and execution.54*/55export class SlashCommandHandler extends Disposable {5657private static readonly _slashDecoType = 'sessions-slash-command';58private static readonly _slashPlaceholderDecoType = 'sessions-slash-placeholder';59private static _slashDecosRegistered = false;6061private readonly _slashCommands: ISessionsSlashCommandData[] = [];62private _cachedPromptCommands: readonly IChatPromptSlashCommand[] = [];6364constructor(65private readonly _editor: CodeEditorWidget,66@ICommandService private readonly commandService: ICommandService,67@ICodeEditorService private readonly codeEditorService: ICodeEditorService,68@ILanguageFeaturesService private readonly languageFeaturesService: ILanguageFeaturesService,69@IThemeService private readonly themeService: IThemeService,70@IAICustomizationWorkspaceService private readonly aiCustomizationWorkspaceService: IAICustomizationWorkspaceService,71@IPromptsService private readonly promptsService: IPromptsService,72) {73super();74this._registerSlashCommands();75this._registerCompletions();76this._registerDecorations();77this._refreshPromptCommands();78this._register(this.promptsService.onDidChangeSlashCommands(() => this._refreshPromptCommands()));79}8081clearInput(): void {82this._editor.getModel()?.setValue('');83}8485private _refreshPromptCommands(): void {86this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(CancellationToken.None).then(commands => {87this._cachedPromptCommands = commands;88this._updateDecorations();89}, () => { /* swallow errors from stale refresh */ });90}9192/**93* Attempts to parse and execute a slash command from the input.94* Returns `true` if a command was handled.95*/96tryExecuteSlashCommand(query: string): boolean {97const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su);98if (!match) {99return false;100}101102const commandName = match[1];103const slashCommand = this._slashCommands.find(c => c.command === commandName);104if (!slashCommand) {105return false;106}107108slashCommand.execute(match[2]?.trim() ?? '');109return true;110}111112/**113* If the query starts with a prompt/skill slash command (e.g. `/my-prompt args`),114* expands it into a CLI-friendly markdown reference so the agent can locate the115* file. Returns `undefined` when the query is not a prompt slash command.116*/117tryExpandPromptSlashCommand(query: string): string | undefined {118const match = query.match(/^\/([\w\p{L}\d_\-\.:]+)\s*(.*)/su);119if (!match) {120return undefined;121}122123const commandName = match[1];124const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName);125if (!promptCommand) {126return undefined;127}128129const args = match[2]?.trim() ?? '';130const uri = promptCommand.uri;131const typeLabel = promptCommand.type === PromptsType.skill ? 'skill' : 'prompt file';132const expanded = `Use the ${typeLabel} located at [${promptCommand.name}](${uri.toString()}).`;133return args ? `${expanded} ${args}` : expanded;134}135136private _registerSlashCommands(): void {137const openSection = (section: AICustomizationManagementSection) =>138() => this.commandService.executeCommand(AICustomizationManagementCommands.OpenEditor, section);139140this._slashCommands.push({141command: 'agents',142detail: localize('slashCommand.agents', "View and manage custom agents"),143sortText: 'z3_agents',144executeImmediately: true,145execute: openSection(AICustomizationManagementSection.Agents),146});147this._slashCommands.push({148command: 'skills',149detail: localize('slashCommand.skills', "View and manage skills"),150sortText: 'z3_skills',151executeImmediately: true,152execute: openSection(AICustomizationManagementSection.Skills),153});154this._slashCommands.push({155command: 'instructions',156detail: localize('slashCommand.instructions', "View and manage instructions"),157sortText: 'z3_instructions',158executeImmediately: true,159execute: openSection(AICustomizationManagementSection.Instructions),160});161this._slashCommands.push({162command: 'hooks',163detail: localize('slashCommand.hooks', "View and manage hooks"),164sortText: 'z3_hooks',165executeImmediately: true,166execute: openSection(AICustomizationManagementSection.Hooks),167});168}169170private _registerDecorations(): void {171if (!SlashCommandHandler._slashDecosRegistered) {172SlashCommandHandler._slashDecosRegistered = true;173this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashDecoType, {174color: themeColorFromId(chatSlashCommandForeground),175backgroundColor: themeColorFromId(chatSlashCommandBackground),176borderRadius: '3px',177});178this.codeEditorService.registerDecorationType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, {});179}180181this._register(this._editor.onDidChangeModelContent(() => this._updateDecorations()));182this._updateDecorations();183}184185private _updateDecorations(): void {186const model = this._editor.getModel();187const value = model?.getValue() ?? '';188const match = value.match(/^\/([\w\p{L}\d_\-\.:]+)\s?/u);189190if (!match) {191this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []);192this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []);193return;194}195196const commandName = match[1];197const slashCommand = this._slashCommands.find(c => c.command === commandName);198const promptCommand = this._cachedPromptCommands.find(c => c.name === commandName);199if (!slashCommand && !promptCommand) {200this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, []);201this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []);202return;203}204205// Highlight the slash command text206const commandEnd = match[0].trimEnd().length;207const commandDeco: IDecorationOptions[] = [{208range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: commandEnd + 1 },209}];210this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashDecoType, commandDeco);211212// Show the command description as a placeholder after the command213const restOfInput = value.slice(match[0].length).trim();214const detail = slashCommand?.detail ?? promptCommand?.description;215if (!restOfInput && detail) {216const placeholderCol = match[0].length + 1;217const placeholderDeco: IDecorationOptions[] = [{218range: { startLineNumber: 1, startColumn: placeholderCol, endLineNumber: 1, endColumn: model!.getLineMaxColumn(1) },219renderOptions: {220after: {221contentText: detail,222color: this._getPlaceholderColor(),223}224}225}];226this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, placeholderDeco);227} else {228this._editor.setDecorationsByType('sessions-chat', SlashCommandHandler._slashPlaceholderDecoType, []);229}230}231232private _getPlaceholderColor(): string | undefined {233const theme = this.themeService.getColorTheme();234return theme.getColor(inputPlaceholderForeground)?.toString();235}236237private _registerCompletions(): void {238const uri = this._editor.getModel()?.uri;239if (!uri) {240return;241}242243this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, {244_debugDisplayName: 'sessionsSlashCommands',245triggerCharacters: ['/'],246provideCompletionItems: (model: ITextModel, position: Position, _context: CompletionContext, _token: CancellationToken) => {247const range = this._computeCompletionRanges(model, position, /\/\w*/g);248if (!range) {249return null;250}251252// Only allow slash commands at the start of input253const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn));254if (textBefore.trim() !== '') {255return null;256}257258return {259suggestions: this._slashCommands.map((c, i): CompletionItem => {260const withSlash = `/${c.command}`;261return {262label: withSlash,263insertText: c.executeImmediately ? '' : `${withSlash} `,264detail: c.detail,265range,266sortText: c.sortText ?? 'a'.repeat(i + 1),267kind: CompletionItemKind.Text,268command: c.executeImmediately ? { id: SESSIONS_EXECUTE_SLASH_COMMAND_ID, title: withSlash, arguments: [this, withSlash] } : undefined,269};270})271};272}273}));274275// Dynamic completions for individual prompt/skill files (filtered to match276// what the sessions customizations view shows).277this._register(this.languageFeaturesService.completionProvider.register({ scheme: uri.scheme, hasAccessToAllModels: true }, {278_debugDisplayName: 'sessionsPromptSlashCommands',279triggerCharacters: ['/'],280provideCompletionItems: async (model: ITextModel, position: Position, _context: CompletionContext, token: CancellationToken) => {281const range = this._computeCompletionRanges(model, position, /\/[\p{L}0-9_.:-]*/gu);282if (!range) {283return null;284}285286const textBefore = model.getValueInRange(new Range(1, 1, range.replace.startLineNumber, range.replace.startColumn));287if (textBefore.trim() !== '') {288return null;289}290291const promptCommands = await this.aiCustomizationWorkspaceService.getFilteredPromptSlashCommands(token);292const userInvocable = promptCommands.filter(c => c.userInvocable);293if (userInvocable.length === 0) {294return null;295}296297return {298suggestions: userInvocable.map((c, i): CompletionItem => {299const label = `/${c.name}`;300return {301label: { label, description: c.description },302insertText: `${label} `,303documentation: c.description,304range,305sortText: 'b'.repeat(i + 1),306kind: CompletionItemKind.Text,307};308})309};310}311}));312}313314private _computeCompletionRanges(model: ITextModel, position: Position, reg: RegExp): { insert: Range; replace: Range } | undefined {315const varWord = getWordAtText(position.column, reg, model.getLineContent(position.lineNumber), 0);316if (!varWord && model.getWordUntilPosition(position).word) {317return;318}319320if (!varWord && position.column > 1) {321const textBefore = model.getValueInRange(new Range(position.lineNumber, position.column - 1, position.lineNumber, position.column));322if (textBefore !== ' ') {323return;324}325}326327let insert: Range;328let replace: Range;329if (!varWord) {330insert = replace = Range.fromPositions(position);331} else {332insert = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, position.column);333replace = new Range(position.lineNumber, varWord.startColumn, position.lineNumber, varWord.endColumn);334}335336return { insert, replace };337}338}339340341