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