// Import useInput from vendored Ink for bracketed paste support import { Box, Text, useInput } from "ink"; import type React from "react"; import { memo, useEffect, useMemo, useState } from "react"; import type { ApprovalContext } from "../../permissions/analyzer"; import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff"; import { resolvePlaceholders } from "../helpers/pasteRegistry"; import type { ApprovalRequest } from "../helpers/stream"; import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; import { colors } from "./colors"; import { PasteAwareTextInput } from "./PasteAwareTextInput"; type Props = { approvals: ApprovalRequest[]; approvalContexts: ApprovalContext[]; progress?: { current: number; total: number }; totalTools?: number; isExecuting?: boolean; onApproveAll: () => void; onApproveAlways: (scope?: "project" | "session") => void; onDenyAll: (reason: string) => void; }; type DynamicPreviewProps = { toolName: string; toolArgs: string; parsedArgs: Record | null; precomputedDiff: AdvancedDiffSuccess | null; }; // Options renderer - memoized to prevent unnecessary re-renders const OptionsRenderer = memo( ({ options, selectedOption, }: { options: Array<{ label: string; action: () => void }>; selectedOption: number; }) => { return ( {options.map((option, index) => { const isSelected = index === selectedOption; const color = isSelected ? colors.approval.header : undefined; return ( {isSelected ? ">" : " "} {index + 1}. {option.label} ); })} ); }, ); OptionsRenderer.displayName = "OptionsRenderer"; // Dynamic preview component - defined outside to avoid recreation on every render const DynamicPreview: React.FC = ({ toolName, toolArgs, parsedArgs, precomputedDiff, }) => { const t = toolName.toLowerCase(); if ( t === "bash" || t === "shell_command" || t === "shellcommand" || t === "run_shell_command" || t === "runshellcommand" ) { const cmdVal = parsedArgs?.command; const cmd = typeof cmdVal === "string" ? cmdVal : toolArgs || "(no arguments)"; const descVal = parsedArgs?.description; const desc = typeof descVal === "string" ? descVal : ""; return ( {cmd} {desc ? {desc} : null} ); } if (t === "shell") { const cmdVal = parsedArgs?.command; const cmd = Array.isArray(cmdVal) ? cmdVal.join(" ") : typeof cmdVal === "string" ? cmdVal : "(no command)"; const justificationVal = parsedArgs?.justification; const justification = typeof justificationVal === "string" ? justificationVal : ""; return ( {cmd} {justification ? {justification} : null} ); } if ( t === "ls" || t === "list_dir" || t === "listdir" || t === "list_directory" || t === "listdirectory" ) { const pathVal = parsedArgs?.path || parsedArgs?.target_directory || parsedArgs?.dir_path; const path = typeof pathVal === "string" ? pathVal : "(current directory)"; const ignoreVal = parsedArgs?.ignore || parsedArgs?.ignore_globs; const ignore = Array.isArray(ignoreVal) && ignoreVal.length > 0 ? ` (ignoring: ${ignoreVal.join(", ")})` : ""; return ( List files in: {path} {ignore ? {ignore} : null} ); } if (t === "read" || t === "read_file" || t === "readfile") { const pathVal = parsedArgs?.file_path || parsedArgs?.target_file; const path = typeof pathVal === "string" ? pathVal : "(no file specified)"; const offsetVal = parsedArgs?.offset; const limitVal = parsedArgs?.limit; const rangeInfo = typeof offsetVal === "number" || typeof limitVal === "number" ? ` (lines ${offsetVal ?? 1}–${typeof offsetVal === "number" && typeof limitVal === "number" ? offsetVal + limitVal : "end"})` : ""; return ( Read file: {path} {rangeInfo} ); } if ( t === "grep" || t === "grep_files" || t === "grepfiles" || t === "search_file_content" || t === "searchfilecontent" ) { const patternVal = parsedArgs?.pattern; const pattern = typeof patternVal === "string" ? patternVal : "(no pattern)"; const pathVal = parsedArgs?.path; const path = typeof pathVal === "string" ? ` in ${pathVal}` : ""; const includeVal = parsedArgs?.include || parsedArgs?.glob; const includeInfo = typeof includeVal === "string" ? ` (${includeVal})` : ""; return ( Search for: {pattern} {path} {includeInfo} ); } if (t === "apply_patch" || t === "applypatch") { const inputVal = parsedArgs?.input; const patchPreview = typeof inputVal === "string" && inputVal.length > 100 ? `${inputVal.slice(0, 100)}...` : typeof inputVal === "string" ? inputVal : "(no patch content)"; return ( Apply patch: {patchPreview} ); } if (t === "update_plan" || t === "updateplan") { const planVal = parsedArgs?.plan; const explanationVal = parsedArgs?.explanation; if (Array.isArray(planVal)) { const explanation = typeof explanationVal === "string" ? explanationVal : undefined; return ( {explanation && ( {explanation} )} {planVal .map((item: unknown, idx: number) => { if (typeof item === "object" && item !== null) { const stepItem = item as { step?: string; status?: string }; const step = stepItem.step || "(no description)"; const status = stepItem.status || "pending"; const checkbox = status === "completed" ? "☒" : "☐"; return ( {checkbox} {step} ); } return null; }) .filter((el): el is React.ReactElement => el !== null)} ); } } if (t === "glob") { const patternVal = parsedArgs?.pattern; const pattern = typeof patternVal === "string" ? patternVal : "(no pattern)"; const dirPathVal = parsedArgs?.dir_path; const dirInfo = typeof dirPathVal === "string" ? ` in ${dirPathVal}` : ""; return ( Find files matching: {pattern} {dirInfo} ); } // File edit previews: write/edit/multi_edit/replace/write_file if ( (t === "write" || t === "edit" || t === "multiedit" || t === "replace" || t === "write_file" || t === "writefile") && parsedArgs ) { try { const filePath = String(parsedArgs.file_path || ""); if (!filePath) throw new Error("no file_path"); if (precomputedDiff) { return ( {t === "write" || t === "write_file" || t === "writefile" ? ( ) : t === "edit" || t === "replace" ? ( ) : ( ) || [] } showHeader={false} /> )} ); } // Fallback to non-precomputed rendering if (t === "write" || t === "write_file" || t === "writefile") { return ( ); } if (t === "edit" || t === "replace") { return ( ); } if (t === "multiedit") { const edits = (parsedArgs.edits as Array<{ old_string: string; new_string: string; replace_all?: boolean; }>) || []; return ( ); } } catch { // Fall through to default } } // Default for file-edit tools when args not parseable yet if ( t === "write" || t === "edit" || t === "multiedit" || t === "replace" || t === "write_file" ) { return ( Preparing preview… ); } // For non-edit tools, pretty-print JSON if available let pretty: string; if (parsedArgs && typeof parsedArgs === "object") { const clone = { ...parsedArgs }; // Remove noisy fields if ("request_heartbeat" in clone) delete clone.request_heartbeat; pretty = JSON.stringify(clone, null, 2); } else { pretty = toolArgs || "(no arguments)"; } return ( {pretty} ); }; export const ApprovalDialog = memo(function ApprovalDialog({ approvals, approvalContexts, progress, totalTools, isExecuting, onApproveAll, onApproveAlways, onDenyAll, }: Props) { const [selectedOption, setSelectedOption] = useState(0); const [isEnteringReason, setIsEnteringReason] = useState(false); const [denyReason, setDenyReason] = useState(""); // Use first approval/context for now (backward compat) // TODO: Support individual approval decisions for multiple approvals // Note: Parent ensures approvals.length > 0 before rendering this component const approvalRequest = approvals[0]; const approvalContext = approvalContexts[0] || null; // Reset state when approval changes (e.g., moving from tool 2 to tool 3) // biome-ignore lint/correctness/useExhaustiveDependencies: need to trigger on progress change useEffect(() => { setSelectedOption(0); setIsEnteringReason(false); setDenyReason(""); }, [progress?.current]); // Build options based on approval context const options = useMemo(() => { const approvalLabel = progress && progress.total > 1 ? "Yes, approve this tool" : "Yes, just this once"; const opts = [{ label: approvalLabel, action: onApproveAll }]; // Add context-aware approval option if available (only for single approvals) if (approvalContext?.allowPersistence) { opts.push({ label: approvalContext.approveAlwaysText, action: () => onApproveAlways( approvalContext.defaultScope === "user" ? "session" : approvalContext.defaultScope, ), }); } // Add deny option const denyLabel = progress && progress.total > 1 ? "No, deny this tool (esc)" : "No, and tell Letta what to do differently (esc)"; opts.push({ label: denyLabel, action: () => {}, // Handled separately via setIsEnteringReason }); return opts; }, [progress, approvalContext, onApproveAll, onApproveAlways]); useInput((_input, key) => { if (isExecuting) return; if (isEnteringReason) { // When entering reason, only handle enter/escape if (key.return) { // Resolve placeholders before sending denial reason const resolvedReason = resolvePlaceholders(denyReason); onDenyAll(resolvedReason); } else if (key.escape) { setIsEnteringReason(false); setDenyReason(""); } return; } if (key.escape) { // Shortcut: ESC immediately opens the deny reason prompt setSelectedOption(options.length - 1); setIsEnteringReason(true); return; } // Navigate with arrow keys if (key.upArrow) { setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1)); } else if (key.downArrow) { setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0)); } else if (key.return) { // Handle selection const selected = options[selectedOption]; if (selected) { // Check if this is the deny option (last option) if (selectedOption === options.length - 1) { setIsEnteringReason(true); } else { selected.action(); } } } // Number key shortcuts const num = parseInt(_input, 10); if (!Number.isNaN(num) && num >= 1 && num <= options.length) { const selected = options[num - 1]; if (selected) { // Check if this is the deny option (last option) if (num === options.length) { setIsEnteringReason(true); } else { selected.action(); } } } }); // Parse JSON args let parsedArgs: Record | null = null; try { parsedArgs = approvalRequest?.toolArgs ? JSON.parse(approvalRequest.toolArgs) : null; } catch { // Keep as-is if not valid JSON } // Compute diff for file-editing tools const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => { if (!parsedArgs || !approvalRequest) return null; const toolName = approvalRequest.toolName.toLowerCase(); if (toolName === "write") { const result = computeAdvancedDiff({ kind: "write", filePath: parsedArgs.file_path as string, content: (parsedArgs.content as string) || "", }); return result.mode === "advanced" ? result : null; } else if (toolName === "edit") { const result = computeAdvancedDiff({ kind: "edit", filePath: parsedArgs.file_path as string, oldString: (parsedArgs.old_string as string) || "", newString: (parsedArgs.new_string as string) || "", replaceAll: parsedArgs.replace_all as boolean | undefined, }); return result.mode === "advanced" ? result : null; } else if (toolName === "multiedit") { const result = computeAdvancedDiff({ kind: "multi_edit", filePath: parsedArgs.file_path as string, edits: (parsedArgs.edits as Array<{ old_string: string; new_string: string; replace_all?: boolean; }>) || [], }); return result.mode === "advanced" ? result : null; } return null; }, [approvalRequest, parsedArgs]); // Guard: should never happen as parent checks length, but satisfies TypeScript if (!approvalRequest) { return null; } // Get the human-readable header label const headerLabel = getHeaderLabel(approvalRequest.toolName); if (isEnteringReason) { return ( What should Letta do differently? (esc to cancel): {"> "} ); } return ( {/* Human-readable header (same color as border) */} {progress && progress.total > 1 ? `${progress.total} tools require approval${totalTools && totalTools > progress.total ? ` (${totalTools} total)` : ""}` : headerLabel} {progress && progress.total > 1 && ( ({progress.current - 1} reviewed,{" "} {progress.total - (progress.current - 1)} remaining) )} {isExecuting && progress && progress.total > 1 && ( Executing tool... )} {/* Dynamic per-tool renderer (indented) */} {/* Prompt */} Do you want to proceed? {/* Options selector (single line per option) */} ); }); ApprovalDialog.displayName = "ApprovalDialog"; // Helper functions for tool name mapping function getHeaderLabel(toolName: string): string { const t = toolName.toLowerCase(); // Anthropic toolset if (t === "bash") return "Bash command"; if (t === "ls") return "List Files"; if (t === "read") return "Read File"; if (t === "write") return "Write File"; if (t === "edit") return "Edit File"; if (t === "multi_edit" || t === "multiedit") return "Edit Files"; if (t === "grep") return "Search in Files"; if (t === "glob") return "Find Files"; if (t === "todo_write" || t === "todowrite") return "Update Todos"; // Codex toolset (snake_case) if (t === "shell_command") return "Shell command"; if (t === "shell") return "Shell script"; if (t === "read_file") return "Read File"; if (t === "list_dir") return "List Files"; if (t === "grep_files") return "Search in Files"; if (t === "apply_patch") return "Apply Patch"; if (t === "update_plan") return "Plan update"; // Codex toolset (PascalCase → lowercased) if (t === "shellcommand") return "Shell command"; if (t === "readfile") return "Read File"; if (t === "listdir") return "List Files"; if (t === "grepfiles") return "Search in Files"; if (t === "applypatch") return "Apply Patch"; if (t === "updateplan") return "Plan update"; // Gemini toolset (snake_case) if (t === "run_shell_command") return "Shell command"; if (t === "list_directory") return "List Directory"; if (t === "search_file_content") return "Search in Files"; if (t === "write_todos") return "Update Todos"; if (t === "read_many_files") return "Read Multiple Files"; // Gemini toolset (PascalCase → lowercased) if (t === "runshellcommand") return "Shell command"; if (t === "listdirectory") return "List Directory"; if (t === "searchfilecontent") return "Search in Files"; if (t === "writetodos") return "Update Todos"; if (t === "readmanyfiles") return "Read Multiple Files"; // Shared/additional tools if (t === "replace") return "Edit File"; if (t === "write_file" || t === "writefile") return "Write File"; if (t === "killbash") return "Kill Shell"; if (t === "bashoutput") return "Shell Output"; return toolName; }