TUI visibly replays the entire transcript after deferred resize reflow on Windows Terminal
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.exeand PowerShell - Multiplexer: none
- Source inspected: current
mainat6efcdad4c3c167741ac3791766152c15b03e3653
codex doctor --json did not return within 30 seconds in this environment, so no doctor report is attached.
Steps to reproduce
- Open Codex CLI in a relatively small Windows Terminal window.
- Continue using one session until its transcript is long.
- Resize the terminal, switch to another window, and leave the Codex window unfocused for a while.
- Return to the Codex window and maximize/resize it, or start typing in the composer.
- 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:
- A terminal resize schedules a debounced size recheck and transcript reflow.
- While the terminal is unfocused, the recheck/reflow can remain pending until a later draw.
FocusGainedschedules a draw, and a user turn can explicitly force an already-pending transcript reflow before processing the submission.handle_draw_size_changerebuilds the transcript when width reflow is required or terminal height changes.reflow_transcript_nowrenders the transcript and calls the terminal clear path immediately. In inline mode,clear_scrollback_and_visible_screen_ansi()writes and flushesCSI 2J/3J.- 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:
- Add a
pending_transcript_replay_clearflag toTui. - Replace the immediate clear in transcript-rebuild paths with a method that only sets this flag and updates the internal viewport anchor.
- Consume the flag inside the existing
stdout().sync_update(...)transaction indraw_with_resize_reflow. - Clear the flag only after the synchronized transaction succeeds, so an I/O error leaves the operation retryable.
- 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:
- Commit: https://github.com/wellorbetter/codex/commit/4811a082079379f6eb9feaa69fe911f0c17b25e5
- Branch: https://github.com/wellorbetter/codex/tree/fix/windows-terminal-reflow-anchor
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-tuitest 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.
4 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
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:
A history writing failure is retryable, because
flush_pending_history_linesis after writing history. The gap occurs specifically when history writing succeeds and the later frame draw fails.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.
reflow_transcript_nowcallsclear_terminal_for_resize_replay→clear_scrollback_and_visible_screen_ansi(), which doeswrite!(backend, "\x1b[r\x1b[0m\x1b[H\x1b[2J\x1b[3J\x1b[H")followed by an explicitflush():insert_history_hyperlink_lines_with_wrap_policy→pending_history_lines) and are flushed at the next draw, insidestdout().sync_update(...)indraw_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:
clear_terminal_for_resize_replaycall inreflow_transcript_nowwith a pending flag (the infrastructure parallel already exists —pending_history_lines/clear_pending_history_lines), anddraw_with_resize_reflow'ssync_updateblock, immediately beforeflush_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(resettingviewport_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_001rows (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_nowperforms 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.@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:
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.\r\nthrough line-buffered stdout, flushing once per row and defeating batching even though the code usesqueue!. 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.pending_history_linesbefore 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.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.