@@ -51,11 +51,11 @@ export const EnterPlanModeDialog = memo(({ onApprove, onReject }: Props) => {
|
||||
|
||||
<Box marginBottom={1} flexDirection="column">
|
||||
<Text>
|
||||
Letta wants to enter plan mode to explore and design an implementation
|
||||
approach.
|
||||
Letta Code wants to enter plan mode to explore and design an
|
||||
implementation approach.
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text>In plan mode, Letta will:</Text>
|
||||
<Text>In plan mode, Letta Code will:</Text>
|
||||
<Text> • Explore the codebase thoroughly</Text>
|
||||
<Text> • Identify existing patterns</Text>
|
||||
<Text> • Design an implementation strategy</Text>
|
||||
|
||||
234
src/cli/components/InlineBashApproval.tsx
Normal file
234
src/cli/components/InlineBashApproval.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type BashInfo = {
|
||||
toolName: string;
|
||||
command: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
bashInfo: BashInfo;
|
||||
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 = "─";
|
||||
|
||||
/**
|
||||
* InlineBashApproval - Renders bash/shell approval UI inline (Claude Code style)
|
||||
*
|
||||
* Option 3 is an inline text input - when selected, user can type directly
|
||||
* without switching to a separate screen.
|
||||
*/
|
||||
export const InlineBashApproval = memo(
|
||||
({
|
||||
bashInfo,
|
||||
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()) {
|
||||
// User typed a reason - send it
|
||||
onDeny(customReason.trim());
|
||||
}
|
||||
// If empty, do nothing (can't submit empty reason)
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
// Clear text first
|
||||
setCustomReason("");
|
||||
} else {
|
||||
// No text, cancel (queue denial, return to input)
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomReason((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
// Printable characters - append to custom reason
|
||||
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) {
|
||||
// Cancel (queue denial, return to input)
|
||||
onCancel?.();
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
|
||||
// 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 this command?
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Command preview */}
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
<Text>{bashInfo.command}</Text>
|
||||
{bashInfo.description && <Text dimColor>{bashInfo.description}</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 (3 if persistence, 2 if not) */}
|
||||
<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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlineBashApproval.displayName = "InlineBashApproval";
|
||||
131
src/cli/components/InlineEnterPlanModeApproval.tsx
Normal file
131
src/cli/components/InlineEnterPlanModeApproval.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
type Props = {
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
isFocused?: boolean;
|
||||
};
|
||||
|
||||
// Horizontal line character for Claude Code style
|
||||
const SOLID_LINE = "─";
|
||||
|
||||
const OptionsRenderer = memo(
|
||||
({
|
||||
options,
|
||||
selectedOption,
|
||||
}: {
|
||||
options: Array<{ label: string }>;
|
||||
selectedOption: number;
|
||||
}) => {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
const color = isSelected ? colors.approval.header : undefined;
|
||||
return (
|
||||
<Box key={option.label} flexDirection="row">
|
||||
<Text color={color}>
|
||||
{isSelected ? "❯" : " "} {index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
OptionsRenderer.displayName = "OptionsRenderer";
|
||||
|
||||
/**
|
||||
* InlineEnterPlanModeApproval - Renders EnterPlanMode approval UI inline
|
||||
*
|
||||
* Uses horizontal lines instead of boxes for visual styling.
|
||||
*/
|
||||
export const InlineEnterPlanModeApproval = memo(
|
||||
({ onApprove, onReject, isFocused = true }: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
const options = [
|
||||
{ label: "Yes, enter plan mode", action: onApprove },
|
||||
{ label: "No, start implementing now", action: onReject },
|
||||
];
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!isFocused) return;
|
||||
|
||||
// CTRL-C: immediately reject (cancel)
|
||||
if (key.ctrl && input === "c") {
|
||||
onReject();
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC: reject (cancel)
|
||||
if (key.escape) {
|
||||
onReject();
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) => Math.min(options.length - 1, prev + 1));
|
||||
} else if (key.return) {
|
||||
options[selectedOption]?.action();
|
||||
} else if (input === "1") {
|
||||
onApprove();
|
||||
} else if (input === "2") {
|
||||
onReject();
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
// Generate horizontal line
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Top solid line */}
|
||||
<Text dimColor>{solidLine}</Text>
|
||||
|
||||
{/* Header */}
|
||||
<Text bold color={colors.approval.header}>
|
||||
Enter plan mode?
|
||||
</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Description */}
|
||||
<Box flexDirection="column" paddingLeft={2}>
|
||||
<Text>
|
||||
Letta Code wants to enter plan mode to explore and design an
|
||||
implementation approach.
|
||||
</Text>
|
||||
<Box height={1} />
|
||||
<Text>In plan mode, Letta Code will:</Text>
|
||||
<Text> · Explore the codebase thoroughly</Text>
|
||||
<Text> · Identify existing patterns</Text>
|
||||
<Text> · Design an implementation strategy</Text>
|
||||
<Text> · Present a plan for your approval</Text>
|
||||
<Box height={1} />
|
||||
<Text dimColor>
|
||||
No code changes will be made until you approve the plan.
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Options */}
|
||||
<Box marginTop={1}>
|
||||
<OptionsRenderer options={options} selectedOption={selectedOption} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlineEnterPlanModeApproval.displayName = "InlineEnterPlanModeApproval";
|
||||
483
src/cli/components/InlineFileEditApproval.tsx
Normal file
483
src/cli/components/InlineFileEditApproval.tsx
Normal file
@@ -0,0 +1,483 @@
|
||||
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 { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
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 [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;
|
||||
|
||||
// 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) {
|
||||
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(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);
|
||||
|
||||
// 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}>
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlineFileEditApproval.displayName = "InlineFileEditApproval";
|
||||
243
src/cli/components/InlineGenericApproval.tsx
Normal file
243
src/cli/components/InlineGenericApproval.tsx
Normal 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";
|
||||
225
src/cli/components/InlinePlanApproval.tsx
Normal file
225
src/cli/components/InlinePlanApproval.tsx
Normal file
@@ -0,0 +1,225 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay";
|
||||
|
||||
type Props = {
|
||||
plan: string;
|
||||
onApprove: () => void;
|
||||
onApproveAndAcceptEdits: () => void;
|
||||
onKeepPlanning: (reason: string) => void;
|
||||
isFocused?: boolean;
|
||||
};
|
||||
|
||||
// Horizontal line characters for Claude Code style
|
||||
const SOLID_LINE = "─";
|
||||
const DOTTED_LINE = "╌";
|
||||
|
||||
/**
|
||||
* InlinePlanApproval - Renders plan approval UI inline (Claude Code style)
|
||||
*
|
||||
* Uses horizontal lines instead of boxes for visual styling:
|
||||
* - ──── solid line at top
|
||||
* - ╌╌╌╌ dotted line around plan content
|
||||
* - Approval options below
|
||||
*/
|
||||
export const InlinePlanApproval = memo(
|
||||
({
|
||||
plan,
|
||||
onApprove,
|
||||
onApproveAndAcceptEdits,
|
||||
onKeepPlanning,
|
||||
isFocused = true,
|
||||
}: Props) => {
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customReason, setCustomReason] = useState("");
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
const customOptionIndex = 2;
|
||||
const maxOptionIndex = customOptionIndex;
|
||||
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||
const customOptionPlaceholder =
|
||||
"Type here to tell Letta Code what to change";
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!isFocused) return;
|
||||
|
||||
// CTRL-C: keep planning with cancel message
|
||||
if (key.ctrl && input === "c") {
|
||||
onKeepPlanning("User pressed CTRL-C to cancel");
|
||||
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()) {
|
||||
onKeepPlanning(customReason.trim());
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customReason) {
|
||||
setCustomReason("");
|
||||
} else {
|
||||
// Esc without text - just clear, stay on planning
|
||||
onKeepPlanning("User cancelled");
|
||||
}
|
||||
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) {
|
||||
onApproveAndAcceptEdits();
|
||||
} else if (selectedOption === 1) {
|
||||
onApprove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
onKeepPlanning("User cancelled");
|
||||
}
|
||||
},
|
||||
{ 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));
|
||||
|
||||
// Hint text based on state
|
||||
const hintText = isOnCustomOption
|
||||
? customReason
|
||||
? "Enter to submit · Esc to clear"
|
||||
: "Type feedback · Esc to cancel"
|
||||
: "Enter to select · Esc to cancel";
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{/* Top solid line */}
|
||||
<Text dimColor>{solidLine}</Text>
|
||||
|
||||
{/* Header */}
|
||||
<Text bold color={colors.approval.header}>
|
||||
Ready to code? Here is your plan:
|
||||
</Text>
|
||||
|
||||
{/* Dotted separator before plan content */}
|
||||
<Text dimColor>{dottedLine}</Text>
|
||||
|
||||
{/* Plan content - no indentation, just like Claude Code */}
|
||||
<MarkdownDisplay text={plan} />
|
||||
|
||||
{/* Dotted separator after plan content */}
|
||||
<Text dimColor>{dottedLine}</Text>
|
||||
|
||||
{/* Question */}
|
||||
<Box marginTop={1}>
|
||||
<Text>Would you like to proceed?</Text>
|
||||
</Box>
|
||||
|
||||
{/* Options */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
{/* Option 1: Yes, and auto-accept edits */}
|
||||
<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, and auto-accept edits
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Option 2: Yes, and manually approve edits */}
|
||||
<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
|
||||
}
|
||||
>
|
||||
Yes, and manually approve edits
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Option 3: Custom input */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={5} flexShrink={0}>
|
||||
<Text
|
||||
color={isOnCustomOption ? colors.approval.header : undefined}
|
||||
>
|
||||
{isOnCustomOption ? "❯" : " "} 3.
|
||||
</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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlinePlanApproval.displayName = "InlinePlanApproval";
|
||||
384
src/cli/components/InlineQuestionApproval.tsx
Normal file
384
src/cli/components/InlineQuestionApproval.tsx
Normal file
@@ -0,0 +1,384 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { Fragment, memo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
header: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
questions: Question[];
|
||||
onSubmit: (answers: Record<string, string>) => void;
|
||||
onCancel?: () => void;
|
||||
isFocused?: boolean;
|
||||
};
|
||||
|
||||
// Horizontal line character for Claude Code style
|
||||
const SOLID_LINE = "─";
|
||||
|
||||
export const InlineQuestionApproval = memo(
|
||||
({ questions, onSubmit, onCancel, isFocused = true }: Props) => {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [customText, setCustomText] = useState("");
|
||||
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(new Set());
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
// Build options list: regular options + "Type something"
|
||||
// For multi-select, we also track a separate "Submit" action
|
||||
const baseOptions = currentQuestion
|
||||
? [
|
||||
...currentQuestion.options,
|
||||
{ label: "Type something.", description: "" },
|
||||
]
|
||||
: [];
|
||||
|
||||
// For multi-select, add Submit as a separate selectable item
|
||||
const optionsWithOther = currentQuestion?.multiSelect
|
||||
? [...baseOptions, { label: "Submit", description: "" }]
|
||||
: baseOptions;
|
||||
|
||||
const customOptionIndex = baseOptions.length - 1; // "Type something" index
|
||||
const submitOptionIndex = currentQuestion?.multiSelect
|
||||
? optionsWithOther.length - 1
|
||||
: -1; // Submit index (only for multi-select)
|
||||
|
||||
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||
const isOnSubmitOption = selectedOption === submitOptionIndex;
|
||||
|
||||
const handleSubmitAnswer = (answer: string) => {
|
||||
if (!currentQuestion) return;
|
||||
const newAnswers = {
|
||||
...answers,
|
||||
[currentQuestion.question]: answer,
|
||||
};
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
setSelectedOption(0);
|
||||
setCustomText("");
|
||||
setSelectedMulti(new Set());
|
||||
} else {
|
||||
onSubmit(newAnswers);
|
||||
}
|
||||
};
|
||||
|
||||
useInput(
|
||||
(input, key) => {
|
||||
if (!isFocused || !currentQuestion) return;
|
||||
|
||||
// CTRL-C: cancel
|
||||
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 || key.tab) {
|
||||
setSelectedOption((prev) =>
|
||||
Math.min(optionsWithOther.length - 1, prev + 1),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// When on custom input option ("Type something")
|
||||
if (isOnCustomOption) {
|
||||
if (key.return) {
|
||||
// Enter toggles the checkbox (same as other options)
|
||||
if (currentQuestion.multiSelect) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(customOptionIndex)) {
|
||||
newSet.delete(customOptionIndex);
|
||||
} else {
|
||||
newSet.add(customOptionIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
// Single-select: submit the custom text if any
|
||||
if (customText.trim()) {
|
||||
handleSubmitAnswer(customText.trim());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (input === " " && currentQuestion.multiSelect) {
|
||||
// Space: if not checked, toggle + insert space. If already checked, just insert space.
|
||||
if (!selectedMulti.has(customOptionIndex)) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.add(customOptionIndex);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
// Always insert the space character
|
||||
setCustomText((prev) => prev + " ");
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
if (customText) {
|
||||
setCustomText("");
|
||||
} else {
|
||||
onCancel?.();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
setCustomText((prev) => prev.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (input && !key.ctrl && !key.meta && input.length === 1) {
|
||||
setCustomText((prev) => prev + input);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When on Submit option (multi-select only)
|
||||
if (isOnSubmitOption) {
|
||||
if (key.return) {
|
||||
// Submit the selected options + custom text if "Type something" is checked
|
||||
const selectedLabels: string[] = [];
|
||||
for (const i of selectedMulti) {
|
||||
if (i === customOptionIndex) {
|
||||
// Include custom text if checkbox is checked and text was entered
|
||||
if (customText.trim()) {
|
||||
selectedLabels.push(customText.trim());
|
||||
}
|
||||
} else {
|
||||
const label = baseOptions[i]?.label;
|
||||
if (label) {
|
||||
selectedLabels.push(label);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (selectedLabels.length > 0) {
|
||||
handleSubmitAnswer(selectedLabels.join(", "));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.escape) {
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC on regular options: cancel
|
||||
if (key.escape) {
|
||||
onCancel?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter behavior depends on single vs multi-select
|
||||
if (key.return) {
|
||||
if (currentQuestion.multiSelect) {
|
||||
// Multi-select: Enter toggles the checkbox (only for regular options, not custom)
|
||||
if (selectedOption < customOptionIndex) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(selectedOption)) {
|
||||
newSet.delete(selectedOption);
|
||||
} else {
|
||||
newSet.add(selectedOption);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Single-select: Enter selects and submits
|
||||
handleSubmitAnswer(optionsWithOther[selectedOption]?.label || "");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Space also toggles for multi-select (like Claude Code) - only regular options
|
||||
if (input === " " && currentQuestion.multiSelect) {
|
||||
if (selectedOption < customOptionIndex) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(selectedOption)) {
|
||||
newSet.delete(selectedOption);
|
||||
} else {
|
||||
newSet.add(selectedOption);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Number keys for quick selection
|
||||
if (input >= "1" && input <= "9") {
|
||||
const optionIndex = Number.parseInt(input, 10) - 1;
|
||||
if (optionIndex < optionsWithOther.length - 1) {
|
||||
if (currentQuestion.multiSelect) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(optionIndex)) {
|
||||
newSet.delete(optionIndex);
|
||||
} else {
|
||||
newSet.add(optionIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
handleSubmitAnswer(optionsWithOther[optionIndex]?.label || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
// Generate horizontal line
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
|
||||
// Hint text based on state - keep consistent to avoid jarring changes
|
||||
const hintText = currentQuestion?.multiSelect
|
||||
? "Enter to toggle · Arrow to navigate · Esc to cancel"
|
||||
: "Enter to select · Arrow to navigate · Esc to cancel";
|
||||
|
||||
if (!currentQuestion) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Top solid line */}
|
||||
<Text dimColor>{solidLine}</Text>
|
||||
|
||||
{/* Header label */}
|
||||
<Text>{currentQuestion.header}</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Question */}
|
||||
<Text bold>{currentQuestion.question}</Text>
|
||||
|
||||
<Box height={1} />
|
||||
|
||||
{/* Progress indicator for multiple questions */}
|
||||
{questions.length > 1 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Question {currentQuestionIndex + 1} of {questions.length}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Options - Format: ❯ N. [ ] Label (selector, number, checkbox, label) */}
|
||||
<Box flexDirection="column">
|
||||
{optionsWithOther.map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
const isChecked = selectedMulti.has(index);
|
||||
const color = isSelected ? colors.approval.header : undefined;
|
||||
const isCustomOption = index === customOptionIndex;
|
||||
const isSubmitOption = index === submitOptionIndex;
|
||||
|
||||
// Calculate prefix width: "❯ N. " = 5 chars, "[ ] " = 4 chars for multi-select
|
||||
const selectorAndNumber = 5; // "❯ N. " or " N. "
|
||||
const checkboxWidth = currentQuestion.multiSelect ? 4 : 0; // "[ ] " or nothing
|
||||
const prefixWidth = selectorAndNumber + checkboxWidth;
|
||||
|
||||
// Submit option renders differently (selector + always bold "Submit")
|
||||
if (isSubmitOption) {
|
||||
return (
|
||||
<Box key="submit" flexDirection="column">
|
||||
{/* Extra newline above Submit */}
|
||||
<Box height={1} />
|
||||
<Box flexDirection="row">
|
||||
<Box width={selectorAndNumber} flexShrink={0}>
|
||||
<Text color={color}>
|
||||
{isSelected ? "❯" : " "}
|
||||
{" "}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Text bold color={color}>
|
||||
Submit
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const hasDescription = option.description && !isCustomOption;
|
||||
|
||||
// Use Fragment to avoid column Box wrapper - render row and description as siblings
|
||||
// Note: Can't use <> shorthand with key, so we import Fragment
|
||||
return (
|
||||
<Fragment key={`${option.label}-${index}`}>
|
||||
<Box flexDirection="row">
|
||||
{/* Selector and number */}
|
||||
<Box width={selectorAndNumber} flexShrink={0}>
|
||||
<Text color={color}>
|
||||
{isSelected ? "❯" : " "} {index + 1}.
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Checkbox (for multi-select) - single Text element to avoid re-mount */}
|
||||
{currentQuestion.multiSelect && (
|
||||
<Box width={checkboxWidth} flexShrink={0}>
|
||||
<Text color={isChecked ? "green" : color}>
|
||||
[{isChecked ? "✓" : " "}]{" "}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{/* Label */}
|
||||
<Box flexGrow={1} width={Math.max(0, columns - prefixWidth)}>
|
||||
{isCustomOption ? (
|
||||
// Custom input option ("Type something")
|
||||
customText ? (
|
||||
<Text wrap="wrap">
|
||||
{customText}
|
||||
{isSelected && "█"}
|
||||
</Text>
|
||||
) : (
|
||||
<Text wrap="wrap" dimColor>
|
||||
{option.label}
|
||||
{isSelected && "█"}
|
||||
</Text>
|
||||
)
|
||||
) : (
|
||||
<Text wrap="wrap" color={color} bold={isSelected}>
|
||||
{option.label}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Description - rendered as sibling row */}
|
||||
{hasDescription && (
|
||||
<Box paddingLeft={prefixWidth}>
|
||||
<Text dimColor>{option.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>{hintText}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
InlineQuestionApproval.displayName = "InlineQuestionApproval";
|
||||
@@ -1,3 +1,4 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { Box, Text } from "ink";
|
||||
import { memo } from "react";
|
||||
import { INTERRUPTED_BY_USER } from "../../constants";
|
||||
@@ -18,6 +19,14 @@ import {
|
||||
isTaskTool,
|
||||
isTodoTool,
|
||||
} from "../helpers/toolNameMapping.js";
|
||||
|
||||
/**
|
||||
* Check if tool is AskUserQuestion
|
||||
*/
|
||||
function isQuestionTool(name: string): boolean {
|
||||
return name === "AskUserQuestion";
|
||||
}
|
||||
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer";
|
||||
import { BlinkDot } from "./BlinkDot.js";
|
||||
@@ -57,9 +66,11 @@ export const ToolCallMessage = memo(
|
||||
({
|
||||
line,
|
||||
precomputedDiffs,
|
||||
lastPlanFilePath,
|
||||
}: {
|
||||
line: ToolCallLine;
|
||||
precomputedDiffs?: Map<string, AdvancedDiffSuccess>;
|
||||
lastPlanFilePath?: string | null;
|
||||
}) => {
|
||||
const columns = useTerminalWidth();
|
||||
|
||||
@@ -99,10 +110,20 @@ export const ToolCallMessage = memo(
|
||||
}
|
||||
}
|
||||
|
||||
// For AskUserQuestion, show friendly header only after completion
|
||||
if (isQuestionTool(rawName)) {
|
||||
if (line.phase === "finished" && line.resultOk !== false) {
|
||||
displayName = "User answered Letta Code's questions:";
|
||||
} else {
|
||||
displayName = "Asking user questions...";
|
||||
}
|
||||
}
|
||||
|
||||
// Format arguments for display using the old formatting logic
|
||||
// Pass rawName to enable special formatting for file tools
|
||||
const formatted = formatArgsDisplay(argsText, rawName);
|
||||
const args = `(${formatted.display})`;
|
||||
// Hide args for question tool (shown in result instead)
|
||||
const args = isQuestionTool(rawName) ? "" : `(${formatted.display})`;
|
||||
|
||||
const rightWidth = Math.max(0, columns - 2); // gutter is 2 cols
|
||||
|
||||
@@ -267,6 +288,78 @@ export const ToolCallMessage = memo(
|
||||
// If MemoryDiffRenderer returns null, fall through to regular handling
|
||||
}
|
||||
|
||||
// Check if this is AskUserQuestion - show pretty Q&A format
|
||||
if (isQuestionTool(rawName) && line.resultOk !== false) {
|
||||
// Parse the result to extract questions and answers
|
||||
// Format: "Question"="Answer", "Question2"="Answer2"
|
||||
const qaPairs: Array<{ question: string; answer: string }> = [];
|
||||
const qaRegex = /"([^"]+)"="([^"]*)"/g;
|
||||
const resultText = line.resultText || "";
|
||||
const matches = resultText.matchAll(qaRegex);
|
||||
for (const match of matches) {
|
||||
if (match[1] && match[2] !== undefined) {
|
||||
qaPairs.push({ question: match[1], answer: match[2] });
|
||||
}
|
||||
}
|
||||
|
||||
if (qaPairs.length > 0) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{qaPairs.map((qa) => (
|
||||
<Box key={qa.question} flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text wrap="wrap">
|
||||
<Text dimColor>·</Text> {qa.question}{" "}
|
||||
<Text dimColor>→</Text> {qa.answer}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Fall through to regular handling if parsing fails
|
||||
}
|
||||
|
||||
// Check if this is ExitPlanMode - show plan content (faded) instead of simple message
|
||||
if (rawName === "ExitPlanMode" && line.resultOk !== false) {
|
||||
// Read plan file path from ref (captured before plan mode was exited)
|
||||
const planFilePath = lastPlanFilePath;
|
||||
let planContent = "";
|
||||
|
||||
if (planFilePath && existsSync(planFilePath)) {
|
||||
try {
|
||||
planContent = readFileSync(planFilePath, "utf-8");
|
||||
} catch {
|
||||
// Fall through to default
|
||||
}
|
||||
}
|
||||
|
||||
if (planContent) {
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
{/* Plan file path */}
|
||||
<Box flexDirection="row">
|
||||
<Box width={prefixWidth} flexShrink={0}>
|
||||
<Text>{prefix}</Text>
|
||||
</Box>
|
||||
<Box flexGrow={1} width={contentWidth}>
|
||||
<Text dimColor>Plan saved to: {planFilePath}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Plan content (faded) - indent to align with content column */}
|
||||
<Box paddingLeft={prefixWidth}>
|
||||
<MarkdownDisplay text={planContent} dimColor={true} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
// Fall through to default if no plan content
|
||||
}
|
||||
|
||||
// Check if this is a file edit tool - show diff instead of success message
|
||||
if (isFileEditTool(rawName) && line.resultOk !== false && line.argsText) {
|
||||
const diff = line.toolCallId
|
||||
|
||||
Reference in New Issue
Block a user