Improve transcript overlay rebuild performance to scale better with session length

Open 💬 1 comment Opened May 9, 2026 by oxysoft

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-494render_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_linesdisplay_linesfull 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-215render_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-512render_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

  1. Prefix-sum height array — precompute cumulative heights, binary search for visible range instead of linear scan from top
  2. 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
  3. Viewport-only materialization — don't wrap all N cells in renderables up front; materialize only the ~20 visible ones plus a small buffer
  4. Incremental insert — when a new cell arrives, append one renderable and update the prefix-sum instead of rebuilding all N
  5. Separate height measurement from renderingdesired_transcript_height and render both call transcript_lines independently; 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

  1. Run Codex CLI, have a long session (100+ tool calls / messages)
  2. Press Ctrl+T to open transcript overlay
  3. Observe freeze proportional to session length
  4. 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_rows is 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

View original on GitHub ↗

This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗