523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
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<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;
|
||
};
|
||
|
||
// 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<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(
|
||
"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 */}
|
||
<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";
|
||
|
||
return (
|
||
<Box flexDirection="column">
|
||
{/* Static diff content - memoized to prevent re-render on keystroke */}
|
||
{memoizedDiffContent}
|
||
|
||
{/* Options */}
|
||
<Box marginTop={1} 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";
|