refactor: flicker render stability (#877)
This commit is contained in:
114
src/cli/App.tsx
114
src/cli/App.tsx
@@ -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>
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in New Issue
Block a user