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

@@ -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>

View 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";

View 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";

View 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";

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";

View 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";

View 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";

View File

@@ -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