mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-10-07 02:58:15 +00:00
feat(hooks): restructure yaml schema and enhance ui configuration (phase 2)
- Refactor YAML structure to be hook-centric (array-based) instead of event-keyed - Remove version key from schema and code - Add "Copy Hook" functionality to duplicate hooks - Enhance Settings UI: - Add event tooltips with descriptions - Add editable hook ID field with validation - Add multi-line command editing - Add "Copy" button - Update config loader to support both new and legacy formats (read-only compatibility) - Update config writer to always emit new format - Add comprehensive tests for migration, copy logic, and new UI components
This commit is contained in:
parent
469d7feedd
commit
d9509c639a
10 changed files with 882 additions and 225 deletions
|
|
@ -96,6 +96,7 @@ export interface ExtensionMessage {
|
|||
| "modes"
|
||||
| "taskWithAggregatedCosts"
|
||||
| "hookExecutionStatus"
|
||||
| "hooksCopyHookResult"
|
||||
text?: string
|
||||
payload?: any // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
checkpointWarning?: {
|
||||
|
|
@ -626,6 +627,7 @@ export interface WebviewMessage {
|
|||
| "hooksOpenHookFile"
|
||||
| "hooksCreateNew"
|
||||
| "hooksUpdateHook"
|
||||
| "hooksCopyHook"
|
||||
text?: string
|
||||
editedMessageContent?: string
|
||||
tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud"
|
||||
|
|
@ -688,6 +690,12 @@ export interface WebviewMessage {
|
|||
hookUpdates?: {
|
||||
events?: string[]
|
||||
matcher?: string
|
||||
id?: string
|
||||
command?: string
|
||||
enabled?: boolean
|
||||
description?: string
|
||||
shell?: string
|
||||
includeConversationHistory?: boolean
|
||||
timeout?: number
|
||||
} // For hooksUpdateHook
|
||||
filePath?: string // For hooksOpenHookFile
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ vi.mock("../../../utils/safeWriteText", () => ({
|
|||
safeWriteText: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
|
||||
vi.mock("../../../services/hooks/HookConfigWriter", async () => {
|
||||
const actual = await vi.importActual<any>("../../../services/hooks/HookConfigWriter")
|
||||
return {
|
||||
...actual,
|
||||
copyHookConfig: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("../../../api/providers/fetchers/modelCache")
|
||||
|
||||
import * as vscode from "vscode"
|
||||
|
|
@ -78,6 +86,7 @@ import * as fsUtils from "../../../utils/fs"
|
|||
import { safeWriteJson } from "../../../utils/safeWriteJson"
|
||||
import { webviewMessageHandler } from "../webviewMessageHandler"
|
||||
import type { ClineProvider } from "../ClineProvider"
|
||||
import { copyHookConfig } from "../../../services/hooks/HookConfigWriter"
|
||||
|
||||
// Create mock HookManager
|
||||
const createMockHookManager = (): IHookManager => ({
|
||||
|
|
@ -395,13 +404,10 @@ describe("webviewMessageHandler - hooks commands", () => {
|
|||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
version: "1",
|
||||
hooks: {
|
||||
PreToolUse: [
|
||||
{ id: hookId, command: "echo hi" },
|
||||
{ id: "keep", command: "echo keep" },
|
||||
],
|
||||
},
|
||||
hooks: [
|
||||
{ id: hookId, events: ["PreToolUse"], command: "echo hi" },
|
||||
{ id: "keep", events: ["PreToolUse"], command: "echo keep" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -413,10 +419,7 @@ describe("webviewMessageHandler - hooks commands", () => {
|
|||
expect(safeWriteJson).toHaveBeenCalledWith(
|
||||
hookFilePath,
|
||||
expect.objectContaining({
|
||||
version: "1",
|
||||
hooks: {
|
||||
PreToolUse: [{ id: "keep", command: "echo keep" }],
|
||||
},
|
||||
hooks: [{ id: "keep", events: ["PreToolUse"], command: "echo keep" }],
|
||||
}),
|
||||
)
|
||||
expect(mockHookManager.reloadHooksConfig).toHaveBeenCalledTimes(1)
|
||||
|
|
@ -450,10 +453,7 @@ describe("webviewMessageHandler - hooks commands", () => {
|
|||
|
||||
vi.mocked(fs.readFile).mockResolvedValueOnce(
|
||||
JSON.stringify({
|
||||
version: "1",
|
||||
hooks: {
|
||||
PreToolUse: [{ id: "keep", command: "echo keep" }],
|
||||
},
|
||||
hooks: [{ id: "keep", events: ["PreToolUse"], command: "echo keep" }],
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -469,6 +469,94 @@ describe("webviewMessageHandler - hooks commands", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("hooksCopyHook", () => {
|
||||
it("should copy hook via HookConfigWriter.copyHookConfig and then reload + post state", async () => {
|
||||
const hookId = "hook-to-copy"
|
||||
const hookFilePath = "/mock/workspace/.roo/hooks/hooks.json"
|
||||
|
||||
const hooksById = new Map<string, ResolvedHook>()
|
||||
hooksById.set(hookId, {
|
||||
id: hookId,
|
||||
event: "PreToolUse" as any,
|
||||
matcher: ".*",
|
||||
command: "echo hi",
|
||||
enabled: true,
|
||||
source: "project" as any,
|
||||
timeout: 30,
|
||||
filePath: hookFilePath,
|
||||
includeConversationHistory: false,
|
||||
} as any)
|
||||
|
||||
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: true,
|
||||
} as HooksConfigSnapshot)
|
||||
|
||||
vi.mocked(copyHookConfig as any).mockResolvedValueOnce("hook-to-copy-copy")
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksCopyHook",
|
||||
hookId,
|
||||
} as any)
|
||||
|
||||
expect(copyHookConfig).toHaveBeenCalledWith(hookFilePath, hookId)
|
||||
expect(mockHookManager.reloadHooksConfig).toHaveBeenCalledTimes(1)
|
||||
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "hooksCopyHookResult",
|
||||
success: true,
|
||||
values: expect.objectContaining({ hookId: "hook-to-copy-copy", filePath: hookFilePath }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should show error and send failure result when copy fails", async () => {
|
||||
const hookId = "hook-to-copy"
|
||||
const hookFilePath = "/mock/workspace/.roo/hooks/hooks.json"
|
||||
|
||||
const hooksById = new Map<string, ResolvedHook>()
|
||||
hooksById.set(hookId, {
|
||||
id: hookId,
|
||||
event: "PreToolUse" as any,
|
||||
matcher: ".*",
|
||||
command: "echo hi",
|
||||
enabled: true,
|
||||
source: "project" as any,
|
||||
timeout: 30,
|
||||
filePath: hookFilePath,
|
||||
includeConversationHistory: false,
|
||||
} as any)
|
||||
|
||||
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
|
||||
hooksByEvent: new Map(),
|
||||
hooksById,
|
||||
loadedAt: new Date(),
|
||||
disabledHookIds: new Set(),
|
||||
hasProjectHooks: true,
|
||||
} as HooksConfigSnapshot)
|
||||
|
||||
vi.mocked(copyHookConfig as any).mockRejectedValueOnce(new Error("boom"))
|
||||
|
||||
await webviewMessageHandler(mockClineProvider, {
|
||||
type: "hooksCopyHook",
|
||||
hookId,
|
||||
} as any)
|
||||
|
||||
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to copy hook")
|
||||
expect(mockClineProvider.postMessageToWebview).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "hooksCopyHookResult",
|
||||
success: false,
|
||||
error: expect.stringContaining("boom"),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hooksOpenHookFile", () => {
|
||||
it("should open hook file in editor when filePath is provided and file exists", async () => {
|
||||
const hookFilePath = "/mock/workspace/.roo/hooks/hooks.json"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
type HookEventType,
|
||||
type HookUpdateData,
|
||||
} from "../../services/hooks/types"
|
||||
import { copyHookConfig } from "../../services/hooks/HookConfigWriter"
|
||||
import { getRooDirectoriesForCwd } from "../../services/roo-config/index.js"
|
||||
import pWaitFor from "p-wait-for"
|
||||
import * as vscode from "vscode"
|
||||
|
|
@ -3457,14 +3458,22 @@ export const webviewMessageHandler = async (
|
|||
return false
|
||||
}
|
||||
|
||||
// New hook-centric format: hooks: [ ... ]
|
||||
let removed = false
|
||||
for (const [event, defs] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(defs)) continue
|
||||
const before = defs.length
|
||||
const after = defs.filter((d: any) => d?.id !== hookId)
|
||||
if (after.length !== before) {
|
||||
removed = true
|
||||
;(hooks as any)[event] = after
|
||||
if (Array.isArray(hooks)) {
|
||||
const before = hooks.length
|
||||
;(parsed as any).hooks = hooks.filter((d: any) => d?.id !== hookId)
|
||||
removed = (parsed as any).hooks.length !== before
|
||||
} else {
|
||||
// Legacy event-keyed format: hooks: { PreToolUse: [ ... ] }
|
||||
for (const [event, defs] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(defs)) continue
|
||||
const before = defs.length
|
||||
const after = defs.filter((d: any) => d?.id !== hookId)
|
||||
if (after.length !== before) {
|
||||
removed = true
|
||||
;(hooks as any)[event] = after
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3534,6 +3543,87 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
}
|
||||
|
||||
case "hooksCopyHook": {
|
||||
const hookManager = provider.getHookManager()
|
||||
if (!hookManager || !message.hookId) {
|
||||
break
|
||||
}
|
||||
|
||||
try {
|
||||
const hookId = message.hookId
|
||||
const snapshot = hookManager.getConfigSnapshot()
|
||||
const targetHook = snapshot?.hooksById.get(hookId)
|
||||
const targetFilePath = targetHook?.filePath
|
||||
|
||||
let newId: string | undefined
|
||||
let copiedFromFilePath: string | undefined
|
||||
|
||||
if (typeof targetFilePath === "string" && targetFilePath.length > 0) {
|
||||
newId = await copyHookConfig(targetFilePath, hookId)
|
||||
copiedFromFilePath = targetFilePath
|
||||
} else {
|
||||
// Fallback: scan all loaded roo directories for hook configs and copy matching id.
|
||||
const cwd = provider.cwd
|
||||
const rooDirs = getRooDirectoriesForCwd(cwd)
|
||||
const candidateDirs: string[] = []
|
||||
candidateDirs.push(path.join(rooDirs[0], "hooks"))
|
||||
if (message.hooksSource === "mode") {
|
||||
const mode = (await provider.getState()).mode
|
||||
candidateDirs.push(path.join(rooDirs[1], `hooks-${mode}`))
|
||||
}
|
||||
candidateDirs.push(path.join(rooDirs[1], "hooks"))
|
||||
|
||||
for (const dir of candidateDirs) {
|
||||
let entries: any[] = []
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const files = entries
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => path.join(dir, e.name))
|
||||
.filter((p) => {
|
||||
const l = p.toLowerCase()
|
||||
return l.endsWith(".json") || l.endsWith(".yaml") || l.endsWith(".yml")
|
||||
})
|
||||
.sort()
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
newId = await copyHookConfig(filePath, hookId)
|
||||
copiedFromFilePath = filePath
|
||||
break
|
||||
} catch {
|
||||
// not found / not writable; keep scanning
|
||||
}
|
||||
}
|
||||
if (newId) break
|
||||
}
|
||||
}
|
||||
|
||||
if (!newId) {
|
||||
throw new Error("Hook not found in any writable config file")
|
||||
}
|
||||
|
||||
await hookManager.reloadHooksConfig()
|
||||
await provider.postStateToWebview()
|
||||
await provider.postMessageToWebview({
|
||||
type: "hooksCopyHookResult",
|
||||
success: true,
|
||||
values: { hookId: newId, filePath: copiedFromFilePath },
|
||||
})
|
||||
} catch (error) {
|
||||
provider.log(`Failed to copy hook: ${error instanceof Error ? error.message : String(error)}`)
|
||||
vscode.window.showErrorMessage("Failed to copy hook")
|
||||
await provider.postMessageToWebview({
|
||||
type: "hooksCopyHookResult",
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case "hooksOpenHookFile": {
|
||||
const { filePath: hookFilePath } = message
|
||||
if (!hookFilePath) {
|
||||
|
|
@ -3572,7 +3662,31 @@ export const webviewMessageHandler = async (
|
|||
)
|
||||
: undefined,
|
||||
matcher: typeof message.hookUpdates.matcher === "string" ? message.hookUpdates.matcher : undefined,
|
||||
command:
|
||||
typeof (message.hookUpdates as any).command === "string"
|
||||
? (message.hookUpdates as any).command
|
||||
: undefined,
|
||||
timeout: typeof message.hookUpdates.timeout === "number" ? message.hookUpdates.timeout : undefined,
|
||||
enabled:
|
||||
typeof (message.hookUpdates as any).enabled === "boolean"
|
||||
? (message.hookUpdates as any).enabled
|
||||
: undefined,
|
||||
description:
|
||||
typeof (message.hookUpdates as any).description === "string"
|
||||
? (message.hookUpdates as any).description
|
||||
: undefined,
|
||||
shell:
|
||||
typeof (message.hookUpdates as any).shell === "string"
|
||||
? (message.hookUpdates as any).shell
|
||||
: undefined,
|
||||
includeConversationHistory:
|
||||
typeof (message.hookUpdates as any).includeConversationHistory === "boolean"
|
||||
? (message.hookUpdates as any).includeConversationHistory
|
||||
: undefined,
|
||||
id:
|
||||
typeof (message.hookUpdates as any).id === "string"
|
||||
? (message.hookUpdates as any).id
|
||||
: undefined,
|
||||
}
|
||||
|
||||
await hookManager.updateHook(message.filePath, message.hookId, hookUpdates)
|
||||
|
|
@ -3605,15 +3719,15 @@ export const webviewMessageHandler = async (
|
|||
break
|
||||
}
|
||||
|
||||
// Create the example hook file
|
||||
const exampleContent = `version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: example-hook
|
||||
matcher: "edit"
|
||||
enabled: true
|
||||
command: 'echo "Verification hook triggered"'
|
||||
timeout: 5
|
||||
// Create the example hook file (Phase 2 hook-centric schema)
|
||||
const exampleContent = `hooks:
|
||||
- id: example-hook
|
||||
events: ["PreToolUse"]
|
||||
matcher: "edit"
|
||||
enabled: true
|
||||
command: |-
|
||||
echo "Verification hook triggered"
|
||||
timeout: 5
|
||||
`
|
||||
await safeWriteText(exampleFilePath, exampleContent)
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import YAML from "yaml"
|
|||
import { z } from "zod"
|
||||
import {
|
||||
HooksConfigFileSchema,
|
||||
LegacyHooksConfigFileSchema,
|
||||
HooksConfigSnapshot,
|
||||
HookDefinition,
|
||||
ResolvedHook,
|
||||
|
|
@ -98,6 +99,46 @@ function validateConfig(
|
|||
return { success: false, errors }
|
||||
}
|
||||
|
||||
function toHooksByEventMap(data: z.infer<typeof HooksConfigFileSchema>): Map<HookEventType, HookDefinition[]> {
|
||||
const hooks = new Map<HookEventType, HookDefinition[]>()
|
||||
|
||||
// New format: hooks: HookDefinitionWithEvents[]
|
||||
if (Array.isArray((data as any).hooks)) {
|
||||
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) {
|
||||
const hookDef: HookDefinition = {
|
||||
id: def.id,
|
||||
matcher: def.matcher,
|
||||
enabled: def.enabled,
|
||||
command: def.command,
|
||||
timeout: def.timeout,
|
||||
description: def.description,
|
||||
shell: def.shell,
|
||||
includeConversationHistory: def.includeConversationHistory,
|
||||
}
|
||||
if (!hooks.has(event)) {
|
||||
hooks.set(event, [])
|
||||
}
|
||||
hooks.get(event)!.push(hookDef)
|
||||
}
|
||||
}
|
||||
return hooks
|
||||
}
|
||||
|
||||
// Legacy format: hooks: Record<Event, HookDefinition[]>
|
||||
const legacy = LegacyHooksConfigFileSchema.parse(data)
|
||||
const hooksRecord = legacy.hooks || {}
|
||||
for (const [eventStr, definitions] of Object.entries(hooksRecord)) {
|
||||
const event = eventStr as HookEventType
|
||||
if (definitions && definitions.length > 0) {
|
||||
hooks.set(event, definitions)
|
||||
}
|
||||
}
|
||||
|
||||
return hooks
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a single config file.
|
||||
*/
|
||||
|
|
@ -119,14 +160,7 @@ async function loadConfigFile(filePath: string, source: HookSource): Promise<Loa
|
|||
return result
|
||||
}
|
||||
|
||||
// Convert hooks record to Map
|
||||
const hooksRecord = validated.data.hooks || {}
|
||||
for (const [eventStr, definitions] of Object.entries(hooksRecord)) {
|
||||
const event = eventStr as HookEventType
|
||||
if (definitions && definitions.length > 0) {
|
||||
result.hooks.set(event, definitions)
|
||||
}
|
||||
}
|
||||
result.hooks = toHooksByEventMap(validated.data)
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
// File doesn't exist - not an error, just skip
|
||||
|
|
|
|||
|
|
@ -1,9 +1,50 @@
|
|||
import YAML from "yaml"
|
||||
import { HookUpdateData, HookEventType, HookDefinition } from "./types"
|
||||
import { HookUpdateData, HookEventType, HookDefinitionWithEvents } from "./types"
|
||||
import fs from "fs/promises"
|
||||
import { safeWriteJson } from "../../utils/safeWriteJson"
|
||||
import { safeWriteText } from "../../utils/safeWriteText"
|
||||
|
||||
function parseConfigContent(content: string, filePath: string): any {
|
||||
const isJson = filePath.toLowerCase().endsWith(".json")
|
||||
try {
|
||||
return isJson ? JSON.parse(content) : YAML.parse(content)
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse config file ${filePath}: ${e}`)
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNewFormatHooksArray(parsed: any): HookDefinitionWithEvents[] {
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("Invalid config file format")
|
||||
}
|
||||
if (!parsed.hooks) {
|
||||
parsed.hooks = []
|
||||
}
|
||||
if (!Array.isArray(parsed.hooks)) {
|
||||
throw new Error(
|
||||
"This operation requires a hook-centric config file (hooks: [ ... ]). " +
|
||||
"Legacy event-keyed hook configs are supported for loading but are not writable by the UI.",
|
||||
)
|
||||
}
|
||||
return parsed.hooks as HookDefinitionWithEvents[]
|
||||
}
|
||||
|
||||
function generateCopyHookId(existingIds: Set<string>, originalId: string): string {
|
||||
const base = `${originalId}-copy`
|
||||
if (!existingIds.has(base)) {
|
||||
return base
|
||||
}
|
||||
|
||||
for (let i = 2; i < 10_000; i++) {
|
||||
const candidate = `${base}-${i}`
|
||||
if (!existingIds.has(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unable to generate unique copied hook ID for '${originalId}'`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a hook configuration in a YAML/JSON file.
|
||||
*
|
||||
|
|
@ -15,107 +56,83 @@ export async function updateHookConfig(filePath: string, hookId: string, updates
|
|||
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}`)
|
||||
}
|
||||
const parsed = parseConfigContent(content, filePath)
|
||||
const hooks = ensureNewFormatHooksArray(parsed)
|
||||
|
||||
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) {
|
||||
const hookIndex = hooks.findIndex((h) => h?.id === hookId)
|
||||
if (hookIndex === -1) {
|
||||
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
|
||||
const hook = hooks[hookIndex]
|
||||
|
||||
if (updates.id !== undefined) {
|
||||
const trimmed = updates.id.trim()
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("Hook ID cannot be empty")
|
||||
}
|
||||
}
|
||||
if (updates.timeout !== undefined) {
|
||||
templateHook.timeout = updates.timeout
|
||||
if (trimmed.length > 100) {
|
||||
throw new Error("Hook ID must be 100 characters or less")
|
||||
}
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) {
|
||||
throw new Error("Hook ID must contain only letters, numbers, hyphens, and underscores")
|
||||
}
|
||||
const duplicate = hooks.some((h, idx) => idx !== hookIndex && h?.id === trimmed)
|
||||
if (duplicate) {
|
||||
throw new Error("Hook ID must be unique within this file")
|
||||
}
|
||||
hook.id = trimmed
|
||||
}
|
||||
|
||||
// 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)
|
||||
hook.events = [...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 })
|
||||
}
|
||||
if (updates.matcher !== undefined) {
|
||||
if (updates.matcher === "") {
|
||||
delete (hook as any).matcher
|
||||
} else {
|
||||
hook.matcher = updates.matcher
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
if (updates.command !== undefined) {
|
||||
const trimmed = updates.command.trim()
|
||||
if (trimmed.length === 0) {
|
||||
throw new Error("Command cannot be empty")
|
||||
}
|
||||
hook.command = updates.command
|
||||
}
|
||||
|
||||
if (updates.timeout !== undefined) {
|
||||
hook.timeout = updates.timeout
|
||||
}
|
||||
|
||||
if (updates.enabled !== undefined) {
|
||||
hook.enabled = updates.enabled
|
||||
}
|
||||
|
||||
if (updates.description !== undefined) {
|
||||
if (updates.description === "") {
|
||||
delete (hook as any).description
|
||||
} else {
|
||||
hook.description = updates.description
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.shell !== undefined) {
|
||||
if (updates.shell === "") {
|
||||
delete (hook as any).shell
|
||||
} else {
|
||||
hook.shell = updates.shell
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.includeConversationHistory !== undefined) {
|
||||
hook.includeConversationHistory = updates.includeConversationHistory
|
||||
}
|
||||
|
||||
// Write back (atomic)
|
||||
|
|
@ -125,3 +142,46 @@ export async function updateHookConfig(filePath: string, hookId: string, updates
|
|||
await safeWriteText(filePath, YAML.stringify(parsed))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Duplicate a hook within the same config file.
|
||||
*
|
||||
* The copied hook will be inserted after the source hook in the array.
|
||||
* The new hook will receive a unique ID within the file:
|
||||
* - `${id}-copy`
|
||||
* - `${id}-copy-2`, `${id}-copy-3`, ...
|
||||
*/
|
||||
export async function copyHookConfig(filePath: string, hookId: string): Promise<string> {
|
||||
const isJson = filePath.toLowerCase().endsWith(".json")
|
||||
const content = await fs.readFile(filePath, "utf-8")
|
||||
|
||||
const parsed = parseConfigContent(content, filePath)
|
||||
const hooks = ensureNewFormatHooksArray(parsed)
|
||||
|
||||
const hookIndex = hooks.findIndex((h) => h?.id === hookId)
|
||||
if (hookIndex === -1) {
|
||||
throw new Error(`Hook with ID '${hookId}' not found in ${filePath}`)
|
||||
}
|
||||
|
||||
const sourceHook = hooks[hookIndex]
|
||||
const existingIds = new Set(hooks.map((h) => h?.id).filter((id): id is string => typeof id === "string"))
|
||||
const newId = generateCopyHookId(existingIds, sourceHook.id)
|
||||
|
||||
const copied: HookDefinitionWithEvents = {
|
||||
...sourceHook,
|
||||
id: newId,
|
||||
events: Array.isArray(sourceHook.events) ? [...sourceHook.events] : [],
|
||||
}
|
||||
|
||||
// Insert right after the original hook for better UX.
|
||||
hooks.splice(hookIndex + 1, 0, copied)
|
||||
|
||||
// Write back (atomic)
|
||||
if (isJson) {
|
||||
await safeWriteJson(filePath, parsed)
|
||||
} else {
|
||||
await safeWriteText(filePath, YAML.stringify(parsed))
|
||||
}
|
||||
|
||||
return newId
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,13 +49,12 @@ describe("HookConfigLoader", () => {
|
|||
|
||||
it("should parse YAML config files", async () => {
|
||||
const yamlContent = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: lint-check
|
||||
matcher: "Edit|Write"
|
||||
command: "./lint.sh"
|
||||
timeout: 30
|
||||
- id: lint-check
|
||||
events: ["PreToolUse"]
|
||||
matcher: "Edit|Write"
|
||||
command: "./lint.sh"
|
||||
timeout: 30
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
|
|
@ -84,10 +83,7 @@ hooks:
|
|||
|
||||
it("should parse JSON config files", async () => {
|
||||
const jsonContent = JSON.stringify({
|
||||
version: "1",
|
||||
hooks: {
|
||||
PostToolUse: [{ id: "notify-slack", command: "./notify.sh" }],
|
||||
},
|
||||
hooks: [{ id: "notify-slack", events: ["PostToolUse"], command: "./notify.sh" }],
|
||||
})
|
||||
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
|
|
@ -113,11 +109,10 @@ hooks:
|
|||
it("should report validation errors for invalid config", async () => {
|
||||
// Missing required 'command' field
|
||||
const invalidYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: bad-hook
|
||||
command: ""
|
||||
- id: bad-hook
|
||||
events: ["PreToolUse"]
|
||||
command: ""
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
|
|
@ -142,18 +137,16 @@ hooks:
|
|||
|
||||
it("should merge configs with project taking precedence over global", async () => {
|
||||
const globalYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: shared-hook
|
||||
command: "global-command"
|
||||
- id: shared-hook
|
||||
events: ["PreToolUse"]
|
||||
command: "global-command"
|
||||
`
|
||||
const projectYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: shared-hook
|
||||
command: "project-command"
|
||||
- id: shared-hook
|
||||
events: ["PreToolUse"]
|
||||
command: "project-command"
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
|
|
@ -184,11 +177,10 @@ hooks:
|
|||
|
||||
it("should include mode-specific hooks when mode is provided", async () => {
|
||||
const modeYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: mode-hook
|
||||
command: "./mode-specific.sh"
|
||||
- id: mode-hook
|
||||
events: ["PreToolUse"]
|
||||
command: "./mode-specific.sh"
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
|
|
@ -212,11 +204,10 @@ hooks:
|
|||
|
||||
it("should set hasProjectHooks flag when project hooks exist", async () => {
|
||||
const projectYaml = `
|
||||
version: "1"
|
||||
hooks:
|
||||
PreToolUse:
|
||||
- id: project-hook
|
||||
command: "./project.sh"
|
||||
- id: project-hook
|
||||
events: ["PreToolUse"]
|
||||
command: "./project.sh"
|
||||
`
|
||||
mockFsPromises.readdir.mockImplementation(async (dirPath) => {
|
||||
const dir = dirPath.toString()
|
||||
|
|
|
|||
|
|
@ -58,34 +58,46 @@ export function isBlockingEvent(event: HookEventType): boolean {
|
|||
/**
|
||||
* Schema for a single hook definition within a config file.
|
||||
*/
|
||||
export const HookDefinitionSchema = z.object({
|
||||
/** Unique identifier for this hook */
|
||||
id: z.string().min(1, "Hook ID cannot be empty"),
|
||||
export const HookDefinitionSchema = z
|
||||
.object({
|
||||
/** Unique identifier for this hook */
|
||||
id: z.string().min(1, "Hook ID cannot be empty"),
|
||||
|
||||
/** Tool name filter (regex/glob pattern). If omitted, matches all tools. */
|
||||
matcher: z.string().optional(),
|
||||
/** Tool name filter (regex/glob pattern). If omitted, matches all tools. */
|
||||
matcher: z.string().optional(),
|
||||
|
||||
/** Whether this hook is enabled. Defaults to true. */
|
||||
enabled: z.boolean().optional().default(true),
|
||||
/** Whether this hook is enabled. Defaults to true. */
|
||||
enabled: z.boolean().optional().default(true),
|
||||
|
||||
/** Shell command to execute */
|
||||
command: z.string().min(1, "Command cannot be empty"),
|
||||
/** Shell command to execute */
|
||||
command: z.string().min(1, "Command cannot be empty"),
|
||||
|
||||
/** Timeout in seconds. Defaults to 60. */
|
||||
timeout: z.number().positive().optional().default(60),
|
||||
/** Timeout in seconds. Defaults to 60. */
|
||||
timeout: z.number().positive().optional().default(60),
|
||||
|
||||
/** Human-readable description of what this hook does */
|
||||
description: z.string().optional(),
|
||||
/** Human-readable description of what this hook does */
|
||||
description: z.string().optional(),
|
||||
|
||||
/** Override shell (default: user's shell on Unix, PowerShell on Windows) */
|
||||
shell: z.string().optional(),
|
||||
/** Override shell (default: user's shell on Unix, PowerShell on Windows) */
|
||||
shell: z.string().optional(),
|
||||
|
||||
/** Opt-in to receive conversation history in stdin. Defaults to false. */
|
||||
includeConversationHistory: z.boolean().optional().default(false),
|
||||
})
|
||||
/** Opt-in to receive conversation history in stdin. Defaults to false. */
|
||||
includeConversationHistory: z.boolean().optional().default(false),
|
||||
})
|
||||
.strip()
|
||||
|
||||
export type HookDefinition = z.infer<typeof HookDefinitionSchema>
|
||||
|
||||
/**
|
||||
* Schema for a single hook definition in the new (hook-centric) config format.
|
||||
*/
|
||||
export const HookDefinitionWithEventsSchema = HookDefinitionSchema.extend({
|
||||
/** Event types this hook should run for */
|
||||
events: z.array(HookEventType).min(1, "Hook must have at least one event"),
|
||||
}).strip()
|
||||
|
||||
export type HookDefinitionWithEvents = z.infer<typeof HookDefinitionWithEventsSchema>
|
||||
|
||||
// ============================================================================
|
||||
// Hook Config File Schema
|
||||
// ============================================================================
|
||||
|
|
@ -93,13 +105,27 @@ export type HookDefinition = z.infer<typeof HookDefinitionSchema>
|
|||
/**
|
||||
* Schema for a hooks configuration file (.roo/hooks/*.yaml or *.json).
|
||||
*/
|
||||
export const HooksConfigFileSchema = z.object({
|
||||
/** Config format version */
|
||||
version: z.literal("1"),
|
||||
export const LegacyHooksConfigFileSchema = z
|
||||
.object({
|
||||
/** Hooks organized by event type (legacy format) */
|
||||
hooks: z.record(HookEventType, z.array(HookDefinitionSchema)).optional().default({}),
|
||||
})
|
||||
.strip()
|
||||
|
||||
/** Hooks organized by event type */
|
||||
hooks: z.record(HookEventType, z.array(HookDefinitionSchema)).optional().default({}),
|
||||
})
|
||||
export type LegacyHooksConfigFile = z.infer<typeof LegacyHooksConfigFileSchema>
|
||||
|
||||
export const HooksConfigFileSchema = z.union([
|
||||
z
|
||||
.object({
|
||||
/** Hooks stored as an array with an events field (new format) */
|
||||
hooks: z.array(HookDefinitionWithEventsSchema).optional().default([]),
|
||||
})
|
||||
.strip(),
|
||||
LegacyHooksConfigFileSchema,
|
||||
])
|
||||
// Strip unknown keys (e.g., legacy version key)
|
||||
// so the system does not read/write/require version keys.
|
||||
// Note: .strip() is already applied to each branch above.
|
||||
|
||||
export type HooksConfigFile = z.infer<typeof HooksConfigFileSchema>
|
||||
|
||||
|
|
@ -116,9 +142,16 @@ export type HookSource = "project" | "mode" | "global"
|
|||
* Data for updating a hook configuration.
|
||||
*/
|
||||
export interface HookUpdateData {
|
||||
/** Rename the hook ID (must be unique within the file) */
|
||||
id?: string
|
||||
events?: HookEventType[]
|
||||
matcher?: string
|
||||
command?: string
|
||||
timeout?: number
|
||||
enabled?: boolean
|
||||
description?: string
|
||||
shell?: string
|
||||
includeConversationHistory?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { RefreshCw, FolderOpen, AlertTriangle, Clock, FishingHook, X, Plus } from "lucide-react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { RefreshCw, FolderOpen, AlertTriangle, Clock, FishingHook, X, Plus, Copy } from "lucide-react"
|
||||
import {
|
||||
VSCodeDropdown,
|
||||
VSCodeOption,
|
||||
|
|
@ -51,6 +51,7 @@ export const HooksSettings: React.FC = () => {
|
|||
const { hooks, hooksEnabled } = useExtensionState()
|
||||
const [executionHistory, setExecutionHistory] = useState<HookExecutionRecord[]>(hooks?.executionHistory || [])
|
||||
const [isUpdatingEnabled, setIsUpdatingEnabled] = useState(false)
|
||||
const [pendingFocusHookId, setPendingFocusHookId] = useState<string | null>(null)
|
||||
|
||||
// Master toggle state - defaults to true if not explicitly set
|
||||
const isHooksEnabled = hooksEnabled ?? true
|
||||
|
|
@ -80,6 +81,12 @@ export const HooksSettings: React.FC = () => {
|
|||
setExecutionHistory((prev) => [record, ...prev].slice(0, 50)) // Keep last 50
|
||||
}
|
||||
}
|
||||
|
||||
if (message.type === "hooksCopyHookResult") {
|
||||
if (message.success && message.values?.hookId) {
|
||||
setPendingFocusHookId(String(message.values.hookId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("message", handleMessage)
|
||||
|
|
@ -217,7 +224,13 @@ export const HooksSettings: React.FC = () => {
|
|||
) : (
|
||||
<div className="space-y-3">
|
||||
{enabledHooks.map((hook) => (
|
||||
<HookItem key={hook.id} hook={hook} onToggle={handleToggleHook} />
|
||||
<HookItem
|
||||
key={hook.id}
|
||||
hook={hook}
|
||||
onToggle={handleToggleHook}
|
||||
autoExpandHookId={pendingFocusHookId}
|
||||
onAutoExpanded={() => setPendingFocusHookId(null)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -269,14 +282,22 @@ export const HooksSettings: React.FC = () => {
|
|||
interface HookItemProps {
|
||||
hook: HookInfo
|
||||
onToggle: (hookId: string, enabled: boolean) => void
|
||||
autoExpandHookId?: string | null
|
||||
onAutoExpanded?: () => void
|
||||
}
|
||||
|
||||
const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
||||
const HookItem: React.FC<HookItemProps> = ({ hook, onToggle, autoExpandHookId, onAutoExpanded }) => {
|
||||
const { t } = useAppTranslation()
|
||||
const { hooks } = useExtensionState()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [hookLogs, setHookLogs] = useState<HookExecutionRecord[]>([])
|
||||
const [isUpdatingConfig, setIsUpdatingConfig] = useState(false)
|
||||
const [hookIdDraft, setHookIdDraft] = useState(hook.id)
|
||||
const [hookIdError, setHookIdError] = useState<string | null>(null)
|
||||
const [commandDraft, setCommandDraft] = useState(hook.commandPreview)
|
||||
const commandTextAreaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const canEditConfig = Boolean(hook.filePath)
|
||||
|
||||
const hooksForId = useMemo(() => {
|
||||
const enabledHooks = hooks?.enabledHooks ?? []
|
||||
|
|
@ -289,6 +310,29 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
return HOOK_EVENT_OPTIONS.filter((e) => events.includes(e))
|
||||
}, [hooksForId])
|
||||
|
||||
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
|
||||
|
|
@ -312,6 +356,15 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
setCustomMatcher(matcherCustom)
|
||||
}, [matcherCustom])
|
||||
|
||||
useEffect(() => {
|
||||
setHookIdDraft(hook.id)
|
||||
setHookIdError(null)
|
||||
}, [hook.id])
|
||||
|
||||
useEffect(() => {
|
||||
setCommandDraft(hook.commandPreview)
|
||||
}, [hook.commandPreview])
|
||||
|
||||
const timeoutSeconds = hook.timeout
|
||||
const timeoutSelection = useMemo(() => {
|
||||
const match = TIMEOUT_OPTIONS.find((o) => o.seconds === timeoutSeconds)
|
||||
|
|
@ -319,7 +372,13 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
}, [timeoutSeconds])
|
||||
|
||||
const postHookUpdate = useCallback(
|
||||
(updates: { events?: HookEventOption[]; matcher?: string; timeout?: number }) => {
|
||||
(updates: {
|
||||
events?: HookEventOption[]
|
||||
matcher?: string
|
||||
timeout?: number
|
||||
id?: string
|
||||
command?: string
|
||||
}) => {
|
||||
if (!hook.filePath) return
|
||||
setIsUpdatingConfig(true)
|
||||
vscode.postMessage({
|
||||
|
|
@ -333,6 +392,70 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
[hook.filePath, hook.id],
|
||||
)
|
||||
|
||||
const validateHookIdDraft = useCallback(
|
||||
(nextId: string): string | null => {
|
||||
const trimmed = nextId.trim()
|
||||
if (trimmed.length === 0) return t("settings:hooks.idErrors.required")
|
||||
if (trimmed.length > 100) return t("settings:hooks.idErrors.maxLength")
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(trimmed)) return t("settings:hooks.idErrors.invalidChars")
|
||||
|
||||
// Best-effort client-side uniqueness check within current view.
|
||||
const enabledHooks = hooks?.enabledHooks ?? []
|
||||
const duplicate = enabledHooks.some(
|
||||
(h) => h.filePath === hook.filePath && h.id === trimmed && h.id !== hook.id,
|
||||
)
|
||||
if (duplicate) return t("settings:hooks.idErrors.notUnique")
|
||||
return null
|
||||
},
|
||||
[hooks?.enabledHooks, hook.filePath, hook.id, t],
|
||||
)
|
||||
|
||||
const handleSaveHookId = useCallback(() => {
|
||||
if (!canEditConfig) return
|
||||
const error = validateHookIdDraft(hookIdDraft)
|
||||
setHookIdError(error)
|
||||
if (error) return
|
||||
const trimmed = hookIdDraft.trim()
|
||||
if (trimmed === hook.id) return
|
||||
postHookUpdate({ id: trimmed })
|
||||
}, [canEditConfig, hook.id, hookIdDraft, postHookUpdate, validateHookIdDraft])
|
||||
|
||||
const handleCopyHook = useCallback(() => {
|
||||
if (!hook.filePath) return
|
||||
vscode.postMessage({
|
||||
type: "hooksCopyHook",
|
||||
hookId: hook.id,
|
||||
})
|
||||
}, [hook.filePath, hook.id])
|
||||
|
||||
const handleSaveCommand = useCallback(() => {
|
||||
if (!canEditConfig) return
|
||||
const trimmed = commandDraft.trim()
|
||||
if (trimmed.length === 0) {
|
||||
return
|
||||
}
|
||||
postHookUpdate({ command: commandDraft })
|
||||
}, [canEditConfig, commandDraft, postHookUpdate])
|
||||
|
||||
const handleCommandKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key !== "Tab") return
|
||||
e.preventDefault()
|
||||
const el = e.currentTarget
|
||||
const start = el.selectionStart ?? 0
|
||||
const end = el.selectionEnd ?? 0
|
||||
const next = `${commandDraft.slice(0, start)}\t${commandDraft.slice(end)}`
|
||||
setCommandDraft(next)
|
||||
// Re-position cursor after state update.
|
||||
requestAnimationFrame(() => {
|
||||
if (!commandTextAreaRef.current) return
|
||||
commandTextAreaRef.current.selectionStart = start + 1
|
||||
commandTextAreaRef.current.selectionEnd = start + 1
|
||||
})
|
||||
},
|
||||
[commandDraft],
|
||||
)
|
||||
|
||||
const buildMatcherString = useCallback((nextGroups: ToolGroup[], nextCustom: string) => {
|
||||
const parts: string[] = [...nextGroups]
|
||||
const trimmedCustom = nextCustom.trim()
|
||||
|
|
@ -402,7 +525,13 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
})
|
||||
}
|
||||
|
||||
const canEditConfig = Boolean(hook.filePath)
|
||||
useEffect(() => {
|
||||
if (!autoExpandHookId) return
|
||||
if (autoExpandHookId !== hook.id) return
|
||||
setIsExpanded(true)
|
||||
onAutoExpanded?.()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [autoExpandHookId, hook.id])
|
||||
|
||||
const getEnabledDotColor = () => {
|
||||
return hook.enabled ? "var(--vscode-testing-iconPassed)" : "var(--vscode-descriptionForeground)"
|
||||
|
|
@ -434,6 +563,17 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0" onClick={(e) => e.stopPropagation()}>
|
||||
<StandardTooltip content={t("settings:hooks.copyHook")}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleCopyHook}
|
||||
disabled={!hook.filePath}
|
||||
data-testid={`hook-copy-${hook.id}`}
|
||||
aria-label={t("settings:hooks.copyHook")}>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</StandardTooltip>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
|
|
@ -490,8 +630,31 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
{hookLogs.length > 0 && <span className="ml-1 opacity-60">({hookLogs.length})</span>}
|
||||
</VSCodePanelTab>
|
||||
|
||||
<VSCodePanelView id="view-config">
|
||||
{/* VSCodePanels matches tabs to views by id. */}
|
||||
<VSCodePanelView id="config">
|
||||
<div className="flex flex-col gap-3 pt-3 w-full">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-1">
|
||||
{t("settings:hooks.hookId")}
|
||||
</span>
|
||||
<input
|
||||
className={`w-full text-xs bg-vscode-input-background border rounded px-2 py-1 text-vscode-foreground ${
|
||||
hookIdError ? "border-red-500" : "border-vscode-input-border"
|
||||
}`}
|
||||
value={hookIdDraft}
|
||||
disabled={!canEditConfig || isUpdatingConfig}
|
||||
onChange={(e) => {
|
||||
setHookIdDraft(e.target.value)
|
||||
if (hookIdError) {
|
||||
setHookIdError(validateHookIdDraft(e.target.value))
|
||||
}
|
||||
}}
|
||||
onBlur={handleSaveHookId}
|
||||
placeholder="my-hook"
|
||||
/>
|
||||
{hookIdError && <div className="text-xs text-red-400 mt-1">{hookIdError}</div>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-2">
|
||||
{t("settings:hooks.event")}
|
||||
|
|
@ -516,7 +679,21 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
postHookUpdate({ events: next })
|
||||
}}
|
||||
/>
|
||||
<span>{event}</span>
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -559,6 +736,7 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
</label>
|
||||
<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)}
|
||||
|
|
@ -612,19 +790,29 @@ const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
|
|||
</div>
|
||||
</VSCodePanelView>
|
||||
|
||||
<VSCodePanelView id="view-command">
|
||||
<VSCodePanelView id="command">
|
||||
<div className="pt-3 w-full">
|
||||
<div className="bg-vscode-textCodeBlock-background p-3 rounded border border-vscode-widget-border overflow-x-auto max-h-48 overflow-y-auto">
|
||||
<code
|
||||
data-testid={`command-preview-${hook.id}`}
|
||||
className="text-xs font-mono text-vscode-foreground whitespace-pre-wrap break-words">
|
||||
{hook.commandPreview}
|
||||
</code>
|
||||
<span className="text-xs font-medium text-vscode-descriptionForeground block mb-2">
|
||||
{t("settings:hooks.command")}
|
||||
</span>
|
||||
<textarea
|
||||
ref={commandTextAreaRef}
|
||||
data-testid={`command-textarea-${hook.id}`}
|
||||
className="w-full text-xs font-mono bg-vscode-input-background border border-vscode-input-border rounded px-2 py-2 text-vscode-foreground min-h-32"
|
||||
value={commandDraft}
|
||||
disabled={!canEditConfig || isUpdatingConfig}
|
||||
onChange={(e) => setCommandDraft(e.target.value)}
|
||||
onBlur={handleSaveCommand}
|
||||
onKeyDown={handleCommandKeyDown}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="text-xs text-vscode-descriptionForeground mt-2">
|
||||
{t("settings:hooks.commandHint")}
|
||||
</div>
|
||||
</div>
|
||||
</VSCodePanelView>
|
||||
|
||||
<VSCodePanelView id="view-logs">
|
||||
<VSCodePanelView id="logs">
|
||||
<div className="pt-3 w-full">
|
||||
{hookLogs.length === 0 ? (
|
||||
<div className="text-xs text-vscode-descriptionForeground">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,12 @@ import type { HookInfo, HookExecutionRecord, HooksState } from "@roo-code/types"
|
|||
|
||||
// Mock webview-ui-toolkit components
|
||||
vi.mock("@vscode/webview-ui-toolkit/react", () => ({
|
||||
VSCodeDropdown: ({ children, value, onChange, disabled }: any) => (
|
||||
<select value={value} onChange={onChange} disabled={disabled} data-testid="vscode-dropdown">
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
VSCodeOption: ({ children, value }: any) => <option value={value}>{children}</option>,
|
||||
VSCodePanels: ({ children, ...props }: any) => (
|
||||
<div data-testid="vscode-panels" {...props}>
|
||||
{children}
|
||||
|
|
@ -57,6 +63,7 @@ let currentHooksState = mockHooksState
|
|||
vi.mock("@src/context/ExtensionStateContext", () => ({
|
||||
useExtensionState: () => ({
|
||||
hooks: currentHooksState,
|
||||
hooksEnabled: true,
|
||||
}),
|
||||
}))
|
||||
|
||||
|
|
@ -115,13 +122,14 @@ describe("HooksSettings", () => {
|
|||
it("renders hooks list when hooks are configured", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
matcher: "git*",
|
||||
commandPreview: "echo 'Before git command'",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
description: "Test hook",
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -141,13 +149,14 @@ describe("HooksSettings", () => {
|
|||
it("expands and collapses hook accordion on click", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
matcher: "git*",
|
||||
commandPreview: "echo 'Before git command'",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
description: "Test hook",
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -173,10 +182,12 @@ describe("HooksSettings", () => {
|
|||
|
||||
// Check Config tab content (visible by default usually or we can check panels exist)
|
||||
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
|
||||
expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument()
|
||||
// Matcher is split into tool-group checkboxes + custom matcher input
|
||||
const customMatcherInput = screen.getByLabelText("Custom matcher") as HTMLInputElement
|
||||
expect(customMatcherInput).toHaveValue(mockHook.matcher)
|
||||
|
||||
// Check Command tab content
|
||||
expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument()
|
||||
// Command textarea should be present
|
||||
expect(screen.getByTestId(`command-textarea-${mockHook.id}`)).toBeInTheDocument()
|
||||
|
||||
// Click to collapse
|
||||
fireEvent.click(hookHeader!)
|
||||
|
|
@ -188,17 +199,18 @@ describe("HooksSettings", () => {
|
|||
it("shows per-hook logs in Logs tab", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
const mockRecord: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
toolName: "write_to_file",
|
||||
exitCode: 0,
|
||||
duration: 150,
|
||||
|
|
@ -219,21 +231,22 @@ describe("HooksSettings", () => {
|
|||
fireEvent.click(hookHeader!)
|
||||
|
||||
// Find Logs tab panel content
|
||||
expect(screen.getByTestId("panel-view-logs")).toBeInTheDocument()
|
||||
expect(screen.getByTestId("panel-logs")).toBeInTheDocument()
|
||||
expect(screen.getByText(mockRecord.toolName!)).toBeInTheDocument()
|
||||
expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it("renders command preview with wrapping enabled (no truncation)", () => {
|
||||
it("renders command textarea and preserves content", () => {
|
||||
const longCommand = "long_command_".repeat(20)
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
matcher: "git*",
|
||||
commandPreview: longCommand,
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -248,28 +261,25 @@ describe("HooksSettings", () => {
|
|||
const hookHeader = screen.getByText(mockHook.id).closest("div")
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
const commandCode = screen.getByTestId(`command-preview-${mockHook.id}`)
|
||||
expect(commandCode).toHaveClass("whitespace-pre-wrap")
|
||||
expect(commandCode).toHaveClass("break-words")
|
||||
expect(commandCode).not.toHaveClass("truncate")
|
||||
expect(commandCode).toHaveTextContent(longCommand)
|
||||
expect(commandCode.textContent).not.toContain("...")
|
||||
const commandTextArea = screen.getByTestId(`command-textarea-${mockHook.id}`) as HTMLTextAreaElement
|
||||
expect(commandTextArea).toHaveValue(longCommand)
|
||||
})
|
||||
|
||||
it("renders log items with wrapping enabled (no truncation)", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
const mockRecord: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
toolName: "very_long_tool_name_that_should_not_be_truncated_" + "x".repeat(20),
|
||||
exitCode: 0,
|
||||
duration: 150,
|
||||
|
|
@ -297,26 +307,28 @@ describe("HooksSettings", () => {
|
|||
it("filters logs per hook correctly", () => {
|
||||
const mockHook1: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
const mockHook2: HookInfo = {
|
||||
id: "hook-2",
|
||||
event: "after_execute_command",
|
||||
event: "PostToolUse",
|
||||
commandPreview: "echo after",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
const record1: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
toolName: "write_to_file",
|
||||
exitCode: 0,
|
||||
duration: 100,
|
||||
|
|
@ -327,7 +339,7 @@ describe("HooksSettings", () => {
|
|||
const record2: HookExecutionRecord = {
|
||||
timestamp: new Date().toISOString(),
|
||||
hookId: "hook-2",
|
||||
event: "after_execute_command",
|
||||
event: "PostToolUse",
|
||||
toolName: "read_file",
|
||||
exitCode: 0,
|
||||
duration: 50,
|
||||
|
|
@ -356,11 +368,12 @@ describe("HooksSettings", () => {
|
|||
it("shows 'no logs' message when hook has no execution history", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -449,11 +462,12 @@ describe("HooksSettings", () => {
|
|||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -477,11 +491,12 @@ describe("HooksSettings", () => {
|
|||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -511,11 +526,12 @@ describe("HooksSettings", () => {
|
|||
it("renders green status dot in collapsed row", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "global",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -535,11 +551,12 @@ describe("HooksSettings", () => {
|
|||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
|
|
@ -559,11 +576,111 @@ describe("HooksSettings", () => {
|
|||
})
|
||||
})
|
||||
|
||||
it("sends hooksCopyHook message when copy button is clicked", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
fireEvent.click(screen.getByTestId("hook-copy-hook-1"))
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksCopyHook",
|
||||
hookId: "hook-1",
|
||||
})
|
||||
})
|
||||
|
||||
it("sends hooksUpdateHook with id when Hook ID input blurs", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Expand hook
|
||||
const hookHeader = screen.getByText(mockHook.id).closest("div")
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
const idInput = screen.getByDisplayValue(mockHook.id) as HTMLInputElement
|
||||
fireEvent.change(idInput, { target: { value: "hook-1-renamed" } })
|
||||
fireEvent.blur(idInput)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksUpdateHook",
|
||||
hookId: "hook-1",
|
||||
filePath: "/path/to/hooks.json",
|
||||
hookUpdates: { id: "hook-1-renamed" },
|
||||
})
|
||||
})
|
||||
|
||||
it("sends hooksUpdateHook with command when command textarea blurs", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
timeout: 30,
|
||||
filePath: "/path/to/hooks.json",
|
||||
}
|
||||
|
||||
currentHooksState = {
|
||||
enabledHooks: [mockHook],
|
||||
executionHistory: [],
|
||||
hasProjectHooks: false,
|
||||
}
|
||||
|
||||
render(<HooksSettings />)
|
||||
|
||||
// Expand hook
|
||||
const hookHeader = screen.getByText(mockHook.id).closest("div")
|
||||
fireEvent.click(hookHeader!)
|
||||
|
||||
const commandTextArea = screen.getByTestId(`command-textarea-${mockHook.id}`)
|
||||
fireEvent.change(commandTextArea, { target: { value: "echo updated\nsecond line" } })
|
||||
fireEvent.blur(commandTextArea)
|
||||
|
||||
expect(vscode.postMessage).toHaveBeenCalledWith({
|
||||
type: "hooksUpdateHook",
|
||||
hookId: "hook-1",
|
||||
filePath: "/path/to/hooks.json",
|
||||
hookUpdates: { command: "echo updated\nsecond line" },
|
||||
})
|
||||
})
|
||||
|
||||
it("sends hooksOpenHookFile message when open file button is clicked", async () => {
|
||||
const { vscode } = await import("@src/utils/vscode")
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
|
|
@ -590,7 +707,7 @@ describe("HooksSettings", () => {
|
|||
it("shows correct tooltip for open file button when file path is available", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
|
|
@ -614,7 +731,7 @@ describe("HooksSettings", () => {
|
|||
it("shows correct tooltip for open file button when file path is unavailable", () => {
|
||||
const mockHook: HookInfo = {
|
||||
id: "hook-1",
|
||||
event: "before_execute_command",
|
||||
event: "PreToolUse",
|
||||
commandPreview: "echo test",
|
||||
enabled: true,
|
||||
source: "project",
|
||||
|
|
|
|||
|
|
@ -72,6 +72,30 @@
|
|||
"enableHooks": "Enable Hooks",
|
||||
"enableHooksDescription": "Toggle all hooks on or off at once",
|
||||
"enabled": "Enabled",
|
||||
"hookId": "Hook ID",
|
||||
"copyHook": "Copy hook",
|
||||
"showEventDescription": "Show event description",
|
||||
"commandHint": "Tip: Use Tab to insert a tab character. Changes are saved when the field loses focus, then click Reload to apply.",
|
||||
"idErrors": {
|
||||
"required": "Hook ID is required",
|
||||
"maxLength": "Hook ID must be 100 characters or less",
|
||||
"invalidChars": "Hook ID must contain only letters, numbers, hyphens, and underscores",
|
||||
"notUnique": "ID must be unique within this file"
|
||||
},
|
||||
"eventTooltips": {
|
||||
"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."
|
||||
},
|
||||
"event": "Event",
|
||||
"matcher": "Matcher",
|
||||
"command": "Command",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue