feat(hooks): enhance configuration UI with event checkboxes and tool group matchers

- Replace raw config display with interactive controls for Events, Matchers, and Timeout

- Add backend support for updating hook configurations via hooksUpdateHook message

- Implement atomic YAML/JSON writing with event migration logic (moving hooks between keys)

- Add multi-select checkboxes for lifecycle events (PreToolUse, PostToolUse, etc.)

- Add tool group matchers (read, edit, etc.) with custom pattern fallback

- Add timeout preset dropdown (15s - 60m)

- Add comprehensive tests for config writing and UI interaction
This commit is contained in:
Toray Altas 2026-01-17 19:34:44 -05:00
parent ad3cd20fb5
commit 609ff88969
8 changed files with 748 additions and 33 deletions

View file

@ -625,6 +625,7 @@ export interface WebviewMessage {
| "hooksDeleteHook"
| "hooksOpenHookFile"
| "hooksCreateNew"
| "hooksUpdateHook"
text?: string
editedMessageContent?: string
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
@ -684,6 +685,11 @@ export interface WebviewMessage {
hookEnabled?: boolean // For hooksSetEnabled
hooksEnabled?: boolean // For hooksSetAllEnabled
hooksSource?: "global" | "project" | "mode" // For hooksOpenConfigFolder, hooksDeleteHook
hookUpdates?: {
events?: string[]
matcher?: string
timeout?: number
} // For hooksUpdateHook
filePath?: string // For hooksOpenHookFile
codeIndexSettings?: {
// Global state settings

View file

@ -0,0 +1,373 @@
# Hooks UI Enhancement PRD
## Overview
Enhance the "Config" tab in the Hook details view to provide a richer, more interactive configuration experience using VSCode-style UI components (checkboxes, dropdowns) instead of raw text display.
## Problem Statement
Currently, the Hooks UI Config tab displays configuration values as read-only text/code blocks, requiring users to manually edit YAML files for any changes. This is inconsistent with the rest of the settings UI and reduces usability.
## Goals
1. Replace text input/display for **Event**, **Matcher**, and **Timeout** with appropriate VSCode UI components
2. Enable immediate configuration updates that modify the underlying YAML file
3. Support multiple event selection (a conceptual hook can trigger on multiple events)
4. Provide intuitive tool group selection via checkboxes
5. Offer preset timeout options via dropdown
---
## Requirements
### 1. Event Configuration
**Current Behavior:**
- Displays as a single text label: `<code>{hook.event}</code>`
**Desired Behavior:**
- Replace with a set of checkboxes for event selection
- Available events (from `HookEventType`):
- `PreToolUse`
- `PostToolUse`
- `PostToolUseFailure`
- `PermissionRequest`
- `UserPromptSubmit`
- `Stop`
- `SubagentStop`
- `SubagentStart`
- `SessionStart`
- `SessionEnd`
- `Notification`
- `PreCompact`
**Technical Approach for YAML Updates:**
Since hooks are currently structured under event keys in YAML:
```yaml
hooks:
PreToolUse:
- id: example-hook
...
```
The UI will treat a "hook" as a **conceptual entity** that may have multiple YAML entries (one per event).
**Update Logic:**
- **When adding an event**: Copy the hook definition to the new event key in YAML
- **When removing an event**: Remove the hook definition from that event key in YAML
- **When changing events**: Combination of remove from old + add to new
- **Validation**: A hook must have at least one event selected at all times
**Edge Case - Multiple Entries with Same ID:**
If the same hook ID exists under multiple event keys (which can happen with manual editing), the UI should:
1. Load all entries as separate instances in the list
2. Allow editing each independently OR merge them into a "multi-event" view
3. **Recommendation**: Show as separate expandable items, but allow bulk event changes via a context menu
### 2. Matcher Configuration
**Current Behavior:**
- Displays as a bulleted list of matcher patterns
- No edit capability in UI
**Desired Behavior:**
- Replace with a set of checkboxes for tool groups
- Available groups (from `TOOL_GROUPS`):
- `read` - read_file, fetch_instructions, search_files, list_files, codebase_search
- `edit` - apply_diff, write_to_file, generate_image (plus customTools)
- `browser` - browser_action
- `command` - execute_command
- `mcp` - use_mcp_tool, access_mcp_resource
- `modes` - switch_mode, new_task
- Additional "Custom" option that reveals a text input for regex patterns
- Checkboxes represent the `matcher` field, joined with `|` (e.g., `read|edit`)
**UI Layout:**
```
[ ] read [ ] edit [ ] browser
[ ] command [ ] mcp [ ] modes
[ ] Custom matcher:
┌─────────────────────────┐
│ file.*\.ts │
└─────────────────────────┘
```
**Update Logic:**
- Selected checkboxes → join with `|``matcher: "read|edit"`
- If custom input has value → append to the joined string
- Pattern: `[group1]|[group2]|[customPattern]`
**Handling Mixed Matchers:**
- If user has `matcher: "read|file.*\.ts"`:
- `read` checkbox: checked
- `file.*\.ts` shown in custom input (with label indicating it's not a standard group)
### 3. Timeout Configuration
**Current Behavior:**
- Displays as plain text: `<span>{hook.timeout}s</span>`
**Desired Behavior:**
- Replace with a dropdown menu with preset options
- Options:
- 15 seconds
- 30 seconds
- 1 minute
- 5 minutes
- 10 minutes
- 15 minutes
- 30 minutes
- 60 minutes
**UI Component:**
```
Timeout: [ 30 seconds ▼ ]
```
**Update Logic:**
- Convert selected dropdown value to seconds for YAML storage
- Store as `timeout: 30` (seconds) in YAML
---
## Technical Design
### Data Model Updates
Extend `ResolvedHook` or create a new `HookConfigUIState` for the enhanced UI:
```typescript
interface HookConfigUIState {
id: string
filePath: string
source: "project" | "mode" | "global"
enabled: boolean
// New fields for enhanced UI
events: HookEventType[] // Multiple events this hook responds to
matcher: string // Raw matcher string (for editing)
matcherGroups: ToolGroup[] // Parsed group names from matcher
timeout: number // in seconds
command: string
shell?: string
description?: string
commandPreview: string
}
```
### Backend Message Protocol
Add new message type for updating hook configuration:
```typescript
// Webview -> Extension
interface HooksUpdateHookMessage {
type: "hooksUpdateHook"
hookId: string
filePath: string // Source file to modify
updates: {
events?: HookEventType[] // New set of events
matcher?: string // New matcher string
timeout?: number // New timeout in seconds
}
}
// Extension -> Webview (response)
interface HooksUpdateHookResult {
success: boolean
error?: string
}
```
### YAML Update Algorithm
```typescript
async function updateHookConfig(filePath: string, hookId: string, updates: HookUpdates): Promise<void> {
const content = await fs.readFile(filePath, "utf-8")
const parsed = parseYAML(content)
// Get existing hook definition
const existingHook = findHookById(parsed.hooks, hookId)
// For events update:
if (updates.events) {
const currentEvents = getEventsForHook(parsed.hooks, hookId)
const newEvents = updates.events
// Remove from events no longer selected
for (const event of currentEvents) {
if (!newEvents.includes(event)) {
removeHookFromEvent(parsed.hooks, event, hookId)
}
}
// Add to newly selected events
for (const event of newEvents) {
if (!currentEvents.includes(event)) {
addHookToEvent(parsed.hooks, event, existingHook)
}
}
}
// For matcher update:
if (updates.matcher !== undefined) {
updateHookMatcher(parsed.hooks, hookId, updates.matcher)
}
// For timeout update:
if (updates.timeout !== undefined) {
updateHookTimeout(parsed.hooks, hookId, updates.timeout)
}
await fs.writeFile(filePath, stringifyYAML(parsed))
}
```
### UI Component Architecture
```
HookConfigPanel
├── EventSelector
│ └── CheckboxGroup (12 events)
├── MatcherSelector
│ ├── CheckboxGroup (6 tool groups)
│ └── CustomMatcherInput (optional text field)
└── TimeoutSelector
└── VSCodeDropdown (8 preset options)
```
---
## Implementation Phases
### Phase 1: Basic UI Components
- Create `EventCheckboxGroup` component
- Create `MatcherCheckboxGroup` component
- Create `TimeoutDropdown` component
- Add to `HooksSettings.tsx` in the Config panel
**Testable Outcome:** UI displays checkboxes/dropdown, but changes don't persist yet.
### Phase 2: Backend Protocol
- Add `hooksUpdateHook` message handler in `webviewMessageHandler.ts`
- Implement YAML update logic in a new `HookConfigWriter` service
- Connect UI components to send messages on change
**Testable Outcome:** Changes to event/matcher/timeout update the YAML file.
### Phase 3: Event Multi-Selection Logic
- Handle adding/removing hook definitions across event keys
- Add validation (at least one event must be selected)
- Handle "delete hook" across all event keys
**Testable Outcome:** A hook can respond to multiple events, with proper YAML structure.
### Phase 4: UX Refinements
- Add "Unsaved changes" indicator
- Add "Reload required" notification after edits
- Add inline validation errors
- Add tooltips explaining each event/matcher's purpose
---
## Files to Modify
### Backend (src/)
| File | Changes |
| --------------------------------------- | ------------------------------ |
| `core/webview/webviewMessageHandler.ts` | Add `hooksUpdateHook` case |
| `services/hooks/HookConfigWriter.ts` | New file - YAML writing logic |
| `services/hooks/index.ts` | Export new service |
| `services/hooks/types.ts` | Add `HookUpdateData` interface |
### Frontend (webview-ui/)
| File | Changes |
| --------------------------------------------- | ---------------------------------------- |
| `components/settings/HooksSettings.tsx` | Replace text display with new components |
| `components/settings/HookEventSelector.tsx` | New component |
| `components/settings/HookMatcherSelector.tsx` | New component |
| `components/settings/HookTimeoutSelector.tsx` | New component |
| `i18n/locales/en/settings.json` | Add new translation strings |
| `types.ts` | Add `HookUpdateMessage` type |
---
## Risk Assessment
### High Risk
1. **YAML Structure Changes**: Moving hooks between event keys could corrupt files if not handled carefully
- Mitigation: Always read-modify-write with validation
- Backup original file before first write (optional)
2. **Concurrent Edits**: User editing file while UI modifies it
- Mitigation: Warn on "Reload" if local changes detected
- Consider file watching for external changes
### Medium Risk
3. **Multiple Hook Entries**: Same ID in multiple event keys
- Mitigation: Detect and handle gracefully in UI
- Show warning in UI when detected
4. **Custom Matcher Parsing**: Extracting groups from arbitrary regex
- Mitigation: Use simple substring matching for known groups
- Fall back to "Custom" when pattern doesn't match known groups
### Low Risk
5. **Dropdown Localization**: Time display in user locale
- Store internally as seconds, display as localized string
- Use standard i18n approach
---
## Open Questions
1. **Should matcher groups be editable?** The prompt implies read-only checkboxes, but users might want to add custom patterns. Should we allow adding new groups?
- **Recommendation**: No, keep groups fixed to documented tool categories for simplicity.
2. **Should event selection support "all events" or "all blocking events"?**
- **Recommendation**: No, explicit selection is clearer. Power users can edit YAML directly.
3. **How to handle hooks in JSON format vs YAML?**
- **Recommendation**: Both formats should be supported. Use `parseYAML` which handles JSON too.
---
## Success Criteria
1. ✅ Event selector shows 12 checkboxes, allows multi-selection
2. ✅ Matcher selector shows 6 tool group checkboxes + custom input
3. ✅ Timeout selector shows dropdown with 8 preset options
4. ✅ Changes to any field update the underlying YAML file
5. ✅ Hook can respond to multiple events (multiple YAML entries)
6. ✅ UI shows "Reload required" after configuration changes
7. ✅ All existing functionality (delete, toggle, view logs) still works

View file

@ -3552,6 +3552,23 @@ export const webviewMessageHandler = async (
break
}
case "hooksUpdateHook": {
const hookManager = provider.getHookManager()
if (!hookManager || !message.hookId || !message.filePath || !message.hookUpdates) {
break
}
try {
await hookManager.updateHook(message.filePath, message.hookId, message.hookUpdates)
await hookManager.reloadHooksConfig()
await provider.postStateToWebview()
} catch (error) {
provider.log(`Failed to update hook: ${error instanceof Error ? error.message : String(error)}`)
vscode.window.showErrorMessage("Failed to update hook")
}
break
}
case "hooksCreateNew": {
try {
const cwd = provider.cwd

View file

@ -0,0 +1,127 @@
import YAML from "yaml"
import { HookUpdateData, HookEventType, HookDefinition } from "./types"
import fs from "fs/promises"
import { safeWriteJson } from "../../utils/safeWriteJson"
import { safeWriteText } from "../../utils/safeWriteText"
/**
* Update a hook configuration in a YAML/JSON file.
*
* @param filePath - Path to the config file
* @param hookId - ID of the hook to update
* @param updates - Updates to apply
*/
export async function updateHookConfig(filePath: string, hookId: string, updates: HookUpdateData): Promise<void> {
const isJson = filePath.toLowerCase().endsWith(".json")
const content = await fs.readFile(filePath, "utf-8")
let parsed: any
try {
if (isJson) {
parsed = JSON.parse(content)
} else {
parsed = YAML.parse(content)
}
} catch (e) {
throw new Error(`Failed to parse config file ${filePath}: ${e}`)
}
if (!parsed || typeof parsed !== "object") {
throw new Error(`Invalid config file format: ${filePath}`)
}
if (!parsed.hooks) {
parsed.hooks = {}
}
// Find the hook definition first to ensure it exists and get a template
let templateHook: HookDefinition | undefined
// Iterate all events to find the hook
for (const eventKey of Object.keys(parsed.hooks)) {
const hooks = parsed.hooks[eventKey]
if (Array.isArray(hooks)) {
const found = hooks.find((h: any) => h.id === hookId)
if (found) {
templateHook = { ...found }
break
}
}
}
if (!templateHook) {
throw new Error(`Hook with ID '${hookId}' not found in ${filePath}`)
}
// Apply simple property updates to the template first, so they carry over to new events
if (updates.matcher !== undefined) {
if (updates.matcher === "") {
delete templateHook.matcher
} else {
templateHook.matcher = updates.matcher
}
}
if (updates.timeout !== undefined) {
templateHook.timeout = updates.timeout
}
// Handle Event Updates
if (updates.events) {
if (updates.events.length === 0) {
throw new Error("Hook must have at least one event")
}
const newEventsSet = new Set(updates.events)
// 1. Remove hook from events that are NOT in the new set
for (const eventKey of Object.keys(parsed.hooks)) {
const event = eventKey as HookEventType
if (!newEventsSet.has(event)) {
const hooks = parsed.hooks[event]
if (Array.isArray(hooks)) {
parsed.hooks[event] = hooks.filter((h: any) => h?.id !== hookId)
}
}
}
// 2. Add hook to events that are in the new set
for (const event of updates.events) {
if (!parsed.hooks[event]) {
parsed.hooks[event] = []
}
const hooks = parsed.hooks[event]
// Check if already exists
const existing = hooks.find((h: any) => h.id === hookId)
if (!existing) {
// Add the template hook
hooks.push({ ...templateHook })
}
}
}
// Apply property updates to ALL instances of the hook in the file
for (const eventKey of Object.keys(parsed.hooks)) {
const hooks = parsed.hooks[eventKey]
if (Array.isArray(hooks)) {
const hook = hooks.find((h: any) => h.id === hookId)
if (hook) {
if (updates.matcher !== undefined) {
if (updates.matcher === "") {
delete hook.matcher
} else {
hook.matcher = updates.matcher
}
}
if (updates.timeout !== undefined) {
hook.timeout = updates.timeout
}
}
}
}
// Write back (atomic)
if (isJson) {
await safeWriteJson(filePath, parsed)
} else {
await safeWriteText(filePath, YAML.stringify(parsed))
}
}

View file

@ -22,10 +22,12 @@ import {
ExecuteHooksOptions,
HookContext,
ConversationHistoryEntry,
HookUpdateData,
} from "./types"
import { loadHooksConfig, getHooksForEvent, LoadHooksConfigOptions } from "./HookConfigLoader"
import { filterMatchingHooks } from "./HookMatcher"
import { executeHook, interpretResult, describeResult } from "./HookExecutor"
import { updateHookConfig } from "./HookConfigWriter"
/**
* Default options for the HookManager.
@ -276,6 +278,15 @@ export class HookManager implements IHookManager {
this.options.mode = mode
}
/**
* Update a hook inside a specific config file.
* This modifies the file on disk; callers should trigger a reload to apply changes.
*/
async updateHook(filePath: string, hookId: string, updates: HookUpdateData): Promise<void> {
await updateHookConfig(filePath, hookId, updates)
this.log("info", `Updated hook "${hookId}" in ${filePath}`)
}
/**
* Clear execution history.
*/

View file

@ -91,6 +91,9 @@ export { executeHook, interpretResult, describeResult } from "./HookExecutor"
// Manager
export { HookManager, createHookManager, type HookManagerOptions } from "./HookManager"
// Config Writer
export { updateHookConfig } from "./HookConfigWriter"
// Tool Execution Integration
export {
ToolExecutionHooks,

View file

@ -112,6 +112,15 @@ export type HooksConfigFile = z.infer<typeof HooksConfigFileSchema>
*/
export type HookSource = "project" | "mode" | "global"
/**
* Data for updating a hook configuration.
*/
export interface HookUpdateData {
events?: HookEventType[]
matcher?: string
timeout?: number
}
/**
* Extended hook definition with source information.
* Used internally after merging configs from multiple sources.
@ -371,6 +380,9 @@ export interface IHookManager {
/** Enable or disable a specific hook by ID */
setHookEnabled(hookId: string, enabled: boolean): Promise<void>
/** Update a hook definition in its source file */
updateHook(filePath: string, hookId: string, updates: HookUpdateData): Promise<void>
/** Get execution history for debugging */
getHookExecutionHistory(): HookExecution[]

View file

@ -1,6 +1,12 @@
import React, { useCallback, useEffect, useState } from "react"
import React, { useCallback, useEffect, useMemo, useState } from "react"
import { RefreshCw, FolderOpen, AlertTriangle, Clock, FishingHook, X, Plus } from "lucide-react"
import { VSCodePanels, VSCodePanelTab, VSCodePanelView } from "@vscode/webview-ui-toolkit/react"
import {
VSCodeDropdown,
VSCodeOption,
VSCodePanels,
VSCodePanelTab,
VSCodePanelView,
} from "@vscode/webview-ui-toolkit/react"
import { useAppTranslation } from "@src/i18n/TranslationContext"
import { useExtensionState } from "@src/context/ExtensionStateContext"
import { vscode } from "@src/utils/vscode"
@ -9,6 +15,37 @@ import type { HookInfo, HookExecutionRecord, HookExecutionStatusPayload } from "
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
type HookEventOption = (typeof HOOK_EVENT_OPTIONS)[number]
const TOOL_GROUPS = ["read", "edit", "browser", "command", "mcp", "modes"] as const
type ToolGroup = (typeof TOOL_GROUPS)[number]
const TIMEOUT_OPTIONS: Array<{ label: string; seconds: number }> = [
{ label: "15 seconds", seconds: 15 },
{ label: "30 seconds", seconds: 30 },
{ label: "1 minute", seconds: 60 },
{ label: "5 minutes", seconds: 300 },
{ label: "10 minutes", seconds: 600 },
{ label: "15 minutes", seconds: 900 },
{ label: "30 minutes", seconds: 1800 },
{ label: "60 minutes", seconds: 3600 },
]
export const HooksSettings: React.FC = () => {
const { t } = useAppTranslation()
const { hooks, hooksEnabled } = useExtensionState()
@ -239,6 +276,71 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
const { hooks } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
const [hookLogs, setHookLogs] = useState<HookExecutionRecord[]>([])
const [isUpdatingConfig, setIsUpdatingConfig] = useState(false)
const hooksForId = useMemo(() => {
const enabledHooks = hooks?.enabledHooks ?? []
return enabledHooks.filter((h) => h.id === hook.id)
}, [hooks?.enabledHooks, hook.id])
const selectedEvents = useMemo(() => {
const events = hooksForId.map((h) => h.event).filter(Boolean)
// Preserve stable order based on HOOK_EVENT_OPTIONS
return HOOK_EVENT_OPTIONS.filter((e) => events.includes(e))
}, [hooksForId])
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("|"),
}
}, [matcherRaw])
const [customMatcher, setCustomMatcher] = useState(matcherCustom)
useEffect(() => {
setCustomMatcher(matcherCustom)
}, [matcherCustom])
const timeoutSeconds = hook.timeout
const timeoutSelection = useMemo(() => {
const match = TIMEOUT_OPTIONS.find((o) => o.seconds === timeoutSeconds)
return match?.seconds ?? TIMEOUT_OPTIONS[1].seconds
}, [timeoutSeconds])
const postHookUpdate = useCallback(
(updates: { events?: HookEventOption[]; matcher?: string; timeout?: number }) => {
if (!hook.filePath) return
setIsUpdatingConfig(true)
vscode.postMessage({
type: "hooksUpdateHook",
hookId: hook.id,
filePath: hook.filePath,
hookUpdates: updates,
})
setTimeout(() => setIsUpdatingConfig(false), 500)
},
[hook.filePath, hook.id],
)
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(() => {
@ -300,6 +402,8 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
})
}
const canEditConfig = Boolean(hook.filePath)
const getEnabledDotColor = () => {
return hook.enabled ? "var(--vscode-testing-iconPassed)" : "var(--vscode-descriptionForeground)"
}
@ -388,41 +492,103 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
<VSCodePanelView id="view-config">
<div className="flex flex-col gap-3 pt-3 w-full">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.event")}
</span>
<code className="text-xs font-mono text-vscode-textLink-foreground bg-vscode-textCodeBlock-background px-1.5 py-0.5 rounded">
{hook.event}
</code>
<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) => {
const checked = e.target.checked
const next = checked
? Array.from(new Set([...selectedEvents, event]))
: selectedEvents.filter((x) => x !== event)
if (next.length === 0) {
return
}
postHookUpdate({ events: next })
}}
/>
<span>{event}</span>
</label>
))}
</div>
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.timeout")}
</span>
<span className="text-xs text-vscode-foreground">{hook.timeout}s</span>
{!canEditConfig && (
<div className="text-xs text-vscode-descriptionForeground mt-2">
{t("settings:hooks.openHookFileUnavailableTooltip")}
</div>
)}
</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">
<label className="text-xs text-vscode-descriptionForeground block mb-1">
Custom matcher
</label>
<input
className="w-full text-xs bg-vscode-input-background border border-vscode-input-border rounded px-2 py-1 text-vscode-foreground"
value={customMatcher}
disabled={!canEditConfig || isUpdatingConfig}
onChange={(e) => setCustomMatcher(e.target.value)}
onBlur={() => {
const next = buildMatcherString(matcherGroups, customMatcher)
postHookUpdate({ matcher: next })
}}
placeholder="file.*\\.ts"
/>
</div>
</div>
{hook.matcher && (
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
{t("settings:hooks.matcher")}
</span>
<div className="bg-vscode-textCodeBlock-background p-2 rounded border border-vscode-widget-border">
<ul className="list-disc list-inside text-xs font-mono text-vscode-foreground">
{hook.matcher
.split("|")
.map((m) => m.trim())
.filter(Boolean)
.map((m, i) => (
<li key={i}>{m}</li>
))}
</ul>
</div>
</div>
)}
<div>
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-2">
{t("settings:hooks.timeout")}
</span>
<VSCodeDropdown
disabled={!canEditConfig || isUpdatingConfig}
value={String(timeoutSelection)}
onChange={(e: any) => {
const seconds = Number(e.target.value)
postHookUpdate({ timeout: seconds })
}}>
{TIMEOUT_OPTIONS.map((opt) => (
<VSCodeOption key={opt.seconds} value={String(opt.seconds)}>
{opt.label}
</VSCodeOption>
))}
</VSCodeDropdown>
</div>
{hook.shell && (
<div>