diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index ac1d976877..004f978fba 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -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 diff --git a/plans/hooks-ui-enhancement-prd.md b/plans/hooks-ui-enhancement-prd.md new file mode 100644 index 0000000000..17deeca4a6 --- /dev/null +++ b/plans/hooks-ui-enhancement-prd.md @@ -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: `{hook.event}` + +**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: `{hook.timeout}s` + +**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 { + 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 diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index b0ecad6428..a916a757bf 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -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 diff --git a/src/services/hooks/HookConfigWriter.ts b/src/services/hooks/HookConfigWriter.ts new file mode 100644 index 0000000000..47b05c41c0 --- /dev/null +++ b/src/services/hooks/HookConfigWriter.ts @@ -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 { + 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)) + } +} diff --git a/src/services/hooks/HookManager.ts b/src/services/hooks/HookManager.ts index 7ff9338859..e092795937 100644 --- a/src/services/hooks/HookManager.ts +++ b/src/services/hooks/HookManager.ts @@ -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 { + await updateHookConfig(filePath, hookId, updates) + this.log("info", `Updated hook "${hookId}" in ${filePath}`) + } + /** * Clear execution history. */ diff --git a/src/services/hooks/index.ts b/src/services/hooks/index.ts index b65d568aa4..538dc7e198 100644 --- a/src/services/hooks/index.ts +++ b/src/services/hooks/index.ts @@ -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, diff --git a/src/services/hooks/types.ts b/src/services/hooks/types.ts index 1583bbebc4..664c2476db 100644 --- a/src/services/hooks/types.ts +++ b/src/services/hooks/types.ts @@ -112,6 +112,15 @@ export type HooksConfigFile = z.infer */ 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 + /** Update a hook definition in its source file */ + updateHook(filePath: string, hookId: string, updates: HookUpdateData): Promise + /** Get execution history for debugging */ getHookExecutionHistory(): HookExecution[] diff --git a/webview-ui/src/components/settings/HooksSettings.tsx b/webview-ui/src/components/settings/HooksSettings.tsx index e42d101d43..c76e9d6d1b 100644 --- a/webview-ui/src/components/settings/HooksSettings.tsx +++ b/webview-ui/src/components/settings/HooksSettings.tsx @@ -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 = ({ hook, onToggle }) => { const { hooks } = useExtensionState() const [isExpanded, setIsExpanded] = useState(false) const [hookLogs, setHookLogs] = useState([]) + 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 = ({ 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 = ({ hook, onToggle }) => {
-
-
- - {t("settings:hooks.event")} - - - {hook.event} - +
+ + {t("settings:hooks.event")} + +
+ {HOOK_EVENT_OPTIONS.map((event) => ( + + ))}
-
- - {t("settings:hooks.timeout")} - - {hook.timeout}s + {!canEditConfig && ( +
+ {t("settings:hooks.openHookFileUnavailableTooltip")} +
+ )} +
+ +
+ + {t("settings:hooks.matcher")} + +
+ {TOOL_GROUPS.map((group) => ( + + ))} +
+
+ + setCustomMatcher(e.target.value)} + onBlur={() => { + const next = buildMatcherString(matcherGroups, customMatcher) + postHookUpdate({ matcher: next }) + }} + placeholder="file.*\\.ts" + />
- {hook.matcher && ( -
- - {t("settings:hooks.matcher")} - -
-
    - {hook.matcher - .split("|") - .map((m) => m.trim()) - .filter(Boolean) - .map((m, i) => ( -
  • {m}
  • - ))} -
-
-
- )} +
+ + {t("settings:hooks.timeout")} + + { + const seconds = Number(e.target.value) + postHookUpdate({ timeout: seconds }) + }}> + {TIMEOUT_OPTIONS.map((opt) => ( + + {opt.label} + + ))} + +
{hook.shell && (