fix: resolve terminal resize scrollback ghost renders

- Switch resize handler to 300ms trailing-edge debounce for single reflow
- Add \x1b[3J (Erase Saved Lines) to clear scrollback reflow debris
- Remove client-side cursor-up flicker filter and DEC 2026 marker
  stripping — xterm.js 6.0 handles synchronized output natively
- Remove server-side DEC 2026 wrapping to prevent premature sync exit
  from non-reference-counted nested markers
This commit is contained in:
zhouyuan
2026-03-12 01:04:21 +08:00
parent 2329dab240
commit 28dfc279d4
3 changed files with 97 additions and 154 deletions
+88 -144
View File
@@ -758,40 +758,73 @@ class CodemanApp {
const MIN_ROWS = 10;
const throttledResize = () => {
if (this._resizeTimeout) return;
// Trailing-edge debounce: ALL resize work (fit + clear + SIGWINCH) happens
// once after the user stops resizing. During active resize, the terminal
// stays at its old dimensions for up to 300ms.
//
// Why not fit() immediately? Each fitAddon.fit() reflows content at the
// new width — lines that were 7 rows become 10, and the overflow gets
// pushed into scrollback. With continuous resize events, this creates
// dozens of intermediate reflow states in scrollback, appearing as
// duplicate/garbled content when the user scrolls up.
//
// By deferring fit() to the trailing edge, there's exactly ONE reflow
// at the final dimensions, ONE viewport clear, and ONE Ink redraw.
if (this._resizeTimeout) {
clearTimeout(this._resizeTimeout);
}
this._resizeTimeout = setTimeout(() => {
this._resizeTimeout = null;
// Fit xterm.js to final container dimensions
if (this.fitAddon) {
this.fitAddon.fit();
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
if (this.activeSessionId && !keyboardUp) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// Only send resize if dimensions actually changed
if (!this._lastResizeDims ||
cols !== this._lastResizeDims.cols ||
rows !== this._lastResizeDims.rows) {
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
}).catch(() => {});
}
}
// Flush any stale flicker buffer before clearing viewport
if (this.flickerFilterBuffer) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flushFlickerBuffer();
}
// Clear viewport + scrollback for Ink-based sessions before sending SIGWINCH.
// fitAddon.fit() reflows content: lines at old width may wrap to more rows,
// pushing overflow into scrollback. Ink's cursor-up count is based on the
// pre-reflow line count, so ghost renders accumulate in scrollback.
// Fix: \x1b[3J (Erase Saved Lines) clears scrollback reflow debris,
// then \x1b[H\x1b[2J clears the viewport for a clean Ink redraw.
const activeResizeSession = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
if (activeResizeSession && activeResizeSession.mode !== 'shell' && !activeResizeSession._ended
&& this.terminal && this.isTerminalAtBottom()) {
this.terminal.write('\x1b[3J\x1b[H\x1b[2J');
}
// Skip server resize while mobile keyboard is visible — sending SIGWINCH
// causes Ink to re-render at the new row count, garbling terminal output.
// Local fit() still runs so xterm knows the viewport size for scrolling.
const keyboardUp = typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible;
if (this.activeSessionId && !keyboardUp) {
const dims = this.fitAddon.proposeDimensions();
// Enforce minimum dimensions to prevent layout issues
const cols = dims ? Math.max(dims.cols, MIN_COLS) : MIN_COLS;
const rows = dims ? Math.max(dims.rows, MIN_ROWS) : MIN_ROWS;
// Only send resize if dimensions actually changed
if (!this._lastResizeDims ||
cols !== this._lastResizeDims.cols ||
rows !== this._lastResizeDims.rows) {
this._lastResizeDims = { cols, rows };
fetch(`/api/sessions/${this.activeSessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows })
}).catch(() => {});
}
}
// Update subagent connection lines when viewport resizes
// Update subagent connection lines and local echo at new dimensions
this.updateConnectionLines();
// Re-render local echo overlay at new cell dimensions/positions
if (this._localEchoOverlay?.hasPending) {
this._localEchoOverlay.rerender();
}
}, 100); // Throttle to 100ms
}, 300); // Trailing-edge: only fire after 300ms of no resize events
};
window.addEventListener('resize', throttledResize);
@@ -1293,56 +1326,13 @@ class CodemanApp {
const session = this.activeSessionId ? this.sessions.get(this.activeSessionId) : null;
const flickerFilterEnabled = session?.flickerFilterEnabled ?? false;
// Always buffer Ink's cursor-up redraws regardless of flicker filter setting.
// Ink's status bar updates use cursor-up + erase-line + rewrite, which can split
// across render frames causing old/new status text to overlap (garbled output).
// Buffering for 50ms ensures the full redraw arrives atomically.
//
// Shell mode is excluded: shell readline also uses cursor-up for prompt redraws
// (e.g. zsh syntax highlighting on every keystroke), and there's no Ink status bar
// to protect. Applying the filter in shell mode delays character feedback until the
// user stops typing for 50ms, making the terminal feel unresponsive.
const isShellMode = session?.mode === 'shell';
const hasCursorUpRedraw = !isShellMode && /\x1b\[\d{1,2}A/.test(data);
if (hasCursorUpRedraw || (this.flickerFilterActive && !flickerFilterEnabled)) {
this.flickerFilterActive = true;
this.flickerFilterBuffer += data;
// xterm.js 6.0 handles DEC 2026 synchronized output natively — Ink's cursor-up
// redraws are wrapped in 2026h/2026l markers and rendered atomically by xterm.js.
// No client-side cursor-up detection/buffering needed. The old 50ms flicker filter
// was actively harmful: it accumulated multiple resize redraws and flushed them
// together, causing stacked ghost renders due to reflow line-count mismatches.
// Only reset the 50ms timer on cursor-up events (start of a new Ink redraw cycle).
// Non-cursor-up events while the filter is active are trailing data from the same
// redraw — don't extend the deadline further. Without this guard, a busy Claude
// session emitting terminal data faster than SYNC_WAIT_TIMEOUT_MS never flushes,
// accumulating MBs in flickerFilterBuffer that freeze Chrome all at once.
if (hasCursorUpRedraw) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
}
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window
} else if (!this.flickerFilterTimeout) {
// Safety: if no timer is running for some reason, ensure we eventually flush.
this.flickerFilterTimeout = setTimeout(() => {
this.flickerFilterTimeout = null;
this.flushFlickerBuffer();
}, SYNC_WAIT_TIMEOUT_MS);
}
// Safety valve: if buffer grew very large (e.g. from a burst before the timer fired),
// flush immediately to avoid writing a huge block all at once.
if (this.flickerFilterBuffer.length > 256 * 1024) {
if (this.flickerFilterTimeout) {
clearTimeout(this.flickerFilterTimeout);
this.flickerFilterTimeout = null;
}
this.flushFlickerBuffer();
}
return;
}
// Opt-in flicker filter: also buffer screen clear patterns
// Opt-in flicker filter: buffer screen clear patterns (for sessions that enable it)
if (flickerFilterEnabled) {
const hasScreenClear = data.includes('\x1b[2J') ||
data.includes('\x1b[H\x1b[J') ||
@@ -1375,34 +1365,10 @@ class CodemanApp {
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
if (this.pendingWrites.length > 0 && this.terminal) {
// Join chunks for sync marker detection
const pending = this.pendingWrites.join('');
// Check if we have an incomplete sync block (SYNC_START without SYNC_END)
const hasStart = pending.includes(DEC_SYNC_START);
const hasEnd = pending.includes(DEC_SYNC_END);
if (hasStart && !hasEnd) {
// Incomplete sync block - wait for more data (up to 50ms max)
if (!this.syncWaitTimeout) {
this.syncWaitTimeout = setTimeout(() => {
this.syncWaitTimeout = null;
// Force flush after timeout to prevent stuck state
this.flushPendingWrites();
}, 50);
}
this.writeFrameScheduled = false;
return;
}
// Clear any pending sync wait timeout
if (this.syncWaitTimeout) {
clearTimeout(this.syncWaitTimeout);
this.syncWaitTimeout = null;
}
this.flushPendingWrites();
}
// xterm.js 6.0 handles DEC 2026 sync markers natively — it buffers
// content between 2026h/2026l and renders atomically. No need for
// client-side incomplete-block detection; just flush every frame.
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
}
@@ -1487,57 +1453,35 @@ class CodemanApp {
if (this.pendingWrites.length === 0 || !this.terminal) return;
const _t0 = performance.now();
// Extract segments, stripping DEC 2026 markers
// This implements synchronized output for xterm.js which doesn't support DEC 2026 natively
const _joinedLen = this.pendingWrites.reduce((s, w) => s + w.length, 0);
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
// xterm.js 6.0+ natively handles DEC 2026 synchronized output markers.
// Pass raw data through — xterm.js buffers content between markers and
// renders atomically, eliminating split-frame Ink redraws.
const joined = this.pendingWrites.join('');
this.pendingWrites = [];
const _joinedLen = joined.length;
if (_joinedLen > 16384) _crashDiag.log(`FLUSH: ${(_joinedLen/1024).toFixed(0)}KB`);
const segments = extractSyncSegments(joined);
// Write segments respecting a per-frame byte budget.
// Each DEC 2026 sync segment is a complete Ink redraw — writing whole segments
// preserves atomicity (no flicker). But when total data exceeds 48KB, defer
// remaining segments to the next frame to prevent terminal.write() from blocking
// the main thread. 141KB single-frame writes have been observed to freeze Chrome
// for 2+ minutes even with the canvas renderer.
// Per-frame byte budget to prevent main thread blocking.
// Large writes (141KB+) can freeze Chrome for 2+ minutes.
const MAX_FRAME_BYTES = 65536; // 64KB budget per frame
let bytesThisFrame = 0;
let deferred = false;
for (let i = 0; i < segments.length; i++) {
const segment = segments[i];
if (!segment) continue;
const content = segment.startsWith(DEC_SYNC_START)
? segment.slice(DEC_SYNC_START.length)
: segment;
if (!content) continue;
// If we'd exceed the budget, defer this and all remaining segments
if (bytesThisFrame > 0 && bytesThisFrame + content.length > MAX_FRAME_BYTES) {
// Re-queue remaining segments as raw content for next flush
const remaining = segments.slice(i).map(s => {
if (!s) return '';
return s.startsWith(DEC_SYNC_START) ? s.slice(DEC_SYNC_START.length) : s;
}).filter(Boolean).join('');
if (remaining) {
this.pendingWrites.push(remaining);
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
}
}
deferred = true;
break;
if (_joinedLen <= MAX_FRAME_BYTES) {
this.terminal.write(joined);
} else {
// Write first chunk now, defer rest to next frame
this.terminal.write(joined.slice(0, MAX_FRAME_BYTES));
this.pendingWrites.push(joined.slice(MAX_FRAME_BYTES));
deferred = true;
if (!this.writeFrameScheduled) {
this.writeFrameScheduled = true;
requestAnimationFrame(() => {
this.flushPendingWrites();
this.writeFrameScheduled = false;
});
}
this.terminal.write(content);
bytesThisFrame += content.length;
}
const bytesThisFrame = deferred ? MAX_FRAME_BYTES : _joinedLen;
const _dt = performance.now() - _t0;
if (_dt > 100 || deferred) console.warn(`[CRASH-DIAG] flushPendingWrites: ${_dt.toFixed(0)}ms, ${(bytesThisFrame/1024).toFixed(0)}KB written${deferred ? ', rest deferred' : ''} (total ${(_joinedLen/1024).toFixed(0)}KB)`);
+2 -2
View File
@@ -79,11 +79,11 @@ function scheduleBackground(fn) {
else { requestAnimationFrame(fn); }
}
// DEC mode 2026 - Synchronized Output
// DEC mode 2026 - Synchronized Output (xterm.js 6.0+ handles natively)
// Wrap terminal writes with these markers to prevent partial-frame flicker.
// Terminal buffers all output between markers and renders atomically.
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
// xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves.
// xterm.js 6.0+ supports DEC 2026 natively. Constants kept for reference/stripping.
const DEC_SYNC_START = '\x1b[?2026h';
const DEC_SYNC_END = '\x1b[?2026l';
// Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls)
+7 -8
View File
@@ -127,8 +127,8 @@ import {
// When terminal supports this, it buffers all output between start/end markers
// and renders atomically, eliminating partial-frame flicker from Ink redraws.
// Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal
const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update
const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen)
// DEC 2026 sync markers no longer added server-side — xterm.js 6.0 handles
// them natively and Claude CLI already emits its own markers via Ink.
// SSE padding for Cloudflare tunnel buffer flushing.
// Cloudflare quick tunnels buffer small SSE responses, causing lag for real-time events.
@@ -2119,14 +2119,13 @@ export class WebServer extends EventEmitter {
if (chunks && chunks.length > 0) {
// Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData)
const data = chunks.join('');
// Wrap with DEC mode 2026 synchronized output markers
// Terminal buffers all output between markers and renders atomically,
// eliminating partial-frame flicker from Ink's full-screen redraws.
// Unsupported terminals ignore these sequences harmlessly.
const syncData = DEC_SYNC_START + data + DEC_SYNC_END;
// xterm.js 6.0+ handles DEC 2026 synchronized output natively.
// Claude CLI (Ink) already emits its own DEC 2026 markers around redraws.
// Do NOT add an outer wrapper — DEC 2026 is not reference-counted, so
// the inner 2026l would prematurely exit sync mode, defeating the purpose.
// Fast path: build SSE message directly without JSON.stringify on wrapper object.
// Only the terminal data string needs escaping; sessionId is a UUID (safe to template).
const escapedData = JSON.stringify(syncData);
const escapedData = JSON.stringify(data);
// Append tunnel padding for immediate Cloudflare proxy flush —
// terminal data is high-frequency and latency-sensitive.
const padding = this._isTunnelActive ? SSE_PADDING : '';