TUI visibly replays the entire transcript after deferred resize reflow on Windows Terminal

Open 💬 4 comments Opened Aug 16, 2026 by wellorbetter
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

In a long Codex CLI session, resizing or maximizing a Windows Terminal window can make the TUI visibly replay the entire transcript from the top before it returns to the latest output.

The same behavior can occur after returning to a terminal window that has been unfocused for a while: the first draw or key input consumes a previously deferred resize/reflow, the visible screen is cleared, and the conversation appears to stream from top to bottom again. The effect ranges from a brief flash to a long, CPU-heavy stall depending on transcript length.

This appears related to #22936, but the specific problem here is the non-atomic clear/replay sequence. It is not necessary to disable height-triggered transcript repair to avoid the visible replay.

Environment

  • Codex CLI: 0.147.0
  • OS: Windows 11, Microsoft Windows NT 10.0.26200.0 x64
  • Terminal: Windows Terminal 1.24.11911.0
  • Shells observed: native cmd.exe and PowerShell
  • Multiplexer: none
  • Source inspected: current main at 6efcdad4c3c167741ac3791766152c15b03e3653

codex doctor --json did not return within 30 seconds in this environment, so no doctor report is attached.

Steps to reproduce

  1. Open Codex CLI in a relatively small Windows Terminal window.
  2. Continue using one session until its transcript is long.
  3. Resize the terminal, switch to another window, and leave the Codex window unfocused for a while.
  4. Return to the Codex window and maximize/resize it, or start typing in the composer.
  5. Observe the terminal while the first post-focus draw is processed.

The delay in step 3 is not always required, but it makes the problem easier to observe when resize/reflow work remains pending until focus or input resumes drawing.

Expected behavior

Codex may need to rebuild terminal scrollback after a width or height change, but the user should see either the old complete frame or the new complete frame. The terminal should stay anchored at the latest output without exposing the intermediate clear and row-by-row transcript replay.

Actual behavior

The visible screen is cleared and the transcript is then visibly reconstructed from the beginning toward the bottom. On a large transcript this can look like the entire conversation is being loaded again and can cause a noticeable CPU stall.

Root-cause analysis

The behavior can be explained by the current resize-reflow path:

  1. A terminal resize schedules a debounced size recheck and transcript reflow.
  2. While the terminal is unfocused, the recheck/reflow can remain pending until a later draw.
  3. FocusGained schedules a draw, and a user turn can explicitly force an already-pending transcript reflow before processing the submission.
  4. handle_draw_size_change rebuilds the transcript when width reflow is required or terminal height changes.
  5. reflow_transcript_now renders the transcript and calls the terminal clear path immediately. In inline mode, clear_scrollback_and_visible_screen_ansi() writes and flushes CSI 2J/3J.
  6. The reflowed history rows are only queued at that point. They are flushed later by draw_with_resize_reflow, inside a synchronized update.

This splits the operation into two visible phases:

clear + flush
    ...time spent before/later draw...
synchronized viewport update + history replay + composer draw

The terminal can therefore render the cleared state and the subsequent replay instead of presenting one completed frame.

Simply removing height-only reflow does not look safe. #30745 documents a case where height-related source-backed replay restores rows lost after inline viewport changes. The reflow may be necessary; exposing its intermediate state is the avoidable part.

Proposed fix

Defer the destructive clear until draw_with_resize_reflow and execute all terminal mutations in the same synchronized update:

begin synchronized update
  clear visible screen / scrollback
  update inline viewport geometry
  flush all queued reflowed history rows
  redraw the composer and current frame
end synchronized update

A focused implementation can:

  1. Add a pending_transcript_replay_clear flag to Tui.
  2. Replace the immediate clear in transcript-rebuild paths with a method that only sets this flag and updates the internal viewport anchor.
  3. Consume the flag inside the existing stdout().sync_update(...) transaction in draw_with_resize_reflow.
  4. Clear the flag only after the synchronized transaction succeeds, so an I/O error leaves the operation retryable.
  5. Keep the existing width-, height-, stream-finalization-, and backtrack-triggered source repair semantics unchanged.

It is also useful to add debug-level timing events around resize/focus, overdue size rechecks, transcript rendering, and terminal replay. That distinguishes CPU time spent re-rendering Markdown from time spent writing rows to the terminal without logging transcript content.

Candidate implementation and validation

I prepared a focused candidate implementation for discussion:

Validation performed:

  • Added a regression test proving that transcript reflow queues both the terminal clear and replay rows, rather than clearing before the synchronized draw.
  • Existing height-shrink reflow behavior remains covered and unchanged.
  • just test -p codex-tui resize_reflow: 21/21 passed.
  • cargo clippy --tests -p codex-tui: passed with no diagnostics.
  • A full codex-tui test run passed 3580 tests and timed out on 4 unrelated startup/state tests. Three passed when rerun in isolation; one app-server startup/resume test continued to time out without touching the modified paths.

This is shared as analysis and a proposed approach in accordance with the invitation-only contribution policy; I am not opening an unsolicited pull request. I am happy to adjust or submit the patch if a maintainer confirms the approach and invites a PR.

Related issues

  • #22936: long conversations can jump the viewport back to the top on Windows Terminal/WSL
  • #30745: scrollback rows can disappear after inline viewport height changes and reappear after resize-backed replay
  • #21635: capped resize reflow can leave the resumed main view with only a partial transcript
  • #21945: transcript rendering/rebuild cost scales with session length

Adjacent TUI viewport reports

  • #14098: preserve viewport/context when exiting the transcript overlay
  • #10726: a closed Windows/WSL report where Plan mode could not retain the user's scroll position while idle

Similar symptoms on other Codex surfaces

  • #21834: the Windows desktop app jumps upward in long threads after submit
  • #22816: screen-reader focus in the app repeatedly returns to the beginning while output is generated

The last two reports involve the desktop app rather than the Rust CLI TUI and likely have a different renderer/root cause. They are included only to connect the shared user-visible “return to top / lose the latest position” symptom.

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 12 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #38479

Powered by Codex Action

astral303 · 11 days ago

Agreed on the required reordering, that's a great diagnosis! Indeed the updates need to all happen within the synchronized block as you describe.

However, looking at the candidate impl, it can be improved by addressing this:

  • current logic to "flush all queued reflowed history rows" before draw final frame means that if the draw final frame fails, we won't have any rows to replay (and is thus unretryable)
  • the fix is to not clear the rows until after draw final frame succeeds

A history writing failure is retryable, because flush_pending_history_lines is after writing history. The gap occurs specifically when history writing succeeds and the later frame draw fails.

jdcodes1 · 11 days ago

Your step-6 analysis is exactly right, and the code on main @ 1f41cc5d92 makes the fix surprisingly well-contained: the clear and the rebuild live in two different flush domains, and only the clear is in the wrong one.

The two flush domains.

  1. The clear is written and flushed immediately, outside any synchronized update: reflow_transcript_now calls clear_terminal_for_resize_replayclear_scrollback_and_visible_screen_ansi(), which does write!(backend, "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H") followed by an explicit flush():
  1. The reflowed rows are only queued at that point (insert_history_hyperlink_lines_with_wrap_policypending_history_lines) and are flushed at the next draw, inside stdout().sync_update(...) in draw_with_resize_reflow (tui.rs#L1092-L1146). Notably, flush_pending_history_lines (tui.rs#L931-L955) drains all queued batches in one pass — the rows themselves aren't dribbled across frames.

So the user-visible sequence is: blank screen now (flushed clear) → arbitrary gap until the next draw is scheduled and processed (long when the reflow was consumed from a deferred/focus path, exactly your step 3) → bulk scrollback rebuild, which on a long transcript Windows Terminal renders as the row-by-row replay. Synchronized output can't retroactively cover the clear that already flushed in step 1.

Fix shape. Since the rows are already deferred and batch-flushed, atomicity only requires deferring the clear into the same synchronized update:

  • replace the immediate clear_terminal_for_resize_replay call in reflow_transcript_now with a pending flag (the infrastructure parallel already exists — pending_history_lines / clear_pending_history_lines), and
  • consume that flag at the top of draw_with_resize_reflow's sync_update block, immediately before flush_pending_history_lines.

Then the terminal transitions from old-complete-frame to new-complete-frame in one synchronized update, which is precisely your expected behavior. The viewport-anchoring adjustment currently done in clear_terminal_for_resize_replay (resetting viewport_area.y) moves with it.

On the residual WT cost: even inside one synchronized update, rebuilding scrollback means emitting up to WINDOWS_TERMINAL_RESIZE_REFLOW_MAX_ROWS = 9_001 rows (resize_reflow_cap.rs#L20) — synchronized output (DECSET 2026) protects the viewport frame, not the cost of ingesting thousands of scrollback lines, which is where the CPU stall comes from. Deferring the clear removes the visible blank-then-replay; if the stall remains an issue on very long transcripts, the row cap is the existing per-terminal knob to tune, independent of the atomicity fix — consistent with your observation that disabling height-triggered repair isn't necessary.

Test shape: a unit test asserting reflow_transcript_now performs no backend writes (only queues), plus a draw-path test asserting the clear sequence and the history rows appear inside the same synchronized-update envelope in the captured backend output.

astral303 · 9 days ago

@jdcodes1, the two-flush-domain framing is accurate, and deferring the clear into the synchronized update is necessary — my fix implements exactly that ordering. But it isn't sufficient, and a few claims here didn't survive contact with a real Windows Terminal:

  1. "One synchronized update → old frame to new frame" assumes the sync envelope holds, but it doesn't on long transcripts. Windows Terminal force-releases synchronized output 100 ms after DECSET 2026 h — its renderer timeout, per Windows Terminal's own implementation discussion — and then paints whatever partial state exists. My frame captures show the partial frame beginning exactly at that boundary. Computing the reflowed rows is the most expensive part of the operation, so ordering alone cannot guarantee atomic presentation on precisely the long transcripts this issue describes. My fix pre-wraps and buffers every replay row before entering the synchronized update for this reason.
  1. The residual cost is misattributed, and the rows were in fact dribbled. The stall isn't primarily WT ingesting scrollback rows, to be tuned away with the row cap as a terminal-side concern — app-side reflow computation dominates, and where it runs relative to the sync window isn't "independent of the atomicity fix"; it decides whether the update is atomic at all. Separately, history insertion was writing \r\n through line-buffered stdout, flushing once per row and defeating batching even though the code uses queue!. So "the rows aren't dribbled" holds at the queue level but not at the flush level; my fix prepares the complete ANSI batch in memory and hands it to the backend as one write.
  1. The fix shape as described is unretryable. Consuming the pending-clear flag and draining pending_history_lines before the frame draw means a mid-sequence failure (history written, draw fails) leaves nothing to replay — the same gap I noted above about the earlier candidate. My implementation holds the clear → replay → draw sequence as one transaction and retries the complete operation until every terminal write succeeds.
  1. The proposed tests can't observe this defect. Asserting that the clear and rows share one sync envelope in captured backend output validates byte ordering, not what Windows Terminal renders — the 100 ms force-release and per-row flushing are invisible to a captured backend. My test PR drives a real 120×30 Windows Terminal with 1,500 long history rows through three resize cycles and samples the visible frame; that check fails on the pre-fix implementation and passes repeatedly on the fix.

If you want to try it: the production fix is astral303/codex#13 (stacked on my other Windows rendering fixes, astral303/codex#1–#6), and the interactive Windows Terminal regression suite is astral303/codex#7. The single-write batching fix from point 2 is also available standalone — one commit on upstream main, with a regression test asserting a multi-row insertion reaches the backend as exactly one write: fix/tui-single-write-history-insertion.