Files
letta-code/src/cli/components/InlineFileEditApproval.tsx

544 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Box, 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";
import { Text } from "./Text";
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<string, AdvancedDiffSuccess>; // For patch tools with multiple files
onApprove: (diffs?: Map<string, AdvancedDiffSuccess>) => void;
onApproveAlways: (
scope: "project" | "session",
diffs?: Map<string, AdvancedDiffSuccess>,
) => void;
onDeny: (reason: string) => void;
onCancel?: () => void;
isFocused?: boolean;
approveAlwaysText?: string;
allowPersistence?: boolean;
showPreview?: boolean;
defaultScope?: "project" | "session";
};
// 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,
showPreview = true,
defaultScope = "project",
}: 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<string, AdvancedDiffSuccess> => {
const diffs = new Map<string, AdvancedDiffSuccess>();
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(
defaultScope,
diffsToPass.size > 0 ? diffsToPass : undefined,
);
}
return;
}
if (key.escape) {
onCancel?.();
return;
}
// Number keys for quick selection (only for fixed options, not custom text input)
if (input === "1") {
onApprove(diffsToPass.size > 0 ? diffsToPass : undefined);
return;
}
if (input === "2" && allowPersistence) {
onApproveAlways(
defaultScope,
diffsToPass.size > 0 ? diffsToPass : undefined,
);
return;
}
},
{ isActive: isFocused },
);
// Generate horizontal lines
const solidLine = SOLID_LINE.repeat(Math.max(columns, 10));
const dottedLine = DOTTED_LINE.repeat(Math.max(columns, 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 */}
<Text dimColor>{solidLine}</Text>
{/* Header */}
<Text bold color={colors.approval.header}>
{headerText}
</Text>
{/* Dotted separator before diff content */}
<Text dimColor>{dottedLine}</Text>
{/* Diff preview */}
<Box paddingLeft={0}>
{fileEdit.patchInput ? (
// Render patch operations (can be multiple files)
<Box flexDirection="column">
{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 (
<Box key={`patch-add-${op.path}`} flexDirection="column">
{idx > 0 && <Box height={1} />}
<Text dimColor>{displayPath}</Text>
<AdvancedDiffRenderer
precomputed={opDiff}
kind="write"
filePath={op.path}
content={op.content}
showHeader={false}
/>
</Box>
);
} else if (op.kind === "update") {
return (
<Box
key={`patch-update-${op.path}`}
flexDirection="column"
>
{idx > 0 && <Box height={1} />}
<Text dimColor>{displayPath}</Text>
<AdvancedDiffRenderer
precomputed={opDiff}
kind="edit"
filePath={op.path}
oldString={op.oldString}
newString={op.newString}
showHeader={false}
/>
</Box>
);
} else if (op.kind === "delete") {
return (
<Box
key={`patch-delete-${op.path}`}
flexDirection="column"
>
{idx > 0 && <Box height={1} />}
<Text dimColor>{displayPath}</Text>
<Text color="red">File will be deleted</Text>
</Box>
);
}
return null;
})}
</Box>
) : diffKind === "write" ? (
<AdvancedDiffRenderer
precomputed={precomputedDiff}
kind="write"
filePath={fileEdit.filePath}
content={fileEdit.content || ""}
showHeader={false}
/>
) : diffKind === "multi_edit" ? (
<AdvancedDiffRenderer
precomputed={precomputedDiff}
kind="multi_edit"
filePath={fileEdit.filePath}
edits={fileEdit.edits || []}
showHeader={false}
/>
) : (
<AdvancedDiffRenderer
precomputed={precomputedDiff}
kind="edit"
filePath={fileEdit.filePath}
oldString={fileEdit.oldString || ""}
newString={fileEdit.newString || ""}
replaceAll={fileEdit.replaceAll}
showHeader={false}
/>
)}
</Box>
{/* Dotted separator after diff content */}
<Text dimColor>{dottedLine}</Text>
</>
),
// 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";
const optionsMarginTop = showPreview ? 1 : 0;
return (
<Box flexDirection="column">
{/* Static diff content - memoized to prevent re-render on keystroke */}
{showPreview && memoizedDiffContent}
{/* Options */}
<Box marginTop={optionsMarginTop} flexDirection="column">
{/* Option 1: Yes */}
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text
color={
selectedOption === 0 ? colors.approval.header : undefined
}
>
{selectedOption === 0 ? "" : " "} 1.
</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
<Text
wrap="wrap"
color={
selectedOption === 0 ? colors.approval.header : undefined
}
>
Yes
</Text>
</Box>
</Box>
{/* Option 2: Yes, always (only if persistence allowed) */}
{allowPersistence && (
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text
color={
selectedOption === 1 ? colors.approval.header : undefined
}
>
{selectedOption === 1 ? "" : " "} 2.
</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
<Text
wrap="wrap"
color={
selectedOption === 1 ? colors.approval.header : undefined
}
>
{approveAlwaysText ||
"Yes, and don't ask again for this project"}
</Text>
</Box>
</Box>
)}
{/* Custom input option */}
<Box flexDirection="row">
<Box width={5} flexShrink={0}>
<Text
color={isOnCustomOption ? colors.approval.header : undefined}
>
{isOnCustomOption ? "" : " "} {customOptionIndex + 1}.
</Text>
</Box>
<Box flexGrow={1} width={Math.max(0, columns - 5)}>
{customReason ? (
<Text wrap="wrap">
{customReason.slice(0, cursorPos)}
{isOnCustomOption && "█"}
{customReason.slice(cursorPos)}
</Text>
) : (
<Text wrap="wrap" dimColor>
{customOptionPlaceholder}
{isOnCustomOption && "█"}
</Text>
)}
</Box>
</Box>
</Box>
{/* Hint */}
<Box marginTop={1}>
<Text dimColor>{hintText}</Text>
</Box>
</Box>
);
},
);
InlineFileEditApproval.displayName = "InlineFileEditApproval";