Files
codex/codex-rs/tui2/docs/streaming_wrapping_design.md
Josh McKinney 60e8cb037a tui2: stop baking streaming wraps; reflow agent markdown
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.
2026-01-05 14:02:40 -08:00

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:

  1. A cell stores width-agnostic content (string, diff, logical lines, etc.).
  2. At draw time (and on resize), transcript_render asks each cell for lines at the current width (ideally via HistoryCell::transcript_lines_with_joiners(width)).
  3. TranscriptViewCache caches 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.rs
    • MarkdownLogicalLine { 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.rs

    • MarkdownStreamCollector is newline-gated (no change), but now commits Vec<MarkdownLogicalLine> instead of already-wrapped Vec<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.
  • codex-rs/tui2/src/history_cell.rs

    • AgentMessageCell stores Vec<MarkdownLogicalLine>.
    • HistoryCell::transcript_lines_with_joiners(width) wraps each logical line at the current width using word_wrap_line_with_joiners and composes indents as:
      • transcript gutter prefix ( / ), plus
      • markdown-provided initial/subsequent indents.
    • 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.rs

    • deferred_history_cells: Vec<Arc<dyn HistoryCell>> (replaces deferred_history_lines).
    • AppEvent::InsertHistoryCell pushes cells into the deferral list when overlay.is_some().
  • codex-rs/tui2/src/app_backtrack.rs

    • close_transcript_overlay renders 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.rs
    • agent_message_cell_reflows_streamed_prose_on_resize
    • agent_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 stored String using word_wrap_lines_with_joiners (reflowable).
  • ReasoningSummaryCell (codex-rs/tui2/src/history_cell.rs): renders from stored String on each call; it does call append_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): stores Vec<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_lines is 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-split Vec<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_markdown usage), but only if we find a concrete reflow bug beyond AgentMessageCell.