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

@@ -54,15 +54,21 @@ import {
validateProfileLoad,
} from "./commands/profile";
import { AgentSelector } from "./components/AgentSelector";
import { ApprovalDialog } from "./components/ApprovalDialogRich";
// ApprovalDialog removed - all approvals now render inline
import { AssistantMessage } from "./components/AssistantMessageRich";
import { BashCommandMessage } from "./components/BashCommandMessage";
import { CommandMessage } from "./components/CommandMessage";
import { colors } from "./components/colors";
import { EnterPlanModeDialog } from "./components/EnterPlanModeDialog";
// EnterPlanModeDialog removed - now using InlineEnterPlanModeApproval
import { ErrorMessage } from "./components/ErrorMessageRich";
import { FeedbackDialog } from "./components/FeedbackDialog";
import { HelpDialog } from "./components/HelpDialog";
import { InlineBashApproval } from "./components/InlineBashApproval";
import { InlineEnterPlanModeApproval } from "./components/InlineEnterPlanModeApproval";
import { InlineFileEditApproval } from "./components/InlineFileEditApproval";
import { InlineGenericApproval } from "./components/InlineGenericApproval";
import { InlinePlanApproval } from "./components/InlinePlanApproval";
import { InlineQuestionApproval } from "./components/InlineQuestionApproval";
import { Input } from "./components/InputRich";
import { McpSelector } from "./components/McpSelector";
import { MemoryViewer } from "./components/MemoryViewer";
@@ -71,8 +77,7 @@ import { ModelSelector } from "./components/ModelSelector";
import { NewAgentDialog } from "./components/NewAgentDialog";
import { OAuthCodeDialog } from "./components/OAuthCodeDialog";
import { PinDialog, validateAgentName } from "./components/PinDialog";
import { PlanModeDialog } from "./components/PlanModeDialog";
import { QuestionDialog } from "./components/QuestionDialog";
// QuestionDialog removed - now using InlineQuestionApproval
import { ReasoningMessage } from "./components/ReasoningMessageRich";
import { ResumeSelector } from "./components/ResumeSelector";
import { formatUsageStats } from "./components/SessionStats";
@@ -127,8 +132,13 @@ import {
isFileEditTool,
isFileWriteTool,
isPatchTool,
isShellTool,
} from "./helpers/toolNameMapping";
import { isFancyUITool, isTaskTool } from "./helpers/toolNameMapping.js";
import {
alwaysRequiresUserInput,
isFancyUITool,
isTaskTool,
} from "./helpers/toolNameMapping.js";
import { useSuspend } from "./hooks/useSuspend/useSuspend.ts";
import { useSyncedState } from "./hooks/useSyncedState";
import { useTerminalWidth } from "./hooks/useTerminalWidth";
@@ -505,6 +515,7 @@ export default function App({
// Derive current approval from pending approvals and results
// This is the approval currently being shown to the user
const currentApproval = pendingApprovals[approvalResults.length];
const currentApprovalContext = approvalContexts[approvalResults.length];
// Overlay/selector state - only one can be open at a time
type ActiveOverlay =
@@ -778,7 +789,11 @@ export default function App({
new Map(),
);
// Recompute UI state from buffers after chunks (micro-batched)
// Store the last plan file path for post-approval rendering
// (needed because plan mode is exited before rendering the result)
const lastPlanFilePathRef = useRef<string | null>(null);
// Recompute UI state from buffers after each streaming chunk
const refreshDerived = useCallback(() => {
const b = buffersRef.current;
setTokenCount(b.tokenCount);
@@ -1348,9 +1363,11 @@ export default function App({
const { approval, permission } = ac;
let decision = permission.decision;
// Fancy tools should always go through a UI dialog in interactive mode,
// even if a rule says "allow". Deny rules are still respected.
if (isFancyUITool(approval.toolName) && decision === "allow") {
// Some tools always need user input regardless of yolo mode
if (
alwaysRequiresUserInput(approval.toolName) &&
decision === "allow"
) {
decision = "ask";
}
@@ -1364,8 +1381,9 @@ export default function App({
}
}
// Precompute diffs for auto-allowed file edit tools before execution
for (const ac of autoAllowed) {
// Precompute diffs for file edit tools before execution (both auto-allowed and needs-user-input)
// This is needed for inline approval UI to show diffs, and for post-approval rendering
for (const ac of [...autoAllowed, ...needsUserInput]) {
const toolName = ac.approval.toolName;
const toolCallId = ac.approval.toolCallId;
try {
@@ -1893,11 +1911,6 @@ export default function App({
setAutoHandledResults([]);
setAutoDeniedApprovals([]);
// Force clean re-render to avoid streaking artifacts
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
// The epoch increment alone should reset Ink's line tracking
setStaticRenderEpoch((e) => e + 1);
// Send cancel request to backend asynchronously (fire-and-forget)
// Don't wait for it or show errors since user already got feedback
getClient()
@@ -3896,8 +3909,11 @@ DO NOT respond to these messages or otherwise consider them in your response unl
const { approval, permission } = ac;
let decision = permission.decision;
// Fancy tools always need user input (except if denied)
if (isFancyUITool(approval.toolName) && decision === "allow") {
// Some tools always need user input regardless of yolo mode
if (
alwaysRequiresUserInput(approval.toolName) &&
decision === "allow"
) {
decision = "ask";
}
@@ -3912,8 +3928,8 @@ DO NOT respond to these messages or otherwise consider them in your response unl
// If all approvals can be auto-handled (yolo mode), process them immediately
if (needsUserInput.length === 0) {
// Precompute diffs for auto-allowed file edit tools before execution
for (const ac of autoAllowed) {
// Precompute diffs for file edit tools before execution (both auto-allowed and needs-user-input)
for (const ac of [...autoAllowed, ...needsUserInput]) {
const toolName = ac.approval.toolName;
const toolCallId = ac.approval.toolCallId;
try {
@@ -4068,8 +4084,8 @@ DO NOT respond to these messages or otherwise consider them in your response unl
.filter(Boolean) as ApprovalContext[],
);
// Precompute diffs for auto-allowed file edit tools before execution
for (const ac of autoAllowed) {
// Precompute diffs for file edit tools before execution (both auto-allowed and needs-user-input)
for (const ac of [...autoAllowed, ...needsUserInput]) {
const toolName = ac.approval.toolName;
const toolCallId = ac.approval.toolCallId;
try {
@@ -4287,9 +4303,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
setApprovalResults([]);
setAutoHandledResults([]);
setAutoDeniedApprovals([]);
// Force clean re-render to avoid streaking artifacts
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
setStaticRenderEpoch((e) => e + 1);
return;
}
@@ -4306,11 +4319,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
setAutoHandledResults([]);
setAutoDeniedApprovals([]);
// Force clean re-render to avoid streaking artifacts
// The large approval dialog disappearing causes line count mismatch in Ink
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
setStaticRenderEpoch((e) => e + 1);
// Show "thinking" state and lock input while executing approved tools client-side
setStreaming(true);
// Ensure interrupted flag is cleared for this execution
@@ -4583,9 +4591,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
setAutoHandledResults([]);
setAutoDeniedApprovals([]);
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
setStaticRenderEpoch((e) => e + 1);
setStreaming(true);
buffersRef.current.interrupted = false;
@@ -4706,10 +4711,6 @@ DO NOT respond to these messages or otherwise consider them in your response unl
setApprovalResults([]);
setAutoHandledResults([]);
setAutoDeniedApprovals([]);
// Force clean re-render to avoid streaking artifacts
// Note: Removed CLEAR_SCREEN_AND_HOME to avoid 100ms+ flash on long transcripts
setStaticRenderEpoch((e) => e + 1);
}, [pendingApprovals, refreshDerived]);
const handleModelSelect = useCallback(
@@ -5122,6 +5123,10 @@ DO NOT respond to these messages or otherwise consider them in your response unl
const isLast = currentIndex + 1 >= pendingApprovals.length;
// Capture plan file path BEFORE exiting plan mode (for post-approval rendering)
const planFilePath = permissionMode.getPlanFilePath();
lastPlanFilePathRef.current = planFilePath;
// Exit plan mode
const newMode = acceptEdits ? "acceptEdits" : "default";
permissionMode.setMode(newMode);
@@ -5494,6 +5499,7 @@ Plan file path: ${planFilePath}`;
<ToolCallMessage
line={item}
precomputedDiffs={precomputedDiffsRef.current}
lastPlanFilePath={lastPlanFilePathRef.current}
/>
) : item.kind === "subagent_group" ? (
<SubagentGroupStatic agents={item.agents} />
@@ -5525,32 +5531,242 @@ Plan file path: ${planFilePath}`;
{loadingState === "ready" && (
<>
{/* Transcript */}
{liveItems.length > 0 && pendingApprovals.length === 0 && (
{/* Show liveItems always - all approvals now render inline */}
{liveItems.length > 0 && (
<Box flexDirection="column">
{liveItems.map((ln) => (
<Box key={ln.id} marginTop={1}>
{ln.kind === "user" ? (
<UserMessage line={ln} />
) : ln.kind === "reasoning" ? (
<ReasoningMessage line={ln} />
) : ln.kind === "assistant" ? (
<AssistantMessage line={ln} />
) : ln.kind === "tool_call" ? (
<ToolCallMessage
line={ln}
precomputedDiffs={precomputedDiffsRef.current}
/>
) : ln.kind === "error" ? (
<ErrorMessage line={ln} />
) : ln.kind === "status" ? (
<StatusMessage line={ln} />
) : ln.kind === "command" ? (
<CommandMessage line={ln} />
) : ln.kind === "bash_command" ? (
<BashCommandMessage line={ln} />
) : null}
</Box>
))}
{liveItems.map((ln) => {
// Check if this tool call matches the current ExitPlanMode approval
const isExitPlanModeApproval =
ln.kind === "tool_call" &&
currentApproval?.toolName === "ExitPlanMode" &&
ln.toolCallId === currentApproval?.toolCallId;
// Check if this tool call matches a file edit/write/patch approval
const isFileEditApproval =
ln.kind === "tool_call" &&
currentApproval &&
(isFileEditTool(currentApproval.toolName) ||
isFileWriteTool(currentApproval.toolName) ||
isPatchTool(currentApproval.toolName)) &&
ln.toolCallId === currentApproval.toolCallId;
// Check if this tool call matches a bash/shell approval
const isBashApproval =
ln.kind === "tool_call" &&
currentApproval &&
isShellTool(currentApproval.toolName) &&
ln.toolCallId === currentApproval.toolCallId;
// Check if this tool call matches an EnterPlanMode approval
const isEnterPlanModeApproval =
ln.kind === "tool_call" &&
currentApproval?.toolName === "EnterPlanMode" &&
ln.toolCallId === currentApproval?.toolCallId;
// Check if this tool call matches an AskUserQuestion approval
const isAskUserQuestionApproval =
ln.kind === "tool_call" &&
currentApproval?.toolName === "AskUserQuestion" &&
ln.toolCallId === currentApproval?.toolCallId;
// Parse file edit info from approval args
const getFileEditInfo = () => {
if (!isFileEditApproval || !currentApproval) return null;
try {
const args = JSON.parse(currentApproval.toolArgs || "{}");
// For patch tools, use the input field
if (isPatchTool(currentApproval.toolName)) {
return {
toolName: currentApproval.toolName,
filePath: "", // Patch can have multiple files
patchInput: args.input as string | undefined,
toolCallId: ln.toolCallId,
};
}
// For regular file edit/write tools
return {
toolName: currentApproval.toolName,
filePath: String(args.file_path || ""),
content: args.content as string | undefined,
oldString: args.old_string as string | undefined,
newString: args.new_string as string | undefined,
replaceAll: args.replace_all as boolean | undefined,
edits: args.edits as
| Array<{
old_string: string;
new_string: string;
replace_all?: boolean;
}>
| undefined,
toolCallId: ln.toolCallId,
};
} catch {
return null;
}
};
const fileEditInfo = getFileEditInfo();
// Parse bash info from approval args
const getBashInfo = () => {
if (!isBashApproval || !currentApproval) return null;
try {
const args = JSON.parse(currentApproval.toolArgs || "{}");
const t = currentApproval.toolName.toLowerCase();
// Handle different bash tool arg formats
let command = "";
let description = "";
if (t === "shell") {
// Shell tool uses command array and justification
const cmdVal = args.command;
command = Array.isArray(cmdVal)
? cmdVal.join(" ")
: typeof cmdVal === "string"
? cmdVal
: "(no command)";
description =
typeof args.justification === "string"
? args.justification
: "";
} else {
// Bash/shell_command uses command string and description
command =
typeof args.command === "string"
? args.command
: "(no command)";
description =
typeof args.description === "string"
? args.description
: "";
}
return {
toolName: currentApproval.toolName,
command,
description,
};
} catch {
return null;
}
};
const bashInfo = getBashInfo();
return (
<Box key={ln.id} flexDirection="column" marginTop={1}>
{/* For ExitPlanMode awaiting approval: render InlinePlanApproval */}
{isExitPlanModeApproval ? (
<InlinePlanApproval
plan={readPlanFile()}
onApprove={() => handlePlanApprove(false)}
onApproveAndAcceptEdits={() =>
handlePlanApprove(true)
}
onKeepPlanning={handlePlanKeepPlanning}
isFocused={true}
/>
) : isFileEditApproval && fileEditInfo ? (
<InlineFileEditApproval
fileEdit={fileEditInfo}
precomputedDiff={
ln.toolCallId
? precomputedDiffsRef.current.get(ln.toolCallId)
: undefined
}
allDiffs={precomputedDiffsRef.current}
onApprove={(diffs) => handleApproveCurrent(diffs)}
onApproveAlways={(scope, diffs) =>
handleApproveAlways(scope, diffs)
}
onDeny={(reason) => handleDenyCurrent(reason)}
onCancel={handleCancelApprovals}
isFocused={true}
approveAlwaysText={
currentApprovalContext?.approveAlwaysText
}
allowPersistence={
currentApprovalContext?.allowPersistence ?? true
}
/>
) : isBashApproval && bashInfo ? (
<InlineBashApproval
bashInfo={bashInfo}
onApprove={() => handleApproveCurrent()}
onApproveAlways={(scope) =>
handleApproveAlways(scope)
}
onDeny={(reason) => handleDenyCurrent(reason)}
onCancel={handleCancelApprovals}
isFocused={true}
approveAlwaysText={
currentApprovalContext?.approveAlwaysText
}
allowPersistence={
currentApprovalContext?.allowPersistence ?? true
}
/>
) : isEnterPlanModeApproval ? (
<InlineEnterPlanModeApproval
onApprove={handleEnterPlanModeApprove}
onReject={handleEnterPlanModeReject}
isFocused={true}
/>
) : isAskUserQuestionApproval ? (
<InlineQuestionApproval
questions={getQuestionsFromApproval(currentApproval)}
onSubmit={handleQuestionSubmit}
onCancel={handleCancelApprovals}
isFocused={true}
/>
) : ln.kind === "tool_call" &&
currentApproval &&
ln.toolCallId === currentApproval.toolCallId ? (
// Generic fallback for any other tool needing approval
<InlineGenericApproval
toolName={currentApproval.toolName}
toolArgs={currentApproval.toolArgs}
onApprove={() => handleApproveCurrent()}
onApproveAlways={(scope) =>
handleApproveAlways(scope)
}
onDeny={(reason) => handleDenyCurrent(reason)}
onCancel={handleCancelApprovals}
isFocused={true}
approveAlwaysText={
currentApprovalContext?.approveAlwaysText
}
allowPersistence={
currentApprovalContext?.allowPersistence ?? true
}
/>
) : ln.kind === "user" ? (
<UserMessage line={ln} />
) : ln.kind === "reasoning" ? (
<ReasoningMessage line={ln} />
) : ln.kind === "assistant" ? (
<AssistantMessage line={ln} />
) : ln.kind === "tool_call" ? (
<ToolCallMessage
line={ln}
precomputedDiffs={precomputedDiffsRef.current}
lastPlanFilePath={lastPlanFilePathRef.current}
/>
) : ln.kind === "error" ? (
<ErrorMessage line={ln} />
) : ln.kind === "status" ? (
<StatusMessage line={ln} />
) : ln.kind === "command" ? (
<CommandMessage line={ln} />
) : ln.kind === "bash_command" ? (
<BashCommandMessage line={ln} />
) : null}
</Box>
);
})}
</Box>
)}
@@ -5823,58 +6039,12 @@ Plan file path: ${planFilePath}`;
/>
)}
{/* Plan Mode Dialog - for ExitPlanMode tool */}
{currentApproval?.toolName === "ExitPlanMode" && (
<PlanModeDialog
plan={readPlanFile()}
onApprove={() => handlePlanApprove(false)}
onApproveAndAcceptEdits={() => handlePlanApprove(true)}
onKeepPlanning={handlePlanKeepPlanning}
/>
)}
{/* Plan Mode Dialog - NOW RENDERED INLINE with tool call (see liveItems above) */}
{/* ExitPlanMode approval is handled by InlinePlanApproval component */}
{/* Question Dialog - for AskUserQuestion tool */}
{currentApproval?.toolName === "AskUserQuestion" && (
<QuestionDialog
questions={getQuestionsFromApproval(currentApproval)}
onSubmit={handleQuestionSubmit}
onCancel={handleCancelApprovals}
/>
)}
{/* Enter Plan Mode Dialog - for EnterPlanMode tool */}
{currentApproval?.toolName === "EnterPlanMode" && (
<EnterPlanModeDialog
onApprove={handleEnterPlanModeApprove}
onReject={handleEnterPlanModeReject}
/>
)}
{/* Approval Dialog - for standard tools (not fancy UI tools) */}
{currentApproval && !isFancyUITool(currentApproval.toolName) && (
<ApprovalDialog
approvals={[currentApproval]}
approvalContexts={
approvalContexts[approvalResults.length]
? [
approvalContexts[
approvalResults.length
] as ApprovalContext,
]
: []
}
progress={{
current: approvalResults.length + 1,
total: pendingApprovals.length,
}}
totalTools={autoHandledResults.length + pendingApprovals.length}
isExecuting={isExecutingTool}
onApproveAll={handleApproveCurrent}
onApproveAlways={handleApproveAlways}
onDenyAll={handleDenyCurrent}
onCancel={handleCancelApprovals}
/>
)}
{/* AskUserQuestion now rendered inline via InlineQuestionApproval */}
{/* EnterPlanMode now rendered inline in liveItems above */}
{/* ApprovalDialog removed - all approvals now render inline via InlineGenericApproval fallback */}
</>
)}
</Box>

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

View File

@@ -104,8 +104,33 @@ export function isPlanTool(rawName: string, displayName?: string): boolean {
/**
* Checks if a tool requires a specialized UI dialog instead of standard approval
* Note: ExitPlanMode, file edit/write/patch tools, and shell tools now render inline
* (not overlay), but still need this flag to bypass the standard ApprovalDialog rendering
*/
export function isFancyUITool(name: string): boolean {
return (
name === "AskUserQuestion" ||
name === "EnterPlanMode" ||
name === "ExitPlanMode" ||
// File edit/write/patch tools now render inline
isFileEditTool(name) ||
isFileWriteTool(name) ||
isPatchTool(name) ||
// Shell/bash tools now render inline
isShellTool(name)
);
}
/**
* Checks if a tool always requires user interaction, even in yolo mode.
* These are tools that fundamentally need user input to proceed:
* - AskUserQuestion: needs user to answer questions
* - EnterPlanMode: needs user to approve entering plan mode
* - ExitPlanMode: needs user to approve the plan
*
* Other tools (bash, file edits) should respect yolo mode and auto-approve.
*/
export function alwaysRequiresUserInput(name: string): boolean {
return (
name === "AskUserQuestion" ||
name === "EnterPlanMode" ||
@@ -175,14 +200,13 @@ export function isPatchTool(name: string): boolean {
* Checks if a tool is a shell/bash tool
*/
export function isShellTool(name: string): boolean {
const n = name.toLowerCase();
return (
name === "bash" ||
name === "Bash" ||
name === "shell" ||
name === "Shell" ||
name === "shell_command" ||
name === "ShellCommand" ||
name === "run_shell_command" ||
name === "RunShellCommand"
n === "bash" ||
n === "shell" ||
n === "shell_command" ||
n === "shellcommand" ||
n === "run_shell_command" ||
n === "runshellcommand"
);
}

View File

@@ -411,7 +411,6 @@ function getDefaultDecision(toolName: string): PermissionDecision {
"Grep",
"TodoWrite",
"BashOutput",
"ExitPlanMode",
"LS",
// Codex toolset (snake_case) - tools that don't require approval
"read_file",