[BUG] Scrolling up is broken while a long response streams: the live frame outgrows the viewport and repaints reset the screen
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?
Summary
While Claude is streaming a long response, scrolling up in the terminal to re-read earlier messages does not work. The view will not hold where I put it, and the content above jumps around or goes stale. Once the turn finishes, scrolling behaves normally again. So the trigger is specifically the text being filled in during streaming, not session length and not idle state.
The underlying reason is that on the classic (non-fullscreen) renderer, the "live" frame Claude repaints on every streaming tick is allowed to grow taller than the terminal viewport. Once it does, the renderer can no longer address the rows that have scrolled above the top of the screen, and it takes one of two bad paths on every tick: silently skip the changed rows, or blow away the whole screen and reprint it. Both fight the terminal's own scrollback while you are trying to scroll it.
Environment
- Claude Code 2.1.263 (native install,
GIT_SHA 37ae3f38d765199d54a6913cd61c6c9ad8576cc6,BUILD_TIME 2026-09-06T01:08:56Z) - macOS 13.6, iTerm2 3.6.6,
TERM=xterm-256color, no tmux - Renderer: classic /
default, not fullscreen. Notuikey insettings.json, and the cached gatetengu_pewter_brookisfalse, so the resolver lands on the classic renderer. This matters because the fullscreen renderer has its own separate scroll machinery, and the analysis below does not apply to it.
Steps to reproduce
- On the classic renderer (
"tui": "default"insettings.jsonforces it), start a session and let the transcript fill past one screen. - Ask something that produces a long streaming answer, long enough that the in-progress message alone is taller than the terminal window.
- While it is still streaming, scroll up to read an earlier message.
What Should Happen?
Scrolling up during streaming should hold position, the same way it does in tail -f in a scrollback-capable terminal or in less +F. Output being appended below should never move, blank, or reprint content that is already above the viewport. Content that has scrolled into the terminal's scrollback should be effectively read-only from the TUI's point of view.
Error Messages/Logs
# No user-visible error. The relevant instrumentation is already in the binary but off by default:
CLAUDE_CODE_DEBUG_REPAINTS=1 claude --debug --debug-file /tmp/cc-repaint.log
# Then reproduce and grep. These three strings are the smoking guns, in order of usefulness:
grep -F '[REPAINT] full reset' /tmp/cc-repaint.log # fires on the offscreen-reset path, and prints the culprit owner chain
grep -F 'Full reset (shrink->below)' /tmp/cc-repaint.log
grep -F 'Slow render:' /tmp/cc-repaint.log
# The [REPAINT] line has exactly the shape a maintainer needs:
# [REPAINT] full reset · offscreen · row <N>
# prev: "<the row as it was>"
# next: "<the row as it should be>"
# culprit: <component owner chain that changed an unaddressable row>
Additional Context
Root cause
I pulled the JS out of the shipped 2.1.263 binary and traced this. Everything in this section is read directly out of that bundle. Symbols are minified so I am describing by behavior and by the log strings, which are greppable and map back to real source.
The renderer keeps a virtual screen that can be taller than the real viewport
The classic renderer diffs a previous frame against a next frame. Before diffing it computes a floor:
let Y = S ? 1 : 0,
X = W ? Math.max(0, t.screen.height - t.viewport.height + Y)
: Math.max(t.screen.height, s.screen.height) - s.viewport.height + Y;
t is the previous frame, s is the next one, W means the frame grew. X is the number of rows at the top of the virtual screen that have already scrolled above the terminal viewport and are therefore no longer addressable with cursor movement.
When the streaming message is shorter than the window, X is 0 and everything is fine. When the streamed message grows past the window height, X becomes positive and keeps climbing as the message grows.
Every changed row above that floor hits one of two bad paths
This is the diff walker's very first check, and it is where the bug lives:
if (ue < X) { if (c || z) return ce = !0, le = ue, !0; return }
ue is the row, c is "alt screen active" (false on the classic renderer), z is "the frame shrank".
So on the classic renderer:
- Frame grew, row above the floor changed: the change is silently dropped. Nothing repaints that row. Whatever is on screen and in scrollback for that row stays stale.
- Frame shrank, row above the floor changed: bail out to a full-screen reset.
The full reset is what destroys scroll position
The bail-out calls the reset builder, which emits a clearTerminal op followed by a reprint of the entire virtual screen:
function Za(t, s, c, f, m, y) {
let b = f ? 0 : Math.min(m, Math.max(0, t.screen.height - t.viewport.height + 1)),
S = new Kd({ x: 0, y: b }, t.viewport.width);
return ng(S, t, b, t.screen.height, c),
[{ type: "clearTerminal", reason: s, altScreen: f, viewportRows: t.viewport.height, debug: y }, ...S.diff];
}
On the classic renderer clearTerminal resolves to home the cursor, erase every visible row, and home again:
case "clearTerminal": i += l.altScreen ? eBn() : Mat(l.viewportRows); break;
// Mat(t) => CSI H + (CSI 2K + cursor-up).repeat(t) + CSI H
and then ng() reprints the whole frame, advancing row to row by emitting newlines. When the frame is taller than the viewport, that reprint pushes another copy of the block through the terminal and lands the cursor back at the bottom. That is the moment the scroll position is lost, and it is also why bands of already-emitted lines show up duplicated in scrollback.
There are three separate entrances to this reset, all of them shrink-driven, all reachable mid-stream:
- the
ue < Xbail above, withreason: "offscreen" if (z) { let fe = t.screen.height - s.screen.height; if (fe > t.viewport.height) return Za(s, "offscreen", ...) }if (S && T && N) return n("Full reset (shrink->below): ..."), Za(s, "offscreen", ...)
Why a streaming message triggers shrinks at all
This is the one step I could not confirm statically, and it is the part most worth a maintainer's eye. A full reset needs the frame to shrink, which is not the obvious thing for a message that is growing. The plausible mechanism is that markdown re-rendering does not grow monotonically as tokens arrive: a fence closing re-styles a block, a table reflows its column widths when a wider row lands, a list re-wraps, a long word moves to the next line. Any of those can make the rendered block briefly shorter than it was on the previous tick. Because the frame is already taller than the viewport, a shrink of even one row above the floor is enough to trigger the reset.
The culprit: field on the [REPAINT] log line was clearly built to answer exactly this question. Running the repro with CLAUDE_CODE_DEBUG_REPAINTS=1 should name the component directly.
Suggested direction
The root problem is that the live frame is permitted to exceed the viewport height at all. A few options, roughly in order of how well they address it:
- Cap the live region at the viewport height and commit completed lines of the streaming message into scrollback as they finalize, so the repainted region stays small and addressable. This is essentially what the fullscreen renderer already does with its committed-lines mirror.
- Make block heights monotonic during streaming, so an in-progress block never shrinks. Reserving the previous height until the block is final would remove the shrink trigger entirely.
- At minimum, stop emitting a full reset while the user is scrolled away from the bottom. Right now the reset path does not consult scroll state at all.
Option 1 also fixes the silent-drop half of the bug, which option 3 does not.
Related but distinct
- #87450 reports the same symptom family (scrollback going away when streaming arrives while scrolled up) but no mechanism. If the analysis above holds, that issue is likely the same root cause.
- #86826 is the fullscreen renderer re-pinning to bottom on parallel agent output. Different renderer, different code path.
- #89081 is a rolling window while idle in the VS Code extension, explicitly not during streaming.
- #84247 and #46834 describe duplicated bands of lines in scrollback after relayouts, which is the same full-reset reprint seen from a different trigger.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗