Files
letta-code/src/cli/components/ApprovalDialog.tsx
2025-10-24 21:19:24 -07:00

200 lines
6.4 KiB
TypeScript

// Import useInput from vendored Ink for bracketed paste support
import { Box, Text, useInput } from "ink";
import RawTextInput from "ink-text-input";
import { type ComponentType, useMemo, useState } from "react";
import { type AdvancedDiffSuccess, computeAdvancedDiff } from "../helpers/diff";
import type { ApprovalRequest } from "../helpers/stream";
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
type Props = {
approvalRequest: ApprovalRequest;
onApprove: () => void;
onApproveAlways: () => void;
onDeny: (reason: string) => void;
};
export function ApprovalDialog({
approvalRequest,
onApprove,
onApproveAlways,
onDeny,
}: Props) {
const [selectedOption, setSelectedOption] = useState(0);
const [isEnteringReason, setIsEnteringReason] = useState(false);
const [denyReason, setDenyReason] = useState("");
const options = [
"Approve (once)",
"Approve and don't ask again",
"Deny and provide feedback",
];
useInput((_input, key) => {
if (isEnteringReason) {
// When entering reason, only handle enter/escape
if (key.return) {
onDeny(denyReason);
} else if (key.escape) {
setIsEnteringReason(false);
setDenyReason("");
}
return;
}
// Navigate with arrow keys
if (key.upArrow) {
setSelectedOption((prev) => (prev > 0 ? prev - 1 : options.length - 1));
} else if (key.downArrow) {
setSelectedOption((prev) => (prev < options.length - 1 ? prev + 1 : 0));
} else if (key.return) {
// Handle selection
if (selectedOption === 0) {
onApprove();
} else if (selectedOption === 1) {
onApproveAlways();
} else if (selectedOption === 2) {
setIsEnteringReason(true);
}
}
});
// Pretty print JSON args
let formattedArgs = approvalRequest.toolArgs;
let parsedArgs: Record<string, unknown> | null = null;
try {
parsedArgs = JSON.parse(approvalRequest.toolArgs);
formattedArgs = JSON.stringify(parsedArgs, null, 2);
} catch {
// Keep as-is if not valid JSON
}
// Compute diff for file-editing tools
const precomputedDiff = useMemo((): AdvancedDiffSuccess | null => {
if (!parsedArgs) return null;
const toolName = approvalRequest.toolName.toLowerCase();
if (toolName === "write") {
const result = computeAdvancedDiff({
kind: "write",
filePath: parsedArgs.file_path as string,
content: (parsedArgs.content as string) || "",
});
return result.mode === "advanced" ? result : null;
} else if (toolName === "edit") {
const result = computeAdvancedDiff({
kind: "edit",
filePath: parsedArgs.file_path as string,
oldString: (parsedArgs.old_string as string) || "",
newString: (parsedArgs.new_string as string) || "",
replaceAll: parsedArgs.replace_all as boolean | undefined,
});
return result.mode === "advanced" ? result : null;
} else if (toolName === "multiedit") {
const result = computeAdvancedDiff({
kind: "multi_edit",
filePath: parsedArgs.file_path as string,
edits:
(parsedArgs.edits as Array<{
old_string: string;
new_string: string;
replace_all?: boolean;
}>) || [],
});
return result.mode === "advanced" ? result : null;
}
return null;
}, [approvalRequest, parsedArgs]);
return (
<Box flexDirection="column" gap={1}>
<Text bold>Tool Approval Required</Text>
<Box flexDirection="column">
<Text>
Tool: <Text bold>{approvalRequest.toolName}</Text>
</Text>
{/* Show diff for file-editing tools */}
{precomputedDiff && parsedArgs && (
<Box paddingLeft={2} flexDirection="column">
{approvalRequest.toolName.toLowerCase() === "write" ? (
<AdvancedDiffRenderer
precomputed={precomputedDiff}
kind="write"
filePath={parsedArgs.file_path as string}
content={(parsedArgs.content as string) || ""}
showHeader={false}
/>
) : approvalRequest.toolName.toLowerCase() === "edit" ? (
<AdvancedDiffRenderer
precomputed={precomputedDiff}
kind="edit"
filePath={parsedArgs.file_path as string}
oldString={(parsedArgs.old_string as string) || ""}
newString={(parsedArgs.new_string as string) || ""}
replaceAll={parsedArgs.replace_all as boolean | undefined}
showHeader={false}
/>
) : approvalRequest.toolName.toLowerCase() === "multiedit" ? (
<AdvancedDiffRenderer
precomputed={precomputedDiff}
kind="multi_edit"
filePath={parsedArgs.file_path as string}
edits={
(parsedArgs.edits as Array<{
old_string: string;
new_string: string;
replace_all?: boolean;
}>) || []
}
showHeader={false}
/>
) : null}
</Box>
)}
{/* Fallback: Show raw args if no diff */}
{!precomputedDiff && (
<>
<Text dimColor>Arguments:</Text>
<Box paddingLeft={2}>
<Text dimColor>{formattedArgs}</Text>
</Box>
</>
)}
</Box>
<Box flexDirection="column">
{isEnteringReason ? (
<Box flexDirection="column">
<Text>Enter reason for denial (ESC to cancel):</Text>
<Box>
<Text dimColor>{"> "}</Text>
{(() => {
const TextInputAny = RawTextInput as unknown as ComponentType<{
value: string;
onChange: (s: string) => void;
}>;
return (
<TextInputAny value={denyReason} onChange={setDenyReason} />
);
})()}
</Box>
</Box>
) : (
<>
<Text dimColor>Use / to select, Enter to confirm:</Text>
{options.map((option) => (
<Text key={option}>
{selectedOption === options.indexOf(option) ? "→ " : " "}
{option}
</Text>
))}
</>
)}
</Box>
</Box>
);
}