TUI can permanently lose transcript scrollback when resize lands during replay/stream (cells become incomplete; later resizes can't restore)

Open 💬 1 comment Opened Aug 11, 2026 by hidenori-endo

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

  1. codex resume <session-id> for a long session (observed: 4605-line session JSONL).
  2. Confirm the working directory prompt with Enter; the replay starts.
  3. 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).
  4. After the prompt returns, scrollback contains only part of the transcript:
  • observed scroll + viewport total 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.
  1. pane read --source recent on 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_cells is 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_rows cap.
  • #37635 — repeated repaints during paginated history refill on resume.

View original on GitHub ↗

1 Comment

jdcodes1 · 9 days ago

Confirmed the guard gap on main (1f41cc5d92): reflow_transcript_now checks only transcript_cells.is_empty() before clearing the terminal and re-emitting — it never consults initial_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 from finish_initial_history_replay_buffer once 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.