import { Box, Text, useInput } from "ink"; import { memo, useMemo, useState } from "react"; import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { colors } from "./colors"; type Props = { taskInfo: { subagentType: string; description: string; prompt: string; model?: string; }; onApprove: () => void; onApproveAlways: (scope: "project" | "session") => void; onDeny: (reason: string) => void; onCancel?: () => void; isFocused?: boolean; approveAlwaysText?: string; allowPersistence?: boolean; }; // Horizontal line character for Claude Code style const SOLID_LINE = "─"; /** * Truncate text to max length with ellipsis */ function truncate(text: string, maxLength: number): string { if (text.length <= maxLength) return text; return `${text.slice(0, maxLength - 3)}...`; } /** * InlineTaskApproval - Renders Task tool approval UI inline with pretty formatting * * Shows subagent type, description, and prompt in a readable format. */ export const InlineTaskApproval = memo( ({ taskInfo, onApprove, onApproveAlways, onDeny, onCancel, isFocused = true, approveAlwaysText, allowPersistence = true, }: Props) => { const [selectedOption, setSelectedOption] = useState(0); const { text: customReason, cursorPos, handleKey, clear, } = useTextInputCursor(); const columns = useTerminalWidth(); useProgressIndicator(); // Custom option index depends on whether "always" option is shown const customOptionIndex = allowPersistence ? 2 : 1; const maxOptionIndex = customOptionIndex; const isOnCustomOption = selectedOption === customOptionIndex; const customOptionPlaceholder = "No, and tell Letta Code what to do differently"; useInput( (input, key) => { if (!isFocused) return; // CTRL-C: cancel (queue denial, return to input) if (key.ctrl && input === "c") { onCancel?.(); return; } // Arrow navigation always works if (key.upArrow) { setSelectedOption((prev) => Math.max(0, prev - 1)); return; } if (key.downArrow) { setSelectedOption((prev) => Math.min(maxOptionIndex, prev + 1)); return; } // When on custom input option if (isOnCustomOption) { if (key.return) { if (customReason.trim()) { onDeny(customReason.trim()); } return; } if (key.escape) { if (customReason) { clear(); } else { onCancel?.(); } return; } // Handle text input (arrows, backspace, typing) if (handleKey(input, key)) return; } // When on regular options if (key.return) { if (selectedOption === 0) { onApprove(); } else if (selectedOption === 1 && allowPersistence) { onApproveAlways("session"); } return; } if (key.escape) { onCancel?.(); } }, { isActive: isFocused }, ); // Generate horizontal line const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); const contentWidth = Math.max(0, columns - 4); // 2 padding on each side // Memoize the static task content so it doesn't re-render on keystroke const memoizedTaskContent = useMemo(() => { const { subagentType, description, prompt, model } = taskInfo; // Truncate prompt if too long (show first ~200 chars) const truncatedPrompt = truncate(prompt, 300); return ( <> {/* Top solid line */} {solidLine} {/* Header */} Run Task? {/* Task details */} {/* Subagent type */} Type: {subagentType} {/* Model (if specified) */} {model && ( Model: {model} )} {/* Description */} Description: {description} {/* Prompt */} Prompt: {truncatedPrompt} ); }, [taskInfo, solidLine, contentWidth]); // Hint text based on state const hintText = isOnCustomOption ? customReason ? "Enter to submit · Esc to clear" : "Type reason · Esc to cancel" : "Enter to select · Esc to cancel"; // Generate "always" text for Task tool const alwaysText = approveAlwaysText || "Yes, allow Task operations during this session"; return ( {/* Static task content - memoized to prevent re-render on keystroke */} {memoizedTaskContent} {/* Options */} {/* Option 1: Yes */} {selectedOption === 0 ? "❯" : " "} 1. Yes {/* Option 2: Yes, always (only if persistence allowed) */} {allowPersistence && ( {selectedOption === 1 ? "❯" : " "} 2. {alwaysText} )} {/* Custom input option */} {isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}. {customReason ? ( {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} {customReason.slice(cursorPos)} ) : ( {customOptionPlaceholder} {isOnCustomOption && "█"} )} {/* Hint */} {hintText} ); }, ); InlineTaskApproval.displayName = "InlineTaskApproval";