feat: improve hooks settings UI

Add open project/global folder buttons at bottom
Move reload to bottom
Add enable-all toggle (with new message type)
Convert hooks list to accordion + per-hook logs
This commit is contained in:
Toray Altas 2026-01-16 18:27:13 -05:00
parent 85f2bd64dc
commit e079e6c7aa
7 changed files with 693 additions and 93 deletions

View file

@ -617,6 +617,7 @@ export interface WebviewMessage {
| "debugSetting"
| "hooksReloadConfig"
| "hooksSetEnabled"
| "hooksSetAllEnabled"
| "hooksOpenConfigFolder"
text?: string
editedMessageContent?: string
@ -675,6 +676,7 @@ export interface WebviewMessage {
useProviderSignup?: boolean // For rooCloudSignIn to use provider signup flow
hookId?: string // For hooksSetEnabled
hookEnabled?: boolean // For hooksSetEnabled
hooksEnabled?: boolean // For hooksSetAllEnabled
hooksSource?: "global" | "project" // For hooksOpenConfigFolder
codeIndexSettings?: {
// Global state settings

View file

@ -2240,16 +2240,21 @@ export class ClineProvider
}
const snapshot = this.hookManager.getConfigSnapshot()
const enabledHooks = this.hookManager.getEnabledHooks()
const executionHistory = this.hookManager.getHookExecutionHistory()
// Build a list of *all* known hooks (including currently disabled ones)
// so the webview can show per-hook toggles and a global "Enable Hooks" toggle.
const allHooks = snapshot
? Array.from(snapshot.hooksByEvent.values()).reduce((acc, hooks) => acc.concat(hooks), [] as any[])
: []
// Convert ResolvedHook[] to HookInfo[]
const hookInfos = enabledHooks.map((hook) => ({
const hookInfos = allHooks.map((hook) => ({
id: hook.id,
event: hook.event,
matcher: hook.matcher,
commandPreview: hook.command.length > 100 ? hook.command.substring(0, 97) + "..." : hook.command,
enabled: hook.enabled ?? true,
enabled: (hook.enabled ?? true) && !(snapshot?.disabledHookIds?.has(hook.id) ?? false),
source: hook.source,
timeout: hook.timeout ?? 60,
shell: hook.shell,

View file

@ -227,6 +227,92 @@ describe("webviewMessageHandler - hooks commands", () => {
})
})
describe("hooksSetAllEnabled", () => {
it("should call setHookEnabled for all hooks in snapshot and postStateToWebview", async () => {
const hooksById = new Map<string, ResolvedHook>()
hooksById.set("hook-1", {
id: "hook-1",
event: "PreToolUse" as any,
matcher: ".*",
command: "echo 1",
enabled: true,
source: "global" as any,
timeout: 30,
includeConversationHistory: false,
} as any)
hooksById.set("hook-2", {
id: "hook-2",
event: "PostToolUse" as any,
matcher: ".*",
command: "echo 2",
enabled: true,
source: "project" as any,
timeout: 30,
includeConversationHistory: false,
} as any)
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
hooksByEvent: new Map(),
hooksById,
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: false,
} as HooksConfigSnapshot)
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetAllEnabled",
hooksEnabled: false,
})
expect(mockHookManager.setHookEnabled).toHaveBeenCalledTimes(2)
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("hook-1", false)
expect(mockHookManager.setHookEnabled).toHaveBeenCalledWith("hook-2", false)
expect(mockClineProvider.postStateToWebview).toHaveBeenCalledTimes(1)
})
it("should not call setHookEnabled when hooksEnabled is not a boolean", async () => {
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetAllEnabled",
hooksEnabled: "false",
} as any)
expect(mockHookManager.setHookEnabled).not.toHaveBeenCalled()
expect(mockClineProvider.postStateToWebview).not.toHaveBeenCalled()
})
it("should show error message when bulk setHookEnabled fails", async () => {
const hooksById = new Map<string, ResolvedHook>()
hooksById.set("hook-1", {
id: "hook-1",
event: "PreToolUse" as any,
matcher: ".*",
command: "echo 1",
enabled: true,
source: "global" as any,
timeout: 30,
includeConversationHistory: false,
} as any)
vi.mocked(mockHookManager.getConfigSnapshot).mockReturnValue({
hooksByEvent: new Map(),
hooksById,
loadedAt: new Date(),
disabledHookIds: new Set(),
hasProjectHooks: false,
} as HooksConfigSnapshot)
vi.mocked(mockHookManager.setHookEnabled).mockRejectedValueOnce(new Error("boom"))
await webviewMessageHandler(mockClineProvider, {
type: "hooksSetAllEnabled",
hooksEnabled: true,
})
expect(mockClineProvider.log).toHaveBeenCalledWith("Failed to set all hooks enabled: boom")
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to enable all hooks")
})
})
describe("hooksOpenConfigFolder", () => {
it("should open project hooks folder by default", async () => {
await webviewMessageHandler(mockClineProvider, {

View file

@ -3374,6 +3374,30 @@ export const webviewMessageHandler = async (
break
}
case "hooksSetAllEnabled": {
// Enable or disable ALL currently known hooks.
// This mirrors MCP's "Enable MCP Servers" top-level toggle.
const hookManager = provider.getHookManager()
if (hookManager && typeof message.hooksEnabled === "boolean") {
try {
const snapshot = hookManager.getConfigSnapshot()
const allHookIds = snapshot ? Array.from(snapshot.hooksById.keys()) : []
for (const hookId of allHookIds) {
await hookManager.setHookEnabled(hookId, message.hooksEnabled)
}
await provider.postStateToWebview()
} catch (error) {
provider.log(
`Failed to set all hooks enabled: ${error instanceof Error ? error.message : String(error)}`,
)
vscode.window.showErrorMessage(`Failed to ${message.hooksEnabled ? "enable" : "disable"} all hooks`)
}
}
break
}
case "hooksOpenConfigFolder": {
// Open the hooks configuration folder in VS Code
const source = message.hooksSource ?? "project"

View file

@ -12,6 +12,7 @@ export const HooksSettings: React.FC = () => {
const { t } = useAppTranslation()
const { hooks } = useExtensionState()
const [executionHistory, setExecutionHistory] = useState<HookExecutionRecord[]>(hooks?.executionHistory || [])
const [isUpdatingAllEnabled, setIsUpdatingAllEnabled] = useState(false)
// Listen for realtime hookExecutionStatus messages
useEffect(() => {
@ -63,54 +64,57 @@ export const HooksSettings: React.FC = () => {
vscode.postMessage({ type: "hooksSetEnabled", hookId, hookEnabled: enabled })
}, [])
const handleToggleAllHooks = useCallback((enabled: boolean) => {
setIsUpdatingAllEnabled(true)
vscode.postMessage({ type: "hooksSetAllEnabled", hooksEnabled: enabled })
// Optimistically clear the "updating" flag after a short delay.
// The extension will send updated state via postStateToWebview().
setTimeout(() => setIsUpdatingAllEnabled(false), 500)
}, [])
const enabledHooks = hooks?.enabledHooks || []
const hasProjectHooks = hooks?.hasProjectHooks || false
const snapshotTimestamp = hooks?.snapshotTimestamp
const allHooksEnabled = enabledHooks.length > 0 && enabledHooks.every((h) => h.enabled)
return (
<div>
<SectionHeader>{t("settings:sections.hooks")}</SectionHeader>
<Section>
{/* Header with actions */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-base font-medium m-0">{t("settings:hooks.configuredHooks")}</h3>
{snapshotTimestamp && (
<StandardTooltip
content={t("settings:hooks.lastLoadedTooltip", {
time: new Date(snapshotTimestamp).toLocaleString(),
})}>
<Clock className="w-4 h-4 text-vscode-descriptionForeground" />
</StandardTooltip>
)}
{/* Enable all hooks */}
{enabledHooks.length > 0 && (
<div className="flex items-center justify-between gap-3 mb-4 p-3 rounded border border-vscode-input-border bg-vscode-input-background">
<div className="flex flex-col">
<span className="text-sm font-medium">{t("settings:hooks.enableHooks")}</span>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.enableHooksDescription")}
</span>
</div>
<label className="flex items-center gap-2 cursor-pointer flex-shrink-0">
<input
type="checkbox"
checked={allHooksEnabled}
disabled={isUpdatingAllEnabled}
onChange={(e) => handleToggleAllHooks(e.target.checked)}
className="w-4 h-4 cursor-pointer"
/>
<span className="text-sm">{t("settings:hooks.enabled")}</span>
</label>
</div>
<div className="flex items-center gap-2">
<StandardTooltip content={t("settings:hooks.reloadTooltip")}>
<Button variant="ghost" size="sm" onClick={handleReloadConfig}>
<RefreshCw className="w-4 h-4" />
<span className="ml-1">{t("settings:hooks.reload")}</span>
</Button>
</StandardTooltip>
)}
{/* Header */}
<div className="flex items-center gap-2 mb-4">
<h3 className="text-base font-medium m-0">{t("settings:hooks.configuredHooks")}</h3>
{snapshotTimestamp && (
<StandardTooltip
content={
hasProjectHooks
? t("settings:hooks.openProjectFolderTooltip")
: t("settings:hooks.openGlobalFolderTooltip")
}>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenConfigFolder(hasProjectHooks ? "project" : "global")}>
<FolderOpen className="w-4 h-4" />
<span className="ml-1">
{hasProjectHooks
? t("settings:hooks.openProjectFolder")
: t("settings:hooks.openGlobalFolder")}
</span>
</Button>
content={t("settings:hooks.lastLoadedTooltip", {
time: new Date(snapshotTimestamp).toLocaleString(),
})}>
<Clock className="w-4 h-4 text-vscode-descriptionForeground" />
</StandardTooltip>
</div>
)}
</div>
{/* Security warning for project hooks */}
@ -160,6 +164,37 @@ export const HooksSettings: React.FC = () => {
{/* Hook Activity Log */}
<HookActivityLog executionHistory={executionHistory} />
{/* Bottom Action Buttons - mirroring MCP settings order: global, project, refresh */}
<div
style={{
marginTop: "10px",
width: "100%",
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "10px",
}}>
<Button
variant="secondary"
style={{ width: "100%" }}
onClick={() => handleOpenConfigFolder("global")}>
<FolderOpen className="w-4 h-4" />
<span className="ml-2">{t("settings:hooks.openGlobalFolder")}</span>
</Button>
<Button
variant="secondary"
style={{ width: "100%" }}
onClick={() => handleOpenConfigFolder("project")}>
<FolderOpen className="w-4 h-4" />
<span className="ml-2">{t("settings:hooks.openProjectFolder")}</span>
</Button>
<StandardTooltip content={t("settings:hooks.reloadTooltip")}>
<Button variant="secondary" style={{ width: "100%" }} onClick={handleReloadConfig}>
<RefreshCw className="w-4 h-4" />
<span className="ml-2">{t("settings:hooks.reload")}</span>
</Button>
</StandardTooltip>
</div>
</Section>
</div>
)
@ -172,61 +207,236 @@ interface HookItemProps {
const HookItem: React.FC<HookItemProps> = ({ hook, onToggle }) => {
const { t } = useAppTranslation()
const { hooks } = useExtensionState()
const [isExpanded, setIsExpanded] = useState(false)
const [hookLogs, setHookLogs] = useState<HookExecutionRecord[]>([])
// Filter execution history for this specific hook
useEffect(() => {
const history = hooks?.executionHistory || []
const filtered = history.filter((record) => record.hookId === hook.id)
setHookLogs(filtered)
}, [hooks?.executionHistory, hook.id])
// Listen for realtime hookExecutionStatus messages for this hook
useEffect(() => {
const handleMessage = (event: MessageEvent) => {
const message = event.data
if (message.type === "hookExecutionStatus") {
const payload: HookExecutionStatusPayload = message.hookExecutionStatus
// Only process messages for this specific hook
if (payload.hookId === hook.id) {
if (payload.status === "completed" || payload.status === "failed" || payload.status === "blocked") {
const record: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: payload.hookId || "unknown",
event: payload.event,
toolName: payload.toolName,
exitCode: payload.status === "completed" ? 0 : 1,
duration: payload.duration || 0,
timedOut: false,
blocked: payload.status === "blocked",
error: payload.error,
blockMessage: payload.blockMessage,
}
setHookLogs((prev) => [record, ...prev].slice(0, 20)) // Keep last 20 per hook
}
}
}
}
window.addEventListener("message", handleMessage)
return () => window.removeEventListener("message", handleMessage)
}, [hook.id])
const handleToggle = (e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation()
onToggle(hook.id, e.target.checked)
}
return (
<div className="p-3 rounded border border-vscode-input-border bg-vscode-input-background">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<Zap className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
{hook.matcher && (
<>
<span className="text-vscode-descriptionForeground"></span>
<code className="text-xs font-mono text-vscode-descriptionForeground">
{hook.matcher}
</code>
</>
)}
<span
className={`ml-auto text-xs px-2 py-0.5 rounded ${
hook.source === "project"
? "bg-yellow-500/20 text-yellow-500"
: hook.source === "mode"
? "bg-blue-500/20 text-blue-500"
: "bg-gray-500/20 text-gray-400"
}`}>
{hook.source}
</span>
</div>
{hook.description && <p className="text-sm text-vscode-foreground mb-2">{hook.description}</p>}
<div className="flex items-center gap-3 text-xs text-vscode-descriptionForeground">
<code className="font-mono bg-vscode-editor-background px-2 py-1 rounded">
{hook.commandPreview}
</code>
{hook.shell && (
<span>
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
</span>
)}
<span>
{t("settings:hooks.timeout")}: {hook.timeout}s
</span>
</div>
<div className="rounded border border-vscode-input-border bg-vscode-input-background">
{/* Collapsed Header */}
<div
className="flex items-center gap-3 p-3 cursor-pointer hover:bg-vscode-list-hoverBackground"
onClick={() => setIsExpanded(!isExpanded)}>
<span
className="transform transition-transform"
style={{ transform: isExpanded ? "rotate(90deg)" : "" }}>
</span>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Zap className="w-4 h-4 flex-shrink-0 text-vscode-textLink-foreground" />
<code className="text-sm font-mono text-vscode-textLink-foreground truncate">{hook.id}</code>
<span
className={`ml-auto text-xs px-2 py-0.5 rounded flex-shrink-0 ${
hook.source === "project"
? "bg-yellow-500/20 text-yellow-500"
: hook.source === "mode"
? "bg-blue-500/20 text-blue-500"
: "bg-gray-500/20 text-gray-400"
}`}>
{hook.source}
</span>
</div>
<label className="flex items-center gap-2 cursor-pointer flex-shrink-0">
<label
className="flex items-center gap-2 cursor-pointer flex-shrink-0"
onClick={(e) => e.stopPropagation()}>
<input
type="checkbox"
checked={hook.enabled}
onChange={(e) => onToggle(hook.id, e.target.checked)}
onChange={handleToggle}
className="w-4 h-4 cursor-pointer"
/>
<span className="text-sm">{t("settings:hooks.enabled")}</span>
</label>
</div>
{/* Expanded Content */}
{isExpanded && (
<div className="px-3 pb-3 pt-0 space-y-3 border-t border-vscode-input-border">
{/* Hook Details */}
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.event")}:
</span>
<code className="text-sm font-mono text-vscode-textLink-foreground">{hook.event}</code>
</div>
{hook.matcher && (
<div className="flex items-center gap-2">
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.matcher")}:
</span>
<code className="text-xs font-mono text-vscode-descriptionForeground">
{hook.matcher}
</code>
</div>
)}
{hook.description && (
<div>
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.description")}:
</span>
<p className="text-sm text-vscode-foreground mt-1">{hook.description}</p>
</div>
)}
<div className="flex items-center gap-2">
<span className="text-xs text-vscode-descriptionForeground">
{t("settings:hooks.command")}:
</span>
<code className="text-xs font-mono bg-vscode-editor-background px-2 py-1 rounded flex-1">
{hook.commandPreview}
</code>
</div>
<div className="flex items-center gap-4 text-xs text-vscode-descriptionForeground">
{hook.shell && (
<span>
{t("settings:hooks.shell")}: <code className="font-mono">{hook.shell}</code>
</span>
)}
<span>
{t("settings:hooks.timeout")}: {hook.timeout}s
</span>
</div>
</div>
{/* Logs Section */}
<div className="border-t border-vscode-input-border pt-3">
<div className="flex items-center gap-2 mb-2">
<span className="text-sm font-medium">{t("settings:hooks.logs")}</span>
{hookLogs.length > 0 && (
<span className="text-xs text-vscode-descriptionForeground">({hookLogs.length})</span>
)}
</div>
{hookLogs.length === 0 ? (
<div className="text-xs text-vscode-descriptionForeground py-2">
{t("settings:hooks.noLogsForHook")}
</div>
) : (
<div className="space-y-2 max-h-48 overflow-y-auto">
{hookLogs.map((record, index) => (
<HookLogItem key={`${record.timestamp}-${index}`} record={record} />
))}
</div>
)}
</div>
</div>
)}
</div>
)
}
interface HookLogItemProps {
record: HookExecutionRecord
}
const HookLogItem: React.FC<HookLogItemProps> = ({ record }) => {
const { t } = useAppTranslation()
const getStatusDisplay = () => {
if (record.blocked) {
return {
label: t("settings:hooks.status.blocked"),
className: "bg-red-500/20 text-red-500",
icon: <X className="w-3 h-3" />,
}
}
if (record.error || record.exitCode !== 0) {
return {
label: t("settings:hooks.status.failed"),
className: "bg-red-500/20 text-red-500",
icon: <X className="w-3 h-3" />,
}
}
if (record.timedOut) {
return {
label: t("settings:hooks.status.timeout"),
className: "bg-yellow-500/20 text-yellow-500",
icon: <Clock className="w-3 h-3" />,
}
}
return {
label: t("settings:hooks.status.completed"),
className: "bg-green-500/20 text-green-500",
icon: <Zap className="w-3 h-3" />,
}
}
const status = getStatusDisplay()
const timestamp = new Date(record.timestamp)
const timeAgo = getTimeAgo(timestamp)
return (
<div className="p-2 rounded border border-vscode-input-border bg-vscode-editor-background text-xs">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="flex items-center gap-2 flex-1 min-w-0">
<span
className={`flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${status.className}`}>
{status.icon}
{status.label}
</span>
{record.toolName && (
<code className="text-xs font-mono text-vscode-descriptionForeground truncate">
{record.toolName}
</code>
)}
</div>
<div className="flex items-center gap-2 text-xs text-vscode-descriptionForeground flex-shrink-0">
<span>{record.duration}ms</span>
<StandardTooltip content={timestamp.toLocaleString()}>
<span className="cursor-help">{timeAgo}</span>
</StandardTooltip>
</div>
</div>
{(record.error || record.blockMessage) && (
<div className="mt-1 p-2 rounded bg-vscode-input-background text-xs font-mono text-red-400 overflow-x-auto">
{record.blockMessage || record.error}
</div>
)}
</div>
)
}

View file

@ -104,9 +104,173 @@ describe("HooksSettings", () => {
render(<HooksSettings />)
// Hook ID should be visible in collapsed state
expect(screen.getByText(mockHook.id)).toBeInTheDocument()
expect(screen.getByText(mockHook.source)).toBeInTheDocument()
})
it("expands and collapses hook accordion on click", () => {
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
matcher: "git*",
commandPreview: "echo 'Before git command'",
enabled: true,
source: "project",
timeout: 30,
description: "Test hook",
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Hook details should not be visible initially
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
expect(screen.queryByText(mockHook.matcher!)).not.toBeInTheDocument()
// Click to expand
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
// Hook details should now be visible
expect(screen.getByText(mockHook.event)).toBeInTheDocument()
expect(screen.getByText(mockHook.matcher!)).toBeInTheDocument()
expect(screen.getByText(mockHook.commandPreview)).toBeInTheDocument()
// Click to collapse
fireEvent.click(hookHeader!)
// Hook details should be hidden again
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
})
it("shows per-hook logs in expanded view", () => {
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
commandPreview: "echo test",
enabled: true,
source: "global",
timeout: 30,
}
const mockRecord: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: "hook-1",
event: "before_execute_command",
toolName: "write_to_file",
exitCode: 0,
duration: 150,
timedOut: false,
blocked: false,
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [mockRecord],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Expand hook
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
// Logs section should be visible
expect(screen.getByText("settings:hooks.logs")).toBeInTheDocument()
expect(screen.getByText(mockRecord.toolName!)).toBeInTheDocument()
expect(screen.getByText("settings:hooks.status.completed")).toBeInTheDocument()
})
it("filters logs per hook correctly", () => {
const mockHook1: HookInfo = {
id: "hook-1",
event: "before_execute_command",
commandPreview: "echo test",
enabled: true,
source: "global",
timeout: 30,
}
const mockHook2: HookInfo = {
id: "hook-2",
event: "after_execute_command",
commandPreview: "echo after",
enabled: true,
source: "global",
timeout: 30,
}
const record1: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: "hook-1",
event: "before_execute_command",
toolName: "write_to_file",
exitCode: 0,
duration: 100,
timedOut: false,
blocked: false,
}
const record2: HookExecutionRecord = {
timestamp: new Date().toISOString(),
hookId: "hook-2",
event: "after_execute_command",
toolName: "read_file",
exitCode: 0,
duration: 50,
timedOut: false,
blocked: false,
}
currentHooksState = {
enabledHooks: [mockHook1, mockHook2],
executionHistory: [record1, record2],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Expand first hook
const hook1Headers = screen.getAllByText("hook-1")
const hook1Header = hook1Headers[0].closest("div")
fireEvent.click(hook1Header!)
// Should show only hook-1's log
expect(screen.getByText("write_to_file")).toBeInTheDocument()
expect(screen.queryByText("read_file")).not.toBeInTheDocument()
})
it("shows 'no logs' message when hook has no execution history", () => {
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
commandPreview: "echo test",
enabled: true,
source: "global",
timeout: 30,
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Expand hook
const hookHeader = screen.getByText(mockHook.id).closest("div")
fireEvent.click(hookHeader!)
// Should show no logs message
expect(screen.getByText("settings:hooks.noLogsForHook")).toBeInTheDocument()
})
it("shows project hooks warning when hasProjectHooks is true", () => {
@ -121,27 +285,40 @@ describe("HooksSettings", () => {
expect(screen.getByText("settings:hooks.projectHooksWarningMessage")).toBeInTheDocument()
})
it("sends hooksReloadConfig message when Reload button is clicked", async () => {
it("sends hooksReloadConfig message when Reload button is clicked (bottom action)", async () => {
const { vscode } = await import("@src/utils/vscode")
render(<HooksSettings />)
// Reload button is now in bottom action area (mirroring MCP settings)
const reloadButton = screen.getByText("settings:hooks.reload")
fireEvent.click(reloadButton)
expect(vscode.postMessage).toHaveBeenCalledWith({ type: "hooksReloadConfig" })
})
it("sends hooksOpenConfigFolder message when Open Folder button is clicked", async () => {
it("sends hooksOpenConfigFolder message with 'global' when Global Folder button is clicked (bottom action)", async () => {
const { vscode } = await import("@src/utils/vscode")
currentHooksState = {
...mockHooksState,
hasProjectHooks: true,
}
render(<HooksSettings />)
const openFolderButton = screen.getByText("settings:hooks.openProjectFolder")
fireEvent.click(openFolderButton)
// Button is now in bottom action area (like MCP settings)
const globalFolderButton = screen.getByText("settings:hooks.openGlobalFolder")
fireEvent.click(globalFolderButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksOpenConfigFolder",
hooksSource: "global",
})
})
it("sends hooksOpenConfigFolder message with 'project' when Project Folder button is clicked (bottom action)", async () => {
const { vscode } = await import("@src/utils/vscode")
render(<HooksSettings />)
// Button is now in bottom action area (like MCP settings)
const projectFolderButton = screen.getByText("settings:hooks.openProjectFolder")
fireEvent.click(projectFolderButton)
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksOpenConfigFolder",
@ -149,6 +326,19 @@ describe("HooksSettings", () => {
})
})
it("renders both Global and Project folder buttons in bottom action area regardless of hasProjectHooks state", () => {
currentHooksState = {
...mockHooksState,
hasProjectHooks: false,
}
render(<HooksSettings />)
// Both buttons should be present
expect(screen.getByText("settings:hooks.openGlobalFolder")).toBeInTheDocument()
expect(screen.getByText("settings:hooks.openProjectFolder")).toBeInTheDocument()
})
it("sends hooksSetEnabled message when hook toggle is changed", async () => {
const { vscode } = await import("@src/utils/vscode")
const mockHook: HookInfo = {
@ -168,8 +358,9 @@ describe("HooksSettings", () => {
render(<HooksSettings />)
const checkbox = screen.getByRole("checkbox")
fireEvent.click(checkbox)
// First checkbox is the top-level "Enable Hooks" toggle; the second is the per-hook toggle in collapsed header
const checkboxes = screen.getAllByRole("checkbox")
fireEvent.click(checkboxes[1])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksSetEnabled",
@ -178,6 +369,80 @@ describe("HooksSettings", () => {
})
})
it("toggles hook enabled state in collapsed view without expanding accordion", async () => {
const { vscode } = await import("@src/utils/vscode")
const mockHook: HookInfo = {
id: "hook-1",
event: "before_execute_command",
commandPreview: "echo test",
enabled: true,
source: "global",
timeout: 30,
}
currentHooksState = {
enabledHooks: [mockHook],
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
// Hook should be collapsed initially
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
// Click the checkbox (second checkbox, first is "Enable Hooks")
const checkboxes = screen.getAllByRole("checkbox")
fireEvent.click(checkboxes[1])
// Hook should still be collapsed after toggling
expect(screen.queryByText(mockHook.event)).not.toBeInTheDocument()
// Toggle message should have been sent
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksSetEnabled",
hookId: "hook-1",
hookEnabled: false,
})
})
it("sends hooksSetAllEnabled message when top-level Enable Hooks toggle is changed", async () => {
const { vscode } = await import("@src/utils/vscode")
currentHooksState = {
enabledHooks: [
{
id: "hook-1",
event: "event1",
commandPreview: "cmd1",
enabled: true,
source: "global",
timeout: 30,
},
{
id: "hook-2",
event: "event2",
commandPreview: "cmd2",
enabled: true,
source: "project",
timeout: 30,
},
],
executionHistory: [],
hasProjectHooks: false,
}
render(<HooksSettings />)
const checkboxes = screen.getAllByRole("checkbox")
fireEvent.click(checkboxes[0])
expect(vscode.postMessage).toHaveBeenCalledWith({
type: "hooksSetAllEnabled",
hooksEnabled: false,
})
})
it("renders execution history when available", () => {
const mockRecord: HookExecutionRecord = {
timestamp: new Date().toISOString(),

View file

@ -63,9 +63,17 @@
},
"noHooksConfigured": "No hooks configured",
"noHooksHint": "Create hook configuration files to automate actions on tool execution events.",
"enableHooks": "Enable Hooks",
"enableHooksDescription": "Toggle all hooks on or off at once",
"enabled": "Enabled",
"event": "Event",
"matcher": "Matcher",
"description": "Description",
"command": "Command",
"shell": "Shell",
"timeout": "Timeout",
"logs": "Logs",
"noLogsForHook": "No execution logs for this hook yet",
"activityLog": "Hook Activity",
"status": {
"running": "Running",