Improve transcript overlay rebuild performance to scale better with session length
Summary
Opening the transcript overlay (Ctrl+T) in the CLI TUI has lag that scales linearly with session size. A long session with hundreds of cells causes a multi-second freeze on overlay open and degraded scroll/resize performance afterward. The root cause is that the pager overlay eagerly materializes, wraps, and measures every cell in the session with zero virtualization or height caching.
Root cause analysis
The lag is produced by a chain of O(n) operations triggered at overlay-open time and on the first render frame:
1. Clone all cells — O(n)
tui/src/app_backtrack.rs:233:
self.overlay = Some(Overlay::new_transcript(
self.transcript_cells.clone(), // clones entire Vec<Arc<dyn HistoryCell>>
self.keymap.pager.clone(),
));
2. Wrap every cell into a renderable — O(n) allocations
tui/src/pager_overlay.rs:458-494 — render_cells() iterates ALL cells, creating CachedRenderable(InsetRenderable(CellRenderable(...))) for each one. Fresh heap allocations, empty caches.
3. First frame: measure every cell's height — O(n × parse_cost)
tui/src/pager_overlay.rs:146-150:
fn content_height(&self, width: u16) -> usize {
self.renderables.iter()
.map(|c| c.desired_height(width) as usize)
.sum()
}
Called on every render frame (line 158). On first call (and after any width change), each desired_height triggers transcript_lines → display_lines → full pulldown-cmark parse + syntect syntax highlighting from raw markdown source per cell (history_cell.rs:606-624). CachedRenderable caches per-width after first pass, but that first pass is the killer.
4. Linear scan to find visible viewport — O(n)
tui/src/pager_overlay.rs:182-215 — render_content iterates from cell 0, summing heights to locate the scroll offset. No spatial index, no prefix-sum array. For scroll positions near the bottom of a large session, this walks through all preceding cells.
5. Every new cell invalidates all caches
insert_cell at line 506 calls render_cells(&self.cells, ...) again — fresh CachedRenderable wrappers with empty caches. consolidate_cells and replace_cells also call rebuild_renderables() (line 663), which does the same full rebuild. Next frame re-parses everything.
6. No render cache on cells themselves
AgentMarkdownCell stores raw markdown source and re-parses it (pulldown-cmark + syntect) on every call to display_lines / transcript_lines / desired_transcript_height. There is no width-keyed cache on the cell. The CachedRenderable wrapper caches height but not rendered lines, and CellRenderable::render() calls transcript_lines again even after height was cached — double-parse per visible cell on first frame.
7. Resize reflow walks all cells
tui/src/app/resize_reflow.rs:455-512 — render_transcript_lines_for_reflow walks backward through all transcript_cells calling display_lines_for_mode() on each. Bounded by terminal-specific row caps (1k–10k rows of output), but each cell's rendering is uncached markdown parse + wrap.
Why normal chat mode is NOT affected
The inline chat view (ChatWidget::as_renderable) renders only 3 fixed children per frame: active cell, active hook cell, bottom pane. Historical cells are written once to terminal scrollback — the terminal itself is the retained-mode viewport. Normal mode is O(1) per frame regardless of session length.
What a fix looks like
- Prefix-sum height array — precompute cumulative heights, binary search for visible range instead of linear scan from top
- Width-keyed render cache on
HistoryCell— cache(Vec<Line>, u16)(rendered lines + height) per width so markdown is parsed at most once per width per cell - Viewport-only materialization — don't wrap all N cells in renderables up front; materialize only the ~20 visible ones plus a small buffer
- Incremental insert — when a new cell arrives, append one renderable and update the prefix-sum instead of rebuilding all N
- Separate height measurement from rendering —
desired_transcript_heightandrenderboth calltranscript_linesindependently; compute once, use twice
Key files
| File | Role |
|------|------|
| tui/src/pager_overlay.rs | Transcript overlay — all O(n) hot paths live here |
| tui/src/app_backtrack.rs:233 | open_transcript_overlay — clones all cells |
| tui/src/history_cell.rs:177-187, 203-221 | desired_height / desired_transcript_height — uncached markdown re-parse |
| tui/src/app/resize_reflow.rs:455-512 | render_transcript_lines_for_reflow — walks all cells on resize |
| tui/src/wrapping.rs | Stateless wrapping functions, no memoization |
| tui/src/markdown.rs / markdown_render.rs | Markdown parsing — called from scratch on every display_lines |
Steps to reproduce
- Run Codex CLI, have a long session (100+ tool calls / messages)
- Press Ctrl+T to open transcript overlay
- Observe freeze proportional to session length
- Resize the terminal while in transcript mode — observe additional lag
Related issues
- #21635 — symptom of the reflow cap: partial transcript on resume when
terminal_resize_reflow_max_rowsis capped (the cap exists precisely because uncapped reflow is O(n)) - #14098 — viewport preservation on transcript overlay exit (UX consequence of the overlay being a full rebuild)
- #10726 — CLI scroll issues in long sessions
Desktop-app equivalents (same disease, different renderer)
The Electron desktop app has the same architectural problem — eager full-history hydration with no virtualization — tracked separately:
- #11984 — App UI gets extremely slow and laggy during long sessions
- #18693 — Desktop performance collapses with large local conversation histories
- #11011 — Switching between threads is very slow
- #13809 — Long git diffs create lag in Codex App
The CLI TUI and desktop app share the same app-server backend but have completely separate renderers. This ticket covers the Rust TUI (codex-rs/tui/) rendering pipeline specifically.
Environment
- Codex CLI (Rust TUI,
codex-rs/tui/) - Any terminal, any platform
- Severity scales with session length
4 Comments
Additional static profiling points to two hot paths that are especially relevant when one
exec_commandresult contains a very large or character-dense file/output. References below are pinned to upstream commitc8dc8e5fd5fdfe9c249bb98d691372d87e4b3cff.PagerView::render_contentdelegates partially visible cells torender_offset_content. That helper allocates an offscreen buffer whose height ismin(cell_height, viewport_height + scroll_offset), renders the cell from row zero, and copies only the visible tail. Scrolling deeper into one huge cell therefore increases allocation and rendering work with the offset; traversing the cell can become effectively quadratic. This is distinct from the per-session O(n) scan described above because one cell is sufficient to reproduce it.CellRenderable::renderasks theHistoryCellfor its complete transcript lines. ForExecCell,transcript_linessyntax-highlights commands, reparses ANSI content, and wraps every output line even if only a small viewport slice is visible.CellRenderablethen clones the fullHyperlinkLinevector, whilemark_buffer_hyperlinksperforms another line-count/wrapping pass even when no line contains a hyperlink.custom_terminal.rs:diff_buffersscans the viewport and stores one draw command per changed cell, anddrawemits an individualPrintfor each cell. A one-row pager scroll over dense SVG/JS/log output changes almost the whole viewport, whereas sparse rows benefit from clear-to-end handling.u16indesired_transcript_height, andrender_contentalso narrows the partial-cell offset. A single output exceeding 65,535 wrapped rows can become height zero through conversion fallback, while deep offsets are truncated.These observations support the proposed viewport-only rendering and cached prefix-height work. A complete fix should also avoid rendering a partial cell from row zero, cache width-keyed wrapped exec output, use a non-
u16logical height/offset, and coalesce terminal draw runs where practical.Reference implementation
A tested implementation of the transcript virtualization and caching changes is available in https://github.com/ignatremizov/codex/commit/52fec9519fe52cda181a6583e201f9108e0b65c1.
Implementation update for the related reflow/resize regression surface:
The size commit carries resize dimensions through pre-render, transcript reflow, viewport adjustment, and terminal drawing, while retaining one explicit post-reflow recovery sample. Its tests cover backend-size avoidance and recovery-sample draining.
This does not replace the virtualization/caching work proposed here: the largest ordinary typing stalls in #24527 occurred inside terminal-size lookup with reflow approximately zero. This issue remains an important regression and performance review surface for the size change.
I can reproduce related CLI/TUI scrolling stutter on macOS. Adding an independent environment report and sanitized sampling evidence.
Environment
0.146.015.6.1 (24G90)1.3.1Symptom
In a long-running interactive CLI session, scrolling the visible conversation/transcript is not smooth. The stutter is local rendering behavior, not delayed model output.
At the time of the report:
Sanitized sample evidence
A 3-second
sampleof the active CLI process captured work on the TUI main thread through:This matches the suspected mechanism: transcript and exec-cell layout, syntax highlighting, and layout solving occur on the rendering path. Long-lived sessions with tool output make the resulting scroll work visible even while the machine is otherwise idle.
I have intentionally omitted conversation text, local paths, session identifiers, and raw trace files.
Expected behavior
Scrolling should remain smooth as transcript history and tool output grow. Caching rendered lines/heights by terminal width and rendering only the visible region would avoid repeated parse/highlight/layout work on the scroll path.
I reproduced this on macOS while scrolling the Ctrl+T transcript overlay. A process sample placed 1889 of 1962 samples in:
The affected shell command lines were approximately 1.9-2.2 KiB, below the existing generic 4 KiB highlighting guard.
Asked Codex CLI to create PR where we cache syntax-highlighted
ExecCellcommand lines across transcript renders and fall back to plain text for Bash command lines over 1 KiB. Wrapping and live output remain uncached, so resize and output updates stay current.https://github.com/grishy/codex/pull/1
This targets the sampled highlighting hotspot only; the broader pager layout and virtualization work remains.