From 28dfc279d40b70b7e714014186c8f27f693d1af0 Mon Sep 17 00:00:00 2001 From: zhouyuan Date: Thu, 12 Mar 2026 01:04:21 +0800 Subject: [PATCH] fix: resolve terminal resize scrollback ghost renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/web/public/app.js | 232 ++++++++++++++---------------------- src/web/public/constants.js | 4 +- src/web/server.ts | 15 ++- 3 files changed, 97 insertions(+), 154 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 522f9b6a..13ab3812 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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)`); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index a9cd62b9..7543c2e2 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -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) diff --git a/src/web/server.ts b/src/web/server.ts index d8bd2f7b..b988e889 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -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 : '';