Skip to content

fix: tighten xterm session restore against busy ghosts, resize duplicates, and partial-frame snapshots#299

Merged
kbwo merged 4 commits intomainfrom
fix/rendering-2026-04-30
Apr 30, 2026
Merged

fix: tighten xterm session restore against busy ghosts, resize duplicates, and partial-frame snapshots#299
kbwo merged 4 commits intomainfrom
fix/rendering-2026-04-30

Conversation

@kbwo
Copy link
Copy Markdown
Owner

@kbwo kbwo commented Apr 30, 2026

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.

  • Drop scrollback ghosts from Claude session restore (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. Add StateDetector.hasTransientRenderFooter(terminal) (Claude detector inspects the live viewport for spinner activity, token stats, or esc/ctrl+c to interrupt) and fall back to a viewport-only snapshot when it's true. On busy → non-busy transitions advance restoreScrollbackBaseLine to the current baseY so the next restore skips the scrollback range that captured ghost frames during the just-ended turn.
  • Scope transient-footer detection to busy markers only (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).
  • Drop duplicate viewport rows after a terminal height shrink (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. Add SessionManager.performResize which resizes PTY + headless terminal, marks the session ‘resizing’ for 250 ms (suppressing PTY → stdout while still feeding the headless terminal), and emits a sessionResize event carrying a viewport-only repaint payload wrapped in \x1b[?7h\x1b[2J\x1b[H<snap>\x1b[?7l. Session.tsx wires the existing stdout.on('resize', …) listener through the new method and writes the payload.
  • Defer busy session restore until PTY output is quiet (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 for RESTORE_DEFER_QUIET_MS (80 ms) of PTY silence, resetting on every chunk, capped at RESTORE_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.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. 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[?7l deferred 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 typecheck
  • npm run lint
  • Manual: open a busy Claude session, switch to menu and back several times — restored viewport is coherent (no half-drawn frame, no stacked footer rows).
  • Manual: shrink terminal height while a Claude session is active — no duplicate of the bottom rows below the viewport.
  • Manual: idle session re-entry still feels instant.

🤖 Generated with Claude Code

kbwo and others added 4 commits April 30, 2026 12:06
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>
@kbwo kbwo marked this pull request as ready for review April 30, 2026 15:09
@kbwo kbwo merged commit 9236d57 into main Apr 30, 2026
1 check passed
@kbwo kbwo deleted the fix/rendering-2026-04-30 branch April 30, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant