Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts
5245 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 { isEqual } from '../../../../../base/common/resources.js';
7
import { URI } from '../../../../../base/common/uri.js';
8
import { VSBuffer } from '../../../../../base/common/buffer.js';
9
import { ChatViewId } from '../chat.js';
10
import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js';
11
import { localize, localize2 } from '../../../../../nls.js';
12
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
13
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
14
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
15
import { Codicon } from '../../../../../base/common/codicons.js';
16
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
17
import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
18
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
19
import { CancellationToken } from '../../../../../base/common/cancellation.js';
20
import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';
21
import { IFileService } from '../../../../../platform/files/common/files.js';
22
import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
23
import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js';
24
import { ILabelService } from '../../../../../platform/label/common/label.js';
25
import { IEditorService } from '../../../../services/editor/common/editorService.js';
26
import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js';
27
import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js';
28
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
29
import { IPathService } from '../../../../services/path/common/pathService.js';
30
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
31
import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';
32
import { Range } from '../../../../../editor/common/core/range.js';
33
import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js';
34
import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js';
35
import { OS } from '../../../../../base/common/platform.js';
36
37
/**
38
* Action ID for the `Configure Hooks` action.
39
*/
40
const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks';
41
42
interface IHookTypeQuickPickItem extends IQuickPickItem {
43
readonly hookType: typeof HOOK_TYPES[number];
44
}
45
46
interface IHookQuickPickItem extends IQuickPickItem {
47
readonly hookEntry?: IParsedHook;
48
readonly isAddNewHook?: boolean;
49
}
50
51
interface IHookFileQuickPickItem extends IQuickPickItem {
52
readonly fileUri?: URI;
53
readonly isCreateNewFile?: boolean;
54
}
55
56
/**
57
* Detects if existing hooks use Copilot CLI naming convention (camelCase).
58
* Returns true if any existing key matches the Copilot CLI format.
59
*/
60
function usesCopilotCliNaming(hooksObj: Record<string, unknown>): boolean {
61
for (const key of Object.keys(hooksObj)) {
62
// Check if any key resolves to a Copilot CLI hook type
63
if (resolveCopilotCliHookType(key) !== undefined) {
64
return true;
65
}
66
}
67
return false;
68
}
69
70
/**
71
* Gets the appropriate key name for a hook type based on the naming convention used in the file.
72
*/
73
function getHookTypeKeyName(hookTypeId: HookType, useCopilotCliNamingConvention: boolean): string {
74
if (useCopilotCliNamingConvention) {
75
const copilotCliName = getCopilotCliHookTypeName(hookTypeId);
76
if (copilotCliName) {
77
return copilotCliName;
78
}
79
}
80
// Fall back to PascalCase (enum value)
81
return hookTypeId;
82
}
83
84
/**
85
* Adds a hook to an existing hook file.
86
*/
87
async function addHookToFile(
88
hookFileUri: URI,
89
hookTypeId: HookType,
90
fileService: IFileService,
91
editorService: IEditorService,
92
notificationService: INotificationService,
93
bulkEditService: IBulkEditService
94
): Promise<void> {
95
// Parse existing file
96
let hooksContent: { hooks: Record<string, unknown[]> };
97
const fileExists = await fileService.exists(hookFileUri);
98
99
if (fileExists) {
100
const existingContent = await fileService.readFile(hookFileUri);
101
try {
102
hooksContent = JSON.parse(existingContent.value.toString());
103
// Ensure hooks object exists
104
if (!hooksContent.hooks) {
105
hooksContent.hooks = {};
106
}
107
} catch {
108
// If parsing fails, show error and open file for user to fix
109
notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks file. Please fix the JSON syntax errors and try again."));
110
await editorService.openEditor({ resource: hookFileUri });
111
return;
112
}
113
} else {
114
// Create new structure
115
hooksContent = { hooks: {} };
116
}
117
118
// Detect naming convention from existing keys
119
const useCopilotCliNamingConvention = usesCopilotCliNaming(hooksContent.hooks);
120
const hookTypeKeyName = getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention);
121
122
// Also check if there's an existing key for this hook type (with either naming)
123
// Find existing key that resolves to the same hook type
124
let existingKeyForType: string | undefined;
125
for (const key of Object.keys(hooksContent.hooks)) {
126
const resolvedType = resolveCopilotCliHookType(key);
127
if (resolvedType === hookTypeId || key === hookTypeId) {
128
existingKeyForType = key;
129
break;
130
}
131
}
132
133
// Use existing key if found, otherwise use the detected naming convention
134
const keyToUse = existingKeyForType ?? hookTypeKeyName;
135
136
// Add the new hook entry (append if hook type already exists)
137
const newHookEntry = {
138
type: 'command',
139
command: ''
140
};
141
let newHookIndex: number;
142
if (!hooksContent.hooks[keyToUse]) {
143
hooksContent.hooks[keyToUse] = [newHookEntry];
144
newHookIndex = 0;
145
} else {
146
hooksContent.hooks[keyToUse].push(newHookEntry);
147
newHookIndex = hooksContent.hooks[keyToUse].length - 1;
148
}
149
150
// Write the file
151
const jsonContent = JSON.stringify(hooksContent, null, '\t');
152
153
// Check if the file is already open in an editor
154
const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri));
155
156
if (existingEditor) {
157
// File is already open - first focus the editor, then update its model directly
158
await editorService.openEditor({
159
resource: hookFileUri,
160
options: {
161
pinned: false
162
}
163
});
164
165
// Get the code editor and update its content directly
166
const editor = getCodeEditor(editorService.activeTextEditorControl);
167
if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) {
168
const model = editor.getModel();
169
// Apply the full content replacement using executeEdits
170
model.pushEditOperations([], [{
171
range: model.getFullModelRange(),
172
text: jsonContent
173
}], () => null);
174
175
// Find and apply the selection
176
const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');
177
if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) {
178
editor.setSelection({
179
startLineNumber: selection.startLineNumber,
180
startColumn: selection.startColumn,
181
endLineNumber: selection.endLineNumber,
182
endColumn: selection.endColumn
183
});
184
editor.revealLineInCenter(selection.startLineNumber);
185
}
186
} else {
187
// Fallback: active editor/model check failed, apply via bulk edit service
188
await bulkEditService.apply([
189
new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent })
190
], { label: localize('addHook', "Add Hook") });
191
192
// Find the selection for the new hook's command field
193
const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');
194
195
// Re-open editor with selection
196
await editorService.openEditor({
197
resource: hookFileUri,
198
options: {
199
selection,
200
pinned: false
201
}
202
});
203
}
204
} else {
205
// File is not currently open in an editor
206
if (!fileExists) {
207
// File doesn't exist - write new file directly and open
208
await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent));
209
} else {
210
// File exists but isn't open - open it first, then use bulk edit for undo support
211
await editorService.openEditor({
212
resource: hookFileUri,
213
options: { pinned: false }
214
});
215
216
// Apply the edit via bulk edit service for proper undo support
217
await bulkEditService.apply([
218
new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent })
219
], { label: localize('addHook', "Add Hook") });
220
}
221
222
// Find the selection for the new hook's command field
223
const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');
224
225
// Open editor with selection (or re-focus if already open)
226
await editorService.openEditor({
227
resource: hookFileUri,
228
options: {
229
selection,
230
pinned: false
231
}
232
});
233
}
234
}
235
236
/**
237
* Shows the Configure Hooks quick pick UI, allowing the user to view,
238
* open, or create hooks. Can be called from the action or slash command.
239
*/
240
export async function showConfigureHooksQuickPick(
241
accessor: ServicesAccessor,
242
): Promise<void> {
243
const promptsService = accessor.get(IPromptsService);
244
const quickInputService = accessor.get(IQuickInputService);
245
const fileService = accessor.get(IFileService);
246
const labelService = accessor.get(ILabelService);
247
const editorService = accessor.get(IEditorService);
248
const workspaceService = accessor.get(IWorkspaceContextService);
249
const pathService = accessor.get(IPathService);
250
const notificationService = accessor.get(INotificationService);
251
const bulkEditService = accessor.get(IBulkEditService);
252
const remoteAgentService = accessor.get(IRemoteAgentService);
253
254
// Get the remote OS (or fall back to local OS)
255
const remoteEnv = await remoteAgentService.getEnvironment();
256
const targetOS = remoteEnv?.os ?? OS;
257
258
// Get workspace root and user home for path resolution
259
const workspaceFolder = workspaceService.getWorkspace().folders[0];
260
const workspaceRootUri = workspaceFolder?.uri;
261
const userHomeUri = await pathService.userHome();
262
const userHome = userHomeUri.fsPath ?? userHomeUri.path;
263
264
// Parse all hook files upfront to count hooks per type
265
const hookEntries = await parseAllHookFiles(
266
promptsService,
267
fileService,
268
labelService,
269
workspaceRootUri,
270
userHome,
271
targetOS,
272
CancellationToken.None
273
);
274
275
// Count hooks per type
276
const hookCountByType = new Map<HookType, number>();
277
for (const entry of hookEntries) {
278
hookCountByType.set(entry.hookType, (hookCountByType.get(entry.hookType) ?? 0) + 1);
279
}
280
281
// Step 1: Show all lifecycle events with hook counts
282
const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => {
283
const count = hookCountByType.get(hookType.id) ?? 0;
284
const countLabel = count > 0 ? ` (${count})` : '';
285
return {
286
label: `${hookType.label}${countLabel}`,
287
description: hookType.description,
288
hookType
289
};
290
});
291
292
const selectedHookType = await quickInputService.pick(hookTypeItems, {
293
placeHolder: localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'),
294
title: localize('commands.hooks.title', 'Hooks')
295
});
296
297
if (!selectedHookType) {
298
return;
299
}
300
301
// Filter hooks by the selected type
302
const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType.hookType.id);
303
304
// Step 2: Show "Add new hook" + existing hooks of this type
305
const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = [];
306
307
// Add "Add new hook" option at the top
308
hookItems.push({
309
label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`,
310
isAddNewHook: true,
311
alwaysShow: true
312
});
313
314
// Add existing hooks
315
if (hooksOfType.length > 0) {
316
hookItems.push({
317
type: 'separator',
318
label: localize('existingHooks', "Existing Hooks")
319
});
320
321
for (const entry of hooksOfType) {
322
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
323
hookItems.push({
324
label: entry.commandLabel,
325
description,
326
hookEntry: entry
327
});
328
}
329
}
330
331
// Auto-execute if only "Add new hook" is available (no existing hooks)
332
let selectedHook: IHookQuickPickItem | undefined;
333
if (hooksOfType.length === 0) {
334
selectedHook = hookItems[0] as IHookQuickPickItem;
335
} else {
336
selectedHook = await quickInputService.pick(hookItems, {
337
placeHolder: localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'),
338
title: selectedHookType.hookType.label
339
});
340
}
341
342
if (!selectedHook) {
343
return;
344
}
345
346
// Handle clicking on existing hook (focus into command)
347
if (selectedHook.hookEntry) {
348
const entry = selectedHook.hookEntry;
349
let selection: ITextEditorSelection | undefined;
350
351
// Determine the command field name to highlight based on target platform
352
const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);
353
354
// Try to find the command field to highlight
355
if (commandFieldName) {
356
try {
357
const content = await fileService.readFile(entry.fileUri);
358
selection = findHookCommandSelection(
359
content.value.toString(),
360
entry.originalHookTypeId,
361
entry.index,
362
commandFieldName
363
);
364
} catch {
365
// Ignore errors and just open without selection
366
}
367
}
368
369
await editorService.openEditor({
370
resource: entry.fileUri,
371
options: {
372
selection,
373
pinned: false
374
}
375
});
376
return;
377
}
378
379
// Step 3: Handle "Add new hook" - show create new file + existing hook files
380
if (selectedHook.isAddNewHook) {
381
// Get existing hook files (local storage only, not User Data)
382
const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None);
383
384
const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = [];
385
386
// Add "Create new hook config file" option at the top
387
fileItems.push({
388
label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`,
389
isCreateNewFile: true,
390
alwaysShow: true
391
});
392
393
// Add existing hook files
394
if (hookFiles.length > 0) {
395
fileItems.push({
396
type: 'separator',
397
label: localize('existingHookFiles', "Existing Hook Files")
398
});
399
400
for (const hookFile of hookFiles) {
401
const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true });
402
fileItems.push({
403
label: relativePath,
404
fileUri: hookFile.uri
405
});
406
}
407
}
408
409
// Auto-execute if no existing hook files
410
let selectedFile: IHookFileQuickPickItem | undefined;
411
if (hookFiles.length === 0) {
412
selectedFile = fileItems[0] as IHookFileQuickPickItem;
413
} else {
414
selectedFile = await quickInputService.pick(fileItems, {
415
placeHolder: localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'),
416
title: localize('commands.hooks.addHook.title', 'Add Hook')
417
});
418
}
419
420
if (!selectedFile) {
421
return;
422
}
423
424
// Handle creating new hook config file
425
if (selectedFile.isCreateNewFile) {
426
// Get source folders for hooks, filter to local storage only (no User Data)
427
const allFolders = await promptsService.getSourceFolders(PromptsType.hook);
428
const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local);
429
430
if (localFolders.length === 0) {
431
notificationService.error(localize('commands.hook.noLocalFolders', "No local hook folder found. Please configure a hooks folder in your workspace."));
432
return;
433
}
434
435
// Auto-select if only one folder, otherwise show picker
436
let selectedFolder = localFolders[0];
437
if (localFolders.length > 1) {
438
const folderItems = localFolders.map(folder => ({
439
label: labelService.getUriLabel(folder.uri, { relative: true }),
440
folder
441
}));
442
const pickedFolder = await quickInputService.pick(folderItems, {
443
placeHolder: localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'),
444
title: localize('commands.hook.selectFolder.title', 'Hook File Location')
445
});
446
if (!pickedFolder) {
447
return;
448
}
449
selectedFolder = pickedFolder.folder;
450
}
451
452
// Ask for filename
453
const fileName = await quickInputService.input({
454
prompt: localize('commands.hook.filename.prompt', "Enter hook file name"),
455
placeHolder: localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"),
456
validateInput: async (value) => {
457
if (!value || !value.trim()) {
458
return localize('commands.hook.filename.required', "File name is required");
459
}
460
const name = value.trim();
461
// Basic validation - no path separators or invalid characters
462
if (/[/\\:*?"<>|]/.test(name)) {
463
return localize('commands.hook.filename.invalidChars', "File name contains invalid characters");
464
}
465
return undefined;
466
}
467
});
468
469
if (!fileName) {
470
return;
471
}
472
473
// Create the hooks folder if it doesn't exist
474
await fileService.createFolder(selectedFolder.uri);
475
476
// Use user-provided filename with .json extension
477
const hookFileName = fileName.trim().endsWith('.json') ? fileName.trim() : `${fileName.trim()}.json`;
478
const hookFileUri = URI.joinPath(selectedFolder.uri, hookFileName);
479
480
// Check if file already exists
481
if (await fileService.exists(hookFileUri)) {
482
// File exists - add hook to it instead of creating new
483
await addHookToFile(
484
hookFileUri,
485
selectedHookType.hookType.id as HookType,
486
fileService,
487
editorService,
488
notificationService,
489
bulkEditService
490
);
491
return;
492
}
493
494
// Create new hook file with the selected hook type
495
const hooksContent = {
496
hooks: {
497
[selectedHookType.hookType.id]: [
498
{
499
type: 'command',
500
command: ''
501
}
502
]
503
}
504
};
505
506
const jsonContent = JSON.stringify(hooksContent, null, '\t');
507
await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent));
508
509
// Find the selection for the new hook's command field
510
const selection = findHookCommandSelection(jsonContent, selectedHookType.hookType.id, 0, 'command');
511
512
// Open editor with selection
513
await editorService.openEditor({
514
resource: hookFileUri,
515
options: {
516
selection,
517
pinned: false
518
}
519
});
520
return;
521
}
522
523
// Handle adding hook to existing file
524
if (selectedFile.fileUri) {
525
await addHookToFile(
526
selectedFile.fileUri,
527
selectedHookType.hookType.id as HookType,
528
fileService,
529
editorService,
530
notificationService,
531
bulkEditService
532
);
533
}
534
}
535
}
536
537
class ManageHooksAction extends Action2 {
538
constructor() {
539
super({
540
id: CONFIGURE_HOOKS_ACTION_ID,
541
title: localize2('configure-hooks', "Configure Hooks..."),
542
shortTitle: localize2('configure-hooks.short', "Hooks"),
543
icon: Codicon.zap,
544
f1: true,
545
precondition: ChatContextKeys.enabled,
546
category: CHAT_CATEGORY,
547
menu: {
548
id: CHAT_CONFIG_MENU_ID,
549
when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)),
550
order: 12,
551
group: '1_level'
552
}
553
});
554
}
555
556
public override async run(
557
accessor: ServicesAccessor,
558
): Promise<void> {
559
return showConfigureHooksQuickPick(accessor);
560
}
561
}
562
563
/**
564
* Helper to register the `Manage Hooks` action.
565
*/
566
export function registerHookActions(): void {
567
registerAction2(ManageHooksAction);
568
}
569
570