327 lines
9.9 KiB
TypeScript
327 lines
9.9 KiB
TypeScript
import { Box } from "ink";
|
|
import { memo } from "react";
|
|
import type { AdvancedDiffSuccess } from "../helpers/diff";
|
|
import { parsePatchOperations } from "../helpers/formatArgsDisplay";
|
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
|
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
|
import { colors } from "./colors";
|
|
import { BashPreview } from "./previews/BashPreview";
|
|
import { PlanPreview } from "./previews/PlanPreview";
|
|
import { Text } from "./Text";
|
|
|
|
const SOLID_LINE = "─";
|
|
const DOTTED_LINE = "╌";
|
|
|
|
type Props = {
|
|
toolName: string;
|
|
toolArgs: string;
|
|
precomputedDiff?: AdvancedDiffSuccess;
|
|
allDiffs?: Map<string, AdvancedDiffSuccess>;
|
|
planContent?: string;
|
|
planFilePath?: string;
|
|
toolCallId?: string;
|
|
};
|
|
|
|
/**
|
|
* Get a human-readable header for file edit tools
|
|
*/
|
|
function getFileEditHeader(toolName: string, toolArgs: string): string {
|
|
const t = toolName.toLowerCase();
|
|
|
|
try {
|
|
const args = JSON.parse(toolArgs);
|
|
|
|
// Handle patch tools
|
|
if (t === "apply_patch" || t === "applypatch") {
|
|
if (args.input) {
|
|
const operations = parsePatchOperations(args.input);
|
|
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}?`;
|
|
if (op.kind === "update") return `Update ${displayPath}?`;
|
|
if (op.kind === "delete") return `Delete ${displayPath}?`;
|
|
}
|
|
}
|
|
}
|
|
return "Apply patch?";
|
|
}
|
|
|
|
// Handle single-file edit/write tools
|
|
const filePath = args.file_path || "";
|
|
const { relative } = require("node:path");
|
|
const cwd = process.cwd();
|
|
const relPath = relative(cwd, filePath);
|
|
const displayPath = relPath.startsWith("..") ? filePath : relPath;
|
|
|
|
if (
|
|
t === "write" ||
|
|
t === "write_file" ||
|
|
t === "writefile" ||
|
|
t === "write_file_gemini" ||
|
|
t === "writefilegemini"
|
|
) {
|
|
const { existsSync } = require("node:fs");
|
|
try {
|
|
if (existsSync(filePath)) {
|
|
return `Overwrite ${displayPath}?`;
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
return `Write to ${displayPath}?`;
|
|
}
|
|
|
|
if (
|
|
t === "edit" ||
|
|
t === "str_replace_editor" ||
|
|
t === "str_replace_based_edit_tool"
|
|
) {
|
|
return `Update ${displayPath}?`;
|
|
}
|
|
|
|
if (t === "multi_edit" || t === "multiedit") {
|
|
return `Apply edits to ${displayPath}?`;
|
|
}
|
|
} catch {
|
|
// Fall through
|
|
}
|
|
|
|
return `${toolName} requires approval`;
|
|
}
|
|
|
|
/**
|
|
* ApprovalPreview - Renders the preview content for an eagerly-committed approval
|
|
*
|
|
* This component renders the "preview" part of an approval that was committed
|
|
* early to enable flicker-free approval UI. It ensures visual parity with
|
|
* what the inline approval components show.
|
|
*/
|
|
export const ApprovalPreview = memo(
|
|
({
|
|
toolName,
|
|
toolArgs,
|
|
precomputedDiff,
|
|
allDiffs,
|
|
planContent,
|
|
toolCallId,
|
|
}: Props) => {
|
|
const columns = useTerminalWidth();
|
|
const solidLine = SOLID_LINE.repeat(Math.max(columns, 10));
|
|
const dottedLine = DOTTED_LINE.repeat(Math.max(columns, 10));
|
|
|
|
// ExitPlanMode: Use PlanPreview component
|
|
if (toolName === "ExitPlanMode" && planContent) {
|
|
return (
|
|
<Box flexDirection="column">
|
|
<PlanPreview plan={planContent} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Bash/Shell: Use BashPreview component
|
|
if (
|
|
toolName === "Bash" ||
|
|
toolName === "shell" ||
|
|
toolName === "Shell" ||
|
|
toolName === "shell_command"
|
|
) {
|
|
try {
|
|
const args = JSON.parse(toolArgs);
|
|
const command =
|
|
typeof args.command === "string"
|
|
? args.command
|
|
: Array.isArray(args.command)
|
|
? args.command.join(" ")
|
|
: "";
|
|
const description = args.description || args.justification || "";
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<BashPreview command={command} description={description} />
|
|
</Box>
|
|
);
|
|
} catch {
|
|
// Fall through to generic
|
|
}
|
|
}
|
|
|
|
// File Edit tools: Render diff preview
|
|
if (
|
|
toolName === "Edit" ||
|
|
toolName === "MultiEdit" ||
|
|
toolName === "Write" ||
|
|
toolName === "str_replace_editor" ||
|
|
toolName === "str_replace_based_edit_tool" ||
|
|
toolName === "apply_patch" ||
|
|
toolName === "ApplyPatch" ||
|
|
toolName === "memory_apply_patch"
|
|
) {
|
|
const headerText = getFileEditHeader(toolName, toolArgs);
|
|
|
|
try {
|
|
const args = JSON.parse(toolArgs);
|
|
|
|
// Handle patch tools (can have multiple files)
|
|
if (
|
|
args.input &&
|
|
(toolName === "apply_patch" ||
|
|
toolName === "ApplyPatch" ||
|
|
toolName === "memory_apply_patch")
|
|
) {
|
|
const operations = parsePatchOperations(args.input);
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Text dimColor>{solidLine}</Text>
|
|
<Text bold color={colors.approval.header}>
|
|
{headerText}
|
|
</Text>
|
|
<Text dimColor>{dottedLine}</Text>
|
|
|
|
<Box flexDirection="column">
|
|
{operations.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;
|
|
|
|
const diffKey = toolCallId
|
|
? `${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>
|
|
);
|
|
}
|
|
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>
|
|
);
|
|
}
|
|
if (op.kind === "delete") {
|
|
return (
|
|
<Box key={`patch-delete-${op.path}`}>
|
|
{idx > 0 && <Box height={1} />}
|
|
<Text>
|
|
Delete <Text bold>{displayPath}</Text>
|
|
</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</Box>
|
|
|
|
<Text dimColor>{dottedLine}</Text>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
// Single file edit/write
|
|
const filePath = args.file_path || "";
|
|
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Text dimColor>{solidLine}</Text>
|
|
<Text bold color={colors.approval.header}>
|
|
{headerText}
|
|
</Text>
|
|
<Text dimColor>{dottedLine}</Text>
|
|
|
|
{/* Write */}
|
|
{args.content !== undefined && (
|
|
<AdvancedDiffRenderer
|
|
precomputed={precomputedDiff}
|
|
kind="write"
|
|
filePath={filePath}
|
|
content={args.content}
|
|
/>
|
|
)}
|
|
|
|
{/* Multi-edit */}
|
|
{args.edits && Array.isArray(args.edits) && (
|
|
<AdvancedDiffRenderer
|
|
precomputed={precomputedDiff}
|
|
kind="multi_edit"
|
|
filePath={filePath}
|
|
edits={args.edits.map(
|
|
(e: { old_string?: string; new_string?: string }) => ({
|
|
old_string: e.old_string || "",
|
|
new_string: e.new_string || "",
|
|
}),
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Single edit */}
|
|
{args.old_string !== undefined && !args.edits && (
|
|
<AdvancedDiffRenderer
|
|
precomputed={precomputedDiff}
|
|
kind="edit"
|
|
filePath={filePath}
|
|
oldString={args.old_string || ""}
|
|
newString={args.new_string || ""}
|
|
replaceAll={args.replace_all}
|
|
/>
|
|
)}
|
|
|
|
<Text dimColor>{dottedLine}</Text>
|
|
</Box>
|
|
);
|
|
} catch {
|
|
// Fall through to generic
|
|
}
|
|
}
|
|
|
|
// Generic fallback
|
|
return (
|
|
<Box flexDirection="column">
|
|
<Text dimColor>{solidLine}</Text>
|
|
<Text bold color={colors.approval.header}>
|
|
{toolName} requires approval
|
|
</Text>
|
|
<Text dimColor>{dottedLine}</Text>
|
|
</Box>
|
|
);
|
|
},
|
|
);
|
|
|
|
ApprovalPreview.displayName = "ApprovalPreview";
|