fix(tui): prevent reflow glitches (footer + resize) (#1098)

This commit is contained in:
paulbettner
2026-02-25 14:08:33 -05:00
committed by GitHub
parent 33509d9358
commit 0023b9c7e5
8 changed files with 550 additions and 134 deletions

View File

@@ -1733,16 +1733,124 @@ export default function App({
null,
);
const prevColumnsRef = useRef(rawColumns);
const lastResizeColumnsRef = useRef(rawColumns);
const lastResizeRowsRef = useRef(terminalRows);
const lastClearedColumnsRef = useRef(rawColumns);
const pendingResizeRef = useRef(false);
const pendingResizeColumnsRef = useRef<number | null>(null);
const [staticRenderEpoch, setStaticRenderEpoch] = useState(0);
const resizeClearTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastClearAtRef = useRef(0);
const resizeGestureTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null,
);
const didImmediateShrinkClearRef = useRef(false);
const isInitialResizeRef = useRef(true);
const columns = stableColumns;
// Keep bottom chrome from ever exceeding the *actual* terminal width.
// When widening, we prefer the old behavior (wait until settle), so we use
// stableColumns. When shrinking, we must clamp to rawColumns to avoid Ink
// wrapping the footer/input chrome and "printing" divider rows into the
// transcript while dragging.
const chromeColumns = Math.min(rawColumns, stableColumns);
const debugFlicker = process.env.LETTA_DEBUG_FLICKER === "1";
// Terminal resize + Ink:
// When the terminal shrinks, the *previous* frame reflows (wraps to more
// lines) instantly at the emulator level. Ink's incremental redraw then tries
// to clear based on the old line count and can leave stale rows behind.
//
// Fix: on shrink events, clear the screen *synchronously* in the resize event
// handler (before React/Ink flushes the next frame) and remount Static output.
useEffect(() => {
if (
typeof process === "undefined" ||
!process.stdout ||
!("on" in process.stdout) ||
!process.stdout.isTTY
) {
return;
}
const stdout = process.stdout;
const onResize = () => {
const nextColumns = stdout.columns ?? lastResizeColumnsRef.current;
const nextRows = stdout.rows ?? lastResizeRowsRef.current;
const prevColumns = lastResizeColumnsRef.current;
const prevRows = lastResizeRowsRef.current;
lastResizeColumnsRef.current = nextColumns;
lastResizeRowsRef.current = nextRows;
// Skip initial mount.
if (isInitialResizeRef.current) {
return;
}
const shrunk = nextColumns < prevColumns || nextRows < prevRows;
if (!shrunk) {
// Reset shrink-clear guard once the gesture ends.
if (resizeGestureTimeoutRef.current) {
clearTimeout(resizeGestureTimeoutRef.current);
}
resizeGestureTimeoutRef.current = setTimeout(() => {
resizeGestureTimeoutRef.current = null;
didImmediateShrinkClearRef.current = false;
}, RESIZE_SETTLE_MS);
return;
}
// During a shrink gesture, do an immediate clear only once.
// Clearing on every resize event causes extreme flicker.
if (didImmediateShrinkClearRef.current) {
if (resizeGestureTimeoutRef.current) {
clearTimeout(resizeGestureTimeoutRef.current);
}
resizeGestureTimeoutRef.current = setTimeout(() => {
resizeGestureTimeoutRef.current = null;
didImmediateShrinkClearRef.current = false;
}, RESIZE_SETTLE_MS);
return;
}
if (debugFlicker) {
// eslint-disable-next-line no-console
console.error(
`[debug:flicker:resize-immediate-clear] next=${nextColumns}x${nextRows} prev=${prevColumns}x${prevRows} streaming=${streamingRef.current}`,
);
}
// Cancel any debounced clear; we're taking the immediate-clear path.
if (resizeClearTimeout.current) {
clearTimeout(resizeClearTimeout.current);
resizeClearTimeout.current = null;
}
stdout.write(CLEAR_SCREEN_AND_HOME);
setStaticRenderEpoch((epoch) => epoch + 1);
lastClearedColumnsRef.current = nextColumns;
lastClearAtRef.current = Date.now();
didImmediateShrinkClearRef.current = true;
if (resizeGestureTimeoutRef.current) {
clearTimeout(resizeGestureTimeoutRef.current);
}
resizeGestureTimeoutRef.current = setTimeout(() => {
resizeGestureTimeoutRef.current = null;
didImmediateShrinkClearRef.current = false;
}, RESIZE_SETTLE_MS);
};
stdout.on("resize", onResize);
return () => {
stdout.off("resize", onResize);
if (resizeGestureTimeoutRef.current) {
clearTimeout(resizeGestureTimeoutRef.current);
resizeGestureTimeoutRef.current = null;
}
};
}, [debugFlicker, streamingRef]);
useEffect(() => {
if (rawColumns === stableColumns) {
if (stableColumnsTimeoutRef.current) {
@@ -1902,6 +2010,20 @@ export default function App({
prevColumnsRef.current = rawColumns;
}, [rawColumns, streaming, scheduleResizeClear]);
// Reflow Static output for 1-col width changes too.
// rawColumns resize handling intentionally ignores 1-col "jitter" to reduce
// flicker, but that also means widening by small increments won't remount
// Static and existing output won't reflow.
//
// stableColumns only advances once the width has settled, so it's safe to use
// for a low-frequency remount trigger.
useEffect(() => {
if (isInitialResizeRef.current) return;
if (streaming) return;
if (stableColumns === lastClearedColumnsRef.current) return;
scheduleResizeClear(stableColumns);
}, [stableColumns, streaming, scheduleResizeClear]);
useEffect(() => {
if (streaming) {
if (resizeClearTimeout.current) {
@@ -2135,6 +2257,9 @@ export default function App({
const statusLine = useConfigurableStatusLine({
modelId: llmConfigRef.current?.model ?? null,
modelDisplayName: currentModelDisplay,
reasoningEffort: currentReasoningEffort,
systemPromptId: currentSystemPromptId,
toolset: currentToolset,
currentDirectory: process.cwd(),
projectDirectory,
sessionId: conversationId,
@@ -2147,7 +2272,7 @@ export default function App({
usedContextTokens: contextTrackerRef.current.lastContextTokens,
permissionMode: uiPermissionMode,
networkPhase,
terminalWidth: columns,
terminalWidth: chromeColumns,
triggerVersion: statusLineTriggerVersion,
});
@@ -2160,7 +2285,7 @@ export default function App({
previousStreamingForStatusLineRef.current = streaming;
}, [streaming, triggerStatusLineRefresh]);
const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}`;
const statusLineRefreshIdentity = `${conversationId}|${currentModelDisplay ?? ""}|${currentModelProvider ?? ""}|${agentName ?? ""}|${columns}|${contextWindowSize ?? ""}|${currentReasoningEffort ?? ""}|${currentSystemPromptId ?? ""}|${currentToolset ?? ""}`;
// Trigger status line when key session identity/display state changes.
useEffect(() => {
@@ -6650,6 +6775,9 @@ export default function App({
buildStatusLinePayload({
modelId: llmConfigRef.current?.model ?? null,
modelDisplayName: currentModelDisplay,
reasoningEffort: currentReasoningEffort,
systemPromptId: currentSystemPromptId,
toolset: currentToolset,
currentDirectory: wd,
projectDirectory,
sessionId: conversationIdRef.current,
@@ -6663,7 +6791,7 @@ export default function App({
contextTrackerRef.current.lastContextTokens,
permissionMode: uiPermissionMode,
networkPhase,
terminalWidth: columns,
terminalWidth: chromeColumns,
}),
{ timeout: config.timeout, workingDirectory: wd },
);
@@ -12030,6 +12158,8 @@ Plan file path: ${planFilePath}`;
currentModel={currentModelDisplay}
currentModelProvider={currentModelProvider}
currentReasoningEffort={currentReasoningEffort}
currentSystemPromptId={currentSystemPromptId}
currentToolset={currentToolset}
messageQueue={messageQueue}
onEnterQueueEditMode={handleEnterQueueEditMode}
onEscapeCancel={
@@ -12044,7 +12174,7 @@ Plan file path: ${planFilePath}`;
restoredInput={restoredInput}
onRestoredInputConsumed={() => setRestoredInput(null)}
networkPhase={networkPhase}
terminalWidth={columns}
terminalWidth={chromeColumns}
shouldAnimate={shouldAnimate}
statusLineText={statusLine.text || undefined}
statusLineRight={statusLine.rightText || undefined}