Improve transcript overlay rebuild performance to scale better with session length

Open 💬 4 comments 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-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

  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 rendering — desired_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 ↗

4 Comments

ignatremizov · 1 month ago

Additional static profiling points to two hot paths that are especially relevant when one exec_command result contains a very large or character-dense file/output. References below are pinned to upstream commit c8dc8e5fd5fdfe9c249bb98d691372d87e4b3cff.

  1. PagerView::render_content delegates partially visible cells to render_offset_content. That helper allocates an offscreen buffer whose height is min(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.
  1. CellRenderable::render asks the HistoryCell for its complete transcript lines. For ExecCell, transcript_lines syntax-highlights commands, reparses ANSI content, and wraps every output line even if only a small viewport slice is visible. CellRenderable then clones the full HyperlinkLine vector, while mark_buffer_hyperlinks performs another line-count/wrapping pass even when no line contains a hyperlink.
  1. There is also a dense-screen cost in custom_terminal.rs: diff_buffers scans the viewport and stores one draw command per changed cell, and draw emits an individual Print for 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.
  1. Transcript heights are narrowed to u16 in desired_transcript_height, and render_content also 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-u16 logical 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.

mehulparmariitr · 1 month ago

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.

yaoxiaocong-sketch · 25 days ago

I can reproduce related CLI/TUI scrolling stutter on macOS. Adding an independent environment report and sanitized sampling evidence.

Environment

  • Codex CLI: 0.146.0
  • macOS: 15.6.1 (24G90)
  • Hardware: MacBook Air, Apple M4, 16 GB unified memory
  • Terminal: Ghostty 1.3.1
  • No terminal multiplexer
  • Ghostty configuration only sets theme, 16 pt font, and shell integration.

Symptom

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:

  • Overall CPU was about 88% idle.
  • The terminal emulator itself had low CPU use.
  • The active Codex CLI process had been running for about 35 hours.
  • The CLI process was about 173 MB resident at sampling time, with an observed peak physical footprint of about 901 MB.

Sanitized sample evidence

A 3-second sample of the active CLI process captured work on the TUI main thread through:

codex_tui::app::App::render_chat_widget_frame
TranscriptAreaRenderable::desired_height
ExecCell::display_lines
syntect::easy::HighlightLines::highlight_line
syntect::parsing::parser::ParseState::parse_line
ratatui::layout::layout::Layout::split_with_spacers

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.

grishy · 20 hours ago

I reproduced this on macOS while scrolling the Ctrl+T transcript overlay. A process sample placed 1889 of 1962 samples in:

CellRenderable::render_scrolled -> ExecCell::transcript_lines -> syntect -> onig

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 ExecCell command 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.