// src/cli/components/HooksManager.tsx // Interactive TUI for managing hooks configuration import { Box, Text, useInput } from "ink"; import TextInput from "ink-text-input"; import { memo, useCallback, useEffect, useState } from "react"; import type { HookEvent, HookMatcher } from "../../hooks/types"; import { addHookMatcher, countHooksForEvent, countTotalHooks, type HookMatcherWithSource, loadHooksWithSource, removeHookMatcher, type SaveLocation, } from "../../hooks/writer"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { colors } from "./colors"; // 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; } type Screen = | "events" | "matchers" | "add-matcher" | "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: "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" }, ]; // Available tools for matcher suggestions const TOOL_NAMES = [ "Task", "Bash", "Glob", "Grep", "Read", "Edit", "Write", "WebFetch", "TodoWrite", "WebSearch", "AskUserQuestion", "Skill", "EnterPlanMode", "ExitPlanMode", "KillShell", ]; // 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, }: HooksManagerProps) { const terminalWidth = useTerminalWidth(); const boxWidth = Math.min(terminalWidth - 4, 70); const [screen, setScreen] = useState("events"); const [selectedIndex, setSelectedIndex] = useState(0); const [selectedEvent, setSelectedEvent] = useState(null); const [matchers, setMatchers] = useState([]); const [totalHooks, setTotalHooks] = useState(0); // New hook state const [newMatcher, setNewMatcher] = useState(""); const [newCommand, setNewCommand] = useState(""); const [selectedLocation, setSelectedLocation] = useState(0); // Delete confirmation const [deleteMatcherIndex, setDeleteMatcherIndex] = useState(-1); const [deleteConfirmIndex, setDeleteConfirmIndex] = useState(1); // Default to No // Refresh counts - called when hooks change const refreshCounts = useCallback(() => { setTotalHooks(countTotalHooks()); }, []); // Load total hooks count on mount and when returning to events screen useEffect(() => { if (screen === "events") { refreshCounts(); } }, [screen, refreshCounts]); // Load matchers when event is selected const loadMatchers = useCallback((event: HookEvent) => { const loaded = loadHooksWithSource(event); setMatchers(loaded); }, []); // Handle adding a hook const handleAddHook = useCallback(async () => { if (!selectedEvent || !newCommand.trim()) return; const location = SAVE_LOCATIONS[selectedLocation]?.location; if (!location) return; const matcher: HookMatcher = { matcher: newMatcher.trim() || "*", hooks: [{ type: "command", command: newCommand.trim() }], }; await addHookMatcher(selectedEvent, matcher, location); loadMatchers(selectedEvent); refreshCounts(); // Reset and go back to matchers setNewMatcher(""); setNewCommand(""); setSelectedLocation(0); setScreen("matchers"); setSelectedIndex(0); }, [ selectedEvent, newMatcher, newCommand, selectedLocation, loadMatchers, refreshCounts, ]); // Handle deleting a hook const handleDeleteHook = useCallback(async () => { if (deleteMatcherIndex < 0 || !selectedEvent) return; const matcher = matchers[deleteMatcherIndex]; if (!matcher) return; await removeHookMatcher(selectedEvent, matcher.sourceIndex, matcher.source); loadMatchers(selectedEvent); refreshCounts(); // Reset and go back to matchers setDeleteMatcherIndex(-1); setScreen("matchers"); setSelectedIndex(0); }, [ deleteMatcherIndex, selectedEvent, matchers, loadMatchers, refreshCounts, ]); useInput((input, key) => { // CTRL-C: immediately cancel if (key.ctrl && input === "c") { onClose(); return; } // Handle each screen if (screen === "events") { if (key.upArrow) { setSelectedIndex((prev) => Math.max(0, prev - 1)); } else if (key.downArrow) { setSelectedIndex((prev) => Math.min(HOOK_EVENTS.length - 1, prev + 1)); } else if (key.return) { const selected = HOOK_EVENTS[selectedIndex]; if (selected) { setSelectedEvent(selected.event); loadMatchers(selected.event); setScreen("matchers"); setSelectedIndex(0); } } else if (key.escape) { onClose(); } } else if (screen === "matchers") { // Items: [+ Add new matcher] + existing matchers const itemCount = matchers.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 matcher setScreen("add-matcher"); setNewMatcher(""); } else { // Could add edit functionality here } } else if ((input === "d" || input === "D") && selectedIndex > 0) { // Delete selected matcher setDeleteMatcherIndex(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 if (key.return && !key.shift) { setScreen("add-command"); setNewCommand(""); } else if (key.escape) { setScreen("matchers"); setSelectedIndex(0); setNewMatcher(""); } } else if (screen === "add-command") { if (key.return && !key.shift) { setScreen("save-location"); setSelectedLocation(0); } else if (key.escape) { setScreen("add-matcher"); } } 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("matchers"); } } else if (key.escape) { setScreen("matchers"); } } }); // Render Events List if (screen === "events") { return ( {boxTop(boxWidth)} {boxLine( ` Hooks${" ".repeat(boxWidth - 20)}${totalHooks} hooks `, boxWidth, )} {boxBottom(boxWidth)} {HOOK_EVENTS.map((item, index) => { const isSelected = index === selectedIndex; const hookCount = countHooksForEvent(item.event); const prefix = isSelected ? "❯" : " "; const countStr = hookCount > 0 ? ` (${hookCount})` : ""; return ( {prefix} {index + 1}. {item.event} - {item.description} {countStr} ); })} Enter to select · esc to cancel ); } // Render Matchers List if (screen === "matchers" && selectedEvent) { return ( {boxTop(boxWidth)} {boxLine(` ${selectedEvent} - Tool Matchers `, boxWidth)} {boxBottom(boxWidth)} Input to command is JSON of tool call arguments. Exit code 0 - stdout/stderr not shown Exit code 2 - show stderr to model and block tool call Other exit codes - show stderr to user only but continue {/* Add new matcher option */} {selectedIndex === 0 ? "❯" : " "} 1.{" "} + Add new matcher... {/* Existing matchers */} {matchers.map((matcher, index) => { const isSelected = index + 1 === selectedIndex; const prefix = isSelected ? "❯" : " "; const sourceLabel = `[${getSourceLabel(matcher.source)}]`; const matcherPattern = matcher.matcher || "*"; const command = matcher.hooks[0]?.command || ""; const truncatedCommand = command.length > 30 ? `${command.slice(0, 27)}...` : command; return ( {prefix} {index + 2}.{" "} {sourceLabel} {matcherPattern.padEnd(12)} {truncatedCommand} ); })} Enter to select · d to delete · esc to go back ); } // Render Add Matcher - Tool Pattern Input if (screen === "add-matcher" && selectedEvent) { return ( {boxTop(boxWidth)} {boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)} {boxBottom(boxWidth)} Input to command is JSON of tool call arguments. Exit code 0 - stdout/stderr not shown Exit code 2 - show stderr to model and block tool call Possible matcher values for field tool_name: {TOOL_NAMES.join(", ")} Tool matcher: {boxTop(boxWidth - 2)} {BOX_VERTICAL} {boxBottom(boxWidth - 2)} Example Matchers: • Write (single tool) • Write|Edit (multiple tools) • * (all tools) Enter to continue · esc to cancel ); } // Render Add Matcher - Command Input if (screen === "add-command" && selectedEvent) { return ( {boxTop(boxWidth)} {boxLine(` Add new matcher for ${selectedEvent} `, boxWidth)} {boxBottom(boxWidth)} Matcher: {newMatcher || "*"} Command: {boxTop(boxWidth - 2)} {BOX_VERTICAL} {boxBottom(boxWidth - 2)} Enter to continue · esc to go back ); } // Render Save Location Picker if (screen === "save-location" && selectedEvent) { return ( {boxTop(boxWidth)} {boxLine(" Save hook configuration ", boxWidth)} {boxBottom(boxWidth)} Event: {selectedEvent} Matcher: {newMatcher || "*"} Command: {newCommand} Where should this hook be saved? {SAVE_LOCATIONS.map((loc, index) => { const isSelected = index === selectedLocation; const prefix = isSelected ? "❯" : " "; return ( {prefix} {index + 1}. {loc.label} {loc.path} ); })} Enter to confirm · esc to go back ); } // Render Delete Confirmation if (screen === "delete-confirm" && deleteMatcherIndex >= 0) { const matcher = matchers[deleteMatcherIndex]; return ( {boxTop(boxWidth)} {boxLine(" Delete hook? ", boxWidth)} {boxBottom(boxWidth)} Matcher: {matcher?.matcher || "*"} Command: {matcher?.hooks[0]?.command} Source: {matcher ? getSourceLabel(matcher.source) : ""} {deleteConfirmIndex === 0 ? "❯" : " "} Yes, delete {deleteConfirmIndex === 1 ? "❯" : " "} No, cancel Enter to confirm · esc to cancel ); } return null; });