mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
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:
parent
ad3cd20fb5
commit
609ff88969
8 changed files with 748 additions and 33 deletions
|
|
@ -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
|
||||
|
|
|
|||
373
plans/hooks-ui-enhancement-prd.md
Normal file
373
plans/hooks-ui-enhancement-prd.md
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
127
src/services/hooks/HookConfigWriter.ts
Normal file
127
src/services/hooks/HookConfigWriter.ts
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue