TUI can permanently lose transcript scrollback when resize lands during replay/stream (cells become incomplete; later resizes can't restore)
Summary
The codex TUI can permanently lose transcript scrollback when a terminal resize lands while a resumed session is still replaying (or while an agent response is streaming). Resize reflow clears terminal scrollback and re-emits from transcript_cells; when the cells are incomplete at that moment (replay still in progress, stream cells not yet consolidated), the re-emitted scrollback is shorter than the transcript — and unlike #30745, the missing rows are never restored by later resizes, because the rebuild source itself (transcript_cells) is missing them.
Environment
- codex 0.147.0 TUI (main screen; runs inside a headless terminal host that owns the PTY)
- Terminal host: herdr 0.8.0 (ghostty-vt based), which resizes the shared PTY whenever the attached client's viewport changes
- Client: mobile terminal app, 56 columns; toggling the on-screen keyboard changes the viewport between 17 and 45 rows, so resizes arrive repeatedly during a session
Reproduction
codex resume <session-id>for a long session (observed: 4605-line session JSONL).- Confirm the working directory prompt with Enter; the replay starts.
- Immediately trigger a resize while the replay/stream is still settling (in my setup: split/close the pane, equivalent to the client viewport changing mid-session).
- After the prompt returns, scrollback contains only part of the transcript:
- observed
scroll + viewporttotal of 76 rows → 51 rows after one resize round-trip, stable at 51 afterwards. - The 25 missing rows never come back; repeated resizes re-emit the same 51 rows.
pane read --source recenton the surviving scrollback shows the welcome header, an interrupted-conversation notice and the prompt — no conversation history at all.
Additional observation: resuming the same session ID from two TUI instances at once makes the replay entirely empty — the resumed view shows only the welcome header and prompt, with zero history, until all instances are closed and a single instance resumes the session again.
Expected
- A resize that lands mid-replay or mid-stream should not permanently shrink scrollback: either the clear+re-emit should wait until
transcript_cellsis complete, or it should be skipped entirely when cells are known to be incomplete. - (Secondary) Resuming a session ID that is already open in another instance should not silently produce an empty replay.
Actual
In tui/src/app/resize_reflow.rs, reflow_transcript_now() returns early when transcript_cells.is_empty() (after clearing pending history lines); when cells are merely partial, the re-emit is exactly the partial set, and every later resize re-emits the same partial set. The initial replay path (begin_initial_history_replay_buffer / finish_initial_history_replay_buffer) buffers display lines separately from cells, so a resize that lands between replay inserts and the flush can commit only a prefix.
Notes
Related issues:
- #30745 — rows disappear after inline viewport height changes; restored by a terminal resize. In our case the cells themselves are lost, so a terminal resize cannot restore them (same family, worse stage).
- #24235 — scrollback corruption in Termius mobile SSH (mobile-client-specific rendering).
- #21635 — resume main view shows partial transcript due to
terminal_resize_reflow_max_rowscap. - #37635 — repeated repaints during paginated history refill on resume.
1 Comment
Confirmed the guard gap on
main(1f41cc5d92):reflow_transcript_nowchecks onlytranscript_cells.is_empty()before clearing the terminal and re-emitting — it never consultsinitial_history_replay_buffer, the very flag that marks "replay still in progress" (https://github.com/openai/codex/blob/1f41cc5d92/codex-rs/tui/src/app/resize_reflow.rs#L461-L486, buffer machinery at#L124-L160). So a resize that lands inside the replay window purges scrollback (clear_terminal_for_resize_replay) and rebuilds from a partial cell set; since the rebuild source is the partial set, every later resize reproduces the same truncation — permanently, exactly as you observed. Same non-atomic clear+re-emit family as #30745/#37777, but caught at the one stage where the loss is unrecoverable.Fix shape: when
initial_history_replay_buffer.is_some()(and, for the streaming variant, while stream cells are unconsolidated), don't reflow now — record the pending target width and run the reflow fromfinish_initial_history_replay_bufferonce the cells are complete. That converts the race into a slightly delayed reflow with no data loss.The two-instance empty replay is your issue's second bug and matches #38144/#38297 (writer-lock held by the other instance → replay reads an empty projection); worth keeping both linked but tracked separately.