From e424c6ce0c007b9979610c44816d881a276780e7 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 4 Jan 2026 21:41:03 -0800 Subject: [PATCH] fix: patch flicker (#459) Co-authored-by: Letta --- src/cli/App.tsx | 83 ++++- src/cli/components/ApprovalPreview.tsx | 322 ++++++++++++++++++++ src/cli/components/StaticPlanApproval.tsx | 199 ++++++++++++ src/cli/components/ToolCallMessageRich.tsx | 38 +-- src/cli/components/previews/BashPreview.tsx | 45 +++ src/cli/components/previews/PlanPreview.tsx | 48 +++ 6 files changed, 704 insertions(+), 31 deletions(-) create mode 100644 src/cli/components/ApprovalPreview.tsx create mode 100644 src/cli/components/StaticPlanApproval.tsx create mode 100644 src/cli/components/previews/BashPreview.tsx create mode 100644 src/cli/components/previews/PlanPreview.tsx diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 8fed89e..7703ec6 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -62,6 +62,7 @@ import { } from "./commands/profile"; import { AgentSelector } from "./components/AgentSelector"; // ApprovalDialog removed - all approvals now render inline +import { ApprovalPreview } from "./components/ApprovalPreview"; import { AssistantMessage } from "./components/AssistantMessageRich"; import { BashCommandMessage } from "./components/BashCommandMessage"; import { CommandMessage } from "./components/CommandMessage"; @@ -74,7 +75,6 @@ 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"; @@ -89,6 +89,9 @@ import { PinDialog, validateAgentName } from "./components/PinDialog"; import { ReasoningMessage } from "./components/ReasoningMessageRich"; import { ResumeSelector } from "./components/ResumeSelector"; import { formatUsageStats } from "./components/SessionStats"; +// InlinePlanApproval kept for easy rollback if needed +// import { InlinePlanApproval } from "./components/InlinePlanApproval"; +import { StaticPlanApproval } from "./components/StaticPlanApproval"; import { StatusMessage } from "./components/StatusMessage"; import { SubagentGroupDisplay } from "./components/SubagentGroupDisplay"; import { SubagentGroupStatic } from "./components/SubagentGroupStatic"; @@ -311,7 +314,7 @@ function planFileExists(): boolean { } // Read plan content from the plan file -function readPlanFile(): string { +function _readPlanFile(): string { const planFilePath = permissionMode.getPlanFilePath(); if (!planFilePath) { return "No plan file path set."; @@ -378,6 +381,20 @@ type StaticItem = error?: string; }>; } + | { + // Preview content committed early during approval to enable flicker-free UI + // When an approval's content is tall enough to overflow the viewport, + // we commit the preview to static and only show small approval options in dynamic + kind: "approval_preview"; + id: string; + toolCallId: string; + toolName: string; + toolArgs: string; + // Optional precomputed/cached data for rendering + precomputedDiff?: AdvancedDiffSuccess; + planContent?: string; // For ExitPlanMode + planFilePath?: string; // For ExitPlanMode + } | Line; export default function App({ @@ -891,6 +908,10 @@ export default function App({ // (needed because plan mode is exited before rendering the result) const lastPlanFilePathRef = useRef(null); + // Track which approval tool call IDs have had their previews eagerly committed + // This prevents double-committing when the approval changes + const eagerCommittedPreviewsRef = useRef>(new Set()); + // Recompute UI state from buffers after each streaming chunk const refreshDerived = useCallback(() => { const b = buffersRef.current; @@ -962,6 +983,48 @@ export default function App({ } }, [loadingState, startupApproval, startupApprovals]); + // Eager commit for ExitPlanMode: Always commit plan preview to staticItems + // This keeps the dynamic area small (just approval options) to avoid flicker + useEffect(() => { + if (!currentApproval) return; + if (currentApproval.toolName !== "ExitPlanMode") return; + + const toolCallId = currentApproval.toolCallId; + if (!toolCallId) return; + + // Already committed preview for this approval? + if (eagerCommittedPreviewsRef.current.has(toolCallId)) return; + + const planFilePath = permissionMode.getPlanFilePath(); + if (!planFilePath) return; + + try { + const { readFileSync, existsSync } = require("node:fs"); + if (!existsSync(planFilePath)) return; + + const planContent = readFileSync(planFilePath, "utf-8"); + + // Commit preview to static area + const previewItem: StaticItem = { + kind: "approval_preview", + id: `approval-preview-${toolCallId}`, + toolCallId, + toolName: currentApproval.toolName, + toolArgs: currentApproval.toolArgs || "{}", + planContent, + planFilePath, + }; + + setStaticItems((prev) => [...prev, previewItem]); + eagerCommittedPreviewsRef.current.add(toolCallId); + + // Also capture plan file path for post-approval rendering + lastPlanFilePathRef.current = planFilePath; + } catch { + // Failed to read plan, don't commit preview + } + }, [currentApproval]); + // Backfill message history when resuming (only once) useEffect(() => { if ( @@ -5643,6 +5706,16 @@ Plan file path: ${planFilePath}`; ) : item.kind === "bash_command" ? ( + ) : item.kind === "approval_preview" ? ( + ) : null} )} @@ -5788,10 +5861,10 @@ Plan file path: ${planFilePath}`; return ( - {/* For ExitPlanMode awaiting approval: render InlinePlanApproval */} + {/* For ExitPlanMode awaiting approval: render StaticPlanApproval */} + {/* Plan preview is eagerly committed to staticItems, so this only shows options */} {isExitPlanModeApproval ? ( - handlePlanApprove(false)} onApproveAndAcceptEdits={() => handlePlanApprove(true) diff --git a/src/cli/components/ApprovalPreview.tsx b/src/cli/components/ApprovalPreview.tsx new file mode 100644 index 0000000..1b1c3f6 --- /dev/null +++ b/src/cli/components/ApprovalPreview.tsx @@ -0,0 +1,322 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import type { AdvancedDiffSuccess } from "../helpers/diff"; +import { parsePatchOperations } from "../helpers/formatArgsDisplay"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { AdvancedDiffRenderer } from "./AdvancedDiffRenderer"; +import { colors } from "./colors"; +import { BashPreview } from "./previews/BashPreview"; +import { PlanPreview } from "./previews/PlanPreview"; + +const SOLID_LINE = "─"; +const DOTTED_LINE = "╌"; + +type Props = { + toolName: string; + toolArgs: string; + precomputedDiff?: AdvancedDiffSuccess; + allDiffs?: Map; + planContent?: string; + planFilePath?: string; + toolCallId?: string; +}; + +/** + * Get a human-readable header for file edit tools + */ +function getFileEditHeader(toolName: string, toolArgs: string): string { + const t = toolName.toLowerCase(); + + try { + const args = JSON.parse(toolArgs); + + // Handle patch tools + if (t === "apply_patch" || t === "applypatch") { + if (args.input) { + const operations = parsePatchOperations(args.input); + 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}?`; + if (op.kind === "update") return `Update ${displayPath}?`; + if (op.kind === "delete") return `Delete ${displayPath}?`; + } + } + } + return "Apply patch?"; + } + + // Handle single-file edit/write tools + const filePath = args.file_path || ""; + const { relative } = require("node:path"); + const cwd = process.cwd(); + const relPath = relative(cwd, filePath); + const displayPath = relPath.startsWith("..") ? filePath : relPath; + + if ( + t === "write" || + t === "write_file" || + t === "writefile" || + t === "write_file_gemini" || + t === "writefilegemini" + ) { + const { existsSync } = require("node:fs"); + try { + if (existsSync(filePath)) { + return `Overwrite ${displayPath}?`; + } + } catch { + // Ignore + } + return `Write to ${displayPath}?`; + } + + if ( + t === "edit" || + t === "str_replace_editor" || + t === "str_replace_based_edit_tool" + ) { + return `Update ${displayPath}?`; + } + + if (t === "multi_edit" || t === "multiedit") { + return `Apply edits to ${displayPath}?`; + } + } catch { + // Fall through + } + + return `${toolName} requires approval`; +} + +/** + * ApprovalPreview - Renders the preview content for an eagerly-committed approval + * + * This component renders the "preview" part of an approval that was committed + * early to enable flicker-free approval UI. It ensures visual parity with + * what the inline approval components show. + */ +export const ApprovalPreview = memo( + ({ + toolName, + toolArgs, + precomputedDiff, + allDiffs, + planContent, + toolCallId, + }: Props) => { + const columns = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); + const dottedLine = DOTTED_LINE.repeat(Math.max(columns - 2, 10)); + + // ExitPlanMode: Use PlanPreview component + if (toolName === "ExitPlanMode" && planContent) { + return ( + + + + ); + } + + // Bash/Shell: Use BashPreview component + if ( + toolName === "Bash" || + toolName === "shell" || + toolName === "Shell" || + toolName === "shell_command" + ) { + try { + const args = JSON.parse(toolArgs); + const command = + typeof args.command === "string" + ? args.command + : Array.isArray(args.command) + ? args.command.join(" ") + : ""; + const description = args.description || args.justification || ""; + + return ( + + + + ); + } catch { + // Fall through to generic + } + } + + // File Edit tools: Render diff preview + if ( + toolName === "Edit" || + toolName === "MultiEdit" || + toolName === "Write" || + toolName === "str_replace_editor" || + toolName === "str_replace_based_edit_tool" || + toolName === "apply_patch" || + toolName === "ApplyPatch" + ) { + const headerText = getFileEditHeader(toolName, toolArgs); + + try { + const args = JSON.parse(toolArgs); + + // Handle patch tools (can have multiple files) + if ( + args.input && + (toolName === "apply_patch" || toolName === "ApplyPatch") + ) { + const operations = parsePatchOperations(args.input); + + return ( + + {solidLine} + + {headerText} + + {dottedLine} + + + {operations.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; + + const diffKey = toolCallId + ? `${toolCallId}:${op.path}` + : undefined; + const opDiff = + diffKey && allDiffs ? allDiffs.get(diffKey) : undefined; + + if (op.kind === "add") { + return ( + + {idx > 0 && } + {displayPath} + + + ); + } + if (op.kind === "update") { + return ( + + {idx > 0 && } + {displayPath} + + + ); + } + if (op.kind === "delete") { + return ( + + {idx > 0 && } + + Delete {displayPath} + + + ); + } + return null; + })} + + + {dottedLine} + + ); + } + + // Single file edit/write + const filePath = args.file_path || ""; + + return ( + + {solidLine} + + {headerText} + + {dottedLine} + + {/* Write */} + {args.content !== undefined && ( + + )} + + {/* Multi-edit */} + {args.edits && Array.isArray(args.edits) && ( + ({ + old_string: e.old_string || "", + new_string: e.new_string || "", + }), + )} + /> + )} + + {/* Single edit */} + {args.old_string !== undefined && !args.edits && ( + + )} + + {dottedLine} + + ); + } catch { + // Fall through to generic + } + } + + // Generic fallback + return ( + + {solidLine} + + {toolName} requires approval + + {dottedLine} + + ); + }, +); + +ApprovalPreview.displayName = "ApprovalPreview"; diff --git a/src/cli/components/StaticPlanApproval.tsx b/src/cli/components/StaticPlanApproval.tsx new file mode 100644 index 0000000..9fd18b6 --- /dev/null +++ b/src/cli/components/StaticPlanApproval.tsx @@ -0,0 +1,199 @@ +import { Box, Text, useInput } from "ink"; +import { memo, useState } from "react"; +import { useTerminalWidth } from "../hooks/useTerminalWidth"; +import { colors } from "./colors"; + +type Props = { + onApprove: () => void; + onApproveAndAcceptEdits: () => void; + onKeepPlanning: (reason: string) => void; + isFocused?: boolean; +}; + +/** + * StaticPlanApproval - Options-only plan approval component + * + * This component renders ONLY the approval options (no plan preview). + * The plan preview is committed separately to the Static area via the + * eager commit pattern, which keeps this component small (~8 lines) + * and flicker-free. + * + * The plan prop was removed because the plan is rendered in the Static + * area by ApprovalPreview, not here. + */ +export const StaticPlanApproval = memo( + ({ + 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 { + 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 }, + ); + + // 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 ( + + {/* 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} + + + ); + }, +); + +StaticPlanApproval.displayName = "StaticPlanApproval"; diff --git a/src/cli/components/ToolCallMessageRich.tsx b/src/cli/components/ToolCallMessageRich.tsx index 59add89..ee2a0dc 100644 --- a/src/cli/components/ToolCallMessageRich.tsx +++ b/src/cli/components/ToolCallMessageRich.tsx @@ -1,4 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; +// existsSync, readFileSync removed - no longer needed since plan content +// is shown via StaticPlanApproval during approval, not in tool result import { Box, Text } from "ink"; import { memo } from "react"; import { INTERRUPTED_BY_USER } from "../../constants"; @@ -324,40 +325,25 @@ export const ToolCallMessage = memo( // Fall through to regular handling if parsing fails } - // Check if this is ExitPlanMode - show plan content (faded) instead of simple message + // Check if this is ExitPlanMode - just show path, not plan content + // The plan content was already shown during approval via StaticPlanApproval + // (rendered via Ink's and is visible in terminal scrollback) 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) { + if (planFilePath) { return ( - - {/* Plan file path */} - - - {prefix} - - - Plan saved to: {planFilePath} - + + + {prefix} - {/* Plan content (faded) - indent to align with content column */} - - + + Plan saved to: {planFilePath} ); } - // Fall through to default if no plan content + // Fall through to default if no plan path } // Check if this is a file edit tool - show diff instead of success message diff --git a/src/cli/components/previews/BashPreview.tsx b/src/cli/components/previews/BashPreview.tsx new file mode 100644 index 0000000..a8cf6ef --- /dev/null +++ b/src/cli/components/previews/BashPreview.tsx @@ -0,0 +1,45 @@ +import { Box, Text } from "ink"; +import { memo } from "react"; +import { useTerminalWidth } from "../../hooks/useTerminalWidth"; +import { colors } from "../colors"; + +const SOLID_LINE = "─"; + +type Props = { + command: string; + description?: string; +}; + +/** + * BashPreview - Renders the bash command preview (no interactive options) + * + * Used by: + * - InlineBashApproval for memoized content + * - Static area for eagerly-committed command previews + */ +export const BashPreview = memo(({ command, description }: Props) => { + const columns = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); + + return ( + <> + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Run this command? + + + + + {/* Command preview */} + + {command} + {description && {description}} + + + ); +}); + +BashPreview.displayName = "BashPreview"; diff --git a/src/cli/components/previews/PlanPreview.tsx b/src/cli/components/previews/PlanPreview.tsx new file mode 100644 index 0000000..613ed2f --- /dev/null +++ b/src/cli/components/previews/PlanPreview.tsx @@ -0,0 +1,48 @@ +import { Text } from "ink"; +import { memo } from "react"; +import { useTerminalWidth } from "../../hooks/useTerminalWidth"; +import { colors } from "../colors"; +import { MarkdownDisplay } from "../MarkdownDisplay"; + +const SOLID_LINE = "─"; +const DOTTED_LINE = "╌"; + +type Props = { + plan: string; +}; + +/** + * PlanPreview - Renders the plan content preview (no interactive options) + * + * Used by: + * - InlinePlanApproval/StaticPlanApproval for memoized content + * - Static area for eagerly-committed plan previews + */ +export const PlanPreview = memo(({ plan }: Props) => { + const columns = useTerminalWidth(); + const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10)); + const dottedLine = DOTTED_LINE.repeat(Math.max(columns - 2, 10)); + + return ( + <> + {/* Top solid line */} + {solidLine} + + {/* Header */} + + Ready to code? Here is your plan: + + + {/* Dotted separator before plan content */} + {dottedLine} + + {/* Plan content */} + + + {/* Dotted separator after plan content */} + {dottedLine} + + ); +}); + +PlanPreview.displayName = "PlanPreview";