Classic renderer drops or overwrites output when anything above the visible window changes height (verbose makes it frequent)
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)
- iTerm window at 24 rows x 140 columns, verbose on.
- 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-/'"
- BRAVO and CHARLIE finish first and their output pushes the ALPHA block above the visible window while it still says "Running...".
- 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)
- The diff callback inside the renderer's
render(), near theFull reset (shrink->below)debug log.Xis the first row still inside the viewport,fis altScreen,Imeans 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.
- The full reset repaints from the old viewport top, not from the row that changed.
S.triggerYis 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];
}
- In classic mode
clearTerminalis 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.
- 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.
- 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 astriggerY. - 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.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗