[BUG] Inline TUI leaks blank rows on every frame shrink (grow = real newlines, shrink = erase-line only) — redraw workarounds cannot reclaim them; please commit finished content to native scrollback

Status Open
Reported on v2.1.235
Maintainer reply None cached
Activity 1 comment · opened Aug 19, 2026

Summary

In the inline (tui: "default") renderer, every frame shrink permanently leaks blank rows into the terminal: the renderer grows its frame with real newline scrolls, but shrinks it with per-row erase-line + cursor-up. Erased rows are never reclaimed — they accumulate as a growing blank band below the live frame (the "response floats high with dead space" symptom of #52731, which was auto-closed stale without a fix).

We captured the exact byte streams and can show the asymmetry, and — new data point — the documented workarounds do not actually work: a /tui default no-op toggle (the redraw workaround from #52731) repaints the frame but does not reclaim the blank rows, because no application-side byte sequence can delete rows from a terminal's normal-screen buffer. Only a window resize "fixes" it, and only because the terminal emulator rebuilds its buffer.

Environment

  • Claude Code 2.1.235, macOS 15 (arm64)
  • Reproduces in every terminal we tested (xterm.js-based, Terminal.app; #52731 reported the same across iTerm2, kitty, Ghostty, WezTerm, VS Code, Warp) — it is app-side, not a terminal bug (Ghostty maintainers reached the same conclusion in ghostty-org/ghostty#10456)

Reproduction

  1. Run claude inline (/tui default).
  2. Trigger any large frame shrink — the easiest: spawn several parallel subagents/teammates and let the agent-status list collapse as they finish. Long streaming answers with wide tables also trigger it (that was #52731's repro).
  3. After the shrink: a multi-row blank band sits below the live frame. Ctrl+L and /tui default repaint but do not remove it. It grows with each grow/shrink cycle over a session.

Byte-level evidence

Captured from the PTY during an agent-list teardown (2.1.235). Frame growth emits real newlines (scrolls rows into existence — irreversible):

\e[?25l\e[2D\e[9B\r\e[2C\e[9A\e[2m<queued input>\e[22m\r\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\e[2C\e[9A\e[?25h

Frame shrink blanks rows in place and walks away (rows never reclaimed):

\e[?25l\e[2D\e[9B\e[2K\e[1A\e[2K\e[1A\e[2K\e[G\e[1A ... \e[K\r\e[1B\e[K\r\e[1B\e[K\r\e[2A\e[2C\e[6A\e[?25h
\e[?25l\e[2D\e[6B\e[2K\e[1A\e[2K\e[1A\e[2K\e[G\e[1A\e[K\r\e[1B\e[K\r\e[1B\e[K\r\e[2A\e[2C\e[3A\e[?25h

Net effect of any grow→shrink cycle: the grown rows remain as orphaned blanks. This matches Ink's known row-accounting limitation (vadimdemedes/ink#907, closed not-planned); Claude Code's renderer inherits the same erase-only shrink path.

Why no workaround exists outside the app

We verified empirically (against an xterm.js emulator) that external repair is impossible: PTY resize jiggles (the renderer's diff correctly ignores them), full app redraws (paint over rows; cannot delete them), and even direct emulator-buffer manipulation (races the renderer's private frame bookkeeping — any external actor editing the shared screen while the renderer keeps writing with its own coordinates corrupts output). The rows are terminal-buffer state that only the emulator can remove, and only the app knows which rows it has abandoned. The fix has to live in the renderer's write path.

The proven fix pattern

Commit finished content into the terminal's native scrollback via real scroll operations, and only ever repaint a small live region at the bottom:

  • OpenAI's Codex CLI does exactly this (codex-rs/tui/src/insert_history.rs, ratatui Terminal::insert_before): "Codex uses the terminal scrollback itself for finalized chat history." The blank-band failure mode is structurally impossible there — committed rows are terminal-owned and never revisited.
  • Ink itself ships the same pattern as <Static>: content that is done changing renders once, permanently, above the dynamic region.

If Claude Code's inline renderer routed committed turns through a scrollback-commit path (its equivalent of <Static>/insert_before) and kept only the input box + status as the repainted live region, the band could not form — and inline mode would also gain rock-solid native scrollback for free.

Happy to provide fuller PTY captures if useful.

Related

  • #52731 (same symptom, auto-closed stale, no fix landed)
  • vadimdemedes/ink#907 (the underlying row-accounting limitation, closed not-planned)

View original on GitHub ↗

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