fix(cli): stabilize rendering to eliminate line flicker (#774)

Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
Charles Packer
2026-02-01 17:05:37 -08:00
committed by GitHub
parent 2e1bd1ce78
commit 7584f4291f
2 changed files with 61 additions and 67 deletions

View File

@@ -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<number | null>(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 */}
<Box marginTop={1}>
<Input
visible={
!showExitStats &&
pendingApprovals.length === 0 &&
!anySelectorOpen
}
streaming={
streaming && !abortControllerRef.current?.signal.aborted
}
visible={inputVisible}
streaming={streaming}
tokenCount={trajectoryTokenDisplay}
elapsedBaseMs={liveTrajectoryElapsedBaseMs}
elapsedMsOverride={streamElapsedMs}
thinkingMessage={thinkingMessage}
onSubmit={onSubmit}
onBashSubmit={handleBashSubmit}
bashRunning={bashRunning}
onBashInterrupt={handleBashInterrupt}
inputEnabled={inputEnabled}
permissionMode={uiPermissionMode}
onPermissionModeChange={handlePermissionModeChange}
onExit={handleExit}

View File

@@ -185,12 +185,12 @@ export function Input({
streaming,
tokenCount,
elapsedBaseMs = 0,
elapsedMsOverride,
thinkingMessage,
onSubmit,
onBashSubmit,
bashRunning = false,
onBashInterrupt,
inputEnabled = true,
permissionMode: externalMode,
onPermissionModeChange,
onExit,
@@ -217,12 +217,12 @@ export function Input({
streaming: boolean;
tokenCount: number;
elapsedBaseMs?: number;
elapsedMsOverride?: number;
thinkingMessage: string;
onSubmit: (message?: string) => Promise<{ submitted: boolean }>;
onBashSubmit?: (command: string) => Promise<void>;
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<number | undefined>(undefined);
const [currentCursorPosition, setCurrentCursorPosition] = useState(0);
const interactionEnabled = visible && inputEnabled;
// Command history
const [history, setHistory] = useState<string[]>([]);
@@ -328,6 +329,12 @@ export function Input({
const [elapsedMs, setElapsedMs] = useState(0);
const streamStartRef = useRef<number | null>(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}
</Text>
<InputAssist
currentInput={value}
cursorPosition={currentCursorPosition}
onFileSelect={handleFileSelect}
onCommandSelect={handleCommandSelect}
onCommandAutocomplete={handleCommandAutocomplete}
onAutocompleteActiveChange={setIsAutocompleteActive}
agentId={agentId}
agentName={agentName}
serverUrl={serverUrl}
workingDirectory={process.cwd()}
conversationId={conversationId}
/>
{interactionEnabled && (
<InputAssist
currentInput={value}
cursorPosition={currentCursorPosition}
onFileSelect={handleFileSelect}
onCommandSelect={handleCommandSelect}
onCommandAutocomplete={handleCommandAutocomplete}
onAutocompleteActiveChange={setIsAutocompleteActive}
agentId={agentId}
agentName={agentName}
serverUrl={serverUrl}
workingDirectory={process.cwd()}
conversationId={conversationId}
/>
)}
<InputFooter
ctrlCPressed={ctrlCPressed}