From 7584f4291ffeac935d6ff2e8bb36c3fd97384b75 Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Sun, 1 Feb 2026 17:05:37 -0800 Subject: [PATCH] fix(cli): stabilize rendering to eliminate line flicker (#774) Co-authored-by: Letta --- src/cli/App.tsx | 67 ++++++++++++++------------------ src/cli/components/InputRich.tsx | 61 +++++++++++++++-------------- 2 files changed, 61 insertions(+), 67 deletions(-) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index fed2e36..1a35207 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -1182,11 +1182,9 @@ export default function App({ // Trajectory token/time bases (accumulated across runs) const [trajectoryTokenBase, setTrajectoryTokenBase] = useState(0); const [trajectoryElapsedBaseMs, setTrajectoryElapsedBaseMs] = useState(0); - const [streamElapsedMs, setStreamElapsedMs] = useState(0); const trajectoryRunTokenStartRef = useRef(0); const trajectoryTokenDisplayRef = useRef(0); const trajectorySegmentStartRef = useRef(null); - const streamElapsedMsRef = useRef(0); // Current thinking message (rotates each turn) const [thinkingMessage, setThinkingMessage] = useState( @@ -1228,11 +1226,9 @@ export default function App({ sessionStatsRef.current.resetTrajectory(); setTrajectoryTokenBase(0); setTrajectoryElapsedBaseMs(0); - setStreamElapsedMs(0); trajectoryRunTokenStartRef.current = 0; trajectoryTokenDisplayRef.current = 0; trajectorySegmentStartRef.current = null; - streamElapsedMsRef.current = 0; }, []); // Wire up session stats to telemetry for safety net handlers @@ -1262,26 +1258,6 @@ export default function App({ syncTrajectoryElapsedBase, ]); - useEffect(() => { - if (!streaming) { - streamElapsedMsRef.current = 0; - setStreamElapsedMs(0); - return; - } - - openTrajectorySegment(); - const tick = () => { - const start = trajectorySegmentStartRef.current; - const next = start ? performance.now() - start : 0; - streamElapsedMsRef.current = next; - setStreamElapsedMs(next); - }; - - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [streaming, openTrajectorySegment]); - // Run SessionStart hooks when agent becomes available useEffect(() => { if (agentId && !sessionHooksRanRef.current) { @@ -2922,7 +2898,11 @@ export default function App({ const liveElapsedMs = (() => { const snapshot = sessionStatsRef.current.getTrajectorySnapshot(); const base = snapshot?.wallMs ?? 0; - return base + streamElapsedMsRef.current; + const segmentStart = trajectorySegmentStartRef.current; + if (segmentStart === null) { + return base; + } + return base + (performance.now() - segmentStart); })(); closeTrajectorySegment(); llmApiErrorRetriesRef.current = 0; // Reset retry counter on success @@ -3007,7 +2987,8 @@ export default function App({ trajectorySnapshot.wallMs, ); const shouldShowSummary = - trajectorySnapshot.stepCount > 3 || summaryWallMs > 10000; + (trajectorySnapshot.stepCount > 3 && summaryWallMs > 10000) || + summaryWallMs > 60000; if (shouldShowSummary) { const summaryId = uid("trajectory-summary"); buffersRef.current.byId.set(summaryId, { @@ -3114,9 +3095,6 @@ export default function App({ setAutoHandledResults([]); setAutoDeniedApprovals([]); lastSentInputRef.current = null; // Clear - message was received by server - setStreaming(false); - closeTrajectorySegment(); - syncTrajectoryElapsedBase(); // Use new approvals array, fallback to legacy approval for backward compat const approvalsToProcess = @@ -3131,6 +3109,8 @@ export default function App({ `Unexpected empty approvals with stop reason: ${stopReason}`, ); setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); return; } @@ -3174,6 +3154,8 @@ export default function App({ waitingForQueueCancelRef.current = false; queueSnapshotRef.current = []; setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); return; } @@ -3183,6 +3165,8 @@ export default function App({ abortControllerRef.current?.signal.aborted ) { setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); markIncompleteToolsAsCancelled( buffersRef.current, true, @@ -3440,6 +3424,8 @@ export default function App({ queueApprovalResults(allResults, autoAllowedMetadata); } setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); markIncompleteToolsAsCancelled( buffersRef.current, true, @@ -3499,6 +3485,8 @@ export default function App({ waitingForQueueCancelRef.current = false; queueSnapshotRef.current = []; setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); return; } @@ -3561,6 +3549,8 @@ export default function App({ waitingForQueueCancelRef.current = false; queueSnapshotRef.current = []; setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); return; } } finally { @@ -3579,6 +3569,8 @@ export default function App({ abortControllerRef.current?.signal.aborted ) { setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); markIncompleteToolsAsCancelled( buffersRef.current, true, @@ -3598,6 +3590,8 @@ export default function App({ setAutoHandledResults(autoAllowedResults); setAutoDeniedApprovals(autoDeniedResults); setStreaming(false); + closeTrajectorySegment(); + syncTrajectoryElapsedBase(); // Notify user that approval is needed sendDesktopNotification("Approval needed"); return; @@ -9783,6 +9777,9 @@ Plan file path: ${planFilePath}`; liveTrajectoryTokenBase + runTokenDelta, trajectoryTokenDisplayRef.current, ); + const inputVisible = !showExitStats; + const inputEnabled = + !showExitStats && pendingApprovals.length === 0 && !anySelectorOpen; useEffect(() => { trajectoryTokenDisplayRef.current = trajectoryTokenDisplay; @@ -10069,22 +10066,16 @@ Plan file path: ${planFilePath}`; {/* Input row - always mounted to preserve state */} Promise<{ submitted: boolean }>; onBashSubmit?: (command: string) => Promise; bashRunning?: boolean; onBashInterrupt?: () => void; + inputEnabled?: boolean; permissionMode?: PermissionMode; onPermissionModeChange?: (mode: PermissionMode) => void; onExit?: () => void; @@ -257,6 +257,7 @@ export function Input({ const [isAutocompleteActive, setIsAutocompleteActive] = useState(false); const [cursorPos, setCursorPos] = useState(undefined); const [currentCursorPosition, setCurrentCursorPosition] = useState(0); + const interactionEnabled = visible && inputEnabled; // Command history const [history, setHistory] = useState([]); @@ -328,6 +329,12 @@ export function Input({ const [elapsedMs, setElapsedMs] = useState(0); const streamStartRef = useRef(null); + useEffect(() => { + if (!interactionEnabled) { + setIsAutocompleteActive(false); + } + }, [interactionEnabled]); + // Terminal width (reactive to window resizing) const columns = useTerminalWidth(); const contentWidth = Math.max(0, columns - 2); @@ -342,7 +349,7 @@ export function Input({ // Handle profile confirmation: Enter confirms, any other key cancels // When onEscapeCancel is provided, TextInput is unfocused so we handle all keys here useInput((_input, key) => { - if (!visible) return; + if (!interactionEnabled) return; if (!onEscapeCancel) return; // Enter key confirms the action - trigger submit with empty input @@ -357,7 +364,7 @@ export function Input({ // Handle escape key for interrupt (when streaming) or double-escape-to-clear (when not) useInput((_input, key) => { - if (!visible) return; + if (!interactionEnabled) return; // Debug logging for escape key detection if (process.env.LETTA_DEBUG_KEYS === "1" && key.escape) { // eslint-disable-next-line no-console @@ -403,7 +410,7 @@ export function Input({ }); useInput((input, key) => { - if (!visible) return; + if (!interactionEnabled) return; // Handle CTRL-C for double-ctrl-c-to-exit // In bash mode, CTRL-C wipes input but doesn't exit bash mode @@ -435,7 +442,7 @@ export function Input({ // Handle Shift+Tab for permission mode cycling (or ralph mode exit) useInput((_input, key) => { - if (!visible) return; + if (!interactionEnabled) return; // Debug logging for shift+tab detection if (process.env.LETTA_DEBUG_KEYS === "1" && (key.shift || key.tab)) { // eslint-disable-next-line no-console @@ -474,7 +481,7 @@ export function Input({ // Handle up/down arrow keys for wrapped text navigation and command history useInput((_input, key) => { - if (!visible) return; + if (!interactionEnabled) return; // Don't interfere with autocomplete navigation, BUT allow history navigation // when we're already browsing history (historyIndex !== -1) if (isAutocompleteActive && historyIndex === -1) { @@ -662,11 +669,6 @@ export function Input({ // Elapsed time tracking useEffect(() => { - if (elapsedMsOverride !== undefined) { - streamStartRef.current = null; - setElapsedMs(0); - return; - } if (streaming && visible) { // Start tracking when streaming begins if (streamStartRef.current === null) { @@ -682,7 +684,7 @@ export function Input({ // Reset when streaming stops streamStartRef.current = null; setElapsedMs(0); - }, [streaming, visible, elapsedMsOverride]); + }, [streaming, visible]); const handleSubmit = async () => { // Don't submit if autocomplete is active with matches @@ -843,8 +845,7 @@ export function Input({ }, [ralphPending, ralphPendingYolo, ralphActive, currentMode]); const estimatedTokens = charsToTokens(tokenCount); - const effectiveElapsedMs = elapsedMsOverride ?? elapsedMs; - const totalElapsedMs = elapsedBaseMs + effectiveElapsedMs; + const totalElapsedMs = elapsedBaseMs + elapsedMs; const shouldShowTokenCount = streaming && estimatedTokens > TOKEN_DISPLAY_THRESHOLD; const shouldShowElapsed = @@ -952,7 +953,7 @@ export function Input({ onSubmit={handleSubmit} cursorPosition={cursorPos} onCursorMove={setCurrentCursorPosition} - focus={!onEscapeCancel} + focus={interactionEnabled && !onEscapeCancel} onBangAtEmpty={handleBangAtEmpty} onBackspaceAtEmpty={handleBackspaceAtEmpty} onPasteError={onPasteError} @@ -968,19 +969,21 @@ export function Input({ {horizontalLine} - + {interactionEnabled && ( + + )}