refactor: flicker render stability (#877)

This commit is contained in:
Charles Packer
2026-02-09 14:49:29 -08:00
committed by GitHub
parent 3bd815e6b1
commit 101fc6f874
2 changed files with 146 additions and 79 deletions

View File

@@ -244,6 +244,8 @@ import { useTerminalRows, useTerminalWidth } from "./hooks/useTerminalWidth";
// Used only for terminal resize, not for dialog dismissal (see PR for details)
const CLEAR_SCREEN_AND_HOME = "\u001B[2J\u001B[H";
const MIN_RESIZE_DELTA = 2;
const RESIZE_SETTLE_MS = 250;
const MIN_CLEAR_INTERVAL_MS = 750;
const TOOL_CALL_COMMIT_DEFER_MS = 50;
// Eager approval checking is now CONDITIONAL (LET-7101):
@@ -1546,7 +1548,59 @@ export default function App({
const pendingResizeColumnsRef = useRef<number | null>(null);
const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
const resizeClearTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastClearAtRef = useRef(0);
const isInitialResizeRef = useRef(true);
const clearAndRemount = useCallback((targetColumns: number) => {
if (
typeof process !== "undefined" &&
process.stdout &&
"write" in process.stdout &&
process.stdout.isTTY
) {
process.stdout.write(CLEAR_SCREEN_AND_HOME);
}
setStaticRenderEpoch((epoch) => epoch + 1);
lastClearedColumnsRef.current = targetColumns;
lastClearAtRef.current = Date.now();
}, []);
const scheduleResizeClear = useCallback(
(targetColumns: number) => {
if (targetColumns === lastClearedColumnsRef.current) {
return;
}
if (resizeClearTimeout.current) {
clearTimeout(resizeClearTimeout.current);
resizeClearTimeout.current = null;
}
const elapsedSinceClear = Date.now() - lastClearAtRef.current;
const rateLimitDelay =
elapsedSinceClear >= MIN_CLEAR_INTERVAL_MS
? 0
: MIN_CLEAR_INTERVAL_MS - elapsedSinceClear;
const delay = Math.max(RESIZE_SETTLE_MS, rateLimitDelay);
resizeClearTimeout.current = setTimeout(() => {
resizeClearTimeout.current = null;
// If resize changed again while waiting, let the latest schedule win.
if (prevColumnsRef.current !== targetColumns) {
return;
}
if (targetColumns === lastClearedColumnsRef.current) {
return;
}
clearAndRemount(targetColumns);
}, delay);
},
[clearAndRemount],
);
useEffect(() => {
const prev = prevColumnsRef.current;
if (columns === prev) return;
@@ -1567,12 +1621,12 @@ export default function App({
const delta = Math.abs(columns - prev);
const isMinorJitter = delta > 0 && delta < MIN_RESIZE_DELTA;
if (streaming) {
if (isMinorJitter) {
prevColumnsRef.current = columns;
return;
}
if (isMinorJitter) {
prevColumnsRef.current = columns;
return;
}
if (streaming) {
// Defer clear/remount until streaming ends to avoid Ghostty flicker.
pendingResizeRef.current = true;
pendingResizeColumnsRef.current = columns;
@@ -1588,32 +1642,11 @@ export default function App({
}
// Debounce to avoid flicker from rapid resize events (e.g., drag resize, Ghostty focus)
// Clear and remount must happen together - otherwise Static re-renders on top of existing content
const scheduledColumns = columns;
resizeClearTimeout.current = setTimeout(() => {
resizeClearTimeout.current = null;
if (
typeof process !== "undefined" &&
process.stdout &&
"write" in process.stdout &&
process.stdout.isTTY
) {
process.stdout.write(CLEAR_SCREEN_AND_HOME);
}
setStaticRenderEpoch((epoch) => epoch + 1);
lastClearedColumnsRef.current = scheduledColumns;
}, 150);
// and keep clear frequency bounded to prevent flash storms.
scheduleResizeClear(columns);
prevColumnsRef.current = columns;
// Cleanup on unmount
return () => {
if (resizeClearTimeout.current) {
clearTimeout(resizeClearTimeout.current);
resizeClearTimeout.current = null;
}
};
}, [columns, streaming]);
}, [columns, streaming, scheduleResizeClear]);
useEffect(() => {
if (streaming) {
@@ -1635,17 +1668,17 @@ export default function App({
if (pendingColumns === null) return;
if (pendingColumns === lastClearedColumnsRef.current) return;
if (
typeof process !== "undefined" &&
process.stdout &&
"write" in process.stdout &&
process.stdout.isTTY
) {
process.stdout.write(CLEAR_SCREEN_AND_HOME);
}
setStaticRenderEpoch((epoch) => epoch + 1);
lastClearedColumnsRef.current = pendingColumns;
}, [columns, streaming]);
scheduleResizeClear(pendingColumns);
}, [columns, streaming, scheduleResizeClear]);
useEffect(() => {
return () => {
if (resizeClearTimeout.current) {
clearTimeout(resizeClearTimeout.current);
resizeClearTimeout.current = null;
}
};
}, []);
const deferredToolCallCommitsRef = useRef<Map<string, number>>(new Map());
const [deferredCommitAt, setDeferredCommitAt] = useState<number | null>(null);
@@ -10437,6 +10470,7 @@ Plan file path: ${planFilePath}`;
restoredInput={restoredInput}
onRestoredInputConsumed={() => setRestoredInput(null)}
networkPhase={networkPhase}
terminalWidth={columns}
/>
</Box>

View File

@@ -25,7 +25,6 @@ import { ralphMode } from "../../ralph/mode";
import { settingsManager } from "../../settings-manager";
import { charsToTokens, formatCompact } from "../helpers/format";
import type { QueuedMessage } from "../helpers/messageQueueBridge";
import { useTerminalWidth } from "../hooks/useTerminalWidth";
import { colors } from "./colors";
import { InputAssist } from "./InputAssist";
import { PasteAwareTextInput } from "./PasteAwareTextInput";
@@ -39,6 +38,13 @@ const Spinner = SpinnerLib as ComponentType<{ type?: string }>;
// Window for double-escape to clear input
const ESC_CLEAR_WINDOW_MS = 2500;
function truncateEnd(value: string, maxChars: number): string {
if (maxChars <= 0) return "";
if (value.length <= maxChars) return value;
if (maxChars <= 3) return value.slice(0, maxChars);
return `${value.slice(0, maxChars - 3)}...`;
}
/**
* Represents a visual line segment in the text.
* A visual line ends at either a newline character or when it reaches lineWidth.
@@ -117,6 +123,7 @@ const InputFooter = memo(function InputFooter({
isByokProvider,
isAutocompleteActive,
hideFooter,
terminalWidth,
}: {
ctrlCPressed: boolean;
escapePressed: boolean;
@@ -130,47 +137,70 @@ const InputFooter = memo(function InputFooter({
isByokProvider: boolean;
isAutocompleteActive: boolean;
hideFooter: boolean;
terminalWidth: number;
}) {
// Hide footer when autocomplete is showing
if (hideFooter || isAutocompleteActive) {
return null;
}
const hideFooterContent = hideFooter || isAutocompleteActive;
const rightColumnWidth = Math.max(
28,
Math.min(72, Math.floor(terminalWidth * 0.45)),
);
const maxAgentChars = Math.max(10, Math.floor(rightColumnWidth * 0.45));
const displayAgentName = truncateEnd(agentName || "Unnamed", maxAgentChars);
const byokFlag = isByokProvider ? " ▲" : "";
const reservedChars = displayAgentName.length + byokFlag.length + 4;
const maxModelChars = Math.max(8, rightColumnWidth - reservedChars);
const displayModel = truncateEnd(currentModel ?? "unknown", maxModelChars);
return (
<Box justifyContent="space-between" marginBottom={1}>
{ctrlCPressed ? (
<Text dimColor>Press CTRL-C again to exit</Text>
) : escapePressed ? (
<Text dimColor>Press Esc again to clear</Text>
) : isBashMode ? (
<Text>
<Text color={colors.bash.prompt}> bash mode</Text>
<Text color={colors.bash.prompt} dimColor>
{" "}
(backspace to exit)
<Box flexDirection="row" marginBottom={1}>
<Box flexGrow={1} paddingRight={1}>
{hideFooterContent ? (
<Text> </Text>
) : ctrlCPressed ? (
<Text dimColor>Press CTRL-C again to exit</Text>
) : escapePressed ? (
<Text dimColor>Press Esc again to clear</Text>
) : isBashMode ? (
<Text>
<Text color={colors.bash.prompt}> bash mode</Text>
<Text color={colors.bash.prompt} dimColor>
{" "}
(backspace to exit)
</Text>
</Text>
</Text>
) : modeName && modeColor ? (
<Text>
<Text color={modeColor}> {modeName}</Text>
<Text color={modeColor} dimColor>
{" "}
(shift+tab to {showExitHint ? "exit" : "cycle"})
) : modeName && modeColor ? (
<Text>
<Text color={modeColor}> {modeName}</Text>
<Text color={modeColor} dimColor>
{" "}
(shift+tab to {showExitHint ? "exit" : "cycle"})
</Text>
</Text>
</Text>
) : (
<Text dimColor>Press / for commands</Text>
)}
<Text>
<Text color={colors.footer.agentName}>{agentName || "Unnamed"}</Text>
<Text dimColor>
{` [${currentModel ?? "unknown"}`}
{isByokProvider && (
<Text color={isOpenAICodexProvider ? "#74AA9C" : "yellow"}> </Text>
)}
{"]"}
</Text>
</Text>
) : (
<Text dimColor>Press / for commands</Text>
)}
</Box>
<Box width={rightColumnWidth} flexShrink={0} justifyContent="flex-end">
{hideFooterContent ? (
<Text> </Text>
) : (
<Text wrap="truncate-end">
<Text color={colors.footer.agentName}>{displayAgentName}</Text>
<Text
dimColor={!isByokProvider}
color={
isByokProvider
? isOpenAICodexProvider
? "#74AA9C"
: "yellow"
: undefined
}
>
{` [${displayModel}${byokFlag}]`}
</Text>
</Text>
)}
</Box>
</Box>
);
});
@@ -216,6 +246,7 @@ export function Input({
restoredInput,
onRestoredInputConsumed,
networkPhase = null,
terminalWidth,
}: {
visible?: boolean;
streaming: boolean;
@@ -249,6 +280,7 @@ export function Input({
restoredInput?: string | null;
onRestoredInputConsumed?: () => void;
networkPhase?: "upload" | "download" | "error" | null;
terminalWidth: number;
}) {
const [value, setValue] = useState("");
const [escapePressed, setEscapePressed] = useState(false);
@@ -263,8 +295,8 @@ export function Input({
const [cursorPos, setCursorPos] = useState<number | undefined>(undefined);
const [currentCursorPosition, setCurrentCursorPosition] = useState(0);
// Terminal width (reactive to window resizing)
const columns = useTerminalWidth();
// Terminal width is sourced from App.tsx to avoid duplicate resize subscriptions.
const columns = terminalWidth;
const contentWidth = Math.max(0, columns - 2);
const interactionEnabled = visible && inputEnabled;
@@ -1023,6 +1055,7 @@ export function Input({
}
isAutocompleteActive={isAutocompleteActive}
hideFooter={hideFooter}
terminalWidth={columns}
/>
</Box>
) : reserveInputSpace ? (