How streaming output finalizes between two terminal regions
The model pushes tokens out; the terminal is a character grid you cannot rewrite once it is written. Lines that will not change go to scrollback; the tail that still might change stays in the live cell; an unclosed table is held as a whole block.
- A delta with no newline does not change the visible tailstreaming.rs L489
- The collector waits for a newline before committing sourcemarkdown_stream.rs L87
- The scanner asks whether the previous line is a headertable_holdback.rs L23
- Once confirmed, the whole table from the header stays in the tailcontroller.rs L384
- Prose lines enqueue, waiting for a tick into scrollbackcontroller.rs L343
- A tick writes stable lines as a HistoryCellstreaming.rs L399
- insert_history writes into the terminal scrollbackinsert_history.rs L3
- Only when the stream ends does the whole table finalizecontroller.rs L160
You watch the model write an answer in the terminal. Prose is fine — it grows one line at a time. Then it starts a table: header, delimiter, first data row. Column width changes with every new row. The Description that just lined up gets shoved into the next column, and the last frame’s vertical bars still print on the screen.
You scroll up for the last conclusion; the wheel moves, and history stacks onto the tail still being written. A webpage swapping DOM keeps scroll position. A terminal writes one character to stdout and the cursor walks one cell. To keep the old answer and still let the table change column width with new rows, you first have to decide which cells belong to the past and which still belong to now.
Codex splits the render into two regions. Lines that will not change enter an animation queue, wait for a commit tick to become a HistoryCell, and get stuffed into the terminal’s own scrollback with escape sequences. Lines that still might change live only in the live cell; the next frame can replace the whole block.
Source: codex-rs/tui/src/streaming/controller.rs lines 1–36
The controller tracks two lengths at once. enqueued_stable_len is how many lines went to the queue; emitted_stable_len is how many were written into scrollback. The tail starts at the enqueue boundary. Cut on emit and a queued-but-unwritten line would show up again in the live cell. Three pointers move the same way; the already-emitted slice is not allowed to turn back.
A token with no newline does not even update the tail. The smallest time unit the user sees is one line of markdown source. Draw half a table row first and the next second the structure matches — columns vanish and grow back. Unfinished source goes into a buffer; it cannot change the visible tail.
Source: codex-rs/tui/src/chatwidget/streaming.rs lines 489–492
This is a contract about who owns the cells. Characters already given to the terminal scrollback have no writable copy in-process. Scroll, search, copy — those are the terminal’s own skills; the TUI does not keep a full history viewport. The cost is you cannot change a commit. Rewrite it in another language: as long as the surface is a terminal character grid, the problem stays.
One extra row in a markdown table can change every column width. If the header already froze into scrollback on a narrow column, later wide cells cannot go back and fix it. Vertical bars miss, and ghosts stay where they were.
Ordinary prose pipes can get hit too. A line like status | owner | note looks like a header and is just a sentence. Hold too hard and that prose sits until the stream ends.
The scanner only accepts header plus delimiter. Previous line looks like a header and the next has not arrived — hold it optimistically, state PendingHeader. If the next line is ordinary prose, state goes back to None and that line enters the stable queue. Two lines match and you enter Confirmed: from the header on, the whole table stays in the tail until finalize. Prose before the table can still commit.
Source: codex-rs/tui/src/streaming/table_holdback.rs lines 21–32
The tail budget is decided by scan state. In None the budget is 0 and lines go straight to the stable queue. PendingHeader and Confirmed hold the whole block from the header start. Raw mode is also budget 0: tables stream as plain text, and column width is the user’s own terminal selection problem.
Source: codex-rs/tui/src/streaming/controller.rs lines 384–412
Pipes inside an sh fence count as code and do not trigger holdback. With several tables in a row, if the first is not finished the later ones do not finalize early either — a long multi-table answer piles up in the live region until the stream ends.
Any UI that paints a table incrementally has this problem. Column width is global; a local append rewrites already-drawn rows. Leaving the whole unclosed table in the mutable region is the smallest solution to that constraint. On the web, the analog is: do not split a table node into an immutable DOM fragment before it closes.
After stable lines enqueue, dumping them all into the terminal at once is also wrong. A slow stream wants to grow one line at a time, like typing. A fast stream wants the queue to keep up with the model. One speed makes both ends miserable.
There are also two writes in one frame. History lines go above the viewport with escape sequences; ratatui then paints the input box and the live tail. Split that into two refreshes and the user first sees history jump up a notch while the input box still sits in the old place.
Drain has two gears. Smooth emits one line per tick; CatchUp empties the current queue in one go. Depth of 8 lines, or the oldest line past 120 ms, can enter CatchUp. Exit needs depth down to 2, age down to 40 ms, and hold that for 250 ms. After exit, block another 250 ms unless you pile to 64 lines or 300 ms. The policy does not look at whether the text is a heading or a table — only queue depth and age.
Source: codex-rs/tui/src/streaming/chunking.rs lines 82–125
The timer thread only fires CommitTick at the frame interval; how many lines to pull is the policy’s job. That interval is the 120 FPS floor, so Smooth’s ceiling is 120 lines a second. On write, scrollback insert and viewport paint wrap into the same sync_update. The double buffer only writes cells that changed. The draw callback must paint a full frame — miss a block and the terminal keeps leftover glyphs from the last one.
Source: codex-rs/tui/src/tui.rs lines 954–973
Two gears handle two pressures on the same queue. Depth catches a sudden pile of lines; age catches few lines that waited too long. Multiple writes in one frame need an atomic commit — terminals use a synchronized-output protocol, the web uses one DOM replace. Clear then paint, and the in-between frame will be seen.
Claude Code: the whole message tree can still change; sync output asks the terminal first
Claude Code’s TUI is Ink. Each frame first reconciles the React tree, then cell-diffs the two screens, then decides whether to wrap DEC 2026 BSU/ESU. The probe is blunt: when supported, it avoids redraw flicker; tmux splits the packet, atomicity is already gone, and sending those 16 bytes only burdens the outer terminal — so it skips.
It has no stable region, live tail, or table holdback. A node already painted can still change next frame. The cost is reconciliation in JS. Codex hands committed lines to the terminal scrollback; the live region is already small, so every draw walks sync_update and it does not keep a terminal allowlist.
Checkedrestored-src/src/ink/terminal.ts lines 66–74 · 2026-08-22
Grok Build: history stays in-process; every chunk invalidates the cache
Grok’s pager is also a ratatui TUI, but history does not go to the terminal scrollback. Agent blocks live in-process in ScrollbackState, keyed by EntryId. A chunk appends text to the same block, immediately invalidate_cache, and marks height dirty. The next layout recomputes at the new width.
When width changes, in-process blocks can repaint from source. Codex’s already-emitted lines belong to the terminal — you wait for finalize, then build a redrawable cell from the full source. Grok’s bill is keeping every block in memory and invalidating layout cache on every chunk.
Checkedxai-grok-pager/src/scrollback/state/mod.rs lines 915–926 · 2026-08-22
A huge paragraph never breaks — where does the screen stall?
The model never emits a \n in the middle of a huge paragraph. Against push_delta only committing a render on newline, and the comment “Unterminated source is buffered by the controller and cannot change the visible tail”, write: when the visible tail updates, and when this text enters scrollback.
Advanced: if this text is actually half a table row, what does the user see with holdback on versus off?