feat: add disable all hook toggle (#726)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user