Background Streaming assistant prose in tui2 was being rendered with viewport-width wrapping during streaming, then stored in history cells as already split `Line`s. Those width-derived breaks became indistinguishable from hard newlines, so the transcript could not "un-split" on resize. This also degraded copy/paste, since soft wraps looked like hard breaks. What changed - Introduce width-agnostic `MarkdownLogicalLine` output in `tui2/src/markdown_render.rs`, preserving markdown wrap semantics: initial/subsequent indents, per-line style, and a preformatted flag. - Update the streaming collector (`tui2/src/markdown_stream.rs`) to emit logical lines (newline-gated) and remove any captured viewport width. - Update streaming orchestration (`tui2/src/streaming/*`) to queue and emit logical lines, producing `AgentMessageCell::new_logical(...)`. - Make `AgentMessageCell` store logical lines and wrap at render time in `HistoryCell::transcript_lines_with_joiners(width)`, emitting joiners so copy/paste can join soft-wrap continuations correctly. Overlay deferral When an overlay is active, defer *cells* (not rendered `Vec<Line>`) and render them at overlay close time. This avoids baking width-derived wraps based on a stale width. Tests + docs - Add resize/reflow regression tests + snapshots for streamed agent output. - Expand module/API docs for the new logical-line streaming pipeline and clarify joiner semantics. - Align scrollback-related docs/comments with current tui2 behavior (main draw loop does not flush queued "history lines" to the terminal). More details See `codex-rs/tui2/docs/streaming_wrapping_design.md` for the full problem statement and solution approach, and `codex-rs/tui2/docs/tui_viewport_and_history.md` for viewport vs printed output behavior.
7.9 KiB
Streaming Wrapping Reflow (tui2)
This document describes a correctness bug in codex-rs/tui2 and the chosen fix:
while streaming assistant markdown, soft-wrap decisions were effectively persisted as hard line
breaks, so resizing the viewport could not reflow prose.
Goal
- Resizing the viewport reflows transcript prose (including streaming assistant output).
- Width-derived breaks are always treated as soft wraps (not logical newlines).
- Copy/paste continues to treat soft wraps as joinable (via joiners), and hard breaks as newlines.
Non-goals:
- Reflowing terminal scrollback that has already been printed.
- Reflowing content that is intentionally treated as preformatted (e.g., code blocks, raw stdout).
Background: where reflow happens in tui2
TUI2 renders the transcript as a list of HistoryCells:
- A cell stores width-agnostic content (string, diff, logical lines, etc.).
- At draw time (and on resize),
transcript_renderasks each cell for lines at the current width (ideally viaHistoryCell::transcript_lines_with_joiners(width)). TranscriptViewCachecaches the wrapped visual lines keyed by width; a width change triggers a rebuild.
This only works if cells do not persist width-derived wrapping inside their stored state.
The bug: soft wraps became hard breaks during streaming
Ratatui represents multi-line content as Vec<Line>. If we split a paragraph into multiple Lines
because the viewport is narrow, that split is indistinguishable from an explicit newline unless we
also carry metadata describing which breaks were “soft”.
Streaming assistant output used to generate already-wrapped Lines and store them inside the
history cell. Later, when the viewport became wider, the transcript renderer could not “un-split”
those baked lines — they looked like hard breaks.
Chosen solution (A, F1): stream logical markdown lines; wrap in the cell at render-time
User choice recap:
- A: Keep append-only streaming (new history cell per commit tick), but make the streamed data width-agnostic.
- F1: Make the agent message cell responsible for wrapping-to-width so transcript-level wrapping can be a no-op for it.
Key idea: separate markdown parsing from wrapping
We introduce a width-agnostic “logical markdown line” representation that preserves the metadata needed to wrap correctly later:
codex-rs/tui2/src/markdown_render.rsMarkdownLogicalLine { content, initial_indent, subsequent_indent, line_style, is_preformatted }render_markdown_logical_lines(input: &str) -> Vec<MarkdownLogicalLine>
This keeps:
- hard breaks (paragraph/list boundaries, explicit newlines),
- markdown indentation rules for wraps (list markers, nested lists, blockquotes),
- preformatted runs (code blocks) stable.
Updated streaming pipeline
-
codex-rs/tui2/src/markdown_stream.rsMarkdownStreamCollectoris newline-gated (no change), but now commitsVec<MarkdownLogicalLine>instead of already-wrappedVec<Line>.- Width is removed from the collector; wrapping is not performed during streaming.
-
codex-rs/tui2/src/streaming/controller.rs- Emits
AgentMessageCell::new_logical(...)containing logical lines.
- Emits
-
codex-rs/tui2/src/history_cell.rsAgentMessageCellstoresVec<MarkdownLogicalLine>.HistoryCell::transcript_lines_with_joiners(width)wraps each logical line at the current width usingword_wrap_line_with_joinersand composes indents as:- transcript gutter prefix (
•/), plus - markdown-provided initial/subsequent indents.
- transcript gutter prefix (
- Preformatted logical lines are rendered without wrapping.
Result: on resize, the transcript cache rebuilds against the new width and the agent output reflows correctly because the stored content contains no baked soft wraps.
Overlay deferral fix (D): defer cells, not rendered lines
When an overlay (transcript/static) is active, TUI2 is in alt screen and the normal terminal buffer
is not visible. Historically, tui2 attempted to queue “history to print” for the normal buffer by
deferring rendered lines, which baked the then-current width.
User choice recap:
- D: Store deferred cells and render them at overlay close time.
Implementation:
-
codex-rs/tui2/src/app.rsdeferred_history_cells: Vec<Arc<dyn HistoryCell>>(replacesdeferred_history_lines).AppEvent::InsertHistoryCellpushes cells into the deferral list whenoverlay.is_some().
-
codex-rs/tui2/src/app_backtrack.rsclose_transcript_overlayrenders deferred cells at the current width when closing the overlay, then queues the resulting lines for the normal terminal buffer.
Note: as of today, Tui::insert_history_lines queues lines but Tui::draw does not flush them into
the terminal (see codex-rs/tui2/src/tui.rs). This section is therefore best read as “behavior we
want when/if scrollback printing is re-enabled”, not a guarantee that content is printed during the
main TUI loop. For the current intended behavior around printing, see
codex-rs/tui2/docs/tui_viewport_and_history.md.
Tests (G2)
User choice recap:
- G2: Add resize reflow tests + snapshot coverage.
Added coverage:
codex-rs/tui2/src/history_cell.rsagent_message_cell_reflows_streamed_prose_on_resizeagent_message_cell_reflows_streamed_prose_vt100_snapshot
These assert that a streamed agent cell produces fewer visual lines at wider widths and provide snapshots showing reflow for list items and blockquotes.
Audit: other HistoryCells and width-baked paths
This section answers “what else might behave like this?” up front.
History cells
AgentMessageCell(codex-rs/tui2/src/history_cell.rs): was affected; now stores logical markdown lines and wraps at render time.UserHistoryCell(codex-rs/tui2/src/history_cell.rs): wraps at render time from storedStringusingword_wrap_lines_with_joiners(reflowable).ReasoningSummaryCell(codex-rs/tui2/src/history_cell.rs): renders from storedStringon each call; it does callappend_markdown(..., Some(width)), but that wrapping is recomputed per width (reflowable).PrefixedWrappedHistoryCell(codex-rs/tui2/src/history_cell.rs): wraps at render time and returns joiners (reflowable).PlainHistoryCell(codex-rs/tui2/src/history_cell.rs): storesVec<Line>and returns it unchanged (not reflowable by design; used for already-structured/preformatted output).
Rule of thumb: any cell that stores already-wrapped Vec<Line> for prose is a candidate for the
same bug; cells that store source text or logical lines and compute wrapping inside
display_lines(width) are safe.
Width-baked output outside the transcript model
Even with the streaming fix, some paths are inherently width-baked:
- Printed transcript after exit (
codex-rs/tui2/src/app.rs):AppExitInfo.session_linesis rendered once using the final width and then printed; it cannot reflow afterward. - Optional scrollback insertion helper (
codex-rs/tui2/src/insert_history.rs): once ANSI is written to the terminal, that output cannot be reflowed later. This helper is currently used for deterministic ANSI emission (write_spans) and tests; it is not wired into the main TUI draw loop. - Static overlays (
codex-rs/tui2/src/pager_overlay.rs): reflow depends on whether callers provided width-agnostic input; pre-splitVec<Line>cannot be “un-split” within the overlay.
Deferred / follow-ups
The fix above is sufficient to unblock correct reflow on resize. Remaining choices can be deferred:
- Streaming granularity: one logical line can wrap into multiple visual lines, so “commit tick” updates can appear in larger chunks than before. If this becomes a UX issue, we can add a render- time “progressive reveal” layer without reintroducing width baking.
- Expand logical-line rendering to other markdown-ish cells if needed (e.g., unify
append_markdownusage), but only if we find a concrete reflow bug beyondAgentMessageCell.