Path: blob/main/src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts
5245 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 { isEqual } from '../../../../../base/common/resources.js';6import { URI } from '../../../../../base/common/uri.js';7import { VSBuffer } from '../../../../../base/common/buffer.js';8import { ChatViewId } from '../chat.js';9import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js';10import { localize, localize2 } from '../../../../../nls.js';11import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';12import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';13import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';14import { Codicon } from '../../../../../base/common/codicons.js';15import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';16import { IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';17import { PromptsType } from '../../common/promptSyntax/promptTypes.js';18import { CancellationToken } from '../../../../../base/common/cancellation.js';19import { IQuickInputService, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';20import { IFileService } from '../../../../../platform/files/common/files.js';21import { HOOK_TYPES, HookType, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';22import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js';23import { ILabelService } from '../../../../../platform/label/common/label.js';24import { IEditorService } from '../../../../services/editor/common/editorService.js';25import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js';26import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js';27import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';28import { IPathService } from '../../../../services/path/common/pathService.js';29import { INotificationService } from '../../../../../platform/notification/common/notification.js';30import { IBulkEditService, ResourceTextEdit } from '../../../../../editor/browser/services/bulkEditService.js';31import { Range } from '../../../../../editor/common/core/range.js';32import { getCodeEditor } from '../../../../../editor/browser/editorBrowser.js';33import { IRemoteAgentService } from '../../../../services/remote/common/remoteAgentService.js';34import { OS } from '../../../../../base/common/platform.js';3536/**37* Action ID for the `Configure Hooks` action.38*/39const CONFIGURE_HOOKS_ACTION_ID = 'workbench.action.chat.configure.hooks';4041interface IHookTypeQuickPickItem extends IQuickPickItem {42readonly hookType: typeof HOOK_TYPES[number];43}4445interface IHookQuickPickItem extends IQuickPickItem {46readonly hookEntry?: IParsedHook;47readonly isAddNewHook?: boolean;48}4950interface IHookFileQuickPickItem extends IQuickPickItem {51readonly fileUri?: URI;52readonly isCreateNewFile?: boolean;53}5455/**56* Detects if existing hooks use Copilot CLI naming convention (camelCase).57* Returns true if any existing key matches the Copilot CLI format.58*/59function usesCopilotCliNaming(hooksObj: Record<string, unknown>): boolean {60for (const key of Object.keys(hooksObj)) {61// Check if any key resolves to a Copilot CLI hook type62if (resolveCopilotCliHookType(key) !== undefined) {63return true;64}65}66return false;67}6869/**70* Gets the appropriate key name for a hook type based on the naming convention used in the file.71*/72function getHookTypeKeyName(hookTypeId: HookType, useCopilotCliNamingConvention: boolean): string {73if (useCopilotCliNamingConvention) {74const copilotCliName = getCopilotCliHookTypeName(hookTypeId);75if (copilotCliName) {76return copilotCliName;77}78}79// Fall back to PascalCase (enum value)80return hookTypeId;81}8283/**84* Adds a hook to an existing hook file.85*/86async function addHookToFile(87hookFileUri: URI,88hookTypeId: HookType,89fileService: IFileService,90editorService: IEditorService,91notificationService: INotificationService,92bulkEditService: IBulkEditService93): Promise<void> {94// Parse existing file95let hooksContent: { hooks: Record<string, unknown[]> };96const fileExists = await fileService.exists(hookFileUri);9798if (fileExists) {99const existingContent = await fileService.readFile(hookFileUri);100try {101hooksContent = JSON.parse(existingContent.value.toString());102// Ensure hooks object exists103if (!hooksContent.hooks) {104hooksContent.hooks = {};105}106} catch {107// If parsing fails, show error and open file for user to fix108notificationService.error(localize('commands.new.hook.parseError', "Failed to parse existing hooks file. Please fix the JSON syntax errors and try again."));109await editorService.openEditor({ resource: hookFileUri });110return;111}112} else {113// Create new structure114hooksContent = { hooks: {} };115}116117// Detect naming convention from existing keys118const useCopilotCliNamingConvention = usesCopilotCliNaming(hooksContent.hooks);119const hookTypeKeyName = getHookTypeKeyName(hookTypeId, useCopilotCliNamingConvention);120121// Also check if there's an existing key for this hook type (with either naming)122// Find existing key that resolves to the same hook type123let existingKeyForType: string | undefined;124for (const key of Object.keys(hooksContent.hooks)) {125const resolvedType = resolveCopilotCliHookType(key);126if (resolvedType === hookTypeId || key === hookTypeId) {127existingKeyForType = key;128break;129}130}131132// Use existing key if found, otherwise use the detected naming convention133const keyToUse = existingKeyForType ?? hookTypeKeyName;134135// Add the new hook entry (append if hook type already exists)136const newHookEntry = {137type: 'command',138command: ''139};140let newHookIndex: number;141if (!hooksContent.hooks[keyToUse]) {142hooksContent.hooks[keyToUse] = [newHookEntry];143newHookIndex = 0;144} else {145hooksContent.hooks[keyToUse].push(newHookEntry);146newHookIndex = hooksContent.hooks[keyToUse].length - 1;147}148149// Write the file150const jsonContent = JSON.stringify(hooksContent, null, '\t');151152// Check if the file is already open in an editor153const existingEditor = editorService.editors.find(e => isEqual(e.resource, hookFileUri));154155if (existingEditor) {156// File is already open - first focus the editor, then update its model directly157await editorService.openEditor({158resource: hookFileUri,159options: {160pinned: false161}162});163164// Get the code editor and update its content directly165const editor = getCodeEditor(editorService.activeTextEditorControl);166if (editor && editor.hasModel() && isEqual(editor.getModel().uri, hookFileUri)) {167const model = editor.getModel();168// Apply the full content replacement using executeEdits169model.pushEditOperations([], [{170range: model.getFullModelRange(),171text: jsonContent172}], () => null);173174// Find and apply the selection175const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');176if (selection && selection.endLineNumber !== undefined && selection.endColumn !== undefined) {177editor.setSelection({178startLineNumber: selection.startLineNumber,179startColumn: selection.startColumn,180endLineNumber: selection.endLineNumber,181endColumn: selection.endColumn182});183editor.revealLineInCenter(selection.startLineNumber);184}185} else {186// Fallback: active editor/model check failed, apply via bulk edit service187await bulkEditService.apply([188new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent })189], { label: localize('addHook', "Add Hook") });190191// Find the selection for the new hook's command field192const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');193194// Re-open editor with selection195await editorService.openEditor({196resource: hookFileUri,197options: {198selection,199pinned: false200}201});202}203} else {204// File is not currently open in an editor205if (!fileExists) {206// File doesn't exist - write new file directly and open207await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent));208} else {209// File exists but isn't open - open it first, then use bulk edit for undo support210await editorService.openEditor({211resource: hookFileUri,212options: { pinned: false }213});214215// Apply the edit via bulk edit service for proper undo support216await bulkEditService.apply([217new ResourceTextEdit(hookFileUri, { range: new Range(1, 1, Number.MAX_SAFE_INTEGER, 1), text: jsonContent })218], { label: localize('addHook', "Add Hook") });219}220221// Find the selection for the new hook's command field222const selection = findHookCommandSelection(jsonContent, keyToUse, newHookIndex, 'command');223224// Open editor with selection (or re-focus if already open)225await editorService.openEditor({226resource: hookFileUri,227options: {228selection,229pinned: false230}231});232}233}234235/**236* Shows the Configure Hooks quick pick UI, allowing the user to view,237* open, or create hooks. Can be called from the action or slash command.238*/239export async function showConfigureHooksQuickPick(240accessor: ServicesAccessor,241): Promise<void> {242const promptsService = accessor.get(IPromptsService);243const quickInputService = accessor.get(IQuickInputService);244const fileService = accessor.get(IFileService);245const labelService = accessor.get(ILabelService);246const editorService = accessor.get(IEditorService);247const workspaceService = accessor.get(IWorkspaceContextService);248const pathService = accessor.get(IPathService);249const notificationService = accessor.get(INotificationService);250const bulkEditService = accessor.get(IBulkEditService);251const remoteAgentService = accessor.get(IRemoteAgentService);252253// Get the remote OS (or fall back to local OS)254const remoteEnv = await remoteAgentService.getEnvironment();255const targetOS = remoteEnv?.os ?? OS;256257// Get workspace root and user home for path resolution258const workspaceFolder = workspaceService.getWorkspace().folders[0];259const workspaceRootUri = workspaceFolder?.uri;260const userHomeUri = await pathService.userHome();261const userHome = userHomeUri.fsPath ?? userHomeUri.path;262263// Parse all hook files upfront to count hooks per type264const hookEntries = await parseAllHookFiles(265promptsService,266fileService,267labelService,268workspaceRootUri,269userHome,270targetOS,271CancellationToken.None272);273274// Count hooks per type275const hookCountByType = new Map<HookType, number>();276for (const entry of hookEntries) {277hookCountByType.set(entry.hookType, (hookCountByType.get(entry.hookType) ?? 0) + 1);278}279280// Step 1: Show all lifecycle events with hook counts281const hookTypeItems: IHookTypeQuickPickItem[] = HOOK_TYPES.map(hookType => {282const count = hookCountByType.get(hookType.id) ?? 0;283const countLabel = count > 0 ? ` (${count})` : '';284return {285label: `${hookType.label}${countLabel}`,286description: hookType.description,287hookType288};289});290291const selectedHookType = await quickInputService.pick(hookTypeItems, {292placeHolder: localize('commands.hooks.selectEvent.placeholder', 'Select a lifecycle event'),293title: localize('commands.hooks.title', 'Hooks')294});295296if (!selectedHookType) {297return;298}299300// Filter hooks by the selected type301const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType.hookType.id);302303// Step 2: Show "Add new hook" + existing hooks of this type304const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = [];305306// Add "Add new hook" option at the top307hookItems.push({308label: `$(plus) ${localize('commands.addNewHook.label', 'Add new hook...')}`,309isAddNewHook: true,310alwaysShow: true311});312313// Add existing hooks314if (hooksOfType.length > 0) {315hookItems.push({316type: 'separator',317label: localize('existingHooks', "Existing Hooks")318});319320for (const entry of hooksOfType) {321const description = labelService.getUriLabel(entry.fileUri, { relative: true });322hookItems.push({323label: entry.commandLabel,324description,325hookEntry: entry326});327}328}329330// Auto-execute if only "Add new hook" is available (no existing hooks)331let selectedHook: IHookQuickPickItem | undefined;332if (hooksOfType.length === 0) {333selectedHook = hookItems[0] as IHookQuickPickItem;334} else {335selectedHook = await quickInputService.pick(hookItems, {336placeHolder: localize('commands.hooks.selectHook.placeholder', 'Select a hook to open or add a new one'),337title: selectedHookType.hookType.label338});339}340341if (!selectedHook) {342return;343}344345// Handle clicking on existing hook (focus into command)346if (selectedHook.hookEntry) {347const entry = selectedHook.hookEntry;348let selection: ITextEditorSelection | undefined;349350// Determine the command field name to highlight based on target platform351const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);352353// Try to find the command field to highlight354if (commandFieldName) {355try {356const content = await fileService.readFile(entry.fileUri);357selection = findHookCommandSelection(358content.value.toString(),359entry.originalHookTypeId,360entry.index,361commandFieldName362);363} catch {364// Ignore errors and just open without selection365}366}367368await editorService.openEditor({369resource: entry.fileUri,370options: {371selection,372pinned: false373}374});375return;376}377378// Step 3: Handle "Add new hook" - show create new file + existing hook files379if (selectedHook.isAddNewHook) {380// Get existing hook files (local storage only, not User Data)381const hookFiles = await promptsService.listPromptFilesForStorage(PromptsType.hook, PromptsStorage.local, CancellationToken.None);382383const fileItems: (IHookFileQuickPickItem | IQuickPickSeparator)[] = [];384385// Add "Create new hook config file" option at the top386fileItems.push({387label: `$(new-file) ${localize('commands.createNewHookFile.label', 'Create new hook config file...')}`,388isCreateNewFile: true,389alwaysShow: true390});391392// Add existing hook files393if (hookFiles.length > 0) {394fileItems.push({395type: 'separator',396label: localize('existingHookFiles', "Existing Hook Files")397});398399for (const hookFile of hookFiles) {400const relativePath = labelService.getUriLabel(hookFile.uri, { relative: true });401fileItems.push({402label: relativePath,403fileUri: hookFile.uri404});405}406}407408// Auto-execute if no existing hook files409let selectedFile: IHookFileQuickPickItem | undefined;410if (hookFiles.length === 0) {411selectedFile = fileItems[0] as IHookFileQuickPickItem;412} else {413selectedFile = await quickInputService.pick(fileItems, {414placeHolder: localize('commands.hooks.selectFile.placeholder', 'Select a hook file or create a new one'),415title: localize('commands.hooks.addHook.title', 'Add Hook')416});417}418419if (!selectedFile) {420return;421}422423// Handle creating new hook config file424if (selectedFile.isCreateNewFile) {425// Get source folders for hooks, filter to local storage only (no User Data)426const allFolders = await promptsService.getSourceFolders(PromptsType.hook);427const localFolders = allFolders.filter(f => f.storage === PromptsStorage.local);428429if (localFolders.length === 0) {430notificationService.error(localize('commands.hook.noLocalFolders', "No local hook folder found. Please configure a hooks folder in your workspace."));431return;432}433434// Auto-select if only one folder, otherwise show picker435let selectedFolder = localFolders[0];436if (localFolders.length > 1) {437const folderItems = localFolders.map(folder => ({438label: labelService.getUriLabel(folder.uri, { relative: true }),439folder440}));441const pickedFolder = await quickInputService.pick(folderItems, {442placeHolder: localize('commands.hook.selectFolder.placeholder', 'Select a location for the hook file'),443title: localize('commands.hook.selectFolder.title', 'Hook File Location')444});445if (!pickedFolder) {446return;447}448selectedFolder = pickedFolder.folder;449}450451// Ask for filename452const fileName = await quickInputService.input({453prompt: localize('commands.hook.filename.prompt', "Enter hook file name"),454placeHolder: localize('commands.hook.filename.placeholder', "e.g., hooks, diagnostics, security"),455validateInput: async (value) => {456if (!value || !value.trim()) {457return localize('commands.hook.filename.required', "File name is required");458}459const name = value.trim();460// Basic validation - no path separators or invalid characters461if (/[/\\:*?"<>|]/.test(name)) {462return localize('commands.hook.filename.invalidChars', "File name contains invalid characters");463}464return undefined;465}466});467468if (!fileName) {469return;470}471472// Create the hooks folder if it doesn't exist473await fileService.createFolder(selectedFolder.uri);474475// Use user-provided filename with .json extension476const hookFileName = fileName.trim().endsWith('.json') ? fileName.trim() : `${fileName.trim()}.json`;477const hookFileUri = URI.joinPath(selectedFolder.uri, hookFileName);478479// Check if file already exists480if (await fileService.exists(hookFileUri)) {481// File exists - add hook to it instead of creating new482await addHookToFile(483hookFileUri,484selectedHookType.hookType.id as HookType,485fileService,486editorService,487notificationService,488bulkEditService489);490return;491}492493// Create new hook file with the selected hook type494const hooksContent = {495hooks: {496[selectedHookType.hookType.id]: [497{498type: 'command',499command: ''500}501]502}503};504505const jsonContent = JSON.stringify(hooksContent, null, '\t');506await fileService.writeFile(hookFileUri, VSBuffer.fromString(jsonContent));507508// Find the selection for the new hook's command field509const selection = findHookCommandSelection(jsonContent, selectedHookType.hookType.id, 0, 'command');510511// Open editor with selection512await editorService.openEditor({513resource: hookFileUri,514options: {515selection,516pinned: false517}518});519return;520}521522// Handle adding hook to existing file523if (selectedFile.fileUri) {524await addHookToFile(525selectedFile.fileUri,526selectedHookType.hookType.id as HookType,527fileService,528editorService,529notificationService,530bulkEditService531);532}533}534}535536class ManageHooksAction extends Action2 {537constructor() {538super({539id: CONFIGURE_HOOKS_ACTION_ID,540title: localize2('configure-hooks', "Configure Hooks..."),541shortTitle: localize2('configure-hooks.short', "Hooks"),542icon: Codicon.zap,543f1: true,544precondition: ChatContextKeys.enabled,545category: CHAT_CATEGORY,546menu: {547id: CHAT_CONFIG_MENU_ID,548when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)),549order: 12,550group: '1_level'551}552});553}554555public override async run(556accessor: ServicesAccessor,557): Promise<void> {558return showConfigureHooksQuickPick(accessor);559}560}561562/**563* Helper to register the `Manage Hooks` action.564*/565export function registerHookActions(): void {566registerAction2(ManageHooksAction);567}568569570