Conversation
PR #282 restores up to 200 lines of normal-buffer scrollback on session re-entry, but Claude's Ink renderer pushes earlier copies of its bottom footer (spinner activity, token stats, "esc to interrupt", "accept edits on … (shift+tab to cycle)") into scrollback whenever chat content scrolls beneath the box. Replaying that scrollback on restore paints duplicated footer rows. PR #293 tried to fix this with a viewport-only restore while busy plus a deferred refresh that issued `\x1b[2J\x1b[H<viewport>`. The refresh produced a different visual artifact: a duplicated tail of the viewport appeared below the live content with one corrupted row from interleaved characters of two footer rows. That PR was reverted in #297. Take a different path that does not rely on a deferred refresh: - Add `StateDetector.hasTransientRenderFooter(terminal)` and implement it in the Claude detector. It inspects the live viewport for spinner activity, token stats, the persistent shift+tab footer, and the "esc/ctrl+c to interrupt" hints. Driven by viewport content rather than the session state mutex so it survives the busy↔idle race. - In `getRestoreSnapshot`, fall back to a viewport-only snapshot (`scrollback: 0`) plus a cursor restore when the detector reports a transient footer. - On busy → non-busy transitions, advance `restoreScrollbackBaseLine` to the current `baseY` so subsequent restores skip the scrollback range that captured ghost frames during the just-ended busy turn. No deferred refresh, no `\x1b[2J\x1b[H` mid-flight clear, so the bottom- duplicate artifact from #293 cannot recur. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The persistent "(shift+tab to cycle)" footer is always rendered during an active Claude session even at full idle, so detecting it forced session restore into the viewport-only path on nearly every Claude re-entry and threw away the bounded 200-line scrollback recall. Scrollback ghosts only arise when scrolling happens while a transient footer is being redrawn — i.e. during a busy turn. Limit detection to busy markers (spinner activity, token stats, "esc/ctrl+c to interrupt"). The busy → non-busy baseline bump still excludes ghost-bearing ranges from a just-ended turn, so the steady-idle path safely keeps the full bounded scrollback restore. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
When the user shrinks the terminal vertically while a session is open,
Ink-based TUIs (e.g. Claude Code) re-emit their full static history on
SIGWINCH so that line wrapping adapts to the new column count. The
re-emitted rows arrive on the PTY and get forwarded straight to the
user's terminal, where they append below the (already-clipped) viewport
and produce duplicated rows equal to the resize delta.
Add `SessionManager.performResize` that owns the resize flow:
- Resize the PTY and the headless xterm so both stay in sync with the
user's terminal dimensions.
- Mark the session as "resizing" and suppress live PTY → stdout
forwarding for a 250 ms quiet window. Data still flows to the
headless terminal, so state detection and future restore snapshots
remain accurate; only the duplicated re-emission to the user's
terminal is dropped.
- Emit a fresh `sessionResize` event carrying a viewport-only repaint
payload that wraps the snapshot in DECAWM-on / `\x1b[2J\x1b[H` /
DECAWM-off so the SerializeAddon's wrapped-row encoding renders
correctly under Session.tsx's auto-wrap-disabled live mode.
Session.tsx wires `stdout.on('resize', …)` to `performResize` and
listens for `sessionResize` to write the repaint payload.
Unlike the reverted PR #293 path, there is exactly one snapshot write
per resize event with no deferred refresh, so the bottom-duplicate
artifact from #293 cannot recur.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Going from session → menu → session sometimes restored a mostly empty viewport with garbled tail rows when the TUI was busy at re-entry. The viewport-only restore introduced for busy sessions captures the headless xterm state synchronously inside setSessionActive, but Ink-based TUIs emit a single visual frame across multiple PTY chunks (cursor home → upper rows → cursor jump → lower rows → spinner box). Snapshotting between chunks freezes a half-drawn frame and paints it on the user's terminal; the live data that follows can land on top of stale rows because chunk-internal cursor positioning targets the new frame, not the snapshot. Same headless state, different chunk boundary on each re-entry, so the bug appeared randomly and went away after one more menu round-trip. Defer the snapshot when the live viewport shows a transient busy footer: - `setSessionActive(true)` schedules the emit through a quiet timer that waits for `RESTORE_DEFER_QUIET_MS` (80 ms) of PTY silence, resetting on every chunk, capped at `RESTORE_DEFER_MAX_MS` (250 ms). - While the deferral is pending, PTY data still flows into the headless terminal so the eventual snapshot is in sync, but we skip forwarding/buffering it for the user terminal — the snapshot will reproduce that data via the serialize addon. - Idle restores keep the existing synchronous path, so non-busy entries remain instant. - Session.tsx wraps the restore payload in `\x1b[?7h …\x1b[?7l` so the deferred emit, which fires after Session.tsx already disabled DECAWM for live TUI redraws, still has DECAWM on while the SerializeAddon's wrapped-row encoding is replayed. The redundant `\x1b[?7h` prefix is a no-op for the synchronous restore path. Unlike the reverted PR #293, there is exactly one snapshot write per session re-entry (no deferred refresh layered on top of an initial restore), so the bottom-duplicate artifact from #293 cannot recur. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A small stack of follow-up fixes to the xterm session restore work from #282. Each commit targets a distinct rendering glitch that was reproducible in real Claude Code sessions, with no new dependencies and the
restoringSessions/ serialize-addon model intact.31eb96e) — Ink redraws push earlier copies of the bottom footer (spinner, token stats,accept edits on …) into normal-buffer scrollback as chat content scrolls. PR fix: stabilize xterm session restore rendering #282's bounded-scrollback restore replayed those copies and stacked duplicate footer rows on re-entry. AddStateDetector.hasTransientRenderFooter(terminal)(Claude detector inspects the live viewport for spinner activity, token stats, oresc/ctrl+c to interrupt) and fall back to a viewport-only snapshot when it's true. Onbusy → non-busytransitions advancerestoreScrollbackBaseLineto the currentbaseYso the next restore skips the scrollback range that captured ghost frames during the just-ended turn.bf4dcea) — The persistent(shift+tab to cycle)footer is rendered during every active Claude session, including idle, so detecting it forced restore into the viewport-only path on virtually every Claude re-entry and threw away the bounded 200-line scrollback recall. Limit detection to busy markers (the busy → non-busy baseline bump still excludes ghost-bearing scrollback from a just-ended turn).635c7f8) — On SIGWINCH Ink-based TUIs re-emit their full static history to adapt to new line wrapping. Forwarding that re-emission appends duplicates equal to the resize delta below the (already-clipped) viewport. AddSessionManager.performResizewhich resizes PTY + headless terminal, marks the session ‘resizing’ for 250 ms (suppressing PTY → stdout while still feeding the headless terminal), and emits asessionResizeevent carrying a viewport-only repaint payload wrapped in\x1b[?7h\x1b[2J\x1b[H<snap>\x1b[?7l. Session.tsx wires the existingstdout.on('resize', …)listener through the new method and writes the payload.a7d4627) — Ink emits a single visual frame across multiple PTY chunks. Capturing the viewport-only snapshot synchronously can land between chunks and freeze a half-drawn frame; on re-entry the user sees a mostly empty viewport with a garbled tail that survives until the next menu round-trip. Defer the snapshot when the live viewport shows a transient footer: the deferred timer waits forRESTORE_DEFER_QUIET_MS(80 ms) of PTY silence, resetting on every chunk, capped atRESTORE_DEFER_MAX_MS(250 ms). PTY data still flows into the headless terminal during the wait so the eventual snapshot is in sync, but it is not forwarded or buffered for the user terminal (the snapshot will reproduce it).Session.tsxwraps the restore payload in\x1b[?7h…\x1b[?7lso the deferred emit, which fires after Session.tsx already disabled DECAWM for live TUI redraws, still has DECAWM on while the SerializeAddon's wrapped-row encoding is replayed. Idle restores keep the synchronous path.Why not just re-apply #293
PR #293 also targeted the busy/refresh problem with two snapshot writes per re-entry (initial sync emit + a
\x1b[?7h\x1b[2J\x1b[H<viewport>\x1b[?7ldeferred refresh). That refresh interleaved with live data between the two writes and produced the garbled ‘bottom-duplicate’ artifact reverted in #297. The fixes here keep at most one snapshot write per session re-entry / per resize, so that interaction surface is gone by construction.Test plan
npm run test(851 passed / 5 skipped — new tests cover the busy-marker detector, scrollback baseline bump, deferred restore timing & deadline cap, deferred-restore cancellation on deactivate, resize repaint payload, resize suppression window, inactive-session resize skip, and resize cleanup on deactivate)npm run typechecknpm run lint🤖 Generated with Claude Code