Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 54 additions & 17 deletions src/vs/workbench/contrib/chat/browser/promptSyntax/hookActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import { CancellationToken } from '../../../../../base/common/cancellation.js';
import { IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickSeparator } from '../../../../../platform/quickinput/common/quickInput.js';
import { IFileService } from '../../../../../platform/files/common/files.js';
import { HOOK_METADATA, HOOKS_BY_TARGET, HookType, IHookTypeMeta } from '../../common/promptSyntax/hookTypes.js';
import { getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
import { formatHookCommandLabel, getEffectiveCommandFieldKey } from '../../common/promptSyntax/hookSchema.js';
import { getCopilotCliHookTypeName, resolveCopilotCliHookType } from '../../common/promptSyntax/hookCopilotCliCompat.js';
import { getHookSourceFormat, HookSourceFormat, buildNewHookEntry } from '../../common/promptSyntax/hookCompatibility.js';
import { getClaudeHookTypeName, resolveClaudeHookType } from '../../common/promptSyntax/hookClaudeCompat.js';
import { ILabelService } from '../../../../../platform/label/common/label.js';
import { IEditorService } from '../../../../services/editor/common/editorService.js';
import { ITextEditorSelection } from '../../../../../platform/editor/common/editor.js';
import { findHookCommandSelection, parseAllHookFiles, IParsedHook } from './hookUtils.js';
import { findHookCommandSelection, findHookCommandInYaml, parseAllHookFiles, IParsedHook } from './hookUtils.js';
import { IWorkspaceContextService } from '../../../../../platform/workspace/common/workspace.js';
import { IPathService } from '../../../../services/path/common/pathService.js';
import { INotificationService } from '../../../../../platform/notification/common/notification.js';
Expand Down Expand Up @@ -348,7 +348,8 @@ export async function showConfigureHooksQuickPick(
workspaceRootUri,
userHome,
targetOS,
CancellationToken.None
CancellationToken.None,
{ includeAgentHooks: true }
);

// Count hooks per type
Expand Down Expand Up @@ -451,6 +452,10 @@ export async function showConfigureHooksQuickPick(
// Filter hooks by the selected type
const hooksOfType = hookEntries.filter(h => h.hookType === selectedHookType!.hookType);

// Separate hooks by source
const fileHooks = hooksOfType.filter(h => !h.agentName);
const agentHooks = hooksOfType.filter(h => h.agentName);

// Step 2: Show "Add new hook" + existing hooks of this type
const hookItems: (IHookQuickPickItem | IQuickPickSeparator)[] = [];

Expand All @@ -461,14 +466,14 @@ export async function showConfigureHooksQuickPick(
alwaysShow: true
});

// Add existing hooks
if (hooksOfType.length > 0) {
// Add existing file-based hooks
if (fileHooks.length > 0) {
hookItems.push({
type: 'separator',
label: localize('existingHooks', "Existing Hooks")
});

for (const entry of hooksOfType) {
for (const entry of fileHooks) {
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
hookItems.push({
label: entry.commandLabel,
Expand All @@ -478,6 +483,26 @@ export async function showConfigureHooksQuickPick(
}
}

// Add agent-defined hooks grouped by agent name
if (agentHooks.length > 0) {
const agentNames = [...new Set(agentHooks.map(h => h.agentName!))];
for (const agentName of agentNames) {
hookItems.push({
type: 'separator',
label: localize('agentHooks', "Agent: {0}", agentName)
});

for (const entry of agentHooks.filter(h => h.agentName === agentName)) {
const description = labelService.getUriLabel(entry.fileUri, { relative: true });
hookItems.push({
label: entry.commandLabel,
description,
hookEntry: entry
});
}
}
}

// Auto-execute if only "Add new hook" is available (no existing hooks)
if (hooksOfType.length === 0) {
selectedHook = hookItems[0] as IHookQuickPickItem;
Expand Down Expand Up @@ -507,22 +532,34 @@ export async function showConfigureHooksQuickPick(
const entry = selectedHook.hookEntry;
let selection: ITextEditorSelection | undefined;

// Determine the command field name to highlight based on target platform
const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);

// Try to find the command field to highlight
if (commandFieldName) {
if (entry.agentName) {
// Agent hook: search the YAML frontmatter for the command
try {
const content = await fileService.readFile(entry.fileUri);
selection = findHookCommandSelection(
content.value.toString(),
entry.originalHookTypeId,
entry.index,
commandFieldName
);
const commandText = formatHookCommandLabel(entry.command, targetOS);
if (commandText) {
selection = findHookCommandInYaml(content.value.toString(), commandText);
}
} catch {
// Ignore errors and just open without selection
}
} else {
// File hook: use JSON-based selection finder
const commandFieldName = getEffectiveCommandFieldKey(entry.command, targetOS);

if (commandFieldName) {
try {
const content = await fileService.readFile(entry.fileUri);
selection = findHookCommandSelection(
content.value.toString(),
entry.originalHookTypeId,
entry.index,
commandFieldName
);
} catch {
// Ignore errors and just open without selection
}
}
}

picker.hide();
Expand Down
79 changes: 79 additions & 0 deletions src/vs/workbench/contrib/chat/browser/promptSyntax/hookUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,46 @@ export function findHookCommandSelection(content: string, hookType: string, inde
};
}

/**
* Finds the selection range for a hook command string in a YAML/Markdown file
* (e.g., an agent `.md` file with YAML frontmatter).
*
* Searches for the command text within `command:` lines and selects the value.
*
* @param content The full file content
* @param commandText The command string to locate
* @returns The selection range, or undefined if not found
*/
export function findHookCommandInYaml(content: string, commandText: string): ITextEditorSelection | undefined {
// Search for command: lines where the value matches the full command text.
// Handles: command: "commandText", command: 'commandText', command: commandText
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trimStart();
if (!trimmed.startsWith('command:') && !trimmed.startsWith('- command:')) {
continue;
}
const idx = line.indexOf(commandText);
if (idx !== -1) {
// Verify this is a full match (not a substring of a longer command)
const afterIdx = idx + commandText.length;
const charAfter = afterIdx < line.length ? line.charCodeAt(afterIdx) : -1;
// Accept if what follows is end of line, a quote, or whitespace
if (charAfter === -1 || charAfter === 34 /* " */ || charAfter === 39 /* ' */ || charAfter === 32 /* space */ || charAfter === 9 /* tab */) {
return {
startLineNumber: i + 1,
startColumn: idx + 1,
endLineNumber: i + 1,
endColumn: idx + 1 + commandText.length
};
}
}
}

return undefined;
}

/**
* Parsed hook information.
*/
Expand All @@ -129,11 +169,15 @@ export interface IParsedHook {
originalHookTypeId: string;
/** If true, this hook is disabled via `disableAllHooks: true` in its file */
disabled?: boolean;
/** If set, this hook came from a custom agent's frontmatter */
agentName?: string;
}

export interface IParseAllHookFilesOptions {
/** Additional file URIs to parse (e.g., files skipped due to disableAllHooks) */
additionalDisabledFileUris?: readonly URI[];
/** If true, also collect hooks from custom agent frontmatter */
includeAgentHooks?: boolean;
}

/**
Expand Down Expand Up @@ -227,5 +271,40 @@ export async function parseAllHookFiles(
}
}

// Collect hooks from custom agents' frontmatter
if (options?.includeAgentHooks) {
const agents = await promptsService.getCustomAgents(token);
for (const agent of agents) {
if (!agent.hooks) {
continue;
}
for (const hookTypeValue of Object.values(HookType)) {
const commands = agent.hooks[hookTypeValue];
if (!commands || commands.length === 0) {
continue;
}
const hookTypeMeta = HOOK_METADATA[hookTypeValue];
if (!hookTypeMeta) {
continue;
}
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
const commandLabel = formatHookCommandLabel(command, os) || nls.localize('commands.hook.emptyCommand', '(empty command)');
parsedHooks.push({
hookType: hookTypeValue,
hookTypeLabel: hookTypeMeta.label,
command,
commandLabel,
fileUri: agent.uri,
filePath: labelService.getUriLabel(agent.uri, { relative: true }),
index: i,
originalHookTypeId: hookTypeValue,
agentName: agent.name,
});
}
}
}
}

return parsedHooks;
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import { ChatMessageRole, IChatMessage, ILanguageModelsService } from '../langua
import { ILanguageModelToolsService } from '../tools/languageModelToolsService.js';
import { ChatSessionOperationLog } from '../model/chatSessionOperationLog.js';
import { IPromptsService } from '../promptSyntax/service/promptsService.js';
import { ChatRequestHooks } from '../promptSyntax/hookSchema.js';
import { ChatRequestHooks, mergeHooks } from '../promptSyntax/hookSchema.js';

const serializedChatKey = 'interactive.sessions';

Expand Down Expand Up @@ -959,6 +959,20 @@ export class ChatService extends Disposable implements IChatService {
this.logService.warn('[ChatService] Failed to collect hooks:', error);
}

// Merge hooks from the selected custom agent's frontmatter (if any)
const agentName = options?.modeInfo?.modeInstructions?.name;
if (agentName) {
try {
const agents = await this.promptsService.getCustomAgents(token, model.sessionResource);
const customAgent = agents.find(a => a.name === agentName);
if (customAgent?.hooks) {
collectedHooks = mergeHooks(collectedHooks, customAgent.hooks);
}
} catch (error) {
this.logService.warn('[ChatService] Failed to collect agent hooks:', error);
}
}

const stopWatch = new StopWatch(false);
store.add(token.onCancellationRequested(() => {
this.trace('sendRequest', `Request for session ${model.sessionResource} was cancelled`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
*--------------------------------------------------------------------------------------------*/

import { URI } from '../../../../../base/common/uri.js';
import { toHookType, resolveHookCommand, IHookCommand } from './hookSchema.js';
import { toHookType, resolveHookCommand, IHookCommand, ChatRequestHooks } from './hookSchema.js';
import { HOOKS_BY_TARGET, HookType } from './hookTypes.js';
import { Target } from './promptTypes.js';
import { IMapValue, IValue } from './promptFileParser.js';

/**
* Cached inverse mapping from HookType to Claude hook type name.
Expand Down Expand Up @@ -189,3 +190,97 @@ function normalizeForResolve(raw: Record<string, unknown>): Record<string, unkno
}
return raw;
}

/**
* Converts an {@link IValue} YAML AST node into a plain JavaScript value
* (string, array, or object) suitable for passing to hook parsing helpers.
*/
function yamlValueToPlain(value: IValue): unknown {
switch (value.type) {
case 'scalar':
return value.value;
case 'sequence':
return value.items.map(yamlValueToPlain);
case 'map': {
const obj: Record<string, unknown> = {};
for (const prop of value.properties) {
obj[prop.key.value] = yamlValueToPlain(prop.value);
}
return obj;
}
}
}

/**
* Parses hooks from a subagent's YAML frontmatter `hooks` attribute.
*
* Supports two formats for hook entries:
*
* 1. **Direct command** (our format, without matcher):
* ```yaml
* hooks:
* PreToolUse:
* - type: command
* command: "./scripts/validate.sh"
* ```
*
* 2. **Nested with matcher** (Claude Code format):
* ```yaml
* hooks:
* PreToolUse:
* - matcher: "Bash"
* hooks:
* - type: command
* command: "./scripts/validate.sh"
* ```
*
* @param hooksMap The raw YAML map value from the `hooks` frontmatter attribute.
* @param workspaceRootUri Workspace root for resolving relative `cwd` paths.
* @param userHome User home directory path for tilde expansion.
* @param target The agent's target, used to resolve hook type names correctly.
* @returns Resolved hooks organized by hook type, ready for use in {@link ChatRequestHooks}.
*/
export function parseSubagentHooksFromYaml(
hooksMap: IMapValue,
workspaceRootUri: URI | undefined,
userHome: string,
target: Target = Target.Undefined,
): ChatRequestHooks {
const result: Record<string, IHookCommand[]> = {};
const targetHookMap = HOOKS_BY_TARGET[target] ?? HOOKS_BY_TARGET[Target.Undefined];

for (const prop of hooksMap.properties) {
const hookTypeName = prop.key.value;

// Resolve hook type name using the target's own map first, then fall back to canonical names
const hookType = targetHookMap[hookTypeName] ?? toHookType(hookTypeName);
if (!hookType) {
continue;
}

// The value must be a sequence (array of hook entries)
if (prop.value.type !== 'sequence') {
continue;
}

const commands: IHookCommand[] = [];

for (const item of prop.value.items) {
// Convert the YAML AST node to a plain object so the existing
// extractHookCommandsFromItem helper can handle both direct
// commands and nested matcher structures.
const plainItem = yamlValueToPlain(item);
const extracted = extractHookCommandsFromItem(plainItem, workspaceRootUri, userHome);
commands.push(...extracted);
}

if (commands.length > 0) {
if (!result[hookType]) {
result[hookType] = [];
}
result[hookType].push(...commands);
}
}

return result as ChatRequestHooks;
}
20 changes: 20 additions & 0 deletions src/vs/workbench/contrib/chat/common/promptSyntax/hookSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ export type ChatRequestHooks = {
readonly [K in HookType]?: readonly IHookCommand[];
};

/**
* Merges two sets of hooks by concatenating the command arrays for each hook type.
* Additional hooks are appended after the base hooks.
*/
export function mergeHooks(base: ChatRequestHooks | undefined, additional: ChatRequestHooks): ChatRequestHooks {
if (!base) {
return additional;
}

const result: Partial<Record<HookType, readonly IHookCommand[]>> = { ...base };
for (const hookType of Object.values(HookType)) {
const baseArr = base[hookType];
const additionalArr = additional[hookType];
if (additionalArr && additionalArr.length > 0) {
result[hookType] = baseArr ? [...baseArr, ...additionalArr] : additionalArr;
}
}
return result as ChatRequestHooks;
}

/**
* JSON Schema for GitHub Copilot hook configuration files.
* Hooks enable executing custom shell commands at strategic points in an agent's workflow.
Expand Down
Loading
Loading