feat: add disable all hook toggle (#726)

This commit is contained in:
jnjpng
2026-01-28 15:40:29 -08:00
committed by GitHub
parent 52c8d6f7b4
commit 5b75244476
6 changed files with 178 additions and 18 deletions

View File

@@ -2,7 +2,7 @@
// Interactive TUI for managing hooks configuration
import { Box, Text, useInput } from "ink";
import { memo, useCallback, useEffect, useState } from "react";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
type HookEvent,
type HookMatcher,
@@ -18,11 +18,14 @@ import {
countTotalHooks,
type HookMatcherWithSource,
type HookWithSource,
isUserHooksDisabled,
loadMatchersWithSource,
loadSimpleMatchersWithSource,
removeHook,
type SaveLocation,
setHooksDisabled,
} from "../../hooks/writer";
import { settingsManager } from "../../settings-manager";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { PasteAwareTextInput } from "./PasteAwareTextInput";
@@ -145,6 +148,9 @@ export const HooksManager = memo(function HooksManager({
// Dynamic tool names from agent
const [toolNames, setToolNames] = useState<string[]>(FALLBACK_TOOL_NAMES);
// Track whether all hooks are disabled
const [hooksDisabled, setHooksDisabledState] = useState(isUserHooksDisabled);
// Fetch agent tools on mount
useEffect(() => {
if (!agentId) return;
@@ -188,9 +194,30 @@ export const HooksManager = memo(function HooksManager({
// Refresh counts - called when hooks change
const refreshCounts = useCallback(() => {
setTotalHooks(countTotalHooks());
setHooksDisabledState(isUserHooksDisabled());
}, []);
// Load total hooks count on mount and when returning to events screen
// Track if initial settings load has been done
const initialLoadDone = useRef(false);
// Ensure settings are loaded before counting hooks (runs once on mount)
useEffect(() => {
if (initialLoadDone.current) return;
initialLoadDone.current = true;
const loadSettings = async () => {
try {
await settingsManager.loadProjectSettings();
await settingsManager.loadLocalProjectSettings();
} catch {
// Settings may already be loaded or not available
}
refreshCounts();
};
loadSettings();
}, [refreshCounts]);
// Refresh counts when returning to events screen
useEffect(() => {
if (screen === "events") {
refreshCounts();
@@ -267,6 +294,13 @@ export const HooksManager = memo(function HooksManager({
setSelectedIndex(0);
}, [deleteHookIndex, selectedEvent, hooks, loadHooks, refreshCounts]);
// Handle toggling the "disable all hooks" setting
const handleToggleDisableAll = useCallback(() => {
const newValue = !hooksDisabled;
setHooksDisabled(newValue);
setHooksDisabledState(newValue);
}, [hooksDisabled]);
useInput((input, key) => {
// CTRL-C: immediately cancel
if (key.ctrl && input === "c") {
@@ -276,17 +310,26 @@ export const HooksManager = memo(function HooksManager({
// Handle each screen
if (screen === "events") {
// Total items: 1 (disable toggle) + HOOK_EVENTS.length
const totalItems = 1 + HOOK_EVENTS.length;
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(HOOK_EVENTS.length - 1, prev + 1));
setSelectedIndex((prev) => Math.min(totalItems - 1, prev + 1));
} else if (key.return) {
const selected = HOOK_EVENTS[selectedIndex];
if (selected) {
setSelectedEvent(selected.event);
loadHooks(selected.event);
setScreen("hooks-list");
setSelectedIndex(0);
if (selectedIndex === 0) {
// Toggle "disable all hooks"
handleToggleDisableAll();
} else {
// Select a hook event (index is shifted by 1)
const selected = HOOK_EVENTS[selectedIndex - 1];
if (selected) {
setSelectedEvent(selected.event);
loadHooks(selected.event);
setScreen("hooks-list");
setSelectedIndex(0);
}
}
} else if (key.escape) {
onClose();
@@ -372,20 +415,45 @@ export const HooksManager = memo(function HooksManager({
// Render Events List
if (screen === "events") {
const disableToggleSelected = selectedIndex === 0;
const disableToggleLabel = hooksDisabled
? "Enable all hooks"
: "Disable all hooks";
const titleBase = " Hooks";
const titleSuffix = hooksDisabled ? " (disabled)" : "";
const hooksCountText = `${totalHooks} hooks `;
const titlePadding =
boxWidth -
titleBase.length -
titleSuffix.length -
hooksCountText.length -
2;
return (
<Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text>
<Text>
{boxLine(
` Hooks${" ".repeat(boxWidth - 20)}${totalHooks} hooks `,
boxWidth,
)}
{BOX_VERTICAL}
{titleBase}
<Text color="red">{titleSuffix}</Text>
{" ".repeat(Math.max(0, titlePadding))}
{hooksCountText}
{BOX_VERTICAL}
</Text>
<Text>{boxBottom(boxWidth)}</Text>
<Text> </Text>
{/* Disable all hooks toggle - first item */}
<Text>
<Text color={disableToggleSelected ? colors.input.prompt : undefined}>
{disableToggleSelected ? "" : " "} 1.
</Text>
<Text dimColor> {disableToggleLabel}</Text>
</Text>
{/* Hook events */}
{HOOK_EVENTS.map((item, index) => {
const isSelected = index === selectedIndex;
const isSelected = index + 1 === selectedIndex;
const hookCount = countHooksForEvent(item.event);
const prefix = isSelected ? "" : " ";
const countStr = hookCount > 0 ? ` (${hookCount})` : "";
@@ -393,7 +461,7 @@ export const HooksManager = memo(function HooksManager({
return (
<Text key={item.event}>
<Text color={isSelected ? colors.input.prompt : undefined}>
{prefix} {index + 1}. {item.event}
{prefix} {index + 2}. {item.event}
</Text>
<Text dimColor> - {item.description}</Text>
<Text color="yellow">{countStr}</Text>

View File

@@ -20,7 +20,7 @@ import type {
UserPromptSubmitHookInput,
} from "./types";
export { clearHooksCache } from "./loader";
export { areHooksDisabled, clearHooksCache } from "./loader";
// Re-export types for convenience
export * from "./types";

View File

@@ -239,6 +239,59 @@ export function hasHooksForEvent(
}
}
/**
* Check if all hooks are disabled via hooks.disabled across settings levels.
*
* Precedence:
* 1. If user has disabled: false → ENABLED (explicit user override)
* 2. If user has disabled: true → DISABLED
* 3. If project OR project-local has disabled: true → DISABLED
* 4. Default → ENABLED
*/
export function areHooksDisabled(
workingDirectory: string = process.cwd(),
): boolean {
try {
// Check user-level settings first (highest precedence)
const userDisabled = settingsManager.getSettings().hooks?.disabled;
if (userDisabled === false) {
// User explicitly enabled - overrides project settings
return false;
}
if (userDisabled === true) {
// User explicitly disabled
return true;
}
// User setting is undefined, check project-level settings
try {
const projectDisabled =
settingsManager.getProjectSettings(workingDirectory)?.hooks?.disabled;
if (projectDisabled === true) {
return true;
}
} catch {
// Project settings not loaded, skip
}
// Check project-local settings
try {
const localDisabled =
settingsManager.getLocalProjectSettings(workingDirectory)?.hooks
?.disabled;
if (localDisabled === true) {
return true;
}
} catch {
// Local project settings not loaded, skip
}
return false;
} catch {
return false;
}
}
/**
* Convenience function to load hooks and get matching ones for an event
*/
@@ -247,6 +300,11 @@ export async function getHooksForEvent(
toolName?: string,
workingDirectory: string = process.cwd(),
): Promise<HookCommand[]> {
// Check if all hooks are disabled
if (areHooksDisabled(workingDirectory)) {
return [];
}
const config = await loadHooks(workingDirectory);
return getMatchingHooks(config, event, toolName);
}

View File

@@ -66,8 +66,12 @@ export interface SimpleHookMatcher {
* Full hooks configuration stored in settings
* - Tool events (PreToolUse, PostToolUse, PermissionRequest) use HookMatcher[] with matcher patterns
* - Simple events use SimpleHookMatcher[] (same structure, just no matcher field)
* - disabled: when true, prevents all hooks from firing (checked across all config levels)
*/
export type HooksConfig = {
/** When true, disables all hooks. User false overrides project settings; otherwise any true disables. */
disabled?: boolean;
} & {
[K in ToolHookEvent]?: HookMatcher[];
} & {
[K in SimpleHookEvent]?: SimpleHookMatcher[];

View File

@@ -309,7 +309,11 @@ export function countTotalHooks(
for (const location of locations) {
const hooks = loadHooksFromLocation(location, workingDirectory);
for (const event of Object.keys(hooks) as HookEvent[]) {
for (const key of Object.keys(hooks)) {
// Skip non-event keys like 'disabled'
if (key === "disabled") continue;
const event = key as HookEvent;
if (isToolEvent(event)) {
// Tool events have HookMatcher[] with nested hooks
const matchers = (hooks[event as ToolHookEvent] || []) as HookMatcher[];
@@ -361,3 +365,29 @@ export function countHooksForEvent(
return count;
}
/**
* Check if user-level hooks.disabled is set to true.
* NOTE: This only checks user settings. For full precedence logic
* (user → project → project-local), use areHooksDisabled from loader.ts.
*/
export function isUserHooksDisabled(): boolean {
try {
return settingsManager.getSettings().hooks?.disabled === true;
} catch {
return false;
}
}
/**
* Set whether all hooks are disabled (writes to user-level hooks.disabled)
*/
export function setHooksDisabled(disabled: boolean): void {
const currentHooks = settingsManager.getSettings().hooks || {};
settingsManager.updateSettings({
hooks: {
...currentHooks,
disabled,
},
});
}

View File

@@ -47,7 +47,7 @@ export interface Settings {
pinnedAgents?: string[]; // DEPRECATED: kept for backwards compat, use pinnedAgentsByServer
createDefaultAgents?: boolean; // Create Memo/Incognito default agents on startup (default: true)
permissions?: PermissionRules;
hooks?: HooksConfig; // Hook commands that run at various lifecycle points
hooks?: HooksConfig; // Hook commands that run at various lifecycle points (includes disabled flag)
env?: Record<string, string>;
// Server-indexed settings (agent IDs are server-specific)
sessionsByServer?: Record<string, SessionRef>; // key = normalized base URL (e.g., "api.letta.com", "localhost:8283")