732 lines
22 KiB
TypeScript
732 lines
22 KiB
TypeScript
// 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;
|
||
});
|