fix: patch approval rendering to reduce churn (#453)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
@@ -121,6 +121,33 @@ export const InlineBashApproval = memo(
|
||||
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
|
||||
// Memoize the static command content so it doesn't re-render on keystroke
|
||||
// This prevents flicker when typing feedback in the custom input field
|
||||
const memoizedCommandContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{/* 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>
|
||||
</>
|
||||
),
|
||||
[bashInfo.command, bashInfo.description, solidLine],
|
||||
);
|
||||
|
||||
// Hint text based on state
|
||||
const hintText = isOnCustomOption
|
||||
? customReason
|
||||
@@ -130,21 +157,8 @@ export const InlineBashApproval = memo(
|
||||
|
||||
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>
|
||||
{/* Static command content - memoized to prevent re-render on keystroke */}
|
||||
{memoizedCommandContent}
|
||||
|
||||
{/* Options */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -282,6 +282,143 @@ export const InlineFileEditApproval = memo(
|
||||
const headerText = getHeaderText(fileEdit);
|
||||
const diffKind = getDiffKind(fileEdit.toolName);
|
||||
|
||||
// Memoize the static diff content so it doesn't re-render on keystroke
|
||||
// This prevents flicker when typing feedback in the custom input field
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: JSON.stringify(fileEdit.edits) provides stable value comparison for arrays
|
||||
const memoizedDiffContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{/* 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>
|
||||
</>
|
||||
),
|
||||
// Use primitive values to avoid memo invalidation when parent re-renders.
|
||||
// Arrays/objects are compared by reference, so we stringify edits for stable comparison.
|
||||
[
|
||||
fileEdit.filePath,
|
||||
fileEdit.content,
|
||||
fileEdit.oldString,
|
||||
fileEdit.newString,
|
||||
fileEdit.replaceAll,
|
||||
fileEdit.patchInput,
|
||||
fileEdit.toolCallId,
|
||||
JSON.stringify(fileEdit.edits),
|
||||
precomputedDiff,
|
||||
allDiffs,
|
||||
solidLine,
|
||||
dottedLine,
|
||||
headerText,
|
||||
diffKind,
|
||||
],
|
||||
);
|
||||
|
||||
// Hint text based on state
|
||||
const hintText = isOnCustomOption
|
||||
? customReason
|
||||
@@ -291,109 +428,8 @@ export const InlineFileEditApproval = memo(
|
||||
|
||||
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>
|
||||
{/* Static diff content - memoized to prevent re-render on keystroke */}
|
||||
{memoizedDiffContent}
|
||||
|
||||
{/* Options */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
@@ -131,6 +131,30 @@ export const InlineGenericApproval = memo(
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
const formattedArgs = formatArgs(toolArgs);
|
||||
|
||||
// Memoize the static tool content so it doesn't re-render on keystroke
|
||||
// This prevents flicker when typing feedback in the custom input field
|
||||
const memoizedToolContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{/* 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>
|
||||
</>
|
||||
),
|
||||
[toolName, formattedArgs, solidLine],
|
||||
);
|
||||
|
||||
// Hint text based on state
|
||||
const hintText = isOnCustomOption
|
||||
? customReason
|
||||
@@ -140,20 +164,8 @@ export const InlineGenericApproval = memo(
|
||||
|
||||
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>
|
||||
{/* Static tool content - memoized to prevent re-render on keystroke */}
|
||||
{memoizedToolContent}
|
||||
|
||||
{/* Options */}
|
||||
<Box marginTop={1} flexDirection="column">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
import { MarkdownDisplay } from "./MarkdownDisplay";
|
||||
@@ -109,6 +109,32 @@ export const InlinePlanApproval = memo(
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
const dottedLine = DOTTED_LINE.repeat(Math.max(columns - 2, 10));
|
||||
|
||||
// Memoize the static plan content so it doesn't re-render on keystroke
|
||||
// This prevents flicker when typing feedback in the custom input field
|
||||
const memoizedPlanContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{/* 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>
|
||||
</>
|
||||
),
|
||||
[plan, solidLine, dottedLine],
|
||||
);
|
||||
|
||||
// Hint text based on state
|
||||
const hintText = isOnCustomOption
|
||||
? customReason
|
||||
@@ -118,22 +144,8 @@ export const InlinePlanApproval = memo(
|
||||
|
||||
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>
|
||||
{/* Static plan content - memoized to prevent re-render on keystroke */}
|
||||
{memoizedPlanContent}
|
||||
|
||||
{/* Question */}
|
||||
<Box marginTop={1}>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { Fragment, memo, useState } from "react";
|
||||
import { Fragment, memo, useMemo, useState } from "react";
|
||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||
import { colors } from "./colors";
|
||||
|
||||
@@ -251,6 +251,43 @@ export const InlineQuestionApproval = memo(
|
||||
// Generate horizontal line
|
||||
const solidLine = SOLID_LINE.repeat(Math.max(columns - 2, 10));
|
||||
|
||||
// Memoize the static header content so it doesn't re-render on keystroke
|
||||
// This prevents flicker when typing in the custom input field
|
||||
const memoizedHeaderContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[
|
||||
currentQuestion?.header,
|
||||
currentQuestion?.question,
|
||||
currentQuestionIndex,
|
||||
questions.length,
|
||||
solidLine,
|
||||
],
|
||||
);
|
||||
|
||||
// Hint text based on state - keep consistent to avoid jarring changes
|
||||
const hintText = currentQuestion?.multiSelect
|
||||
? "Enter to toggle · Arrow to navigate · Esc to cancel"
|
||||
@@ -260,27 +297,8 @@ export const InlineQuestionApproval = memo(
|
||||
|
||||
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>
|
||||
)}
|
||||
{/* Static header content - memoized to prevent re-render on keystroke */}
|
||||
{memoizedHeaderContent}
|
||||
|
||||
{/* Options - Format: ❯ N. [ ] Label (selector, number, checkbox, label) */}
|
||||
<Box flexDirection="column">
|
||||
|
||||
Reference in New Issue
Block a user