From b59488425523cca835baa10548459421cdd49a67 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Thu, 7 May 2026 18:33:16 +0800 Subject: [PATCH] fix(render): preserve scrollback bytes across superseded commit-throttle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When two stdout writes would land within MIN_COMMIT_GAP_MS (50ms), the second is throttled via setTimeout. If a height-changing render arrives before that timer fires, the supersede path (`commit-throttle- superseded-by-height`) calls clearTimeout — but the throttled render's payload, which carried the new scrollback bytes, is then garbage collected. `writtenMessageCountRef` had already been bumped synchronously, so subsequent renders don't re-emit those bytes either. Net effect: the message vanishes from the visible scrollback even though it lives in `state.messages`. Reproducing this required a multi-line commit followed by a frame shrink (e.g. end-of-turn spinner removal). One observed case: streaming "Here's the open PR:\n\n**PR #9**...\n\nWhich PR..." across two buffer commits — the first commit drew "Here's the open PR:" then scheduled a 1ms throttle for the rest. The shrink at finishReason=stop fired 1ms later, supersede cancelled the throttle, and the final two paragraphs were lost on screen. Fix: hoist the per-render `scrollbackContent` accumulator into a cross-render `pendingScrollbackRef`. doFlush clears it only after the write actually lands. If the throttle is cancelled, the bytes survive to the next render — `didCommitMessages` stays true (because scrollbackContent is non-empty), the geometry path includes the bytes in the new render's preBuf, and they reach stdout alongside the new (smaller) frame in a single atomic write — no extra flicker, no lost text. Also clears the ref in the /clear path, since wiping scrollback makes any pending bytes stale (their messages no longer exist). --- packages/cli/src/ui/components/ChatInput.tsx | 35 ++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/ChatInput.tsx b/packages/cli/src/ui/components/ChatInput.tsx index 3c3910b..a06a6c7 100644 --- a/packages/cli/src/ui/components/ChatInput.tsx +++ b/packages/cli/src/ui/components/ChatInput.tsx @@ -767,6 +767,20 @@ export function ChatInput({ const justClearedRef = useRef(false) /** How many messages we've already committed to scrollback. */ const writtenMessageCountRef = useRef(0) + /** Scrollback bytes collected this render that haven't reached stdout yet. + * Survives across renders so a cancelled commit-throttle doesn't drop + * message bytes — `writtenMessageCountRef` is bumped synchronously when + * we walk new messages, so a follow-up render won't re-collect them via + * `writeMessageToStdout`. Without this ref, the only path that carried + * the bytes was the local `scrollbackContent` of the render that + * scheduled the throttle; if that throttle got superseded by a height + * change 1ms later (`commit-throttle-superseded-by-height`), the bytes + * vanished. Cleared inside `doFlush` once the write actually lands. + * Symptom this cures: streamed multi-line replies whose final commit + * shrinks the frame (end-of-turn spinner removal) silently lose the + * last message — visible as a reply that stops mid-paragraph in the + * scrollback even though the full text is in `state.messages`. */ + const pendingScrollbackRef = useRef('') // Permission dialog: selection index (0 = Yes, 1 = No). Rendered inside // our cell buffer — not via Ink — so the dialog never fights our // cursor management. Reset to 0 whenever the prompt changes (new tool @@ -1403,6 +1417,11 @@ export function ChatInput({ lastFrameTopRef.current = 0 freeBlanksAboveFrameRef.current = 0 blankRowsAboveFrameRef.current = 0 + // The clear wipes the scrollback we were about to write to. Any + // pending bytes from a prior cancelled throttle are now stale — + // they belong to messages that no longer exist (post-/clear, + // messages.length is 0). + pendingScrollbackRef.current = '' activeRef.current = false justClearedRef.current = true // Drops scrollback-spacing flags + buffered read-group entries @@ -1424,9 +1443,8 @@ export function ChatInput({ // blank rows in terminal scrollback (the "lots of blank lines" // symptom on multi-read chains). const hasNewMessages = messages.length > writtenMessageCountRef.current - let scrollbackContent = '' const collectWrite: (data: string) => void = (data) => { - scrollbackContent += data + pendingScrollbackRef.current += data } if (hasNewMessages) { for (let i = writtenMessageCountRef.current; i < messages.length; i++) { @@ -1445,6 +1463,11 @@ export function ChatInput({ if (!isLoading) { flushPendingReadGroup(collectWrite) } + // Snapshot the cross-render ref into a local. The geometry path reads + // `scrollbackContent` multiple times and the snapshot keeps a single + // render's view consistent. The bytes stay in the ref until doFlush + // confirms they made it to stdout — see pendingScrollbackRef's docs. + const scrollbackContent = pendingScrollbackRef.current const didCommitMessages = scrollbackContent.length > 0 // Capture "is this the first active paint?" BEFORE we flip activeRef. @@ -3191,6 +3214,14 @@ export function ChatInput({ prevFrameRef.current = frame lastFrameHRef.current = nextH lastFrameTopRef.current = frameTop + // Bytes are now on stdout. Drop the ref so the next render doesn't + // re-emit them. Setting to '' (rather than slicing scrollbackContent + // off the front) is safe: any render that mutates the ref between + // scheduling and firing this throttled doFlush would have entered + // the commit branch (didCommitMessages || hasNewMessages) and + // cancelled this throttle in line 3235's `clearTimeout`, replacing + // it with a fresh throttle whose payload includes the new bytes. + pendingScrollbackRef.current = '' if (pendingFreeBlanks !== freeBlanksAboveFrameRef.current) { debugLog( 'chatinput.geom.persist',