From 19ecc2af1a27f1fcb0898d63998af79565f051e7 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Wed, 31 Dec 2025 15:32:06 -0800 Subject: [PATCH] feat: inline dialogs (#436) Co-authored-by: Letta --- src/cli/App.tsx | 396 ++++++++++---- src/cli/components/EnterPlanModeDialog.tsx | 6 +- src/cli/components/InlineBashApproval.tsx | 234 +++++++++ .../InlineEnterPlanModeApproval.tsx | 131 +++++ src/cli/components/InlineFileEditApproval.tsx | 483 ++++++++++++++++++ src/cli/components/InlineGenericApproval.tsx | 243 +++++++++ src/cli/components/InlinePlanApproval.tsx | 225 ++++++++ src/cli/components/InlineQuestionApproval.tsx | 384 ++++++++++++++ src/cli/components/ToolCallMessageRich.tsx | 95 +++- src/cli/helpers/toolNameMapping.ts | 40 +- src/permissions/checker.ts | 1 - 11 files changed, 2112 insertions(+), 126 deletions(-) create mode 100644 src/cli/components/InlineBashApproval.tsx create mode 100644 src/cli/components/InlineEnterPlanModeApproval.tsx create mode 100644 src/cli/components/InlineFileEditApproval.tsx create mode 100644 src/cli/components/InlineGenericApproval.tsx create mode 100644 src/cli/components/InlinePlanApproval.tsx create mode 100644 src/cli/components/InlineQuestionApproval.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 18b3938..d665073 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -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(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}`; ) : item.kind === "subagent_group" ? ( @@ -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 && ( - {liveItems.map((ln) => ( - - {ln.kind === "user" ? ( - - ) : ln.kind === "reasoning" ? ( - - ) : ln.kind === "assistant" ? ( - - ) : ln.kind === "tool_call" ? ( - - ) : ln.kind === "error" ? ( - - ) : ln.kind === "status" ? ( - - ) : ln.kind === "command" ? ( - - ) : ln.kind === "bash_command" ? ( - - ) : null} - - ))} + {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 ( + + {/* For ExitPlanMode awaiting approval: render InlinePlanApproval */} + {isExitPlanModeApproval ? ( + handlePlanApprove(false)} + onApproveAndAcceptEdits={() => + handlePlanApprove(true) + } + onKeepPlanning={handlePlanKeepPlanning} + isFocused={true} + /> + ) : isFileEditApproval && fileEditInfo ? ( + 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 ? ( + handleApproveCurrent()} + onApproveAlways={(scope) => + handleApproveAlways(scope) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> + ) : isEnterPlanModeApproval ? ( + + ) : isAskUserQuestionApproval ? ( + + ) : ln.kind === "tool_call" && + currentApproval && + ln.toolCallId === currentApproval.toolCallId ? ( + // Generic fallback for any other tool needing approval + handleApproveCurrent()} + onApproveAlways={(scope) => + handleApproveAlways(scope) + } + onDeny={(reason) => handleDenyCurrent(reason)} + onCancel={handleCancelApprovals} + isFocused={true} + approveAlwaysText={ + currentApprovalContext?.approveAlwaysText + } + allowPersistence={ + currentApprovalContext?.allowPersistence ?? true + } + /> + ) : ln.kind === "user" ? ( + + ) : ln.kind === "reasoning" ? ( + + ) : ln.kind === "assistant" ? ( + + ) : ln.kind === "tool_call" ? ( + + ) : ln.kind === "error" ? ( + + ) : ln.kind === "status" ? ( + + ) : ln.kind === "command" ? ( + + ) : ln.kind === "bash_command" ? ( + + ) : null} + + ); + })} )} @@ -5823,58 +6039,12 @@ Plan file path: ${planFilePath}`; /> )} - {/* Plan Mode Dialog - for ExitPlanMode tool */} - {currentApproval?.toolName === "ExitPlanMode" && ( - 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" && ( - - )} - - {/* Enter Plan Mode Dialog - for EnterPlanMode tool */} - {currentApproval?.toolName === "EnterPlanMode" && ( - - )} - - {/* Approval Dialog - for standard tools (not fancy UI tools) */} - {currentApproval && !isFancyUITool(currentApproval.toolName) && ( - - )} + {/* AskUserQuestion now rendered inline via InlineQuestionApproval */} + {/* EnterPlanMode now rendered inline in liveItems above */} + {/* ApprovalDialog removed - all approvals now render inline via InlineGenericApproval fallback */} )} diff --git a/src/cli/components/EnterPlanModeDialog.tsx b/src/cli/components/EnterPlanModeDialog.tsx index a64cb6e..cebe8b7 100644 --- a/src/cli/components/EnterPlanModeDialog.tsx +++ b/src/cli/components/EnterPlanModeDialog.tsx @@ -51,11 +51,11 @@ export const EnterPlanModeDialog = memo(({ onApprove, onReject }: Props) => { - 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. - In plan mode, Letta will: + In plan mode, Letta Code will: • Explore the codebase thoroughly • Identify existing patterns • Design an implementation strategy diff --git a/src/cli/components/InlineBashApproval.tsx b/src/cli/components/InlineBashApproval.tsx new file mode 100644 index 0000000..9a0d153 --- /dev/null +++ b/src/cli/components/InlineBashApproval.tsx @@ -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 ( + + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Run this command? + + + + + {/* Command preview */} + + {bashInfo.command} + {bashInfo.description && {bashInfo.description}} + + + {/* Options */} + + {/* Option 1: Yes */} + + + + {selectedOption === 0 ? "❯" : " "} 1. + + + + + Yes + + + + + {/* Option 2: Yes, always (only if persistence allowed) */} + {allowPersistence && ( + + + + {selectedOption === 1 ? "❯" : " "} 2. + + + + + {approveAlwaysText || + "Yes, and don't ask again for this project"} + + + + )} + + {/* Custom input option (3 if persistence, 2 if not) */} + + + + {isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}. + + + + {customReason ? ( + + {customReason} + {isOnCustomOption && "█"} + + ) : ( + + {customOptionPlaceholder} + {isOnCustomOption && "█"} + + )} + + + + + {/* Hint */} + + {hintText} + + + ); + }, +); + +InlineBashApproval.displayName = "InlineBashApproval"; diff --git a/src/cli/components/InlineEnterPlanModeApproval.tsx b/src/cli/components/InlineEnterPlanModeApproval.tsx new file mode 100644 index 0000000..eeb7459 --- /dev/null +++ b/src/cli/components/InlineEnterPlanModeApproval.tsx @@ -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 ( + + {options.map((option, index) => { + const isSelected = index === selectedOption; + const color = isSelected ? colors.approval.header : undefined; + return ( + + + {isSelected ? "❯" : " "} {index + 1}. {option.label} + + + ); + })} + + ); + }, +); + +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 ( + + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Enter plan mode? + + + + + {/* Description */} + + + Letta Code wants to enter plan mode to explore and design an + implementation approach. + + + In plan mode, Letta Code will: + · Explore the codebase thoroughly + · Identify existing patterns + · Design an implementation strategy + · Present a plan for your approval + + + No code changes will be made until you approve the plan. + + + + {/* Options */} + + + + + ); + }, +); + +InlineEnterPlanModeApproval.displayName = "InlineEnterPlanModeApproval"; diff --git a/src/cli/components/InlineFileEditApproval.tsx b/src/cli/components/InlineFileEditApproval.tsx new file mode 100644 index 0000000..5776606 --- /dev/null +++ b/src/cli/components/InlineFileEditApproval.tsx @@ -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; // For patch tools with multiple files + onApprove: (diffs?: Map) => void; + onApproveAlways: ( + scope: "project" | "session", + diffs?: Map, + ) => 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 => { + const diffs = new Map(); + 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 ( + + {/* Top solid line */} + {solidLine} + + {/* Header */} + + {headerText} + + + {/* Dotted separator before diff content */} + {dottedLine} + + {/* Diff preview */} + + {fileEdit.patchInput ? ( + // Render patch operations (can be multiple files) + + {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 ( + + {idx > 0 && } + {displayPath} + + + ); + } else if (op.kind === "update") { + return ( + + {idx > 0 && } + {displayPath} + + + ); + } else if (op.kind === "delete") { + return ( + + {idx > 0 && } + {displayPath} + File will be deleted + + ); + } + return null; + })} + + ) : diffKind === "write" ? ( + + ) : diffKind === "multi_edit" ? ( + + ) : ( + + )} + + + {/* Dotted separator after diff content */} + {dottedLine} + + {/* Options */} + + {/* Option 1: Yes */} + + + + {selectedOption === 0 ? "❯" : " "} 1. + + + + + Yes + + + + + {/* Option 2: Yes, always (only if persistence allowed) */} + {allowPersistence && ( + + + + {selectedOption === 1 ? "❯" : " "} 2. + + + + + {approveAlwaysText || + "Yes, and don't ask again for this project"} + + + + )} + + {/* Custom input option */} + + + + {isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}. + + + + {customReason ? ( + + {customReason} + {isOnCustomOption && "█"} + + ) : ( + + {customOptionPlaceholder} + {isOnCustomOption && "█"} + + )} + + + + + {/* Hint */} + + {hintText} + + + ); + }, +); + +InlineFileEditApproval.displayName = "InlineFileEditApproval"; diff --git a/src/cli/components/InlineGenericApproval.tsx b/src/cli/components/InlineGenericApproval.tsx new file mode 100644 index 0000000..b2ab050 --- /dev/null +++ b/src/cli/components/InlineGenericApproval.tsx @@ -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 ( + + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Run {toolName}? + + + + + {/* Arguments preview */} + + {formattedArgs} + + + {/* Options */} + + {/* Option 1: Yes */} + + + + {selectedOption === 0 ? "❯" : " "} 1. + + + + + Yes + + + + + {/* Option 2: Yes, always (only if persistence allowed) */} + {allowPersistence && ( + + + + {selectedOption === 1 ? "❯" : " "} 2. + + + + + {approveAlwaysText || + "Yes, and don't ask again for this project"} + + + + )} + + {/* Custom input option */} + + + + {isOnCustomOption ? "❯" : " "} {customOptionIndex + 1}. + + + + {customReason ? ( + + {customReason} + {isOnCustomOption && "█"} + + ) : ( + + {customOptionPlaceholder} + {isOnCustomOption && "█"} + + )} + + + + + {/* Hint */} + + {hintText} + + + ); + }, +); + +InlineGenericApproval.displayName = "InlineGenericApproval"; diff --git a/src/cli/components/InlinePlanApproval.tsx b/src/cli/components/InlinePlanApproval.tsx new file mode 100644 index 0000000..d237c24 --- /dev/null +++ b/src/cli/components/InlinePlanApproval.tsx @@ -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 ( + + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Ready to code? Here is your plan: + + + {/* Dotted separator before plan content */} + {dottedLine} + + {/* Plan content - no indentation, just like Claude Code */} + + + {/* Dotted separator after plan content */} + {dottedLine} + + {/* Question */} + + Would you like to proceed? + + + {/* Options */} + + {/* Option 1: Yes, and auto-accept edits */} + + + + {selectedOption === 0 ? "❯" : " "} 1. + + + + + Yes, and auto-accept edits + + + + + {/* Option 2: Yes, and manually approve edits */} + + + + {selectedOption === 1 ? "❯" : " "} 2. + + + + + Yes, and manually approve edits + + + + + {/* Option 3: Custom input */} + + + + {isOnCustomOption ? "❯" : " "} 3. + + + + {customReason ? ( + + {customReason} + {isOnCustomOption && "█"} + + ) : ( + + {customOptionPlaceholder} + {isOnCustomOption && "█"} + + )} + + + + + {/* Hint */} + + {hintText} + + + ); + }, +); + +InlinePlanApproval.displayName = "InlinePlanApproval"; diff --git a/src/cli/components/InlineQuestionApproval.tsx b/src/cli/components/InlineQuestionApproval.tsx new file mode 100644 index 0000000..0b61d86 --- /dev/null +++ b/src/cli/components/InlineQuestionApproval.tsx @@ -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) => 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>({}); + const [selectedOption, setSelectedOption] = useState(0); + const [customText, setCustomText] = useState(""); + const [selectedMulti, setSelectedMulti] = useState>(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 ( + + {/* Top solid line */} + {solidLine} + + {/* Header label */} + {currentQuestion.header} + + + + {/* Question */} + {currentQuestion.question} + + + + {/* Progress indicator for multiple questions */} + {questions.length > 1 && ( + + + Question {currentQuestionIndex + 1} of {questions.length} + + + )} + + {/* Options - Format: ❯ N. [ ] Label (selector, number, checkbox, label) */} + + {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 ( + + {/* Extra newline above Submit */} + + + + + {isSelected ? "❯" : " "} + {" "} + + + + + Submit + + + + + ); + } + + 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 ( + + + {/* Selector and number */} + + + {isSelected ? "❯" : " "} {index + 1}. + + + {/* Checkbox (for multi-select) - single Text element to avoid re-mount */} + {currentQuestion.multiSelect && ( + + + [{isChecked ? "✓" : " "}]{" "} + + + )} + {/* Label */} + + {isCustomOption ? ( + // Custom input option ("Type something") + customText ? ( + + {customText} + {isSelected && "█"} + + ) : ( + + {option.label} + {isSelected && "█"} + + ) + ) : ( + + {option.label} + + )} + + + {/* Description - rendered as sibling row */} + {hasDescription && ( + + {option.description} + + )} + + ); + })} + + + {hintText} + + + + ); + }, +); + +InlineQuestionApproval.displayName = "InlineQuestionApproval"; diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index b26c556..59add89 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -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; + 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 ( + + {qaPairs.map((qa) => ( + + + {prefix} + + + + · {qa.question}{" "} + {qa.answer} + + + + ))} + + ); + } + // 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 ( + + {/* Plan file path */} + + + {prefix} + + + Plan saved to: {planFilePath} + + + {/* Plan content (faded) - indent to align with content column */} + + + + + ); + } + // 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 diff --git a/src/cli/helpers/toolNameMapping.ts b/src/cli/helpers/toolNameMapping.ts index aac8c08..52e5d47 100644 --- a/src/cli/helpers/toolNameMapping.ts +++ b/src/cli/helpers/toolNameMapping.ts @@ -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" ); } diff --git a/src/permissions/checker.ts b/src/permissions/checker.ts index cb24d0e..7c369a1 100644 --- a/src/permissions/checker.ts +++ b/src/permissions/checker.ts @@ -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",