tmux long-session rendering corruption: ghost status bars in scrollback, cursor hide imbalance, stale cursor after resize

Resolved 💬 3 comments Opened Apr 7, 2026 by lassen-s Closed Apr 11, 2026

Environment

  • Claude Code version: 2.1.92
  • OS: macOS Darwin 24.6.0
  • Shell: zsh inside tmux
  • Terminal: tmux-256color / truecolor
  • Model: Sonnet 4.6 (1M context)
  • Session length at repro: hours, many tool calls

Symptoms

In long-running emdash sessions inside tmux:

  1. Ghost status bar lines — The status bar (dir git:(branch) | model) appears 5–6× interleaved with conversation content in the scrollback buffer, at varying vertical positions.
  2. Cursor permanently hidden — After tmux detach/reattach or fg from a suspended session, cursor disappears (\x1B[?25l written, \x1B[?25h never reasserted).
  3. Nimbus bubble displaced — Speech bubble renders mid-conversation instead of near the input box after a resize event.
  4. Content doubling — Some output lines appear twice at slightly different positions after terminal resize.

Root Cause Analysis (binary-level investigation on v2.1.92)

By extracting strings from the Bun binary I located the relevant rendering code. Three distinct bugs:

Bug 1: Primary-buffer rendering — scrollback contamination (by design, needs opt-in fix)

The main TUI (altScreenActive = false by default) runs in the primary buffer, not the alternate screen (\x1B[?1049h). Every render tick is captured in tmux's scrollback. enterAlternateScreen() / exitAlternateScreen() exist in the codebase but are only called for specific full-screen sub-UIs.

// From binary — normal startup:
altScreenActive = false  // primary buffer is the default

This is the fundamental cause of scrollback contamination. Not easily fixable without an opt-in setting.

Bug 2 (high priority): handleResize does not invalidate displayCursor in primary-buffer mode

The renderer uses delta-based cursor repositioning:

// Simplified from binary:
if (Z !== null && !this.altScreenActive && J) {
  let x = A.cursor.x - Z.x;   // delta from last stored position
  let p = A.cursor.y - Z.y;
  M.unshift({ type: "stdout", content: moveCursor(x, p) })
}
this.displayCursor = W  // store new position

handleResize updates terminalColumns/terminalRows but does not clear displayCursor. After a resize, the delta (A.cursor.y - Z.y) is computed against a stale pre-resize position, causing the status bar to be painted at the wrong row.

Proposed fix (handleResize):

handleResize = () => {
  const H = this.options.stdout.columns || 80;
  const _ = this.options.stdout.rows || 24;
  if (H === this.terminalColumns && _ === this.terminalRows) return;
  this.terminalColumns = H;
  this.terminalRows = _;
  this.altScreenParkPatch = Aoq(this.terminalRows);
  if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) {
    // ... existing altScreen handling ...
  } else if (!this.altScreenActive) {
    // NEW: invalidate stale cursor position on resize in primary buffer mode.
    // Without this, delta-based cursor repositioning uses a pre-resize origin
    // and paints the status bar at the wrong row — producing ghost lines.
    this.displayCursor = null;
    this.prevFrameContaminated = true;
  }
};

Bug 3 (medium priority): reassertTerminalModes early-exits for primary-buffer mode

// From binary:
reassertTerminalModes = (H = false) => {
  if (!this.options.stdout.isTTY) return;
  if (this.isPaused) return;
  if (JnH()) this.options.stdout.write(wr + IlH + xlH);
  if (!this.altScreenActive) return;  // ← EARLY EXIT — primary buffer gets nothing
  // altScreen reassertion follows...
};

reassertTerminalModes is called on onStdinResume (tmux detach/reattach, shell fg). The early return for !altScreenActive means no terminal state is reasserted after resume in primary-buffer mode. If \x1B[?25l (cursor hide) was written before the detach, the cursor stays hidden indefinitely.

Proposed fix (reassertTerminalModes):

reassertTerminalModes = (H = false) => {
  if (!this.options.stdout.isTTY) return;
  if (this.isPaused) return;
  if (JnH()) this.options.stdout.write(wr + IlH + xlH);
  if (!this.altScreenActive) {
    // NEW: primary-buffer recovery on stdin resume.
    // Reassert cursor visibility and force cursor reanchor so the next
    // render delta is computed from the correct post-resume position.
    this.options.stdout.write('\x1B[?25h');  // ensure cursor visible
    this.displayCursor = null;               // force cursor reanchor
    this.prevFrameContaminated = true;       // force full repaint
    return;
  }
  // existing altScreen reassertion...
};

Workaround (for users)

reset          # full terminal state reset, clears scrollback
# or
tput reset

If inside tmux, additionally clear scrollback:

reset && tmux clear-history

A ~/.claude/scripts/terminal-reset.sh script wrapping these steps is useful for quick recovery.

Suggested Long-Term Fix (Fix 3)

Add a setting to run the main TUI in the alternate screen, preventing scrollback contamination entirely. The enterAlternateScreen/exitAlternateScreen infrastructure already exists — it needs to be wired to the main render path as an opt-in or auto-detected for tmux ($TMUX set).

// ~/.claude/settings.json
{
  "terminal": {
    "useAlternateScreen": "auto"  // "auto" | "always" | "never"
  }
}

With "auto", enable when $TMUX is set or $TERM is tmux-*.

Labels

rendering, tmux, long-session, status-bar, bug

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗