Files
letta-code/src/cli/components/HooksManager.tsx
2026-02-03 14:13:27 -08:00

732 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// src/cli/components/HooksManager.tsx
// Interactive TUI for managing hooks configuration
import { Box, useInput } from "ink";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import {
type HookEvent,
type HookMatcher,
isToolEvent,
type SimpleHookEvent,
type SimpleHookMatcher,
type ToolHookEvent,
} from "../../hooks/types";
import {
addHookMatcher,
addSimpleHookMatcher,
countHooksForEvent,
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";
import { Text } from "./Text";
// Box drawing characters
const BOX_TOP_LEFT = "╭";
const BOX_TOP_RIGHT = "╮";
const BOX_BOTTOM_LEFT = "╰";
const BOX_BOTTOM_RIGHT = "╯";
const BOX_HORIZONTAL = "─";
const BOX_VERTICAL = "│";
interface HooksManagerProps {
onClose: () => void;
agentId?: string;
}
type Screen =
| "events"
| "hooks-list" // Was "matchers" - now handles both matchers and commands
| "add-matcher" // For tool events only
| "add-command"
| "save-location"
| "delete-confirm";
// All hook events with descriptions
const HOOK_EVENTS: { event: HookEvent; description: string }[] = [
{ event: "PreToolUse", description: "Before tool execution" },
{ event: "PostToolUse", description: "After tool execution" },
{ event: "PostToolUseFailure", description: "After tool execution fails" },
{ event: "PermissionRequest", description: "When permission is requested" },
{ event: "UserPromptSubmit", description: "When user submits a prompt" },
{ event: "Notification", description: "When notifications are sent" },
{ event: "Stop", description: "When the agent finishes responding" },
{ event: "SubagentStop", description: "When a subagent completes" },
{ event: "PreCompact", description: "Before context compaction" },
{ event: "Setup", description: "When invoked with --init flags" },
{ event: "SessionStart", description: "When a session starts" },
{ event: "SessionEnd", description: "When a session ends" },
];
// Fallback tool names if agent tools can't be fetched
const FALLBACK_TOOL_NAMES = [
"Task",
"Bash",
"Glob",
"Grep",
"Read",
"Edit",
"Write",
"TodoWrite",
"AskUserQuestion",
"Skill",
"EnterPlanMode",
"ExitPlanMode",
"BashOutput",
"KillBash",
];
// Save location options
const SAVE_LOCATIONS: {
location: SaveLocation;
label: string;
path: string;
}[] = [
{
location: "project-local",
label: "Project settings (local)",
path: ".letta/settings.local.json",
},
{
location: "project",
label: "Project settings",
path: ".letta/settings.json",
},
{ location: "user", label: "User settings", path: "~/.letta/settings.json" },
];
function getSourceLabel(source: SaveLocation): string {
switch (source) {
case "user":
return "User";
case "project":
return "Project";
case "project-local":
return "Local";
}
}
/**
* Create a box border line
*/
function boxLine(content: string, width: number): string {
const innerWidth = width - 2;
const paddedContent = content.padEnd(innerWidth).slice(0, innerWidth);
return `${BOX_VERTICAL}${paddedContent}${BOX_VERTICAL}`;
}
function boxTop(width: number): string {
return `${BOX_TOP_LEFT}${BOX_HORIZONTAL.repeat(width - 2)}${BOX_TOP_RIGHT}`;
}
function boxBottom(width: number): string {
return `${BOX_BOTTOM_LEFT}${BOX_HORIZONTAL.repeat(width - 2)}${BOX_BOTTOM_RIGHT}`;
}
export const HooksManager = memo(function HooksManager({
onClose,
agentId,
}: HooksManagerProps) {
const terminalWidth = useTerminalWidth();
const boxWidth = Math.min(terminalWidth - 4, 70);
const [screen, setScreen] = useState<Screen>("events");
const [selectedIndex, setSelectedIndex] = useState(0);
const [selectedEvent, setSelectedEvent] = useState<HookEvent | null>(null);
// For tool events: HookMatcherWithSource[], for simple events: HookCommandWithSource[]
const [hooks, setHooks] = useState<HookWithSource[]>([]);
const [totalHooks, setTotalHooks] = useState(0);
// 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;
const fetchAgentTools = async () => {
try {
const { getClient } = await import("../../agent/client");
const client = await getClient();
// Use dedicated tools endpoint instead of fetching whole agent
// Pass limit to avoid pagination issues
const toolsPage = await client.agents.tools.list(agentId, {
limit: 50,
});
const names = toolsPage.items
?.map((t) => t.name)
.filter((n): n is string => !!n);
if (names && names.length > 0) {
// Sort alphabetically for easier scanning
setToolNames(names.sort());
}
} catch {
// Keep fallback tool names on error
}
};
fetchAgentTools();
}, [agentId]);
// New hook state
const [newMatcher, setNewMatcher] = useState("");
const [newCommand, setNewCommand] = useState("");
const [selectedLocation, setSelectedLocation] = useState(0);
// Delete confirmation
const [deleteHookIndex, setDeleteHookIndex] = useState(-1);
const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No
// Helper to check if current event is a tool event
const isCurrentToolEvent = selectedEvent ? isToolEvent(selectedEvent) : false;
// Refresh counts - called when hooks change
const refreshCounts = useCallback(() => {
setTotalHooks(countTotalHooks());
setHooksDisabledState(isUserHooksDisabled());
}, []);
// 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();
}
}, [screen, refreshCounts]);
// Load hooks when event is selected (matchers for both tool and simple events)
const loadHooks = useCallback((event: HookEvent) => {
if (isToolEvent(event)) {
setHooks(loadMatchersWithSource(event as ToolHookEvent));
} else {
setHooks(loadSimpleMatchersWithSource(event as SimpleHookEvent));
}
}, []);
// Handle adding a hook
const handleAddHook = useCallback(async () => {
if (!selectedEvent || !newCommand.trim()) return;
const location = SAVE_LOCATIONS[selectedLocation]?.location;
if (!location) return;
if (isToolEvent(selectedEvent)) {
// Tool events use HookMatcher with matcher pattern
const matcher: HookMatcher = {
matcher: newMatcher.trim() || "*",
hooks: [{ type: "command", command: newCommand.trim() }],
};
await addHookMatcher(selectedEvent as ToolHookEvent, matcher, location);
} else {
// Simple events use SimpleHookMatcher (same structure, just no matcher field)
const matcher: SimpleHookMatcher = {
hooks: [{ type: "command", command: newCommand.trim() }],
};
await addSimpleHookMatcher(
selectedEvent as SimpleHookEvent,
matcher,
location,
);
}
loadHooks(selectedEvent);
refreshCounts();
// Reset and go back to hooks list
setNewMatcher("");
setNewCommand("");
setSelectedLocation(0);
setScreen("hooks-list");
setSelectedIndex(0);
}, [
selectedEvent,
newMatcher,
newCommand,
selectedLocation,
loadHooks,
refreshCounts,
]);
// Handle deleting a hook
const handleDeleteHook = useCallback(async () => {
if (deleteHookIndex < 0 || !selectedEvent) return;
const hook = hooks[deleteHookIndex];
if (!hook) return;
await removeHook(selectedEvent, hook.sourceIndex, hook.source);
loadHooks(selectedEvent);
refreshCounts();
// Reset and go back to hooks list
setDeleteHookIndex(-1);
setScreen("hooks-list");
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") {
onClose();
return;
}
// 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(totalItems - 1, prev + 1));
} else if (key.return) {
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();
}
} else if (screen === "hooks-list") {
// Items: [+ Add new hook] + existing hooks
const itemCount = hooks.length + 1;
if (key.upArrow) {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedIndex((prev) => Math.min(itemCount - 1, prev + 1));
} else if (key.return) {
if (selectedIndex === 0) {
// Add new hook - for tool events, go to matcher screen; for simple, go to command
if (isCurrentToolEvent) {
setScreen("add-matcher");
setNewMatcher("");
} else {
setScreen("add-command");
setNewCommand("");
}
} else {
// Delete selected hook
setDeleteHookIndex(selectedIndex - 1);
setDeleteConfirmIndex(1); // Default to No
setScreen("delete-confirm");
}
} else if (key.escape) {
setScreen("events");
setSelectedIndex(0);
setSelectedEvent(null);
}
} else if (screen === "add-matcher") {
// Text input handles most keys (tool events only)
if (key.return && !key.shift) {
setScreen("add-command");
setNewCommand("");
} else if (key.escape) {
setScreen("hooks-list");
setSelectedIndex(0);
setNewMatcher("");
}
} else if (screen === "add-command") {
if (key.return && !key.shift) {
setScreen("save-location");
setSelectedLocation(0);
} else if (key.escape) {
// Go back to matcher screen for tool events, or hooks list for simple
if (isCurrentToolEvent) {
setScreen("add-matcher");
} else {
setScreen("hooks-list");
setSelectedIndex(0);
}
}
} else if (screen === "save-location") {
if (key.upArrow) {
setSelectedLocation((prev) => Math.max(0, prev - 1));
} else if (key.downArrow) {
setSelectedLocation((prev) =>
Math.min(SAVE_LOCATIONS.length - 1, prev + 1),
);
} else if (key.return) {
handleAddHook();
} else if (key.escape) {
setScreen("add-command");
}
} else if (screen === "delete-confirm") {
if (key.upArrow || key.downArrow) {
setDeleteConfirmIndex((prev) => (prev === 0 ? 1 : 0));
} else if (key.return) {
if (deleteConfirmIndex === 0) {
handleDeleteHook();
} else {
setScreen("hooks-list");
}
} else if (key.escape) {
setScreen("hooks-list");
}
}
});
// 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>
{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 + 1 === selectedIndex;
const hookCount = countHooksForEvent(item.event);
const prefix = isSelected ? "" : " ";
const countStr = hookCount > 0 ? ` (${hookCount})` : "";
return (
<Text key={item.event}>
<Text color={isSelected ? colors.input.prompt : undefined}>
{prefix} {index + 2}. {item.event}
</Text>
<Text dimColor> - {item.description}</Text>
<Text color="yellow">{countStr}</Text>
</Text>
);
})}
<Text> </Text>
<Text dimColor>Enter to select · esc to cancel</Text>
</Box>
);
}
// Render Hooks List (matchers for tool events, commands for simple events)
if (screen === "hooks-list" && selectedEvent) {
const title = isCurrentToolEvent
? ` ${selectedEvent} - Tool Matchers `
: ` ${selectedEvent} - Hooks `;
const addLabel = isCurrentToolEvent
? "+ Add new matcher..."
: "+ Add new hook...";
return (
<Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text>
<Text>{boxLine(title, boxWidth)}</Text>
<Text>{boxBottom(boxWidth)}</Text>
{isCurrentToolEvent ? (
<>
<Text dimColor>
Input to command is JSON of tool call arguments.
</Text>
<Text dimColor>Exit code 0 - stdout/stderr not shown</Text>
<Text dimColor>
Exit code 2 - show stderr to model and block tool call
</Text>
<Text dimColor>
Other exit codes - show stderr to user only but continue
</Text>
</>
) : (
<>
<Text dimColor>Exit code 0 - success, continue</Text>
<Text dimColor>Exit code 2 - show stderr to model and block</Text>
<Text dimColor>Other exit codes - show stderr to user only</Text>
</>
)}
<Text> </Text>
{/* Add new hook option */}
<Text>
<Text color={selectedIndex === 0 ? colors.input.prompt : undefined}>
{selectedIndex === 0 ? "" : " "} 1.{" "}
</Text>
<Text color="green">{addLabel}</Text>
</Text>
{/* Existing hooks */}
{hooks.map((hook, index) => {
const isSelected = index + 1 === selectedIndex;
const prefix = isSelected ? "" : " ";
const sourceLabel = `[${getSourceLabel(hook.source)}]`;
// Handle both tool matchers (with matcher field) and simple matchers (without)
const isToolMatcher = "matcher" in hook;
const matcherPattern = isToolMatcher
? (hook as HookMatcherWithSource).matcher || "*"
: null;
// Both types have hooks array
const command = "hooks" in hook ? hook.hooks[0]?.command || "" : "";
const truncatedCommand =
command.length > 50 ? `${command.slice(0, 47)}...` : command;
return (
<Text key={`${hook.source}-${index}`}>
<Text color={isSelected ? colors.input.prompt : undefined}>
{prefix} {index + 2}.{" "}
</Text>
<Text color="cyan">{sourceLabel}</Text>
{matcherPattern !== null ? (
<Text> {matcherPattern.padEnd(12)} </Text>
) : (
<Text> </Text>
)}
<Text dimColor>{truncatedCommand}</Text>
</Text>
);
})}
<Text> </Text>
<Text dimColor>Enter to select · esc to go back</Text>
</Box>
);
}
// Render Add Matcher - Tool Pattern Input
if (screen === "add-matcher" && selectedEvent) {
return (
<Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text>
<Text>
{boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)}
</Text>
<Text>{boxBottom(boxWidth)}</Text>
<Text dimColor>Input to command is JSON of tool call arguments.</Text>
<Text dimColor>Exit code 0 - stdout/stderr not shown</Text>
<Text dimColor>
Exit code 2 - show stderr to model and block tool call
</Text>
<Text> </Text>
<Text dimColor>Possible matcher values for field tool_name:</Text>
<Text dimColor>{toolNames.join(", ")}</Text>
<Text> </Text>
<Text>Tool matcher:</Text>
<Box
borderStyle="round"
borderColor="gray"
paddingX={1}
width={boxWidth - 2}
flexDirection="column"
>
<Box flexWrap="wrap">
<PasteAwareTextInput
value={newMatcher}
onChange={setNewMatcher}
placeholder="* (matches all tools)"
/>
</Box>
</Box>
<Text> </Text>
<Text dimColor>Example Matchers:</Text>
<Text dimColor> Write (single tool)</Text>
<Text dimColor> Write|Edit (multiple tools)</Text>
<Text dimColor> * (all tools)</Text>
<Text> </Text>
<Text dimColor>Enter to continue · esc to cancel</Text>
</Box>
);
}
// Render Add Command Input
if (screen === "add-command" && selectedEvent) {
const title = isCurrentToolEvent
? ` Add new matcher for ${selectedEvent} `
: ` Add new hook for ${selectedEvent} `;
return (
<Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text>
<Text>{boxLine(title, boxWidth)}</Text>
<Text>{boxBottom(boxWidth)}</Text>
{isCurrentToolEvent && <Text>Matcher: {newMatcher || "*"}</Text>}
{isCurrentToolEvent && <Text> </Text>}
<Text>Command:</Text>
<Box
borderStyle="round"
borderColor="gray"
paddingX={1}
width={boxWidth - 2}
flexDirection="column"
>
<Box flexWrap="wrap">
<PasteAwareTextInput
value={newCommand}
onChange={setNewCommand}
placeholder="/path/to/script.sh"
/>
</Box>
</Box>
<Text> </Text>
<Text dimColor>Enter to continue · esc to go back</Text>
</Box>
);
}
// Render Save Location Picker
if (screen === "save-location" && selectedEvent) {
return (
<Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text>
<Text>{boxLine(" Save hook configuration ", boxWidth)}</Text>
<Text>{boxBottom(boxWidth)}</Text>
<Text> </Text>
<Text>Event: {selectedEvent}</Text>
{isCurrentToolEvent && <Text>Matcher: {newMatcher || "*"}</Text>}
<Text>Command: {newCommand}</Text>
<Text> </Text>
<Text>Where should this hook be saved?</Text>
<Text> </Text>
{SAVE_LOCATIONS.map((loc, index) => {
const isSelected = index === selectedLocation;
const prefix = isSelected ? "" : " ";
return (
<Text key={loc.location}>
<Text color={isSelected ? colors.input.prompt : undefined}>
{prefix} {index + 1}. {loc.label}
</Text>
<Text dimColor> {loc.path}</Text>
</Text>
);
})}
<Text> </Text>
<Text dimColor>Enter to confirm · esc to go back</Text>
</Box>
);
}
// Render Delete Confirmation
if (screen === "delete-confirm" && deleteHookIndex >= 0) {
const hook = hooks[deleteHookIndex];
const isToolMatcher = hook && "matcher" in hook;
const matcherPattern = isToolMatcher
? (hook as HookMatcherWithSource).matcher || "*"
: null;
// Both types have hooks array
const command = hook && "hooks" in hook ? hook.hooks[0]?.command : "";
return (
<Box flexDirection="column" paddingX={1}>
<Text>{boxTop(boxWidth)}</Text>
<Text>{boxLine(" Delete hook? ", boxWidth)}</Text>
<Text>{boxBottom(boxWidth)}</Text>
<Text> </Text>
{matcherPattern !== null && <Text>Matcher: {matcherPattern}</Text>}
<Text>Command: {command}</Text>
<Text>Source: {hook ? getSourceLabel(hook.source) : ""}</Text>
<Text> </Text>
<Text>
<Text
color={deleteConfirmIndex === 0 ? colors.input.prompt : undefined}
>
{deleteConfirmIndex === 0 ? "" : " "} Yes, delete
</Text>
</Text>
<Text>
<Text
color={deleteConfirmIndex === 1 ? colors.input.prompt : undefined}
>
{deleteConfirmIndex === 1 ? "" : " "} No, cancel
</Text>
</Text>
<Text> </Text>
<Text dimColor>Enter to confirm · esc to cancel</Text>
</Box>
);
}
return null;
});