tmux long-session rendering corruption: ghost status bars in scrollback, cursor hide imbalance, stale cursor after resize
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:
- 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. - Cursor permanently hidden — After tmux detach/reattach or
fgfrom a suspended session, cursor disappears (\x1B[?25lwritten,\x1B[?25hnever reasserted). - Nimbus bubble displaced — Speech bubble renders mid-conversation instead of near the input box after a resize event.
- 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
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗