OpenAI Codex · Terminal UI

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.

Course goalAfter this lesson you can explain two things: which lines cannot change once they enter the terminal scrollback; and why an unfinished markdown table must stay in the live region until the stream ends, then finalize in one shot.
Try it first · Which lines are already locked, and why the table still jitters
The same short text with a table — watch it finalize between the two regions
Table holdback
Turn it off and the header locks to a narrow column; later wide cells cannot change it.
Fake terminal · stable region above, tail below Finalized 0 Tail 0 Scan None
Stable regionCommit locks it
Live tailMutable
Waiting to start.
The input box stays put.
Logic trail · each animation step maps to a source span
  1. A delta with no newline does not change the visible tailstreaming.rs L489
  2. The collector waits for a newline before committing sourcemarkdown_stream.rs L87
  3. The scanner asks whether the previous line is a headertable_holdback.rs L23
  4. Once confirmed, the whole table from the header stays in the tailcontroller.rs L384
  5. Prose lines enqueue, waiting for a tick into scrollbackcontroller.rs L343
  6. A tick writes stable lines as a HistoryCellstreaming.rs L399
  7. insert_history writes into the terminal scrollbackinsert_history.rs L3
  8. Only when the stream ends does the whole table finalizecontroller.rs L160
Hit Play to see a short text with a table finalize between the stable region and the live tail.
Locked linesOnce prose enters the stable region, a later column-width change cannot touch it.
Held tablesWith holdback on, the whole table reshuffles together in the tail. Turn it off and the header locks to a narrow column; wide cells have to line up on their own.
When it finalizesWhile the stream is still moving, the table cannot enter scrollback. After finalize, the whole block commits once.
Teaching sketch: the short text and column widths are lesson settings. Trail line numbers map to openai/codex commit 4f39251a01.
Idea 1 · Lines that will not change go to the terminal itself
What problem it solves

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.

What the idea is

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

Model delta arrives by token Newline gate half-line stays buffered Two-region split stable lines / live tail Stable region after a tick, into scrollback Live tail next frame can replace the block Emitted lines are not taken back; the tail starts at the enqueue edge
Teaching structure diagram: one stream, first through the newline gate, then split into locked history and a still-mutable tail.
Why it lasts

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.

Idea 2 · Until the table closes, the whole block stays in the tail
What problem it solves

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.

What the idea is

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.

None lines go to the stable queue looks like header PendingHeader hold first, wait for next delimiter Confirmed from header on, stay in tail prose arrives Back to None, release finalize, then one commit Only header plus delimiter; ordinary piped prose is not held to stream end
Teaching state diagram: optimistic hold only waits a moment; once confirmed, the whole table waits for stream end.
Why it lasts

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.

Commit locks it. An unclosed table stays held.
Idea 3 · The queue has two speeds; writes in one frame must travel together
What problem it solves

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.

What the idea is

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

Stable queue queued by arrival time Smooth one line per tick CatchUp empty the current queue one sync_update history insert and viewport paint together Enter threshold is higher than exit, so it does not chatter around 8 lines
Teaching sequence diagram: how many lines to pull is queue pressure; the two writes must wrap in the same frame.
Why it lasts

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.

Side-by-side · Where history lives decides which layers you need

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.

Checked restored-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.

Checked xai-grok-pager/src/scrollback/state/mod.rs lines 915–926 · 2026-08-22
Classroom Exercise
01

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?

Takeaway: A token with no newline is invisible. Lines that will not change enter the terminal scrollback — commit locks them. Lines that still might change, especially an unclosed table, live only in the live tail and finalize once when the stream ends.