Resumed session duplicates the last message's first line(s) into scrollback when the transcript overflows the terminal (inline mode, 2.1.168)
Summary
When you --resume a session whose rendered transcript is taller than the terminal, in the default inline (non-fullscreen) renderer, a transcript line at the scrollback/viewport boundary gets duplicated in the terminal's scrollback. In the common case this is the first line(s) of the last assistant message: the ● header and its following blank appear stacked above the message body. Making the terminal tall enough that the whole transcript fits removes it entirely (zero copies).
A self-contained reproduction script is attached at the bottom of this issue. It generates a throwaway overflowing session, resumes it on whatever claude binary you point it at, and counts the duplicates. I verified it reproducing on a genuinely pristine 2.1.157 and on 2.1.168; both strand the same row, so this predates the 2.1.168 internals and is not specific to it. Linux x64, inline mode.
Code references below are to the public source leak. The structural anchors named (the frame-height line, the diff guard, renderFrameSlice, fullResetSequence, setCellAt) were located in the shipped 2.1.168 binary, and the behavioral claims were checked by instrumenting it. Exact source line ranges are cited from the leak and not independently byte-verified.
Reproduction
- Have a session whose transcript, when rendered, is taller than the terminal (history goes to native terminal scrollback in inline mode).
claude --resume <id>in a terminal shorter than the transcript.- Scroll up: a line at the scrollback/viewport boundary is duplicated, commonly the first line + blank of the last message.
- Resize the terminal so the full transcript fits and resume again. The duplication is gone.
Whether the duplicated line is a message header or a body line depends on which row lands on the boundary at the height you pick. The attached script sweeps a few heights so the boundary lands on a header. Copy count varies (two to N, observed up to 13) with how many times the view re-renders while settling.
Expected vs. actual
- Expected: every transcript line printed once.
- Actual: the line at the scrollback/viewport boundary is re-emitted into scrollback on post-mount re-renders, leaving two to N copies.
Root cause
In inline mode the frame buffer height is the full content height, not the viewport. src/ink/renderer.ts (createRenderer): const height = options.altScreen ? terminalRows : yogaHeight. So the screen buffer is yogaHeight, the entire transcript, and committed history lives in the repaintable frame and in native scrollback at the same time. (Measured on a 200x67 resume: frame about 294 to 296 rows against the 67-row viewport.)
On resume the view re-renders about a dozen times while layout settles, with byte-identical message props (these are not content changes; observed by instrumenting the render path). At least one settle grows the rendered height, for example an async StatusNotices entry rendering into the top LogoHeader chrome a beat after first paint (src/components/Messages.tsx). Each settle that grows the frame re-runs the inline writer over an already-overflowing buffer, and a boundary row at the scrollback/viewport seam gets re-emitted into scrollback. A terminal cannot clear or repaint its scrollback, so each such row is stranded as a duplicate. The growth append (renderFrameSlice(prev.screen.height, next.screen.height) in src/ink/log-update.ts) is the most direct path for this, but instrumentation did not isolate a single emitting call: the duplication also survives the full-reset path and survives suppressing the growth trigger (below), so the strand is a property of repainting an overflowing live frame, not of one specific call site.
Two findings narrowing the cause, both from instrumenting the binary, resuming a fixture, and counting the painted lines:
- It is not the diff-guard. The scrollback-reachability guard in
log-update's first-pass diff callback,if (y < viewportY) { needsFullReset = true; ... }, is unconditional in the leak but in 2.1.168 fires only foraltScreen || shrinking, so the growing case hits a barereturnand skips it. Instrumenting confirms: on a growing resume settle the binary does not take the guard, and it duplicates anyway. (I am not asserting why the condition differs from the leak, only that, as shipped, growing skips it. The bug also reproduces on 2.1.157, so it is not introduced by this change.) - A "full reset" does not fix it. The full-reset path (reached via the diff guard / the offscreen-reset site, both calling
fullResetSequence) is itself a frame repaint from the top of the frame. It re-emits across the same scrollback/viewport seam rather than retracting the rows already stranded in scrollback. Empirically, routing the growing case into the full reset made the duplication worse, not better. (The exact repaint interval the shippedfullResetSequenceuses is not pinned down here; the verified, range-independent fact is that it re-emits the seam and tested worse.)
Things that do not fix it (built and tested)
- Restoring the growing full-reset still duplicates, and is worse. The full reset re-emits across the same seam.
- Suppressing the specific
StatusNoticesentry still duplicates. Other above-the-fold settles trigger it; it is not a single-trigger bug. - Capping the frame height to the viewport alone (
height = Math.min(yogaHeight, terminalRows)) is destructive: it drops the over-height cells (setCellAtdiscardsy >= height), losing history. - The only intervention that yields zero copies is removing the overflow (a terminal tall enough for the frame to fit).
Suggested fix (proposed, not yet built or tested)
To be explicit: the structural fix below was not empirically verified. What the tests establish is the negative space. Every fix that leaves the frame overflowing still duplicates, and the only thing that reached zero copies was eliminating the overflow. The fix that follows is the structural form of "eliminate the overflow" without losing history. It is reasoned, not measured.
Bound the live, repaintable frame to at most the viewport, and commit already-scrolled history to scrollback exactly once (a true print-once / Ink <Static> style channel), so no row ever exists in both the repaintable frame and scrollback. shouldRenderStatically() in src/components/Messages.tsx already distinguishes committed messages (consumed as isStatic in MessageRow.tsx), but today that only gates React.memo and render style, not scrollback isolation. There is no <Static> print-once path for the transcript in 2.1.168. Promoting committed content into a real commit channel is the inline-mode analogue of what alt-screen already gets for free (no scrollback to strand into).
Design rationale from the investigation (predictions about the fix, not findings):
- The commit should physically move committed rows out of the repaintable buffer (emit once, then truncate), not merely mask them in the diff, otherwise a later full repaint can still find and re-emit them.
- Cap and commit need to be one operation: cap without commit loses history (see the destructive cap above); commit without cap still overflows.
- The commit boundary must be stable across re-renders. A height or count derived boundary that shifts by a row on a later settle would re-strand. The existing
computeSliceStartUUID anchor inMessages.tsxsolves the analogous stability problem at a different layer (it is a message-count render cap, not a frame-height boundary), but the count-versus-content-anchor lesson there is the relevant precedent.
Environment
- Claude Code 2.1.168 (also confirmed on 2.1.157), Linux x64, default inline (non-fullscreen) renderer.
Reproduction script
Generates a throwaway overflowing session, resumes it on the binary you pass, sweeps a few terminal heights, and reports any line stranded as a duplicate. A tall control run (transcript fits) shows zero. Self-cleaning; uses your existing logged-in config and removes the one throwaway project it creates.
<details>
<summary><code>repro_resume_scrollback_dup.sh</code></summary>
#!/usr/bin/env bash
# End-to-end reproduction for the inline-mode resume scrollback-duplication bug.
#
# It generates a throwaway session whose rendered transcript overflows a short
# terminal, resumes it on the `claude` binary you point it at (inside tmux),
# captures the full scrollback, and checks whether any transcript line was
# stranded as a duplicate.
#
# Every rendered line carries a unique tag (MSGnn-HEAD for a message's first
# line, MSGnn-Lkk for its body). On a clean render each tag appears once. The
# bug re-emits whichever line lands on the scrollback/viewport seam, so that
# tag appears twice. A HEAD duplicate is the headline symptom (the message's
# first line shows up again above its body); a body duplicate is the same bug
# landing on a different row. A tall control run (transcript fits, no overflow)
# shows zero duplicates.
#
# Usage: repro_resume_scrollback_dup.sh [path-to-claude]
# Requires: tmux, python3, and a logged-in `claude` (uses your real config;
# creates and then deletes one throwaway project session).
set -euo pipefail
CLAUDE_BIN="${1:-$(command -v claude || true)}"
COLS=200
SHORT_ROWS=(46 48 50 52) # overflow; sweep a few so the seam lands on a tag
TALL_ROWS=400 # transcript fits; control, expect zero duplicates
SETTLE=13 # seconds to let the resumed view mount and settle
[ -x "$CLAUDE_BIN" ] || { echo "no executable claude at: '$CLAUDE_BIN'" >&2; exit 2; }
command -v tmux >/dev/null || { echo "tmux required" >&2; exit 2; }
command -v python3 >/dev/null || { echo "python3 required" >&2; exit 2; }
echo "claude: $CLAUDE_BIN ($("$CLAUDE_BIN" --version 2>/dev/null))"
WORK="$(mktemp -d /tmp/ccreproXXXXXX)" # no dots: claude maps . to - in project dirs
SID="$(python3 -c 'import uuid;print(uuid.uuid4())')"
ENC="$(printf '%s' "$WORK" | sed 's#/#-#g; s#\.#-#g')"
PROJ_DIR="$HOME/.claude/projects/$ENC"
TMUX_SESS="ccrepro_$$"
cleanup() {
tmux kill-session -t "$TMUX_SESS" 2>/dev/null || true
rm -rf "$WORK" "$PROJ_DIR"
}
trap cleanup EXIT
mkdir -p "$PROJ_DIR"
python3 - "$SID" "$WORK" > "$PROJ_DIR/$SID.jsonl" <<'PY'
import json, sys, uuid
from datetime import datetime, timezone, timedelta
sid, cwd = sys.argv[1], sys.argv[2]
t0 = datetime(2026, 1, 1, tzinfo=timezone.utc)
def ts(i): return (t0 + timedelta(seconds=i)).isoformat().replace("+00:00", "Z")
def base(i):
return dict(isSidechain=False, userType="external", entrypoint="cli", cwd=cwd,
sessionId=sid, version="2.1.168", gitBranch="main", timestamp=ts(i))
recs, parent = [], None
for k in range(40):
uu = str(uuid.uuid4())
recs.append(dict(base(2*k), parentUuid=parent, type="user", uuid=uu,
promptId=str(uuid.uuid4()), promptSource="typed",
permissionMode="bypassPermissions", slug="resume-scrollback-repro",
message=dict(role="user", content=f"UMSG{k:02d}")))
au = str(uuid.uuid4())
body = (f"MSG{k:02d}-HEAD unique header line for message {k:02d}\n\n" +
"\n".join(f"MSG{k:02d}-L{j:02d} body line {j} of message {k:02d}" for j in range(3)))
recs.append(dict(base(2*k+1), parentUuid=uu, type="assistant", uuid=au, requestId=f"req_{k}",
message=dict(model="claude-opus-4-6", id=f"msg_{k}", type="message",
role="assistant", content=[dict(type="text", text=body)],
stop_reason="end_turn", stop_sequence=None,
usage=dict(input_tokens=1, output_tokens=1))))
parent = au
for r in recs:
print(json.dumps(r))
PY
echo "session: $SID ($(wc -l < "$PROJ_DIR/$SID.jsonl") records), throwaway project $(basename "$PROJ_DIR")"
# resume at COLS x rows, return the duplicated tags (one per line, empty if none)
dups_at() {
local rows="$1"
tmux kill-session -t "$TMUX_SESS" 2>/dev/null || true
tmux new-session -d -s "$TMUX_SESS" -x "$COLS" -y "$rows"
tmux send-keys -t "$TMUX_SESS" "cd $WORK && $CLAUDE_BIN -r $SID" Enter
sleep "$SETTLE"
local cap; cap="$(tmux capture-pane -p -S - -t "$TMUX_SESS")"
tmux send-keys -t "$TMUX_SESS" Escape 2>/dev/null || true
tmux send-keys -t "$TMUX_SESS" C-c 2>/dev/null || true; sleep 0.4
tmux send-keys -t "$TMUX_SESS" C-c 2>/dev/null || true; sleep 0.5
tmux kill-session -t "$TMUX_SESS" 2>/dev/null || true
printf '%s\n' "$cap" | grep -oE 'MSG[0-9]+-(HEAD|L[0-9]+)' | sort | uniq -d
}
reproduced=0
echo
echo "overflowing runs (${COLS} cols):"
for rows in "${SHORT_ROWS[@]}"; do
d="$(dups_at "$rows")"
if [ -n "$d" ]; then
reproduced=1
head_hit=""; printf '%s\n' "$d" | grep -q HEAD && head_hit=" <- message header duplicated"
echo " ${COLS}x${rows}: STRANDED $(printf '%s' "$d" | tr '\n' ' ')${head_hit}"
else
echo " ${COLS}x${rows}: clean (seam landed on a blank row)"
fi
done
echo "control run (transcript fits):"
dc="$(dups_at "$TALL_ROWS")"
echo " ${COLS}x${TALL_ROWS}: ${dc:+STRANDED }${dc:-clean}"
echo
if [ "$reproduced" -eq 1 ] && [ -z "$dc" ]; then
echo "REPRODUCED: a transcript line is duplicated into scrollback when the view overflows, and not when it fits."
exit 0
else
echo "NOT reproduced this run (overflow dup=$reproduced, control dup='${dc:-none}')."
echo "On a slow box raise SETTLE; otherwise widen SHORT_ROWS so the seam lands on a tagged row."
exit 1
fi
Sample output (pristine 2.1.168, checksum-verified against the release manifest; 2.1.157 strands the same rows):
overflowing runs (200 cols):
200x46: STRANDED MSG34-L01 MSG34-L02
200x48: STRANDED MSG34-L00
200x50: STRANDED MSG34-HEAD <- message header duplicated
200x52: clean (seam landed on a blank row)
control run (transcript fits):
200x400: clean
REPRODUCED: a transcript line is duplicated into scrollback when the view overflows, and not when it fits.
</details>
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗