From 7cd0c538143b55ac9996d65cf50b589950464b53 Mon Sep 17 00:00:00 2001 From: Teigen Date: Fri, 22 May 2026 15:12:05 +0800 Subject: [PATCH] fix(web): stop auto-sending Ctrl+L from session selection paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code 2.x treats Ctrl+L (\x0c) as a two-step "clear conversation" command (first press shows the confirmation prompt, second press clears). The frontend previously fired \x0c from three places to force Ink to redraw stale CUP-positioned frames in the tailed buffer; if a page refresh or SSE reconnect ran the same path twice within Claude's confirmation window the second \x0c silently nuked the user's conversation. Removed the \x0c sends from: - selectSession() — main offender, runs on every tab switch & page reload - restoreTerminalSize() — manual "restore size" button - sendPendingCtrlL() — dead code path (pendingCtrlL was never populated) Trade-off: occasional stale Ink frames immediately after refresh; the user's first keypress causes Ink to redraw and the artifact vanishes. Losing the conversation silently is far worse than a brief cosmetic glitch. --- src/web/public/app.js | 22 +++++++--------- src/web/public/terminal-ui.js | 47 ++++++++++++++--------------------- 2 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 8f3fbf4d..c8c13be8 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2643,19 +2643,15 @@ class CodemanApp { }); } - // Fire-and-forget resize + Ctrl+L to force Ink redraw. - // Tailed buffers accumulate stale CUP-positioned Ink frames that overlap - // in the viewport (e.g. duplicate "bypass permissions" bars). Ctrl+L - // triggers a full Ink redraw which overwrites all stale frame content. - // sendResize may be a no-op if dimensions match, so Ctrl+L is essential. - this.sendResize(sessionId).then(() => { - if (selectGen !== this._selectGeneration) return; - fetch(`/api/sessions/${sessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: '\x0c' }) - }).catch(() => {}); - }); + // Fire-and-forget resize to nudge Ink via SIGWINCH on real size changes. + // Previously we also sent Ctrl+L (\x0c) here to force a full Ink redraw, + // but Claude Code 2.x treats Ctrl+L as a two-step "clear conversation" + // command — if a page refresh or SSE reconnect ran selectSession twice + // within Claude's confirmation window, the second \x0c silently wiped the + // conversation. Stale Ink frames in the tailed buffer are a cosmetic + // annoyance that disappear on the user's next keypress; data loss is not + // acceptable. Do NOT re-introduce Ctrl+L here. + this.sendResize(sessionId); // Defer secondary panel updates so they don't block the main thread // after terminal content is already visible. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4cc55b98..a3cc067c 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1704,7 +1704,8 @@ Object.assign(CodemanApp.prototype, { /** * Restore terminal size to match web UI dimensions. * Use this after mobile screen attachment has squeezed the terminal. - * Sends resize to PTY and Ctrl+L to trigger Claude to redraw. + * Sends only resize — SIGWINCH triggers Ink redraw on real dimension changes. + * Ctrl+L is NOT sent here (Claude Code 2.x treats it as "clear conversation"). */ async restoreTerminalSize() { if (!this.activeSessionId) { @@ -1719,16 +1720,10 @@ Object.assign(CodemanApp.prototype, { } try { - // Send resize to restore proper dimensions (with minimum enforcement) + // Send resize to restore proper dimensions (with minimum enforcement). + // The PTY's SIGWINCH on real dim change is enough for Ink to redraw. await this.sendResize(this.activeSessionId); - // Send Ctrl+L to trigger Claude to redraw at new size - await fetch(`/api/sessions/${this.activeSessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: '\x0c' }), - }); - this.showToast(`Terminal restored to ${dims.cols}x${dims.rows}`, 'success'); } catch (err) { console.error('Failed to restore terminal size:', err); @@ -1736,26 +1731,20 @@ Object.assign(CodemanApp.prototype, { } }, - // Send Ctrl+L to fix display for newly created sessions once Claude is running - sendPendingCtrlL(sessionId) { - if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { - return; - } - this.pendingCtrlL.delete(sessionId); - - // Only send if this is the active session - if (sessionId !== this.activeSessionId) { - return; - } - - // Send resize + Ctrl+L to fix the display (with minimum dimension enforcement) - this.sendResize(sessionId).then(() => { - fetch(`/api/sessions/${sessionId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ input: '\x0c' }), - }); - }); + // Legacy hook for newly-created sessions; kept as a no-op so the SSE + // idle/working handlers can still call it without conditional guards. + // + // Originally this sent Ctrl+L (\x0c) when a flagged session first reached + // idle/working to scrub mux-init junk from the screen. Two problems: + // 1. `pendingCtrlL` was never actually populated anywhere (dead path). + // 2. Claude Code 2.x interprets Ctrl+L as a two-step "clear conversation" + // command — sending it from background flows risked nuking the user's + // conversation if it coincided with another Ctrl+L (e.g. from + // selectSession on page reload). + // If a per-session display-fix is ever needed again, do it via sendResize + // or an Ink-safe control sequence, NOT \x0c. + sendPendingCtrlL(_sessionId) { + // intentionally empty }, async copyTerminal() {