diff --git a/src/cli/components/InlineBashApproval.tsx b/src/cli/components/InlineBashApproval.tsx index 9a0d153..289ff92 100644 --- a/src/cli/components/InlineBashApproval.tsx +++ b/src/cli/components/InlineBashApproval.tsx @@ -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 */} + {solidLine} + + {/* Header */} + + Run this command? + + + + + {/* Command preview */} + + {bashInfo.command} + {bashInfo.description && ( + {bashInfo.description} + )} + + + ), + [bashInfo.command, bashInfo.description, solidLine], + ); + // Hint text based on state const hintText = isOnCustomOption ? customReason @@ -130,21 +157,8 @@ export const InlineBashApproval = memo( return ( - {/* Top solid line */} - {solidLine} - - {/* Header */} - - Run this command? - - - - - {/* Command preview */} - - {bashInfo.command} - {bashInfo.description && {bashInfo.description}} - + {/* Static command content - memoized to prevent re-render on keystroke */} + {memoizedCommandContent} {/* Options */} diff --git a/src/cli/components/InlineFileEditApproval.tsx b/src/cli/components/InlineFileEditApproval.tsx index 5776606..f6aec8f 100644 --- a/src/cli/components/InlineFileEditApproval.tsx +++ b/src/cli/components/InlineFileEditApproval.tsx @@ -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 */} + {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} + + ), + // 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 ( - {/* 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} + {/* Static diff content - memoized to prevent re-render on keystroke */} + {memoizedDiffContent} {/* Options */} diff --git a/src/cli/components/InlineGenericApproval.tsx b/src/cli/components/InlineGenericApproval.tsx index b2ab050..67d43b9 100644 --- a/src/cli/components/InlineGenericApproval.tsx +++ b/src/cli/components/InlineGenericApproval.tsx @@ -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 */} + {solidLine} + + {/* Header */} + + Run {toolName}? + + + + + {/* Arguments preview */} + + {formattedArgs} + + + ), + [toolName, formattedArgs, solidLine], + ); + // Hint text based on state const hintText = isOnCustomOption ? customReason @@ -140,20 +164,8 @@ export const InlineGenericApproval = memo( return ( - {/* Top solid line */} - {solidLine} - - {/* Header */} - - Run {toolName}? - - - - - {/* Arguments preview */} - - {formattedArgs} - + {/* Static tool content - memoized to prevent re-render on keystroke */} + {memoizedToolContent} {/* Options */} diff --git a/src/cli/components/InlinePlanApproval.tsx b/src/cli/components/InlinePlanApproval.tsx index d237c24..c2c9f85 100644 --- a/src/cli/components/InlinePlanApproval.tsx +++ b/src/cli/components/InlinePlanApproval.tsx @@ -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 */} + {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} + + ), + [plan, solidLine, dottedLine], + ); + // Hint text based on state const hintText = isOnCustomOption ? customReason @@ -118,22 +144,8 @@ export const InlinePlanApproval = memo( 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} + {/* Static plan content - memoized to prevent re-render on keystroke */} + {memoizedPlanContent} {/* Question */} diff --git a/src/cli/components/InlineQuestionApproval.tsx b/src/cli/components/InlineQuestionApproval.tsx index 501b55a..06f59a4 100644 --- a/src/cli/components/InlineQuestionApproval.tsx +++ b/src/cli/components/InlineQuestionApproval.tsx @@ -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 */} + {solidLine} + + {/* Header label */} + {currentQuestion?.header} + + + + {/* Question */} + {currentQuestion?.question} + + + + {/* Progress indicator for multiple questions */} + {questions.length > 1 && ( + + + Question {currentQuestionIndex + 1} of {questions.length} + + + )} + + ), + [ + 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 ( - {/* 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} - - - )} + {/* Static header content - memoized to prevent re-render on keystroke */} + {memoizedHeaderContent} {/* Options - Format: ❯ N. [ ] Label (selector, number, checkbox, label) */}