From 5f8e6308a3d8c2a606b28208b49c902c1ca69128 Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 00:30:47 -0400 Subject: [PATCH 1/2] feat(settings): add global hooks panel --- packages/types/src/__tests__/hooks.test.ts | 9 + packages/types/src/global-settings.ts | 2 + packages/types/src/vscode-extension-host.ts | 1 + src/core/config/ContextProxy.ts | 1 + .../config/__tests__/ContextProxy.spec.ts | 21 + .../config/__tests__/importExport.spec.ts | 36 ++ src/core/config/importExport.ts | 4 + src/core/webview/ClineProvider.ts | 4 + .../webview/__tests__/ClineProvider.spec.ts | 20 + .../__tests__/webviewMessageHandler.spec.ts | 65 +++ src/core/webview/webviewMessageHandler.ts | 44 +- .../src/components/settings/HooksSettings.tsx | 396 ++++++++++++++++++ .../src/components/settings/SettingsView.tsx | 15 + .../settings/__tests__/HooksSettings.spec.tsx | 94 +++++ .../settings/__tests__/SettingsView.spec.tsx | 87 ++++ webview-ui/src/i18n/locales/ca/settings.json | 44 ++ webview-ui/src/i18n/locales/de/settings.json | 44 ++ webview-ui/src/i18n/locales/en/settings.json | 49 +++ webview-ui/src/i18n/locales/es/settings.json | 44 ++ webview-ui/src/i18n/locales/fr/settings.json | 44 ++ webview-ui/src/i18n/locales/hi/settings.json | 44 ++ webview-ui/src/i18n/locales/id/settings.json | 44 ++ webview-ui/src/i18n/locales/it/settings.json | 44 ++ webview-ui/src/i18n/locales/ja/settings.json | 44 ++ webview-ui/src/i18n/locales/ko/settings.json | 44 ++ webview-ui/src/i18n/locales/nl/settings.json | 44 ++ webview-ui/src/i18n/locales/pl/settings.json | 44 ++ .../src/i18n/locales/pt-BR/settings.json | 44 ++ webview-ui/src/i18n/locales/ru/settings.json | 44 ++ webview-ui/src/i18n/locales/tr/settings.json | 44 ++ webview-ui/src/i18n/locales/vi/settings.json | 44 ++ .../src/i18n/locales/zh-CN/settings.json | 44 ++ .../src/i18n/locales/zh-TW/settings.json | 44 ++ 33 files changed, 1595 insertions(+), 1 deletion(-) create mode 100644 webview-ui/src/components/settings/HooksSettings.tsx create mode 100644 webview-ui/src/components/settings/__tests__/HooksSettings.spec.tsx diff --git a/packages/types/src/__tests__/hooks.test.ts b/packages/types/src/__tests__/hooks.test.ts index f29603cd15..a36168f2df 100644 --- a/packages/types/src/__tests__/hooks.test.ts +++ b/packages/types/src/__tests__/hooks.test.ts @@ -17,6 +17,7 @@ import { type HookDefinition, } from "../hooks.js" import { clineMessageSchema, clineSaySchema } from "../message.js" +import { GLOBAL_SETTINGS_KEYS, globalSettingsSchema } from "../global-settings.js" const encoder = new TextEncoder() @@ -29,6 +30,14 @@ const sessionHook: HookDefinition = { argv: ["node", "setup.js"], } +describe("hook global settings", () => { + it("is optional, known, and accepts an explicit empty array", () => { + expect(globalSettingsSchema.parse({}).hookDefinitions).toBeUndefined() + expect(globalSettingsSchema.parse({ hookDefinitions: [] }).hookDefinitions).toEqual([]) + expect(GLOBAL_SETTINGS_KEYS).toContain("hookDefinitions") + }) +}) + const preToolHook: HookDefinition = { id: "pre-read", name: "Check reads", diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index dc3ea072fd..32402d5411 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -3,6 +3,7 @@ import { z } from "zod" import { codebaseIndexConfigSchema, codebaseIndexModelsSchema } from "./codebase-index.js" import { experimentsSchema } from "./experiment.js" import { historyItemSchema } from "./history.js" +import { hookDefinitionsSchema } from "./hooks.js" import { customModePromptsSchema, customSupportPromptsSchema, modeConfigSchema } from "./mode.js" import { type ProviderSettings, @@ -273,6 +274,7 @@ export const globalSettingsSchema = z.object({ * Tools in this list will be excluded from prompt generation and rejected at execution time. */ disabledTools: z.array(toolNamesSchema).optional(), + hookDefinitions: hookDefinitionsSchema.optional(), }) export type GlobalSettings = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index 63d5be87a8..04a8037a81 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -322,6 +322,7 @@ export type ExtensionState = Pick< | "requestDelaySeconds" | "showWorktreesInHomeScreen" | "disabledTools" + | "hookDefinitions" > & { lockApiConfigAcrossModes?: boolean version: string diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 97d4104afc..b87a68d028 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -36,6 +36,7 @@ const globalSettingsExportSchema = globalSettingsSchema.omit({ taskHistory: true, listApiConfigMeta: true, currentApiConfigName: true, + hookDefinitions: true, }) export class ContextProxy { diff --git a/src/core/config/__tests__/ContextProxy.spec.ts b/src/core/config/__tests__/ContextProxy.spec.ts index 0a24141155..ebdd93e257 100644 --- a/src/core/config/__tests__/ContextProxy.spec.ts +++ b/src/core/config/__tests__/ContextProxy.spec.ts @@ -150,6 +150,27 @@ describe("ContextProxy", () => { }) }) + describe("hook definitions", () => { + it("initializes, stores, and excludes hook definitions from export", async () => { + const definitions = [ + { + id: "session-hook", + name: "Session hook", + enabled: false, + phase: "sessionStart" as const, + executable: "node", + argv: ["script.js"], + }, + ] + + expect(mockGlobalState.get).toHaveBeenCalledWith("hookDefinitions") + await proxy.setValue("hookDefinitions", definitions) + expect(proxy.getValue("hookDefinitions")).toEqual(definitions) + expect(mockGlobalState.update).toHaveBeenCalledWith("hookDefinitions", definitions) + expect(await proxy.export()).not.toHaveProperty("hookDefinitions") + }) + }) + describe("updateGlobalState", () => { it("should update state directly in original context", async () => { await proxy.updateGlobalState("apiProvider", "deepseek") diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 313183c795..1fe5ee47ba 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -219,6 +219,42 @@ describe("importExport", () => { ]) }) + it("does not install hook definitions from imported settings", async () => { + ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) + ;(fs.readFile as Mock).mockResolvedValue( + JSON.stringify({ + providerProfiles: { + currentApiConfigName: "test", + apiConfigs: { test: { apiProvider: "openai", id: "test-id" } }, + }, + globalSettings: { + mode: "code", + hookDefinitions: [ + { + id: "imported", + name: "Imported", + enabled: true, + phase: "sessionStart", + executable: "node", + argv: [], + }, + ], + }, + }), + ) + mockProviderSettingsManager.export.mockResolvedValue({ currentApiConfigName: "test", apiConfigs: {} }) + mockProviderSettingsManager.listConfig.mockResolvedValue([]) + + const result = await importSettings({ + providerSettingsManager: mockProviderSettingsManager, + contextProxy: mockContextProxy, + customModesManager: mockCustomModesManager, + }) + + expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code" }) + expect(result).toMatchObject({ success: true, warnings: [expect.stringContaining("cannot be imported")] }) + }) + it("should return success: false when file content is invalid", async () => { ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) diff --git a/src/core/config/importExport.ts b/src/core/config/importExport.ts index b5fd5fdf98..ba1f1d708f 100644 --- a/src/core/config/importExport.ts +++ b/src/core/config/importExport.ts @@ -97,6 +97,10 @@ function sanitizeGlobalSettings(rawGlobalSettings: unknown): { for (const [key, rawValue] of Object.entries(rawGlobalSettings)) { const path = `globalSettings.${key}` + if (key === "hookDefinitions") { + warnings.push(`Setting "${path}" was skipped: Hook definitions cannot be imported.`) + continue + } const schema = globalSettingsShape[key as keyof GlobalSettings] if (!schema) { diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 7a404c9292..cfe6e6a218 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -46,6 +46,7 @@ import { DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + DEFAULT_HOOK_DEFINITIONS, ORGANIZATION_ALLOW_ALL, DEFAULT_MODES, DEFAULT_CHECKPOINT_TIMEOUT_SECONDS, @@ -2389,6 +2390,7 @@ export class ClineProvider autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, + hookDefinitions, } = await this.getState() let cloudOrganizations: CloudOrganizationMembership[] = [] @@ -2573,6 +2575,7 @@ export class ClineProvider autoCloseZooOpenedFilesAfterUserEdited: autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, autoCloseZooOpenedNewFiles: autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + hookDefinitions: hookDefinitions ?? [...DEFAULT_HOOK_DEFINITIONS], openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -2795,6 +2798,7 @@ export class ClineProvider autoCloseZooOpenedFiles: stateValues.autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited: stateValues.autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles: stateValues.autoCloseZooOpenedNewFiles, + hookDefinitions: stateValues.hookDefinitions ?? [...DEFAULT_HOOK_DEFINITIONS], } } diff --git a/src/core/webview/__tests__/ClineProvider.spec.ts b/src/core/webview/__tests__/ClineProvider.spec.ts index 00f848bec4..63a6084242 100644 --- a/src/core/webview/__tests__/ClineProvider.spec.ts +++ b/src/core/webview/__tests__/ClineProvider.spec.ts @@ -1352,6 +1352,26 @@ describe("ClineProvider", () => { expect((await provider.getState()).showRooIgnoredFiles).toBe(false) }) + test("returns persisted hook definitions and the shared empty default in both state stages", async () => { + expect((await provider.getState()).hookDefinitions).toEqual([]) + expect((await provider.getStateToPostToWebview()).hookDefinitions).toEqual([]) + + const definitions = [ + { + id: "session-hook", + name: "Session hook", + enabled: false, + phase: "sessionStart" as const, + executable: "node", + argv: ["script.js"], + }, + ] + await provider.contextProxy.setValue("hookDefinitions", definitions) + + expect((await provider.getState()).hookDefinitions).toEqual(definitions) + expect((await provider.getStateToPostToWebview()).hookDefinitions).toEqual(definitions) + }) + test("handles updatePrompt message correctly", async () => { await provider.resolveWebviewView(mockWebviewView) const messageHandler = (mockWebviewView.webview.onDidReceiveMessage as any).mock.calls[0][0] diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index a3b76aa8b2..d3a92c080c 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -1173,6 +1173,71 @@ describe("webviewMessageHandler - destructiveCommandGuardEnabled", () => { }) }) +describe("webviewMessageHandler - hookDefinitions", () => { + const validHook = { + id: "session-hook", + name: "Session hook", + enabled: false, + phase: "sessionStart" as const, + executable: "node", + argv: ["script.js", " argument with spaces "], + } + + beforeEach(() => vi.clearAllMocks()) + + it("persists the complete hook array without normalizing command fields", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { hookDefinitions: [validHook] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("hookDefinitions", [validHook]) + expect(mockClineProvider.postStateToWebview).toHaveBeenCalledOnce() + }) + + it("persists an explicit empty array to clear hooks", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { hookDefinitions: [] }, + }) + + expect(mockClineProvider.contextProxy.setValue).toHaveBeenCalledWith("hookDefinitions", []) + }) + + it("rejects duplicate definitions atomically before persisting other settings", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { language: "en", hookDefinitions: [validHook, validHook] }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("must be unique")) + }) + + it("rejects malformed phases and command values", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { + hookDefinitions: [{ ...validHook, phase: "postToolUse", executable: " node " }] as any, + }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + it("rejects command fields that schema parsing would otherwise normalize", async () => { + await webviewMessageHandler(mockClineProvider, { + type: "updateSettings", + updatedSettings: { hookDefinitions: [{ ...validHook, executable: " node " }] }, + }) + + expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining("valid as entered")) + }) +}) + describe("webviewMessageHandler - terminalProfile", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5a28ce12d0..52d64bfab4 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -23,6 +23,7 @@ import { checkoutRestorePayloadSchema, getCompletionCheckpoint, providerIdentifiers, + hookDefinitionsSchema, } from "@roo-code/types" import { customToolRegistry } from "@roo-code/core" import { CloudService } from "@roo-code/cloud" @@ -699,8 +700,49 @@ export const webviewMessageHandler = async ( } } + let parsedHookDefinitions + if (Object.prototype.hasOwnProperty.call(message.updatedSettings, "hookDefinitions")) { + const result = hookDefinitionsSchema.safeParse(message.updatedSettings.hookDefinitions) + if (!result.success) { + const details = result.error.issues + .map((issue) => `${issue.path.join(".") || "hookDefinitions"}: ${issue.message}`) + .join(", ") + await vscode.window.showErrorMessage(`Hook settings were not saved: ${details}`) + break + } + const hasNormalizedOrUnsupportedFields = result.data.some((definition, index) => { + const raw = (message.updatedSettings?.hookDefinitions as unknown[])[index] as Record< + string, + unknown + > + const allowedKeys = new Set([ + "id", + "name", + "enabled", + "phase", + "executable", + "argv", + ...(definition.phase === "preToolUse" ? ["toolMatcher"] : []), + ]) + return ( + Object.keys(raw).some((key) => !allowedKeys.has(key)) || + raw.name !== definition.name || + raw.executable !== definition.executable || + JSON.stringify(raw.argv) !== JSON.stringify(definition.argv) || + JSON.stringify(raw.toolMatcher) !== JSON.stringify(definition.toolMatcher) + ) + }) + if (hasNormalizedOrUnsupportedFields) { + await vscode.window.showErrorMessage( + "Hook settings were not saved: command fields must be valid as entered and unsupported fields are not allowed.", + ) + break + } + parsedHookDefinitions = result.data + } + for (const [key, value] of Object.entries(message.updatedSettings)) { - let newValue = value + let newValue = key === "hookDefinitions" ? parsedHookDefinitions : value if (key === "language") { newValue = value ?? "en" diff --git a/webview-ui/src/components/settings/HooksSettings.tsx b/webview-ui/src/components/settings/HooksSettings.tsx new file mode 100644 index 0000000000..cae06388d6 --- /dev/null +++ b/webview-ui/src/components/settings/HooksSettings.tsx @@ -0,0 +1,396 @@ +import React, { useEffect, useMemo, useRef, useState } from "react" +import { Cable, Edit, Globe, Plus, Trash2, TriangleAlert, X } from "lucide-react" + +import { + hookDefinitionSchema, + hookDefinitionsSchema, + toolNames, + type HookDefinition, + type HookPhase, + type ToolName, +} from "@roo-code/types" + +import { useAppTranslation } from "@/i18n/TranslationContext" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + Button, + Checkbox, + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + Input, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + StandardTooltip, +} from "@/components/ui" + +import { SearchableSetting } from "./SearchableSetting" +import { SectionHeader } from "./SectionHeader" + +type HookDraft = { + id: string + name: string + enabled: boolean + phase: HookPhase + toolMatcher: ToolName[] + executable: string + argv: string[] +} + +type HooksSettingsProps = { + hookDefinitions: HookDefinition[] + onChange: (definitions: HookDefinition[]) => void + setErrorMessage: (message: string | undefined) => void +} + +const createDraft = (): HookDraft => ({ + id: crypto.randomUUID(), + name: "", + enabled: false, + phase: "sessionStart", + toolMatcher: [], + executable: "", + argv: [], +}) + +const toDefinition = (draft: HookDraft): unknown => + draft.phase === "preToolUse" + ? { ...draft, toolMatcher: draft.toolMatcher } + : { + id: draft.id, + name: draft.name, + enabled: draft.enabled, + phase: draft.phase, + executable: draft.executable, + argv: draft.argv, + } + +export const HooksSettings: React.FC = ({ hookDefinitions, onChange, setErrorMessage }) => { + const { t } = useAppTranslation() + const [editorOpen, setEditorOpen] = useState(false) + const [draft, setDraft] = useState(createDraft) + const [editingId, setEditingId] = useState() + const [deleteId, setDeleteId] = useState() + const ownsError = useRef(false) + + const draftResult = useMemo(() => hookDefinitionSchema.safeParse(toDefinition(draft)), [draft]) + const draftIsExact = + draftResult.success && draftResult.data.name === draft.name && draftResult.data.executable === draft.executable + const definitionsResult = useMemo(() => hookDefinitionsSchema.safeParse(hookDefinitions), [hookDefinitions]) + const validationMessage = !definitionsResult.success + ? definitionsResult.error.issues[0]?.message + : editorOpen && !draftIsExact + ? draftResult.success + ? "Name and executable must not have leading or trailing whitespace" + : draftResult.error.issues[0]?.message + : undefined + + useEffect(() => { + if (validationMessage) { + ownsError.current = true + setErrorMessage(t("settings:hooks.validation.invalid", { message: validationMessage })) + } else if (ownsError.current) { + ownsError.current = false + setErrorMessage(undefined) + } + }, [setErrorMessage, t, validationMessage]) + + useEffect( + () => () => { + if (ownsError.current) setErrorMessage(undefined) + }, + [setErrorMessage], + ) + + const openAdd = () => { + setEditingId(undefined) + setDraft(createDraft()) + setEditorOpen(true) + } + + const openEdit = (definition: HookDefinition) => { + setEditingId(definition.id) + setDraft({ + ...definition, + toolMatcher: definition.phase === "preToolUse" ? [...definition.toolMatcher] : [], + argv: [...definition.argv], + }) + setEditorOpen(true) + } + + const saveDraft = () => { + if (!draftResult.success || !draftIsExact) return + const definition = draftResult.data + onChange( + editingId + ? hookDefinitions.map((item) => (item.id === editingId ? definition : item)) + : [...hookDefinitions, definition], + ) + setEditorOpen(false) + } + + const toggleTool = (tool: ToolName, checked: boolean) => { + setDraft((current) => ({ + ...current, + toolMatcher: checked ? [...current.toolMatcher, tool] : current.toolMatcher.filter((item) => item !== tool), + })) + } + + return ( +
+
+ {t("settings:sections.hooks")} +
+ +

+ {t("settings:hooks.description")} +

+
+ +
+ + {t("settings:hooks.securityWarning")} +
+
+ +
+
+ + +
+ + {t("settings:hooks.global")} +
+ {hookDefinitions.length === 0 ? ( +
+ {t("settings:hooks.empty")} +
+ ) : ( + hookDefinitions.map((definition) => ( +
+
+
+
+ {definition.name} + + {t(`settings:hooks.phase.${definition.phase}`)} + + + {definition.enabled + ? t("settings:hooks.enabled") + : t("settings:hooks.disabled")} + +
+ {definition.phase === "preToolUse" && ( +
+ {definition.toolMatcher.join(", ")} +
+ )} +
+ {[definition.executable, ...definition.argv].join(" ")} +
+
+
+ + + + + + +
+
+
+ )) + )} +
+ + +
+ + {t("settings:hooks.footer")} +
+
+ + + + + + {editingId ? t("settings:hooks.dialog.editTitle") : t("settings:hooks.dialog.addTitle")} + + {t("settings:hooks.dialog.description")} + +
+ + + {draft.phase === "preToolUse" && ( +
+ {t("settings:hooks.fields.tools")} +

+ {t("settings:hooks.fields.toolsHint")} +

+
+ {toolNames.map((tool) => ( + + ))} +
+
+ )} + +
+ {t("settings:hooks.fields.arguments")} +

+ {t("settings:hooks.fields.argumentsHint")} +

+ {draft.argv.map((argument, index) => ( +
+ + setDraft({ + ...draft, + argv: draft.argv.map((item, itemIndex) => + itemIndex === index ? event.target.value : item, + ), + }) + } + /> + +
+ ))} + +
+ + {validationMessage && ( +

+ {t("settings:hooks.validation.invalid", { message: validationMessage })} +

+ )} +
+ + + + +
+
+ + !open && setDeleteId(undefined)}> + + + {t("settings:hooks.deleteDialog.title")} + {t("settings:hooks.deleteDialog.description")} + + + setDeleteId(undefined)}> + {t("settings:common.cancel")} + + { + onChange(hookDefinitions.filter(({ id }) => id !== deleteId)) + setDeleteId(undefined) + }}> + {t("settings:hooks.delete")} + + + + +
+ ) +} diff --git a/webview-ui/src/components/settings/SettingsView.tsx b/webview-ui/src/components/settings/SettingsView.tsx index 952c5615af..05201893b2 100644 --- a/webview-ui/src/components/settings/SettingsView.tsx +++ b/webview-ui/src/components/settings/SettingsView.tsx @@ -29,6 +29,7 @@ import { GitCommitVertical, GraduationCap, ScrollText, + Cable, } from "lucide-react" import { @@ -81,6 +82,7 @@ import PromptsSettings from "./PromptsSettings" import { SlashCommandsSettings } from "./SlashCommandsSettings" import { SkillsSettings } from "./SkillsSettings" import { RulesSettings } from "./RulesSettings" +import { HooksSettings } from "./HooksSettings" import { UISettings } from "./UISettings" import ModesView from "../modes/ModesView" import McpView from "../mcp/McpView" @@ -105,6 +107,7 @@ export const sectionNames = [ "slashCommands", "skills", "rules", + "hooks", "checkpoints", "notifications", "contextManagement", @@ -217,6 +220,7 @@ const SettingsView = forwardRef(({ onDone, t autoCloseZooOpenedFiles, autoCloseZooOpenedFilesAfterUserEdited, autoCloseZooOpenedNewFiles, + hookDefinitions, } = cachedState const apiConfiguration = useMemo(() => cachedState.apiConfiguration ?? {}, [cachedState.apiConfiguration]) @@ -448,6 +452,7 @@ const SettingsView = forwardRef(({ onDone, t openRouterImageGenerationSelectedModel, experiments, customSupportPrompts, + hookDefinitions: hookDefinitions ?? [], }, }) @@ -539,6 +544,7 @@ const SettingsView = forwardRef(({ onDone, t { id: "skills", icon: GraduationCap }, { id: "slashCommands", icon: SquareSlash }, { id: "rules", icon: ScrollText }, + { id: "hooks", icon: Cable }, { id: "autoApprove", icon: CheckCheck }, { id: "mcp", icon: Server }, { id: "checkpoints", icon: GitCommitVertical }, @@ -836,6 +842,15 @@ const SettingsView = forwardRef(({ onDone, t {/* Rules Section */} {renderTab === "rules" && } + {/* Hooks Section */} + {renderTab === "hooks" && ( + setCachedStateField("hookDefinitions", definitions)} + setErrorMessage={setErrorMessage} + /> + )} + {/* Checkpoints Section */} {renderTab === "checkpoints" && ( ({ + Button: ({ children, onClick, disabled, ...props }: any) => ( + + ), + Checkbox: ({ checked, onCheckedChange, ...props }: any) => ( + onCheckedChange(event.target.checked)} + {...props} + /> + ), + Input: (props: any) => , + StandardTooltip: ({ children }: any) => children, + Dialog: ({ children, open }: any) => (open ?
{children}
: null), + DialogContent: ({ children }: any) =>
{children}
, + DialogDescription: ({ children }: any) =>

{children}

, + DialogFooter: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, + Select: ({ children, value, onValueChange }: any) => ( + + ), + SelectContent: ({ children }: any) => <>{children}, + SelectItem: ({ children, value }: any) => , + SelectTrigger: ({ children }: any) => <>{children}, + SelectValue: () => null, + AlertDialog: ({ children, open }: any) => (open ?
{children}
: null), + AlertDialogAction: ({ children, onClick }: any) => , + AlertDialogCancel: ({ children, onClick }: any) => , + AlertDialogContent: ({ children }: any) =>
{children}
, + AlertDialogDescription: ({ children }: any) =>

{children}

, + AlertDialogFooter: ({ children }: any) =>
{children}
, + AlertDialogHeader: ({ children }: any) =>
{children}
, + AlertDialogTitle: ({ children }: any) =>

{children}

, +})) + +describe("HooksSettings", () => { + beforeEach(() => { + vi.stubGlobal("crypto", { randomUUID: () => "new-hook-id" }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it("creates a disabled hook and preserves exact argv boundaries", () => { + const onChange = vi.fn() + render() + + fireEvent.click(screen.getByTestId("add-hook")) + expect(screen.getByTestId("hook-enabled")).not.toBeChecked() + fireEvent.change(screen.getByTestId("hook-name"), { target: { value: "Load context" } }) + fireEvent.change(screen.getByTestId("hook-executable"), { target: { value: "node" } }) + fireEvent.click(screen.getByText("settings:hooks.fields.addArgument")) + fireEvent.change(screen.getByLabelText("settings:hooks.fields.argument"), { + target: { value: " argument with spaces " }, + }) + fireEvent.click(screen.getByTestId("save-hook")) + + expect(onChange).toHaveBeenCalledWith([ + { + id: "new-hook-id", + name: "Load context", + enabled: false, + phase: "sessionStart", + executable: "node", + argv: [" argument with spaces "], + }, + ]) + }) + + it("requires exact tool selection for before-tool hooks", () => { + const setErrorMessage = vi.fn() + render() + fireEvent.click(screen.getByTestId("add-hook")) + fireEvent.change(screen.getByTestId("hook-name"), { target: { value: "Guard edits" } }) + fireEvent.change(screen.getByTestId("hook-executable"), { target: { value: "guard" } }) + fireEvent.change(screen.getByRole("combobox"), { target: { value: "preToolUse" } }) + + expect(screen.getByTestId("save-hook")).toBeDisabled() + fireEvent.click(screen.getByLabelText("apply_diff")) + expect(screen.getByTestId("save-hook")).toBeEnabled() + expect(setErrorMessage).toHaveBeenCalledWith("settings:hooks.validation.invalid") + }) +}) diff --git a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx index f4defb87dd..3a35270955 100644 --- a/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/SettingsView.spec.tsx @@ -21,6 +21,34 @@ vi.mock("../ApiConfigManager", () => ({ ), })) +vi.mock("../HooksSettings", () => ({ + HooksSettings: ({ hookDefinitions, onChange }: any) => ( +
+ {hookDefinitions.length} + + +
+ ), +})) + vi.mock("@vscode/webview-ui-toolkit/react", () => ({ VSCodeButton: ({ children, onClick, appearance, "data-testid": dataTestId }: any) => appearance === "icon" ? ( @@ -574,6 +602,65 @@ describe("SettingsView - Sound Settings", () => { }) }) +describe("SettingsView - Hooks Settings", () => { + beforeEach(() => vi.clearAllMocks()) + + it("keeps hook edits cached until Save and includes the complete array", () => { + const { activateTab, getSettingsContent } = renderSettingsView({ hookDefinitions: [] }) + activateTab("hooks") + + fireEvent.click(within(getSettingsContent()).getByTestId("add-cached-hook")) + expect(vscode.postMessage).not.toHaveBeenCalledWith(expect.objectContaining({ type: "updateSettings" })) + + fireEvent.click(screen.getByTestId("save-button")) + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ + hookDefinitions: [expect.objectContaining({ id: "new-hook", enabled: false })], + }), + }), + ) + }) + + it("sends an explicit empty array when clearing hooks", () => { + const { activateTab, getSettingsContent } = renderSettingsView({ + currentApiConfigName: "hooks-profile", + hookDefinitions: [ + { + id: "existing", + name: "Existing", + enabled: false, + phase: "sessionStart", + executable: "node", + argv: [], + }, + ], + }) + activateTab("hooks") + + fireEvent.click(within(getSettingsContent()).getByTestId("clear-cached-hooks")) + fireEvent.click(screen.getByTestId("save-button")) + + expect(vscode.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: "updateSettings", + updatedSettings: expect.objectContaining({ hookDefinitions: [] }), + }), + ) + }) + + it("does not overwrite dirty hook edits on ordinary extension-state pushes", () => { + const { activateTab, getSettingsContent } = renderSettingsView({ hookDefinitions: [] }) + activateTab("hooks") + fireEvent.click(within(getSettingsContent()).getByTestId("add-cached-hook")) + + act(() => mockPostMessage({ hookDefinitions: [] })) + + expect(within(getSettingsContent()).getByTestId("hook-count")).toHaveTextContent("1") + }) +}) + describe("SettingsView - API Configuration", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/webview-ui/src/i18n/locales/ca/settings.json b/webview-ui/src/i18n/locales/ca/settings.json index 49331e3707..982c878207 100644 --- a/webview-ui/src/i18n/locales/ca/settings.json +++ b/webview-ui/src/i18n/locales/ca/settings.json @@ -26,6 +26,7 @@ "discardButton": "Descartar canvis" }, "sections": { + "hooks": "Hooks", "providers": "Proveïdors", "modes": "Modes", "mcp": "Servidors MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Regles" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Has trobat un error?", diff --git a/webview-ui/src/i18n/locales/de/settings.json b/webview-ui/src/i18n/locales/de/settings.json index 5a8c05551f..bfbaafc356 100644 --- a/webview-ui/src/i18n/locales/de/settings.json +++ b/webview-ui/src/i18n/locales/de/settings.json @@ -26,6 +26,7 @@ "discardButton": "Änderungen verwerfen" }, "sections": { + "hooks": "Hooks", "providers": "Anbieter", "modes": "Modi", "mcp": "MCP-Server", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Regeln" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Fehler gefunden?", diff --git a/webview-ui/src/i18n/locales/en/settings.json b/webview-ui/src/i18n/locales/en/settings.json index 2aacc322f0..1ff09ef9ef 100644 --- a/webview-ui/src/i18n/locales/en/settings.json +++ b/webview-ui/src/i18n/locales/en/settings.json @@ -38,12 +38,61 @@ "terminal": "Terminal", "slashCommands": "Slash Commands", "rules": "Rules", + "hooks": "Hooks", "prompts": "Prompts", "ui": "UI", "experimental": "Experimental", "language": "Language", "about": "About Zoo Code" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { + "sessionStart": "Session start", + "preToolUse": "Before tool use" + }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { + "invalid": "Invalid hook definition: {{message}}" + } + }, "about": { "bugReport": { "label": "Found a bug?", diff --git a/webview-ui/src/i18n/locales/es/settings.json b/webview-ui/src/i18n/locales/es/settings.json index 3f99fc1b14..175c1488dd 100644 --- a/webview-ui/src/i18n/locales/es/settings.json +++ b/webview-ui/src/i18n/locales/es/settings.json @@ -26,6 +26,7 @@ "discardButton": "Descartar cambios" }, "sections": { + "hooks": "Hooks", "providers": "Proveedores", "modes": "Modos", "mcp": "Servidores MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Reglas" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "¿Encontraste un error?", diff --git a/webview-ui/src/i18n/locales/fr/settings.json b/webview-ui/src/i18n/locales/fr/settings.json index ac0e6afb22..a3c2a58cb0 100644 --- a/webview-ui/src/i18n/locales/fr/settings.json +++ b/webview-ui/src/i18n/locales/fr/settings.json @@ -26,6 +26,7 @@ "discardButton": "Ignorer les modifications" }, "sections": { + "hooks": "Hooks", "providers": "Fournisseurs", "modes": "Modes", "mcp": "Serveurs MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Règles" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Vous avez trouvé un bug ?", diff --git a/webview-ui/src/i18n/locales/hi/settings.json b/webview-ui/src/i18n/locales/hi/settings.json index b720a5db83..8b4e88ec04 100644 --- a/webview-ui/src/i18n/locales/hi/settings.json +++ b/webview-ui/src/i18n/locales/hi/settings.json @@ -26,6 +26,7 @@ "discardButton": "परिवर्तन छोड़ें" }, "sections": { + "hooks": "Hooks", "providers": "प्रदाता", "modes": "मोड", "mcp": "एमसीपी सर्वर", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "नियम" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "बग मिला?", diff --git a/webview-ui/src/i18n/locales/id/settings.json b/webview-ui/src/i18n/locales/id/settings.json index c46cc5acf1..5fbb8e8f75 100644 --- a/webview-ui/src/i18n/locales/id/settings.json +++ b/webview-ui/src/i18n/locales/id/settings.json @@ -26,6 +26,7 @@ "discardButton": "Buang perubahan" }, "sections": { + "hooks": "Hooks", "providers": "Provider", "modes": "Mode", "mcp": "Server MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Aturan" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Menemukan bug?", diff --git a/webview-ui/src/i18n/locales/it/settings.json b/webview-ui/src/i18n/locales/it/settings.json index ff00dacca7..a976fb6642 100644 --- a/webview-ui/src/i18n/locales/it/settings.json +++ b/webview-ui/src/i18n/locales/it/settings.json @@ -26,6 +26,7 @@ "discardButton": "Scarta modifiche" }, "sections": { + "hooks": "Hooks", "providers": "Fornitori", "modes": "Modalità", "mcp": "Server MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Regole" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Hai trovato un bug?", diff --git a/webview-ui/src/i18n/locales/ja/settings.json b/webview-ui/src/i18n/locales/ja/settings.json index cdcb377cc9..00e6ab6a51 100644 --- a/webview-ui/src/i18n/locales/ja/settings.json +++ b/webview-ui/src/i18n/locales/ja/settings.json @@ -26,6 +26,7 @@ "discardButton": "変更を破棄" }, "sections": { + "hooks": "Hooks", "providers": "プロバイダー", "modes": "モード", "mcp": "MCPサーバー", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "ルール" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "バグを見つけましたか?", diff --git a/webview-ui/src/i18n/locales/ko/settings.json b/webview-ui/src/i18n/locales/ko/settings.json index 4a7845ac2a..9c5cd32625 100644 --- a/webview-ui/src/i18n/locales/ko/settings.json +++ b/webview-ui/src/i18n/locales/ko/settings.json @@ -26,6 +26,7 @@ "discardButton": "변경 사항 버리기" }, "sections": { + "hooks": "Hooks", "providers": "공급자", "modes": "모드", "mcp": "MCP 서버", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "규칙" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "버그를 발견하셨나요?", diff --git a/webview-ui/src/i18n/locales/nl/settings.json b/webview-ui/src/i18n/locales/nl/settings.json index 768018c3ef..0f1b4f0e0d 100644 --- a/webview-ui/src/i18n/locales/nl/settings.json +++ b/webview-ui/src/i18n/locales/nl/settings.json @@ -26,6 +26,7 @@ "discardButton": "Wijzigingen negeren" }, "sections": { + "hooks": "Hooks", "providers": "Providers", "modes": "Modi", "mcp": "MCP-servers", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Regels" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Bug gevonden?", diff --git a/webview-ui/src/i18n/locales/pl/settings.json b/webview-ui/src/i18n/locales/pl/settings.json index 37b37df875..d0420a8511 100644 --- a/webview-ui/src/i18n/locales/pl/settings.json +++ b/webview-ui/src/i18n/locales/pl/settings.json @@ -26,6 +26,7 @@ "discardButton": "Odrzuć zmiany" }, "sections": { + "hooks": "Hooks", "providers": "Dostawcy", "modes": "Tryby", "mcp": "Serwery MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Reguły" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Znalazłeś błąd?", diff --git a/webview-ui/src/i18n/locales/pt-BR/settings.json b/webview-ui/src/i18n/locales/pt-BR/settings.json index c3b91d6b58..8410cca4bb 100644 --- a/webview-ui/src/i18n/locales/pt-BR/settings.json +++ b/webview-ui/src/i18n/locales/pt-BR/settings.json @@ -26,6 +26,7 @@ "discardButton": "Descartar alterações" }, "sections": { + "hooks": "Hooks", "providers": "Provedores", "modes": "Modos", "mcp": "Servidores MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Regras" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Encontrou um bug?", diff --git a/webview-ui/src/i18n/locales/ru/settings.json b/webview-ui/src/i18n/locales/ru/settings.json index c428b31ec1..a113f245ea 100644 --- a/webview-ui/src/i18n/locales/ru/settings.json +++ b/webview-ui/src/i18n/locales/ru/settings.json @@ -26,6 +26,7 @@ "discardButton": "Отменить изменения" }, "sections": { + "hooks": "Hooks", "providers": "Провайдеры", "modes": "Режимы", "mcp": "Серверы MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Правила" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Нашли ошибку?", diff --git a/webview-ui/src/i18n/locales/tr/settings.json b/webview-ui/src/i18n/locales/tr/settings.json index bccb1c08aa..0927c09d49 100644 --- a/webview-ui/src/i18n/locales/tr/settings.json +++ b/webview-ui/src/i18n/locales/tr/settings.json @@ -26,6 +26,7 @@ "discardButton": "Değişiklikleri At" }, "sections": { + "hooks": "Hooks", "providers": "Sağlayıcılar", "modes": "Modlar", "mcp": "MCP Sunucuları", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Kurallar" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Bir hata mı buldunuz?", diff --git a/webview-ui/src/i18n/locales/vi/settings.json b/webview-ui/src/i18n/locales/vi/settings.json index 6b20b9a9a9..8e2c4e1fb7 100644 --- a/webview-ui/src/i18n/locales/vi/settings.json +++ b/webview-ui/src/i18n/locales/vi/settings.json @@ -26,6 +26,7 @@ "discardButton": "Hủy thay đổi" }, "sections": { + "hooks": "Hooks", "providers": "Nhà cung cấp", "modes": "Chế độ", "mcp": "Máy chủ MCP", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "Quy tắc" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "Tìm thấy lỗi?", diff --git a/webview-ui/src/i18n/locales/zh-CN/settings.json b/webview-ui/src/i18n/locales/zh-CN/settings.json index c206c26108..e44d3b53a4 100644 --- a/webview-ui/src/i18n/locales/zh-CN/settings.json +++ b/webview-ui/src/i18n/locales/zh-CN/settings.json @@ -26,6 +26,7 @@ "discardButton": "放弃更改" }, "sections": { + "hooks": "Hooks", "providers": "提供商", "modes": "模式", "mcp": "MCP 服务", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "规则" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "发现 Bug?", diff --git a/webview-ui/src/i18n/locales/zh-TW/settings.json b/webview-ui/src/i18n/locales/zh-TW/settings.json index 64eb5e0b29..708d5d4139 100644 --- a/webview-ui/src/i18n/locales/zh-TW/settings.json +++ b/webview-ui/src/i18n/locales/zh-TW/settings.json @@ -26,6 +26,7 @@ "discardButton": "取消變更" }, "sections": { + "hooks": "Hooks", "providers": "供應商", "modes": "模式", "mcp": "MCP 伺服器", @@ -44,6 +45,49 @@ "skills": "Skills", "rules": "規則" }, + "hooks": { + "description": "Configure global lifecycle hooks. Definitions are saved only when you use the Settings Save button.", + "securityWarning": "Enabled hooks execute trusted code on the VS Code extension host at the task workspace cwd and are not sandboxed.", + "add": "Add hook", + "edit": "Edit hook", + "delete": "Delete hook", + "global": "Global hooks", + "empty": "No hooks configured. Session start hooks run at task start; before-tool hooks match exact tool names. Execution is unavailable until hook runner support is added.", + "enabled": "Enabled", + "disabled": "Disabled", + "footer": "Commands use a fixed 10-second timeout. Executable and argument boundaries are preserved exactly.", + "phase": { "sessionStart": "Session start", "preToolUse": "Before tool use" }, + "search": { + "definitions": "Enabled hooks, phases, and exact tool matching", + "commandFormat": "Executable, exact arguments, and fixed timeout" + }, + "fields": { + "name": "Name", + "phase": "Phase", + "tools": "Exact tool matches", + "toolsHint": "Select one or more exact tool names. This applies only before tool use.", + "executable": "Executable", + "executableHint": "Enter only the executable path or name, without shell syntax or arguments.", + "arguments": "Arguments", + "argumentsHint": "Each row is passed as exactly one argv value. Empty rows are empty arguments.", + "argument": "Argument {{number}}", + "addArgument": "Add argument", + "enabled": "Enabled" + }, + "dialog": { + "addTitle": "Add hook", + "editTitle": "Edit hook", + "description": "Hook changes remain local until you save Settings. New hooks are disabled by default.", + "add": "Add", + "update": "Update" + }, + "deleteDialog": { + "title": "Delete hook", + "description": "Delete this hook from the pending settings changes?", + "confirm": "Delete" + }, + "validation": { "invalid": "Invalid hook definition: {{message}}" } + }, "about": { "bugReport": { "label": "發現錯誤?", From 8089b3c4a65a47cde18357d724a4150ccb01f1cd Mon Sep 17 00:00:00 2001 From: Toray Altas Date: Wed, 5 Aug 2026 03:02:58 -0400 Subject: [PATCH 2/2] test(settings): strengthen hooks coverage --- .../__tests__/webviewMessageHandler.spec.ts | 11 +- .../settings/__tests__/HooksSettings.spec.tsx | 112 +++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d3a92c080c..57e833c340 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -55,7 +55,7 @@ vi.mock("../rulesMessageHandler", () => ({ handleOpenRulesDirectory: vi.fn(), })) -import type { ModelRecord } from "@roo-code/types" +import type { ModelRecord, WebviewMessage } from "@roo-code/types" import { webviewMessageHandler } from "../webviewMessageHandler" import type { ClineProvider } from "../ClineProvider" @@ -1216,12 +1216,15 @@ describe("webviewMessageHandler - hookDefinitions", () => { }) it("rejects malformed phases and command values", async () => { - await webviewMessageHandler(mockClineProvider, { + // This test deliberately crosses the typed webview boundary with a malformed runtime payload. + const message = { type: "updateSettings", updatedSettings: { - hookDefinitions: [{ ...validHook, phase: "postToolUse", executable: " node " }] as any, + hookDefinitions: [{ ...validHook, phase: "postToolUse", executable: " node " }], }, - }) + } as unknown as WebviewMessage + + await webviewMessageHandler(mockClineProvider, message) expect(mockClineProvider.contextProxy.setValue).not.toHaveBeenCalled() expect(vscode.window.showErrorMessage).toHaveBeenCalled() diff --git a/webview-ui/src/components/settings/__tests__/HooksSettings.spec.tsx b/webview-ui/src/components/settings/__tests__/HooksSettings.spec.tsx index 2e19fa3e28..2d71a520e1 100644 --- a/webview-ui/src/components/settings/__tests__/HooksSettings.spec.tsx +++ b/webview-ui/src/components/settings/__tests__/HooksSettings.spec.tsx @@ -1,5 +1,7 @@ import React from "react" +import type { HookDefinition } from "@roo-code/types" + import { fireEvent, render, screen } from "@/utils/test-utils" import { HooksSettings } from "../HooksSettings" @@ -19,7 +21,12 @@ vi.mock("@/components/ui", () => ({ /> ), Input: (props: any) => , - StandardTooltip: ({ children }: any) => children, + StandardTooltip: ({ children, content }: any) => ( +
+ {content} + {children} +
+ ), Dialog: ({ children, open }: any) => (open ?
{children}
: null), DialogContent: ({ children }: any) =>
{children}
, DialogDescription: ({ children }: any) =>

{children}

, @@ -46,6 +53,31 @@ vi.mock("@/components/ui", () => ({ })) describe("HooksSettings", () => { + const sessionHook: HookDefinition = { + id: "session-hook", + name: "Session context", + enabled: true, + phase: "sessionStart", + executable: "node", + argv: ["context.js"], + } + + const preToolHook: HookDefinition = { + id: "pre-tool-hook", + name: "Guard edits", + enabled: true, + phase: "preToolUse", + toolMatcher: ["apply_diff"], + executable: "guard", + argv: [], + } + + const getTooltipButton = (label: string): HTMLButtonElement => { + const button = screen.getByText(label).parentElement?.querySelector("button") + if (!(button instanceof HTMLButtonElement)) throw new Error(`Missing tooltip button: ${label}`) + return button + } + beforeEach(() => { vi.stubGlobal("crypto", { randomUUID: () => "new-hook-id" }) }) @@ -91,4 +123,82 @@ describe("HooksSettings", () => { expect(screen.getByTestId("save-hook")).toBeEnabled() expect(setErrorMessage).toHaveBeenCalledWith("settings:hooks.validation.invalid") }) + + it("edits a hook and updates exact tool and argument selections", () => { + const onChange = vi.fn() + render() + + fireEvent.click(getTooltipButton("settings:hooks.edit")) + fireEvent.change(screen.getByTestId("hook-name"), { target: { value: "Guard reads" } }) + fireEvent.click(screen.getByLabelText("apply_diff")) + expect(screen.getByTestId("save-hook")).toBeDisabled() + fireEvent.click(screen.getByLabelText("read_file")) + fireEvent.click(screen.getByText("settings:hooks.fields.addArgument")) + fireEvent.change(screen.getByLabelText("settings:hooks.fields.argument"), { + target: { value: "--strict" }, + }) + fireEvent.click(screen.getByTestId("save-hook")) + + expect(onChange).toHaveBeenCalledWith([ + { + ...preToolHook, + name: "Guard reads", + toolMatcher: ["read_file"], + argv: ["--strict"], + }, + ]) + }) + + it("removes an existing argument while editing", () => { + const onChange = vi.fn() + render() + + fireEvent.click(getTooltipButton("settings:hooks.edit")) + const argument = screen.getByLabelText("settings:hooks.fields.argument") + const removeButton = argument.parentElement?.querySelector("button") + if (!(removeButton instanceof HTMLButtonElement)) throw new Error("Missing remove-argument button") + fireEvent.click(removeButton) + fireEvent.click(screen.getByTestId("save-hook")) + + expect(onChange).toHaveBeenCalledWith([{ ...sessionHook, argv: [] }]) + }) + + it("deletes an existing hook after confirmation", () => { + const onChange = vi.fn() + render() + + fireEvent.click(getTooltipButton("settings:hooks.delete")) + fireEvent.click(screen.getByRole("button", { name: "settings:hooks.delete" })) + + expect(onChange).toHaveBeenCalledWith([]) + }) + + it("rejects normalized command fields and clears its error when cancelled", () => { + const setErrorMessage = vi.fn() + render() + + fireEvent.click(screen.getByTestId("add-hook")) + fireEvent.change(screen.getByTestId("hook-name"), { target: { value: " Invalid name " } }) + fireEvent.change(screen.getByTestId("hook-executable"), { target: { value: "node" } }) + + expect(screen.getByRole("alert")).toHaveTextContent("settings:hooks.validation.invalid") + expect(screen.getByTestId("save-hook")).toBeDisabled() + fireEvent.click(screen.getByRole("button", { name: "settings:common.cancel" })) + + expect(screen.queryByTestId("hook-dialog")).not.toBeInTheDocument() + expect(setErrorMessage).toHaveBeenLastCalledWith(undefined) + }) + + it("clears an owned validation error when unmounted", () => { + const setErrorMessage = vi.fn() + const { unmount } = render( + , + ) + + fireEvent.click(screen.getByTestId("add-hook")) + expect(setErrorMessage).toHaveBeenCalledWith("settings:hooks.validation.invalid") + unmount() + + expect(setErrorMessage).toHaveBeenLastCalledWith(undefined) + }) })