mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
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:
parent
c0d8507be2
commit
8ed81bcfa8
6 changed files with 768 additions and 228 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
250
src/services/hooks/__tests__/types.spec.ts
Normal file
250
src/services/hooks/__tests__/types.spec.ts
Normal 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
|
||||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue