feat: add event-specific matchers for hooks

Introduce hook event categories and event-specific matcher semantics.

- Add helper APIs to validate matchers per event
- Warn/clear invalid matchers when loading or updating config
- Update Hooks settings UI with searchable event selector and
  matcher controls that adapt to the selected event
- Add tests for matcher validation and parsing
This commit is contained in:
Toray Altas 2026-01-18 20:47:46 -05:00
parent c0d8507be2
commit 8ed81bcfa8
6 changed files with 768 additions and 228 deletions

View file

@ -7,6 +7,8 @@ import {
HookEventType as HookEventTypeSchema,
type HookEventType,
type HookUpdateData,
eventSupportsMatchers,
isValidMatcherForEvent,
} from "../../services/hooks/types"
import { copyHookConfig } from "../../services/hooks/HookConfigWriter"
import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js"
@ -3689,6 +3691,22 @@ export const webviewMessageHandler = async (
: undefined,
}
// Validate matcher against event type.
// Note: For backwards compatibility, the UI may send a single event as `events: [event]`.
// We also accept legacy/multi-event hooks without rejecting them; validation is warning/correction only.
if (hookUpdates.matcher && hookUpdates.events && hookUpdates.events.length > 0) {
const event = hookUpdates.events[0]
if (!eventSupportsMatchers(event)) {
console.warn(`[Hooks] Event "${event}" does not support matchers, clearing matcher`)
hookUpdates.matcher = undefined
} else if (!isValidMatcherForEvent(event, hookUpdates.matcher)) {
console.warn(
`[Hooks] Matcher "${hookUpdates.matcher}" is not valid for event "${event}", clearing matcher`,
)
hookUpdates.matcher = undefined
}
}
await hookManager.updateHook(message.filePath, message.hookId, hookUpdates)
await hookManager.reloadHooksConfig()
await provider.postStateToWebview()

View file

@ -22,6 +22,10 @@ import {
ResolvedHook,
HookSource,
HookEventType,
eventSupportsMatchers,
getValidMatchersForEvent,
isToolEvent,
isValidMatcherForEvent,
} from "./types"
import { getGlobalRooDirectory, getProjectRooDirectoryForCwd } from "../roo-config"
@ -100,7 +104,52 @@ function validateConfig(
return { success: false, errors }
}
function toHooksByEventMap(data: z.infer<typeof HooksConfigFileSchema>): Map<HookEventType, HookDefinition[]> {
/**
* Validate matcher against event type and log warnings for invalid combinations.
* Returns true if valid, false if invalid (but doesn't reject - for backward compat)
*/
function validateMatcherForEvent(
event: HookEventType,
matcher: string | undefined,
hookId: string,
filePath: string,
): boolean {
// If no matcher specified, always valid
if (!matcher) {
return true
}
// If event doesn't support matchers, warn
if (!eventSupportsMatchers(event)) {
console.warn(
`[Hooks] Hook "${hookId}" in ${filePath}: Event "${event}" does not support matchers, ` +
`but matcher "${matcher}" was specified. Matcher will be ignored.`,
)
return false
}
// For tool events, any non-empty matcher is potentially valid (could be regex/glob)
if (isToolEvent(event)) {
return true
}
// For other events with matchers, validate against known values
if (!isValidMatcherForEvent(event, matcher)) {
const validMatchers = getValidMatchersForEvent(event)
console.warn(
`[Hooks] Hook "${hookId}" in ${filePath}: Matcher "${matcher}" is not valid for event "${event}". ` +
`Valid matchers are: ${validMatchers?.join(", ")}`,
)
return false
}
return true
}
function toHooksByEventMap(
data: z.infer<typeof HooksConfigFileSchema>,
filePath: string,
): Map<HookEventType, HookDefinition[]> {
const hooks = new Map<HookEventType, HookDefinition[]>()
// New format: hooks: HookDefinitionWithEvents[]
@ -108,6 +157,7 @@ function toHooksByEventMap(data: z.infer<typeof HooksConfigFileSchema>): Map<Hoo
for (const def of (data as any).hooks as Array<any>) {
const events: HookEventType[] = Array.isArray(def?.events) ? def.events : []
for (const event of events) {
validateMatcherForEvent(event, def.matcher, def.id, filePath)
const hookDef: HookDefinition = {
id: def.id,
matcher: def.matcher,
@ -133,6 +183,9 @@ function toHooksByEventMap(data: z.infer<typeof HooksConfigFileSchema>): Map<Hoo
for (const [eventStr, definitions] of Object.entries(hooksRecord)) {
const event = eventStr as HookEventType
if (definitions && definitions.length > 0) {
for (const def of definitions) {
validateMatcherForEvent(event, def.matcher, def.id, filePath)
}
hooks.set(event, definitions)
}
}
@ -171,7 +224,7 @@ async function loadConfigFile(filePath: string, source: HookSource): Promise<Loa
return result
}
result.hooks = toHooksByEventMap(validated.data)
result.hooks = toHooksByEventMap(validated.data, filePath)
} catch (err) {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
// File doesn't exist - not an error, just skip

View file

@ -0,0 +1,250 @@
import { describe, it, expect } from "vitest"
import {
TOOL_EVENTS,
LIFECYCLE_EVENTS_WITH_MATCHERS,
LIFECYCLE_EVENTS_WITHOUT_MATCHERS,
TOOL_MATCHERS,
SESSION_START_MATCHERS,
NOTIFICATION_MATCHERS,
PRE_COMPACT_MATCHERS,
EVENT_MATCHER_MAP,
isToolEvent,
eventSupportsMatchers,
getValidMatchersForEvent,
isValidMatcherForEvent,
type HookEventType,
} from "../types"
describe("Event Category Constants", () => {
it("TOOL_EVENTS contains all tool-related events", () => {
expect(TOOL_EVENTS).toContain("PreToolUse")
expect(TOOL_EVENTS).toContain("PostToolUse")
expect(TOOL_EVENTS).toContain("PostToolUseFailure")
expect(TOOL_EVENTS).toContain("PermissionRequest")
expect(TOOL_EVENTS).toHaveLength(4)
})
it("LIFECYCLE_EVENTS_WITH_MATCHERS contains events that have matchers", () => {
expect(LIFECYCLE_EVENTS_WITH_MATCHERS).toContain("SessionStart")
expect(LIFECYCLE_EVENTS_WITH_MATCHERS).toContain("Notification")
expect(LIFECYCLE_EVENTS_WITH_MATCHERS).toContain("PreCompact")
expect(LIFECYCLE_EVENTS_WITH_MATCHERS).toHaveLength(3)
})
it("LIFECYCLE_EVENTS_WITHOUT_MATCHERS contains events without matchers", () => {
expect(LIFECYCLE_EVENTS_WITHOUT_MATCHERS).toContain("Stop")
expect(LIFECYCLE_EVENTS_WITHOUT_MATCHERS).toContain("SubagentStart")
expect(LIFECYCLE_EVENTS_WITHOUT_MATCHERS).toContain("SubagentStop")
expect(LIFECYCLE_EVENTS_WITHOUT_MATCHERS).toContain("SessionEnd")
expect(LIFECYCLE_EVENTS_WITHOUT_MATCHERS).toContain("UserPromptSubmit")
expect(LIFECYCLE_EVENTS_WITHOUT_MATCHERS).toHaveLength(5)
})
})
describe("Matcher Constants", () => {
it("TOOL_MATCHERS contains all tool groups", () => {
expect(TOOL_MATCHERS).toContain("read")
expect(TOOL_MATCHERS).toContain("edit")
expect(TOOL_MATCHERS).toContain("browser")
expect(TOOL_MATCHERS).toContain("command")
expect(TOOL_MATCHERS).toContain("mcp")
expect(TOOL_MATCHERS).toContain("modes")
expect(TOOL_MATCHERS).toHaveLength(6)
})
it("SESSION_START_MATCHERS contains session start triggers", () => {
expect(SESSION_START_MATCHERS).toContain("startup")
expect(SESSION_START_MATCHERS).toContain("resume")
expect(SESSION_START_MATCHERS).toContain("clear")
expect(SESSION_START_MATCHERS).toContain("compact")
expect(SESSION_START_MATCHERS).toHaveLength(4)
})
it("NOTIFICATION_MATCHERS contains notification types", () => {
expect(NOTIFICATION_MATCHERS).toContain("permission_prompt")
expect(NOTIFICATION_MATCHERS).toContain("idle_prompt")
expect(NOTIFICATION_MATCHERS).toContain("auth_success")
expect(NOTIFICATION_MATCHERS).toContain("elicitation_dialog")
expect(NOTIFICATION_MATCHERS).toHaveLength(4)
})
it("PRE_COMPACT_MATCHERS contains compaction triggers", () => {
expect(PRE_COMPACT_MATCHERS).toContain("manual")
expect(PRE_COMPACT_MATCHERS).toContain("auto")
expect(PRE_COMPACT_MATCHERS).toHaveLength(2)
})
})
describe("isToolEvent", () => {
it("returns true for tool events", () => {
expect(isToolEvent("PreToolUse")).toBe(true)
expect(isToolEvent("PostToolUse")).toBe(true)
expect(isToolEvent("PostToolUseFailure")).toBe(true)
expect(isToolEvent("PermissionRequest")).toBe(true)
})
it("returns false for lifecycle events", () => {
expect(isToolEvent("SessionStart")).toBe(false)
expect(isToolEvent("SessionEnd")).toBe(false)
expect(isToolEvent("Stop")).toBe(false)
expect(isToolEvent("SubagentStart")).toBe(false)
expect(isToolEvent("SubagentStop")).toBe(false)
expect(isToolEvent("Notification")).toBe(false)
expect(isToolEvent("PreCompact")).toBe(false)
expect(isToolEvent("UserPromptSubmit")).toBe(false)
})
})
describe("eventSupportsMatchers", () => {
it("returns true for tool events", () => {
expect(eventSupportsMatchers("PreToolUse")).toBe(true)
expect(eventSupportsMatchers("PostToolUse")).toBe(true)
expect(eventSupportsMatchers("PostToolUseFailure")).toBe(true)
expect(eventSupportsMatchers("PermissionRequest")).toBe(true)
})
it("returns true for lifecycle events with matchers", () => {
expect(eventSupportsMatchers("SessionStart")).toBe(true)
expect(eventSupportsMatchers("Notification")).toBe(true)
expect(eventSupportsMatchers("PreCompact")).toBe(true)
})
it("returns false for lifecycle events without matchers", () => {
expect(eventSupportsMatchers("Stop")).toBe(false)
expect(eventSupportsMatchers("SubagentStart")).toBe(false)
expect(eventSupportsMatchers("SubagentStop")).toBe(false)
expect(eventSupportsMatchers("SessionEnd")).toBe(false)
expect(eventSupportsMatchers("UserPromptSubmit")).toBe(false)
})
})
describe("getValidMatchersForEvent", () => {
it("returns TOOL_MATCHERS for tool events", () => {
expect(getValidMatchersForEvent("PreToolUse")).toEqual(TOOL_MATCHERS)
expect(getValidMatchersForEvent("PostToolUse")).toEqual(TOOL_MATCHERS)
expect(getValidMatchersForEvent("PostToolUseFailure")).toEqual(TOOL_MATCHERS)
expect(getValidMatchersForEvent("PermissionRequest")).toEqual(TOOL_MATCHERS)
})
it("returns SESSION_START_MATCHERS for SessionStart", () => {
expect(getValidMatchersForEvent("SessionStart")).toEqual(SESSION_START_MATCHERS)
})
it("returns NOTIFICATION_MATCHERS for Notification", () => {
expect(getValidMatchersForEvent("Notification")).toEqual(NOTIFICATION_MATCHERS)
})
it("returns PRE_COMPACT_MATCHERS for PreCompact", () => {
expect(getValidMatchersForEvent("PreCompact")).toEqual(PRE_COMPACT_MATCHERS)
})
it("returns null for events without matchers", () => {
expect(getValidMatchersForEvent("Stop")).toBeNull()
expect(getValidMatchersForEvent("SubagentStart")).toBeNull()
expect(getValidMatchersForEvent("SubagentStop")).toBeNull()
expect(getValidMatchersForEvent("SessionEnd")).toBeNull()
expect(getValidMatchersForEvent("UserPromptSubmit")).toBeNull()
})
})
describe("isValidMatcherForEvent", () => {
describe("for tool events", () => {
it("returns true for any non-empty string (supports regex/glob)", () => {
expect(isValidMatcherForEvent("PreToolUse", "read")).toBe(true)
expect(isValidMatcherForEvent("PreToolUse", "edit|read")).toBe(true)
expect(isValidMatcherForEvent("PreToolUse", "Write|Edit")).toBe(true)
expect(isValidMatcherForEvent("PreToolUse", "mcp__memory__.*")).toBe(true)
expect(isValidMatcherForEvent("PostToolUse", "custom-pattern")).toBe(true)
})
it("returns false for empty string", () => {
expect(isValidMatcherForEvent("PreToolUse", "")).toBe(false)
})
})
describe("for SessionStart", () => {
it("returns true for valid matchers", () => {
expect(isValidMatcherForEvent("SessionStart", "startup")).toBe(true)
expect(isValidMatcherForEvent("SessionStart", "resume")).toBe(true)
expect(isValidMatcherForEvent("SessionStart", "startup|resume")).toBe(true)
})
it("returns false for invalid matchers", () => {
expect(isValidMatcherForEvent("SessionStart", "invalid")).toBe(false)
expect(isValidMatcherForEvent("SessionStart", "read")).toBe(false)
})
})
describe("for Notification", () => {
it("returns true for valid matchers", () => {
expect(isValidMatcherForEvent("Notification", "permission_prompt")).toBe(true)
expect(isValidMatcherForEvent("Notification", "idle_prompt|auth_success")).toBe(true)
})
it("returns false for invalid matchers", () => {
expect(isValidMatcherForEvent("Notification", "invalid")).toBe(false)
expect(isValidMatcherForEvent("Notification", "read")).toBe(false)
})
})
describe("for PreCompact", () => {
it("returns true for valid matchers", () => {
expect(isValidMatcherForEvent("PreCompact", "manual")).toBe(true)
expect(isValidMatcherForEvent("PreCompact", "auto")).toBe(true)
expect(isValidMatcherForEvent("PreCompact", "manual|auto")).toBe(true)
})
it("returns false for invalid matchers", () => {
expect(isValidMatcherForEvent("PreCompact", "invalid")).toBe(false)
})
})
describe("for events without matchers", () => {
it("returns false for any matcher", () => {
expect(isValidMatcherForEvent("Stop", "anything")).toBe(false)
expect(isValidMatcherForEvent("SessionEnd", "read")).toBe(false)
expect(isValidMatcherForEvent("SubagentStart", "")).toBe(false)
})
})
})
describe("EVENT_MATCHER_MAP", () => {
it("has correct mapping for all 12 event types", () => {
// Tool events
expect(EVENT_MATCHER_MAP.PreToolUse).toEqual(TOOL_MATCHERS)
expect(EVENT_MATCHER_MAP.PostToolUse).toEqual(TOOL_MATCHERS)
expect(EVENT_MATCHER_MAP.PostToolUseFailure).toEqual(TOOL_MATCHERS)
expect(EVENT_MATCHER_MAP.PermissionRequest).toEqual(TOOL_MATCHERS)
// Lifecycle with matchers
expect(EVENT_MATCHER_MAP.SessionStart).toEqual(SESSION_START_MATCHERS)
expect(EVENT_MATCHER_MAP.Notification).toEqual(NOTIFICATION_MATCHERS)
expect(EVENT_MATCHER_MAP.PreCompact).toEqual(PRE_COMPACT_MATCHERS)
// Lifecycle without matchers
expect(EVENT_MATCHER_MAP.Stop).toBeNull()
expect(EVENT_MATCHER_MAP.SubagentStart).toBeNull()
expect(EVENT_MATCHER_MAP.SubagentStop).toBeNull()
expect(EVENT_MATCHER_MAP.SessionEnd).toBeNull()
expect(EVENT_MATCHER_MAP.UserPromptSubmit).toBeNull()
})
})
// Ensure the exported HookEventType stays aligned with our hardcoded expectations in this spec.
// This is a no-op at runtime, but TypeScript will verify the union type.
const _eventTypeSmoke: HookEventType[] = [
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"SessionStart",
"Notification",
"PreCompact",
"Stop",
"SubagentStart",
"SubagentStop",
"SessionEnd",
"UserPromptSubmit",
]
void _eventTypeSmoke

View file

@ -52,6 +52,112 @@ export function isBlockingEvent(event: HookEventType): boolean {
return BLOCKING_EVENTS.has(event)
}
// ==========================================================================
// Event Categories and Matchers
// ==========================================================================
// Event categories based on matcher semantics
export const TOOL_EVENTS = ["PreToolUse", "PostToolUse", "PostToolUseFailure", "PermissionRequest"] as const
export type ToolEvent = (typeof TOOL_EVENTS)[number]
export const LIFECYCLE_EVENTS_WITH_MATCHERS = ["SessionStart", "Notification", "PreCompact"] as const
export type LifecycleEventWithMatcher = (typeof LIFECYCLE_EVENTS_WITH_MATCHERS)[number]
export const LIFECYCLE_EVENTS_WITHOUT_MATCHERS = [
"Stop",
"SubagentStart",
"SubagentStop",
"SessionEnd",
"UserPromptSubmit",
] as const
export type LifecycleEventWithoutMatcher = (typeof LIFECYCLE_EVENTS_WITHOUT_MATCHERS)[number]
// Tool matchers (for tool events)
export const TOOL_MATCHERS = ["read", "edit", "browser", "command", "mcp", "modes"] as const
export type ToolMatcher = (typeof TOOL_MATCHERS)[number]
// Session start matchers
export const SESSION_START_MATCHERS = ["startup", "resume", "clear", "compact"] as const
export type SessionStartMatcher = (typeof SESSION_START_MATCHERS)[number]
// Notification matchers
export const NOTIFICATION_MATCHERS = ["permission_prompt", "idle_prompt", "auth_success", "elicitation_dialog"] as const
export type NotificationMatcher = (typeof NOTIFICATION_MATCHERS)[number]
// PreCompact matchers
export const PRE_COMPACT_MATCHERS = ["manual", "auto"] as const
export type PreCompactMatcher = (typeof PRE_COMPACT_MATCHERS)[number]
// Union of all event-specific matchers
export type EventMatcher = ToolMatcher | SessionStartMatcher | NotificationMatcher | PreCompactMatcher
// Map from event type to valid matcher values
export const EVENT_MATCHER_MAP: Record<HookEventType, readonly string[] | null> = {
// Tool events use tool matchers
PreToolUse: TOOL_MATCHERS,
PostToolUse: TOOL_MATCHERS,
PostToolUseFailure: TOOL_MATCHERS,
PermissionRequest: TOOL_MATCHERS,
// Lifecycle events with specific matchers
SessionStart: SESSION_START_MATCHERS,
Notification: NOTIFICATION_MATCHERS,
PreCompact: PRE_COMPACT_MATCHERS,
// Lifecycle events without matchers
Stop: null,
SubagentStart: null,
SubagentStop: null,
SessionEnd: null,
UserPromptSubmit: null,
}
/**
* Check if an event type is a tool event (uses tool matchers)
*/
export function isToolEvent(event: HookEventType): event is ToolEvent {
return (TOOL_EVENTS as readonly string[]).includes(event)
}
/**
* Check if an event type supports matchers
*/
export function eventSupportsMatchers(event: HookEventType): boolean {
return EVENT_MATCHER_MAP[event] !== null
}
/**
* Get valid matcher values for a given event type
* Returns null for events that don't support matchers
*/
export function getValidMatchersForEvent(event: HookEventType): readonly string[] | null {
return EVENT_MATCHER_MAP[event]
}
/**
* Validate if a matcher value is valid for a given event type
* For tool events, also accepts custom patterns (regex/glob)
*/
export function isValidMatcherForEvent(event: HookEventType, matcher: string): boolean {
const validMatchers = EVENT_MATCHER_MAP[event]
// Events without matchers should not have any matcher
if (validMatchers === null) {
return false
}
// For tool events, allow custom patterns (any non-empty string is valid as it could be regex/glob)
if (isToolEvent(event)) {
return matcher.length > 0
}
// For other events with matchers, check against valid values (can be | separated)
const matcherParts = matcher
.split("|")
.map((m) => m.trim())
.filter((m) => m.length > 0)
return matcherParts.every((part) => (validMatchers as readonly string[]).includes(part))
}
// ============================================================================
// Hook Definition Schema
// ============================================================================

View file

@ -10,30 +10,105 @@ import {
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
import { Button, StandardTooltip, ToggleSwitch } from "@src/components/ui"
import {
Button,
SearchableSelect,
StandardTooltip,
ToggleSwitch,
type SearchableSelectOption,
} from "@src/components/ui"
import type { HookInfo, HookExecutionRecord, HookExecutionStatusPayload } from "@roo-code/types"
import { SectionHeader } from "./SectionHeader"
import { Section } from "./Section"
const HOOK_EVENT_OPTIONS = [
"PreToolUse",
"PostToolUse",
"PostToolUseFailure",
"PermissionRequest",
"UserPromptSubmit",
"Stop",
"SubagentStop",
"SubagentStart",
"SessionStart",
"SessionEnd",
"Notification",
"PreCompact",
] as const
import {
TOOL_EVENTS,
LIFECYCLE_EVENTS_WITH_MATCHERS,
LIFECYCLE_EVENTS_WITHOUT_MATCHERS,
TOOL_MATCHERS,
SESSION_START_MATCHERS,
NOTIFICATION_MATCHERS,
PRE_COMPACT_MATCHERS,
isToolEvent,
eventSupportsMatchers,
getValidMatchersForEvent,
type HookEventType,
} from "../../../../src/services/hooks/types"
type HookEventOption = (typeof HOOK_EVENT_OPTIONS)[number]
const TOOL_EVENT_DESCRIPTIONS: Record<(typeof TOOL_EVENTS)[number], string> = {
PreToolUse: "Before a tool is executed",
PostToolUse: "After a tool executes successfully",
PostToolUseFailure: "After a tool execution fails",
PermissionRequest: "When user is shown a permission dialog",
}
const TOOL_GROUPS = ["read", "edit", "browser", "command", "mcp", "modes"] as const
type ToolGroup = (typeof TOOL_GROUPS)[number]
const LIFECYCLE_EVENT_DESCRIPTIONS_WITH_MATCHERS: Record<(typeof LIFECYCLE_EVENTS_WITH_MATCHERS)[number], string> = {
SessionStart: "When a session begins",
Notification: "When a notification is sent",
PreCompact: "Before context compaction",
}
const LIFECYCLE_EVENT_DESCRIPTIONS_WITHOUT_MATCHERS: Record<
(typeof LIFECYCLE_EVENTS_WITHOUT_MATCHERS)[number],
string
> = {
SessionEnd: "When a session ends",
Stop: "When the main agent stops",
SubagentStart: "When a subagent starts",
SubagentStop: "When a subagent stops",
UserPromptSubmit: "When user submits a prompt",
}
// Event options with descriptions for the dropdown
const EVENT_OPTIONS: { value: HookEventType; label: string; description: string; category: string }[] = [
...TOOL_EVENTS.map((event) => ({
value: event,
label: event,
description: TOOL_EVENT_DESCRIPTIONS[event],
category: "Tool Events",
})),
...LIFECYCLE_EVENTS_WITH_MATCHERS.map((event) => ({
value: event,
label: event,
description: LIFECYCLE_EVENT_DESCRIPTIONS_WITH_MATCHERS[event],
category: "Lifecycle Events",
})),
...LIFECYCLE_EVENTS_WITHOUT_MATCHERS.map((event) => ({
value: event,
label: event,
description: LIFECYCLE_EVENT_DESCRIPTIONS_WITHOUT_MATCHERS[event],
category: "Lifecycle Events",
})),
]
// Matcher display labels
const TOOL_MATCHER_LABELS: Record<string, string> = {
read: "Read (file reading)",
edit: "Edit (file writing)",
browser: "Browser (web tools)",
command: "Command (shell/bash)",
mcp: "MCP (protocol tools)",
modes: "Modes (mode tools)",
}
const SESSION_START_MATCHER_LABELS: Record<string, string> = {
startup: "Startup (new session)",
resume: "Resume (existing session)",
clear: "Clear (conversation cleared)",
compact: "Compact (context compacted)",
}
const NOTIFICATION_MATCHER_LABELS: Record<string, string> = {
permission_prompt: "Permission Prompt",
idle_prompt: "Idle Prompt",
auth_success: "Auth Success",
elicitation_dialog: "Elicitation Dialog",
}
const PRE_COMPACT_MATCHER_LABELS: Record<string, string> = {
manual: "Manual (user triggered)",
auto: "Auto (automatic)",
}
const TIMEOUT_OPTIONS: Array<{ label: string; seconds: number }> = [
{ label: "15 seconds", seconds: 15 },
@ -312,90 +387,45 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
const canEditConfig = Boolean(hook.filePath)
const hooksForId = useMemo(() => {
const enabledHooks = hooks?.enabledHooks ?? []
return enabledHooks.filter((h) => h.id === hook.id)
}, [hooks?.enabledHooks, hook.id])
const selectedEvents = useMemo(() => {
// Prefer the aggregated `hook.events` when present (newer extension state).
// Fallback to the legacy state shape where the same ID appeared once per event.
const rawEvents = (hook.events && hook.events.length > 0 ? hook.events : hooksForId.map((h) => h.event)).filter(
Boolean,
)
// Preserve stable order based on HOOK_EVENT_OPTIONS
return HOOK_EVENT_OPTIONS.filter((e) => rawEvents.includes(e))
}, [hooksForId, hook.events])
const eventTooltipText = useCallback(
(event: HookEventOption) => {
// Keep translation fallback-friendly: if keys don't exist yet, show English.
const fallbackMap: Record<HookEventOption, string> = {
PreToolUse: "Before a tool is executed. Can block execution.",
PostToolUse: "After a tool completes successfully.",
PostToolUseFailure: "After a tool fails. Can perform cleanup.",
PermissionRequest: "When a permission dialog is shown. Can auto-approve/deny.",
UserPromptSubmit: "When user submits a prompt. Can modify or block.",
Stop: "When task completes or stops. Can perform cleanup.",
SubagentStop: "When a subtask completes.",
SubagentStart: "When a subtask begins.",
SessionStart: "When a new task session starts.",
SessionEnd: "When task session fully ends.",
Notification: "When status notifications are sent.",
PreCompact: "Before context compaction occurs.",
}
return (t(`settings:hooks.eventTooltips.${event}`) as string) || fallbackMap[event] || event
},
[t],
)
const matcherRaw = hook.matcher ?? ""
const { matcherGroups, matcherCustom } = useMemo(() => {
const parts = matcherRaw
.split("|")
.map((p) => p.trim())
.filter(Boolean)
const groups = parts.filter((p): p is ToolGroup =>
(TOOL_GROUPS as readonly string[]).includes(p),
) as ToolGroup[]
const customParts = parts.filter((p) => !(TOOL_GROUPS as readonly string[]).includes(p))
return {
matcherGroups: groups,
matcherCustom: customParts.join("|"),
const selectedEvent = useMemo<HookEventType | "">(() => {
// Prefer events array if present, take first event
if (hook.events && hook.events.length > 0) {
return hook.events[0] as HookEventType
}
}, [matcherRaw])
// Fall back to legacy event field
if (hook.event) {
return hook.event as HookEventType
}
return ""
}, [hook.events, hook.event])
const customMatcherTooltip = useMemo(() => {
return (
<div className="text-xs leading-snug">
<div className="font-medium mb-1">Regex / matcher tips</div>
<ul className="list-disc list-inside space-y-1">
<li>
This field matches tool names. Use regex alternation with <code className="font-mono">|</code>{" "}
(e.g. <code className="font-mono">fetch_instructions|search_files</code>).
</li>
<li>
If you select tool groups and also add a custom matcher, they are combined with{" "}
<code className="font-mono">|</code>.
</li>
<li>
Available tool groups: <code className="font-mono">read</code>,{" "}
<code className="font-mono">edit</code>, <code className="font-mono">browser</code>,{" "}
<code className="font-mono">command</code>, <code className="font-mono">mcp</code>,{" "}
<code className="font-mono">modes</code>.
</li>
</ul>
</div>
)
const eventOptions = useMemo<SearchableSelectOption[]>(() => {
return EVENT_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label }))
}, [])
const [customMatcher, setCustomMatcher] = useState(matcherCustom)
const { matcherGroups, matcherCustom: initialMatcherCustom } = useMemo(() => {
const matcher = hook.matcher || ""
if (!selectedEvent || !eventSupportsMatchers(selectedEvent)) {
return { matcherGroups: [] as string[], matcherCustom: "" }
}
const validMatchers = getValidMatchersForEvent(selectedEvent) || []
const parts = matcher
.split("|")
.map((p) => p.trim())
.filter((p) => p.length > 0)
const groups = parts.filter((p) => (validMatchers as readonly string[]).includes(p))
const custom = parts.filter((p) => !(validMatchers as readonly string[]).includes(p)).join("|")
return { matcherGroups: groups, matcherCustom: custom }
}, [hook.matcher, selectedEvent])
const [matcherCustom, setMatcherCustom] = useState(initialMatcherCustom)
useEffect(() => {
setCustomMatcher(matcherCustom)
}, [matcherCustom])
setMatcherCustom(initialMatcherCustom)
}, [initialMatcherCustom])
useEffect(() => {
setHookIdDraft(hook.id)
@ -414,8 +444,8 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
const postHookUpdate = useCallback(
(updates: {
events?: HookEventOption[]
matcher?: string
events?: HookEventType[]
matcher?: string | undefined
timeout?: number
id?: string
command?: string
@ -433,27 +463,50 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
[hook.filePath, hook.id],
)
const selectedEventsSet = useMemo(() => new Set(selectedEvents), [selectedEvents])
const buildMatcherString = useCallback((groups: string[], custom: string): string => {
const parts = [...groups]
if (custom.trim()) {
parts.push(custom.trim())
}
return parts.join("|")
}, [])
const handleEventToggle = useCallback(
(event: HookEventOption, checked: boolean) => {
// Base events should come from the current resolved view, but we use a Set to
// ensure we merge cleanly and avoid duplicates before sending to the backend.
const nextSet = new Set<HookEventOption>(selectedEventsSet)
if (checked) {
nextSet.add(event)
} else {
nextSet.delete(event)
const handleEventChange = useCallback(
(newEvent: HookEventType) => {
let newMatcher = hook.matcher
if (!eventSupportsMatchers(newEvent)) {
newMatcher = undefined
} else if (selectedEvent && isToolEvent(selectedEvent) !== isToolEvent(newEvent)) {
newMatcher = undefined
setMatcherCustom("")
}
const next = HOOK_EVENT_OPTIONS.filter((e) => nextSet.has(e))
if (next.length === 0) {
return
}
postHookUpdate({ events: next })
postHookUpdate({
events: [newEvent],
matcher: newMatcher,
})
},
[postHookUpdate, selectedEventsSet],
[hook.matcher, postHookUpdate, selectedEvent],
)
const handleMatcherGroupToggle = useCallback(
(matcher: string) => {
const newGroups = matcherGroups.includes(matcher)
? matcherGroups.filter((g) => g !== matcher)
: [...matcherGroups, matcher]
const newMatcher = buildMatcherString(newGroups, matcherCustom)
postHookUpdate({ matcher: newMatcher || undefined })
},
[buildMatcherString, matcherCustom, matcherGroups, postHookUpdate],
)
const handleMatcherSave = useCallback(() => {
const newMatcher = buildMatcherString(matcherGroups, matcherCustom)
postHookUpdate({ matcher: newMatcher || undefined })
}, [buildMatcherString, matcherCustom, matcherGroups, postHookUpdate])
const validateHookIdDraft = useCallback(
(nextId: string): string | null => {
const trimmed = nextId.trim()
@ -518,15 +571,6 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
[commandDraft],
)
const buildMatcherString = useCallback((nextGroups: ToolGroup[], nextCustom: string) => {
const parts: string[] = [...nextGroups]
const trimmedCustom = nextCustom.trim()
if (trimmedCustom.length > 0) {
parts.push(trimmedCustom)
}
return parts.join("|")
}, [])
// Filter execution history for this specific hook
useEffect(() => {
const history = hooks?.executionHistory || []
@ -613,6 +657,19 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
<div className="flex items-center gap-2 flex-1 min-w-0">
<FishingHook className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
<code className="text-sm font-mono text-vscode-textLink-foreground truncate">{hook.id}</code>
<div className="flex items-center gap-2 min-w-0">
<span className="text-vscode-descriptionForeground">·</span>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs font-medium text-vscode-foreground truncate">
{selectedEvent || "No event"}
</span>
{selectedEvent && eventSupportsMatchers(selectedEvent) && hook.matcher && (
<span className="text-xs text-vscode-descriptionForeground truncate">
[{hook.matcher}]
</span>
)}
</div>
</div>
<span
className={`ml-auto text-xs px-2 py-0.5 rounded flex-shrink-0 ${
hook.source === "project"
@ -718,38 +775,138 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
</div>
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-2">
{t("settings:hooks.event")}
</span>
<div className="grid grid-cols-2 gap-2">
{HOOK_EVENT_OPTIONS.map((event) => (
<label
key={event}
className="flex items-center gap-2 text-xs text-vscode-foreground">
<input
type="checkbox"
checked={selectedEvents.includes(event)}
disabled={!canEditConfig || isUpdatingConfig}
onChange={(e) => handleEventToggle(event, e.target.checked)}
/>
<span className="flex items-center gap-1">
<span>{event}</span>
<StandardTooltip content={eventTooltipText(event)}>
<button
type="button"
className="text-vscode-descriptionForeground hover:text-vscode-foreground"
aria-label={t("settings:hooks.showEventDescription")}
onClick={(e) => e.preventDefault()}>
<span
className="codicon codicon-question"
style={{ fontSize: "12px" }}
/>
</button>
</StandardTooltip>
</span>
</label>
))}
{/* Event Type Dropdown */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-vscode-descriptionForeground">
Event Type
</label>
<SearchableSelect
value={selectedEvent || undefined}
disabled={!canEditConfig || isUpdatingConfig}
onValueChange={(value) => handleEventChange(value as HookEventType)}
options={eventOptions}
placeholder="Select event type..."
searchPlaceholder="Search events..."
emptyMessage="No matching events found."
className="min-w-[200px]"
data-testid={`hook-event-select-${hook.id}`}
/>
{selectedEvent && (
<span className="text-xs text-vscode-descriptionForeground">
{EVENT_OPTIONS.find((o) => o.value === selectedEvent)?.description}
</span>
)}
</div>
{/* Dynamic Matcher Section */}
{selectedEvent && (
<div className="flex flex-col gap-2 mt-2">
<label className="text-xs font-medium text-vscode-descriptionForeground">
Matchers
</label>
{!eventSupportsMatchers(selectedEvent) ? (
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground bg-vscode-editor-background rounded p-2">
<span className="codicon codicon-info" />
<span>
This event type does not use matchers - the hook will fire for
all occurrences.
</span>
</div>
) : isToolEvent(selectedEvent) ? (
<div className="flex flex-col gap-2">
<div className="grid grid-cols-3 gap-2">
{TOOL_MATCHERS.map((matcher) => (
<label
key={matcher}
className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
disabled={!canEditConfig || isUpdatingConfig}
checked={matcherGroups.includes(matcher)}
onChange={() => handleMatcherGroupToggle(matcher)}
className="accent-vscode-button-background"
/>
<span>{TOOL_MATCHER_LABELS[matcher] || matcher}</span>
</label>
))}
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-vscode-descriptionForeground">
Custom pattern (regex/glob):
</label>
<input
type="text"
disabled={!canEditConfig || isUpdatingConfig}
value={matcherCustom}
onChange={(e) => setMatcherCustom(e.target.value)}
onBlur={handleMatcherSave}
placeholder="e.g., Write|Edit or mcp__memory__.*"
className="bg-vscode-input-background text-vscode-input-foreground border border-vscode-input-border rounded px-2 py-1 text-xs"
/>
</div>
</div>
) : selectedEvent === "SessionStart" ? (
<div className="grid grid-cols-2 gap-2">
{SESSION_START_MATCHERS.map((matcher) => (
<label
key={matcher}
className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
disabled={!canEditConfig || isUpdatingConfig}
checked={matcherGroups.includes(matcher)}
onChange={() => handleMatcherGroupToggle(matcher)}
className="accent-vscode-button-background"
/>
<span>
{SESSION_START_MATCHER_LABELS[matcher] || matcher}
</span>
</label>
))}
</div>
) : selectedEvent === "Notification" ? (
<div className="grid grid-cols-2 gap-2">
{NOTIFICATION_MATCHERS.map((matcher) => (
<label
key={matcher}
className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
disabled={!canEditConfig || isUpdatingConfig}
checked={matcherGroups.includes(matcher)}
onChange={() => handleMatcherGroupToggle(matcher)}
className="accent-vscode-button-background"
/>
<span>
{NOTIFICATION_MATCHER_LABELS[matcher] || matcher}
</span>
</label>
))}
</div>
) : selectedEvent === "PreCompact" ? (
<div className="grid grid-cols-2 gap-2">
{PRE_COMPACT_MATCHERS.map((matcher) => (
<label
key={matcher}
className="flex items-center gap-1 text-xs cursor-pointer">
<input
type="checkbox"
disabled={!canEditConfig || isUpdatingConfig}
checked={matcherGroups.includes(matcher)}
onChange={() => handleMatcherGroupToggle(matcher)}
className="accent-vscode-button-background"
/>
<span>
{PRE_COMPACT_MATCHER_LABELS[matcher] || matcher}
</span>
</label>
))}
</div>
) : null}
</div>
)}
{!canEditConfig && (
<div className="text-xs text-vscode-descriptionForeground mt-2">
{t("settings:hooks.openHookFileUnavailableTooltip")}
@ -757,65 +914,6 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, o
)}
</div>
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-2">
{t("settings:hooks.matcher")}
</span>
<div className="grid grid-cols-3 gap-2">
{TOOL_GROUPS.map((group) => (
<label
key={group}
className="flex items-center gap-2 text-xs text-vscode-foreground">
<input
type="checkbox"
checked={matcherGroups.includes(group)}
disabled={!canEditConfig || isUpdatingConfig}
onChange={(e) => {
const checked = e.target.checked
const nextGroups = checked
? Array.from(new Set([...matcherGroups, group]))
: matcherGroups.filter((g) => g !== group)
const next = buildMatcherString(nextGroups, customMatcher)
postHookUpdate({ matcher: next })
}}
/>
<span>{group}</span>
</label>
))}
</div>
<div className="mt-2">
<div className="flex items-center gap-2">
<label className="text-xs text-vscode-descriptionForeground block mb-1">
Custom matcher
</label>
<StandardTooltip content={customMatcherTooltip} maxWidth={360}>
<button
type="button"
className="text-vscode-descriptionForeground hover:text-vscode-foreground"
aria-label="Custom matcher help"
onClick={(e) => e.preventDefault()}>
<span
className="codicon codicon-question"
style={{ fontSize: "12px" }}
/>
</button>
</StandardTooltip>
</div>
<input
className="w-full text-xs bg-vscode-input-background border border-vscode-input-border rounded px-2 py-1 text-vscode-foreground"
aria-label="Custom matcher"
value={customMatcher}
disabled={!canEditConfig || isUpdatingConfig}
onChange={(e) => setCustomMatcher(e.target.value)}
onBlur={() => {
const next = buildMatcherString(matcherGroups, customMatcher)
postHookUpdate({ matcher: next })
}}
placeholder="fetch_instructions|search_files"
/>
</div>
</div>
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-2">
{t("settings:hooks.timeout")}

View file

@ -74,6 +74,18 @@ vi.mock("@src/components/ui", () => ({
{children}
</button>
),
SearchableSelect: ({ value, onValueChange, options, placeholder, "data-testid": dataTestId, ...props }: any) => (
<div data-testid={dataTestId} {...props}>
<select value={value ?? ""} onChange={(e) => onValueChange && onValueChange(e.target.value)}>
<option value="">{placeholder || "Select..."}</option>
{options?.map((option: any) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
),
ToggleSwitch: ({ checked, onChange, ...props }: any) => (
<div role="switch" aria-checked={checked} onClick={onChange} data-testid={props["data-testid"]} />
),
@ -167,9 +179,10 @@ describe("HooksSettings", () => {
render(<HooksSettings />)
// Hook details should not be visible initially
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
expect(screen.queryByText(mockHook.matcher!)).not.toBeInTheDocument()
// Collapsed header should show the selected event (event-centric header)
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
// Tool events support matchers; show matcher summary in header
expect(screen.getByText(`[${mockHook.matcher}]`)).toBeInTheDocument()
// Click to expand
const hookHeader = screen.getByText(mockHook.id).closest("div")
@ -180,10 +193,12 @@ describe("HooksSettings", () => {
expect(screen.getByTestId("tab-command")).toBeInTheDocument()
expect(screen.getByTestId("tab-logs")).toBeInTheDocument()
// Check Config tab content (visible by default usually or we can check panels exist)
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
// Matcher is split into tool-group checkboxes + custom matcher input
const customMatcherInput = screen.getByLabelText("Custom matcher") as HTMLInputElement
// Check Config tab content: event dropdown should have the selected event
expect(screen.getByDisplayValue(mockHook.event)).toBeInTheDocument()
// Tool event matcher custom pattern input should contain the matcher string
const customMatcherInput = screen.getByPlaceholderText(
"e.g., Write|Edit or mcp__memory__.*",
) as HTMLInputElement
expect(customMatcherInput).toHaveValue(mockHook.matcher)
// Command textarea should be present
@ -192,8 +207,8 @@ describe("HooksSettings", () => {
// Click to collapse
fireEvent.click(hookHeader!)
// Hook details should be hidden again
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
// Hook details should be hidden again (tabs/content not rendered)
expect(screen.queryByTestId("tab-config")).not.toBeInTheDocument()
})
it("shows per-hook logs in Logs tab", () => {
@ -507,13 +522,13 @@ describe("HooksSettings", () => {
render(<HooksSettings />)
// Hook should be collapsed initially
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
// Hook should be collapsed initially (no tabs/content)
expect(screen.queryByTestId("tab-config")).not.toBeInTheDocument()
fireEvent.click(screen.getByTestId("hook-enabled-toggle-hook-1"))
// Hook should still be collapsed after toggling
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
expect(screen.queryByTestId("tab-config")).not.toBeInTheDocument()
// Toggle message should have been sent
expect(vscode.postMessage).toHaveBeenCalledWith({