From 0ac6a887d4383a163d4ad72db985dd07378c8f86 Mon Sep 17 00:00:00 2001 From: Siggy Gudbrandsson Date: Wed, 4 Mar 2026 17:40:03 +0000 Subject: [PATCH] fix: prevent Chrome freeze and shell feedback delay from flicker filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related bugs in batchTerminalWrite's cursor-up flicker filter: **Bug 1 — Chrome freeze during active Claude sessions** The cursor-up flicker filter exists to batch Ink's status bar redraws atomically. When a cursor-up sequence arrives, a 50ms flush timer is set. The bug: every subsequent SSE terminal event — including non-cursor-up data — also reset the 50ms timer. During an active Claude run, the server emits terminal data faster than 50ms continuously, so the timer never fired. flickerFilterBuffer accumulated the full session output (potentially MBs). When Claude went idle, the timer fired and flushed everything to terminal.write() in one synchronous call, freezing Chrome. Fix: only reset the 50ms timer on cursor-up events (start of a new Ink redraw cycle). Non-cursor-up data while the filter is active is buffered without extending the deadline. Added a 256KB safety-valve to force-flush if a burst fills the buffer before the timer fires. **Bug 2 — Shell mode shows no character feedback until Enter** Shell sessions (bash/zsh with readline) also emit cursor-up on every keystroke for prompt redraws (syntax highlighting, right-side prompts). The filter treated these as Ink status bar redraws and reset the 50ms timer on each character typed, so nothing appeared until the user stopped typing for 50ms — making the terminal feel completely unresponsive. Fix: shell mode sessions bypass the cursor-up filter entirely (there is no Ink status bar to protect). The local echo overlay is also disabled for shell sessions since the shell handles its own PTY echo and the overlay was looking for the ❯ Claude prompt character which doesn't exist in shell prompts. --- src/web/public/app.js | 46 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 3ae718c..fdb9e81 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1097,18 +1097,47 @@ class CodemanApp { // 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. - const hasCursorUpRedraw = /\x1b\[\d{1,2}A/.test(data); + // + // 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; - if (this.flickerFilterTimeout) { - clearTimeout(this.flickerFilterTimeout); + // 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); } - this.flickerFilterTimeout = setTimeout(() => { - this.flickerFilterTimeout = null; + + // 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(); - }, SYNC_WAIT_TIMEOUT_MS); // 50ms buffer window + } return; } @@ -1238,6 +1267,11 @@ class CodemanApp { } catch { return null; } } }); + } else if (session.mode === 'shell') { + // Shell mode: the shell provides its own PTY echo so the overlay isn't needed. + // Disable it by clearing any pending text. + this._localEchoOverlay.clear(); + this._localEchoEnabled = false; } else { // Claude Code: scan for ❯ prompt character this._localEchoOverlay.setPrompt({ type: 'character', char: '\u276f', offset: 2 });