fix(cli): stabilize rendering to eliminate line flicker (#774)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user