feat: inline dialogs (#436)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2025-12-31 15:32:06 -08:00
committed by GitHub
parent dbf02f90b5
commit 19ecc2af1a
11 changed files with 2112 additions and 126 deletions

View File

@@ -0,0 +1,243 @@
import { Box, Text, useInput } from "ink";
import { memo, useState } from "react";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
type Props = {
toolName: string;
toolArgs: string;
onApprove: () => void;
onApproveAlways: (scope: "project" | "session") => void;
onDeny: (reason: string) => void;
onCancel?: () => void;
isFocused?: boolean;
approveAlwaysText?: string;
allowPersistence?: boolean;
};
// Horizontal line character for Claude Code style
const SOLID_LINE = "─";
/**
* Format tool arguments for display
*/
function formatArgs(toolArgs: string): string {
try {
const parsed = JSON.parse(toolArgs);
// Pretty print with 2-space indent, but limit length
const formatted = JSON.stringify(parsed, null, 2);
// Truncate if too long
if (formatted.length > 500) {
return `${formatted.slice(0, 500)}\n...`;
}
return formatted;
} catch {
// If not valid JSON, return as-is
return toolArgs || "(no arguments)";
}
}
/**
* InlineGenericApproval - Renders generic tool approval UI inline
*
* Used as fallback for any tool not handled by specialized inline components.
*/
export const InlineGenericApproval = memo(
({
toolName,
toolArgs,
onApprove,
onApproveAlways,
onDeny,
onCancel,
isFocused = true,
approveAlwaysText,
allowPersistence = true,
}: Props) => {
const [selectedOption, setSelectedOption] = useState(0);
const [customReason, setCustomReason] = useState("");
const columns = useTerminalWidth();
// Custom option index depends on whether "always" option is shown
const customOptionIndex = allowPersistence ? 2 : 1;
const maxOptionIndex = customOptionIndex;
const isOnCustomOption = selectedOption === customOptionIndex;
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) {
setCustomReason("");
} else {
onCancel?.();
}
return;
}
if (key.backspace || key.delete) {
setCustomReason((prev) => prev.slice(0, -1));
return;
}
if (input && !key.ctrl && !key.meta && input.length === 1) {
setCustomReason((prev) => prev + input);
}
return;
}
// When on regular options
if (key.return) {
if (selectedOption === 0) {
onApprove();
} else if (selectedOption === 1 && allowPersistence) {
onApproveAlways("project");
}
return;
}
if (key.escape) {
onCancel?.();
}
},
{ isActive: isFocused },
);
// Generate horizontal line
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
const formattedArgs = formatArgs(toolArgs);
// 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">
{/* Top solid line */}
<Text dimColor>{solidLine}</Text>
{/* Header */}
<Text bold color={colors.approval.header}>
Run {toolName}?
</Text>
<Box height={1} />
{/* Arguments preview */}
<Box paddingLeft={2} flexDirection="column">
<Text dimColor>{formattedArgs}</Text>
</Box>
{/* 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}
{isOnCustomOption && "█"}
</Text>
) : (
<Text wrap="wrap" dimColor>
{customOptionPlaceholder}
{isOnCustomOption && "█"}
</Text>
)}
</Box>
</Box>
</Box>
{/* Hint */}
<Box marginTop={1}>
<Text dimColor>{hintText}</Text>
</Box>
</Box>
);
},
);
InlineGenericApproval.displayName = "InlineGenericApproval";