Classic renderer drops or overwrites output when anything above the visible window changes height (verbose makes it frequent)

Status Open
Reported on v2.1.284
Maintainer reply None cached
Activity 2 comments · opened Sep 28, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

While a reply is streaming, text I was just reading disappears. Sometimes a whole chunk of thinking is wiped and only the tail is redrawn. Sometimes single lines vanish from the middle of a reply. Sometimes a tool's output never shows up at all.

It is not a scrollback limit (scrollback is set to 100,000 lines) and it happens mid-turn, not later. The session transcript always has the full content, so this is a display problem only.

Settings: verbose: true, showThinkingSummaries: true, classic renderer (tui: default).

This looks like the same thing as #85508, which has no response yet. I spent some time narrowing it down, so below is a reproducible case and what I think the cause is.

What Should Happen?

Streamed output should append to scrollback and stay there. Nothing I have already seen on screen should be erased or overwritten, and every tool result should be printed.

If the renderer has to fix up something that already scrolled out of view, printing a corrected copy below (even if that means a duplicate block in scrollback) is much better than losing text.

Error Messages/Logs

Repro 2, the frame that loses AGENTA-5 (bytes written by the CLI, 24 row window, colour codes left out).
Before this frame the top visible row is "  AGENTA-5" and the cursor is on row 20.

ESC[?2026h ESC[2D ESC[3B \r ESC[9C ESC[23A          <- up 23 rows to the top of the screen
6 \r ESC[9C ESC[1B 7 \r ESC[9C ESC[1B 8 ...         <- rewrites the digit column: 5->6, 6->7, 7->8 ...
... AGENTA-28 \r\r\n AGENTA-29 \r\r\n AGENTA-30 \r\r\n ...   <- then appends new rows, which scroll

"AGENTA-5" was overwritten on screen before it could scroll into history, so it is
never in scrollback. The model had shifted by one row because a status line above
the viewport got shorter, and that row could not be reached, so it was skipped.

Steps to Reproduce

Repro 1 (tool output never appears)

  1. iTerm window at 24 rows x 140 columns, verbose on.
  2. Ask: "Run these three Bash commands in parallel as three separate tool calls: (1) sleep 8; seq 1 40 | sed 's/^/ALPHA-/' (2) sleep 1; seq 1 40 | sed 's/^/BRAVO-/' (3) sleep 4; seq 1 40 | sed 's/^/CHARLIE-/'"
  3. BRAVO and CHARLIE finish first and their output pushes the ALPHA block above the visible window while it still says "Running...".
  4. When ALPHA finishes, its 40 lines are never printed. Scrolling back shows "Running..." under the ALPHA call forever. Part of the CHARLIE block also gets drawn twice with leftover BRAVO lines under it.

Running the same three commands one at a time, verbose on, loses nothing.

Repro 2 (lines vanish from a plain streamed reply)

Launch two background subagents that each print 30 numbered lines, then ask for both outputs to be printed in the reply. One line from each list (for example AGENTA-5 and AGENTB-12) is missing from the terminal, though the transcript has them. In the captured output, a status line above the window got one row shorter, and the next frame moved the cursor to the top row and rewrote every visible line shifted by one. The line that was on the top row was overwritten and never reached scrollback.

Claude Model

Not sure / Multiple models

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.284

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

iTerm2

Additional Information

What I think is going on

The classic renderer diffs frames and moves the cursor up to rewrite changed rows. The terminal cannot move the cursor above the top of the visible screen. In the diff, a changed row above the viewport is skipped unless the screen also shrank, so:

  • If the content above the window grows (a tool finishing, a block expanding), the change is never drawn, and from then on the renderer's idea of which row is where is off by that many rows. Later repaints land on the wrong rows and overwrite text that is on screen.
  • If the content shrinks by more than a screen, the full reset path erases every visible row and redraws only the bottom viewport's worth, so whatever was visible and had not scrolled into history yet is gone.

Verbose makes all of this much more common because tool output and thinking are shown at full height, so the live part of the reply is taller than the window most of the time.

Where it happens (minified names from the 2.1.284 bundle, so they will differ in your source)

  1. The diff callback inside the renderer's render(), near the Full reset (shrink->below) debug log. X is the first row still inside the viewport, f is altScreen, I means the frame shrank:
if (ye < X) {
  if (f || I || ye >= G) return ue = !0, ce = ye, !0; // -> full reset ("offscreen"), triggerY = ce
  return;                                             // classic + not shrinking: change is dropped
}

The bare return is repro 1 (ALPHA's output) and the drift in repro 2.

  1. The full reset repaints from the old viewport top, not from the row that changed. S.triggerY is passed in but not used to pick the start row:
function ql(n, u, f, m, g, S) {
  let R = m ? 0 : Math.min(g, Math.max(0, n.screen.height - n.viewport.height + 1)), ...
  return yg(T, n, R, n.screen.height, f),
    [{ type: "clearTerminal", reason: u, altScreen: m, viewportRows: n.viewport.height, debug: S }, ...T.diff];
}
  1. In classic mode clearTerminal is written as home, then erase-line plus cursor-down for every visible row, then home (ESC[H (ESC[2K ESC[1B) x rows ESC[H). Those rows are erased, not pushed into scrollback, so anything visible that had not scrolled yet is lost.
  1. Relative cursor moves are clamped to the viewport in the writer (case "cursorMove": Math.max(-p, Math.min(p, c.y))), which is why an up-move meant for a scrolled-out row lands on row 1 and overwrites it.
  1. The shrink-by-more-than-a-viewport branch (if (ae > n.viewport.height) return ql(u, "offscreen", ...)) passes no anchor at all, so it can only repaint the bottom.

A possible fix (untested suggestion, not a verified patch)

  • In the classic path, treat any changed row above the viewport as a reset anchored at the first changed row, instead of dropping it.
  • In the classic reset, start painting at min(R, triggerY) so the changed rows are printed again below, and for the shrink branch compute the first row that differs between the two frames and pass it as triggerY.
  • Rather than erasing the visible rows, let them scroll into history. A duplicate block in scrollback is much better than lost text.
  • One thing to watch: if something above the viewport ticks without changing height (a timer), this could reprint often, so it may want to fire only when the rows above the viewport actually shifted.

View original on GitHub ↗

This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗