import { Box, Text, useInput } from "ink"; import { memo, useMemo, useState } from "react"; import type { AdvancedDiffSuccess } from "../helpers/diff"; import { parsePatchToAdvancedDiff } from "../helpers/diff"; import { parsePatchOperations } from "../helpers/formatArgsDisplay"; import { useProgressIndicator } from "../hooks/useProgressIndicator"; import { useTerminalWidth } from "../hooks/useTerminalWidth"; import { useTextInputCursor } from "../hooks/useTextInputCursor"; import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; import { colors } from "./colors"; type FileEditInfo = { toolName: string; filePath: string; // For write tools content?: string; // For edit tools oldString?: string; newString?: string; replaceAll?: boolean; // For multi_edit tools edits?: Array<{ old_string: string; new_string: string; replace_all?: boolean; }>; // For patch tools patchInput?: string; toolCallId?: string; }; type Props = { fileEdit: FileEditInfo; precomputedDiff?: AdvancedDiffSuccess; allDiffs?: Map; // For patch tools with multiple files onApprove: (diffs?: Map) => void; onApproveAlways: ( scope: "project" | "session", diffs?: Map, ) => void; onDeny: (reason: string) => void; onCancel?: () => void; isFocused?: boolean; approveAlwaysText?: string; allowPersistence?: boolean; }; // Horizontal line characters for Claude Code style const SOLID_LINE = "─"; const DOTTED_LINE = "╌"; /** * Get a human-readable header for the file edit */ function getHeaderText(fileEdit: FileEditInfo): string { const t = fileEdit.toolName.toLowerCase(); // Handle patch tools (multi-file) if (t === "apply_patch" || t === "applypatch") { if (fileEdit.patchInput) { const operations = parsePatchOperations(fileEdit.patchInput); if (operations.length > 1) { return `Apply patch to ${operations.length} files?`; } else if (operations.length === 1) { const op = operations[0]; if (op) { const { relative } = require("node:path"); const cwd = process.cwd(); const relPath = relative(cwd, op.path); const displayPath = relPath.startsWith("..") ? op.path : relPath; if (op.kind === "add") { return `Write to ${displayPath}?`; } else if (op.kind === "update") { return `Update ${displayPath}?`; } else if (op.kind === "delete") { return `Delete ${displayPath}?`; } } } } return "Apply patch?"; } // Handle single-file edit/write tools const { relative } = require("node:path"); const cwd = process.cwd(); const relPath = relative(cwd, fileEdit.filePath); const displayPath = relPath.startsWith("..") ? fileEdit.filePath : relPath; if ( t === "write" || t === "write_file" || t === "writefile" || t === "write_file_gemini" || t === "writefilegemini" ) { const { existsSync } = require("node:fs"); try { if (existsSync(fileEdit.filePath)) { return `Overwrite ${displayPath}?`; } } catch { // Ignore errors } return `Write to ${displayPath}?`; } if (t === "edit" || t === "replace") { return `Update ${displayPath}?`; } if (t === "multiedit" || t === "multi_edit") { return `Update ${displayPath}? (${fileEdit.edits?.length || 0} edits)`; } return `Edit ${displayPath}?`; } /** * Determine diff kind based on tool name */ function getDiffKind(toolName: string): "write" | "edit" | "multi_edit" { const t = toolName.toLowerCase(); if ( t === "write" || t === "write_file" || t === "writefile" || t === "write_file_gemini" || t === "writefilegemini" ) { return "write"; } if (t === "multiedit" || t === "multi_edit") { return "multi_edit"; } return "edit"; } /** * InlineFileEditApproval - Renders file edit approval UI inline (Claude Code style) * * Uses horizontal lines instead of boxes for visual styling: * - ──── solid line at top * - ╌╌╌╌ dotted line around diff content * - Approval options below */ export const InlineFileEditApproval = memo( ({ fileEdit, precomputedDiff, allDiffs, 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; // Build diffs map to pass to approval handler (needed for line numbers in result) const diffsToPass = useMemo((): Map => { const diffs = new Map(); const toolCallId = fileEdit.toolCallId; // For Edit/Write/MultiEdit - single file diff if (precomputedDiff && toolCallId) { diffs.set(toolCallId, precomputedDiff); return diffs; } // For Patch tools - use allDiffs or parse patch input if (fileEdit.patchInput && toolCallId) { // First try to use allDiffs if available if (allDiffs) { const operations = parsePatchOperations(fileEdit.patchInput); for (const op of operations) { const key = `${toolCallId}:${op.path}`; const diff = allDiffs.get(key); if (diff) { diffs.set(key, diff); } } } // If no diffs found from allDiffs, parse patch hunks directly if (diffs.size === 0) { const operations = parsePatchOperations(fileEdit.patchInput); for (const op of operations) { const key = `${toolCallId}:${op.path}`; if (op.kind === "add" || op.kind === "update") { const result = parsePatchToAdvancedDiff(op.patchLines, op.path); if (result) { diffs.set(key, result); } } } } } return diffs; }, [fileEdit, precomputedDiff, allDiffs]); 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(diffsToPass.size > 0 ? diffsToPass : undefined); } else if (selectedOption === 1 && allowPersistence) { onApproveAlways( "project", diffsToPass.size > 0 ? diffsToPass : undefined, ); } return; } if (key.escape) { onCancel?.(); } }, { isActive: isFocused }, ); // Generate horizontal lines const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); const dottedLine = DOTTED_LINE.repeat(Math.max(columns - 2, 10)); const headerText = getHeaderText(fileEdit); const diffKind = getDiffKind(fileEdit.toolName); // Memoize the static diff content so it doesn't re-render on keystroke // This prevents flicker when typing feedback in the custom input field // biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify(fileEdit.edits) provides stable value comparison for arrays const memoizedDiffContent = useMemo( () => ( <> {/* Top solid line */} {solidLine} {/* Header */} {headerText} {/* Dotted separator before diff content */} {dottedLine} {/* Diff preview */} {fileEdit.patchInput ? ( // Render patch operations (can be multiple files) {parsePatchOperations(fileEdit.patchInput).map((op, idx) => { const { relative } = require("node:path"); const cwd = process.cwd(); const relPath = relative(cwd, op.path); const displayPath = relPath.startsWith("..") ? op.path : relPath; // Look up precomputed diff using toolCallId:path key const diffKey = fileEdit.toolCallId ? `${fileEdit.toolCallId}:${op.path}` : undefined; const opDiff = diffKey && allDiffs ? allDiffs.get(diffKey) : undefined; if (op.kind === "add") { return ( {idx > 0 && } {displayPath} ); } else if (op.kind === "update") { return ( {idx > 0 && } {displayPath} ); } else if (op.kind === "delete") { return ( {idx > 0 && } {displayPath} File will be deleted ); } return null; })} ) : diffKind === "write" ? ( ) : diffKind === "multi_edit" ? ( ) : ( )} {/* Dotted separator after diff content */} {dottedLine} ), // Use primitive values to avoid memo invalidation when parent re-renders. // Arrays/objects are compared by reference, so we stringify edits for stable comparison. [ fileEdit.filePath, fileEdit.content, fileEdit.oldString, fileEdit.newString, fileEdit.replaceAll, fileEdit.patchInput, fileEdit.toolCallId, JSON.stringify(fileEdit.edits), precomputedDiff, allDiffs, solidLine, dottedLine, headerText, diffKind, ], ); // 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"; return ( {/* Static diff content - memoized to prevent re-render on keystroke */} {memoizedDiffContent} {/* Options */} {/* Option 1: Yes */} {selectedOption === 0 ? "❯" : " "} 1. Yes {/* Option 2: Yes, always (only if persistence allowed) */} {allowPersistence && ( {selectedOption === 1 ? "❯" : " "} 2. {approveAlwaysText || "Yes, and don't ask again for this project"} )} {/* Custom input option */} {isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}. {customReason ? ( {customReason.slice(0, cursorPos)} {isOnCustomOption && "█"} {customReason.slice(cursorPos)} ) : ( {customOptionPlaceholder} {isOnCustomOption && "█"} )} {/* Hint */} {hintText} ); }, ); InlineFileEditApproval.displayName = "InlineFileEditApproval";