From 47963b54fa0c029bce0a767980e891fbc865ec07 Mon Sep 17 00:00:00 2001 From: Teigen Date: Tue, 24 Mar 2026 09:22:06 +0800 Subject: [PATCH] fix: mobile CJK input, terminal flicker, and layout overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Terminal flicker: - Skip buffer-recovered/clear-terminal events during active buffer load to prevent competing clear+rewrite cycles (app.js) - Move viewport+scrollback clear inside dimension-change guard so resize without actual SIGWINCH doesn't blank the terminal (terminal-ui.js) - Sync _lastResizeDims on explicit resize to prevent redundant clears CJK input rewrite (input-cjk.js): - Use InputEvent.inputType to distinguish insertText (final) from insertCompositionText (tentative) — fixes Chinese punctuation and English text being swallowed during Android IME composition - Remove isComposing guard on Enter so it always sends - Phantom character (U+200B) keeps textarea non-empty so Android long-press backspace generates continuous deleteContentBackward events at the keyboard's native repeat rate CJK input settings: - Add "CJK Input" toggle in Settings > Input (index.html, settings-ui.js) - Store as device-specific setting (cjkInputEnabled), not synced to server - Replace INPUT_CJK_FORM env var dependency with user-controlled setting (env var still works as server override) Mobile layout: - Fix welcome screen overflow on phones by constraining .welcome-content to calc(100vw - 1.5rem) (mobile.css) - Move xterm helper textarea on-screen for touch devices to fix iOS keyboard input (styles.css) - Focus terminal synchronously in user-gesture context for iOS Safari keyboard activation (session-ui.js, app.js) - Refocus terminal on tap (not scroll) in touch handler (terminal-ui.js) --- src/web/public/app.js | 33 +++++- src/web/public/index.html | 10 ++ src/web/public/input-cjk.js | 200 ++++++++++++++++++++++++++++------ src/web/public/mobile.css | 1 + src/web/public/session-ui.js | 7 ++ src/web/public/settings-ui.js | 11 +- src/web/public/styles.css | 14 ++- src/web/public/terminal-ui.js | 37 +++++-- 8 files changed, 261 insertions(+), 52 deletions(-) diff --git a/src/web/public/app.js b/src/web/public/app.js index 17f1322a..4c555f24 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -886,6 +886,8 @@ class CodemanApp { // Server sends this after SSE backpressure clears — terminal data was dropped, // so reload the buffer to recover from any display corruption. if (!this.activeSessionId || !this.terminal) return; + // Skip if buffer load already in progress — avoids competing clear+rewrite cycles + if (this._isLoadingBuffer) return; try { const res = await fetch(`/api/sessions/${this.activeSessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`); const data = await res.json(); @@ -909,6 +911,12 @@ class CodemanApp { async _onSessionClearTerminal(data) { if (data.id === this.activeSessionId) { + // Skip if selectSession is already loading the buffer — clearTerminal arriving + // during buffer load would clear the terminal mid-write, causing visible flicker + // and a race between two concurrent chunkedTerminalWrite calls (especially on mobile + // where rAF is slower). selectSession will handle the final buffer state. + if (this._isLoadingBuffer) return; + // Fetch buffer, clear terminal, write buffer, resize (no Ctrl+L needed) try { const res = await fetch(`/api/sessions/${data.id}/terminal`); @@ -1299,6 +1307,16 @@ class CodemanApp { }); } + /** Show/hide the CJK input textarea based on user setting or server override */ + _updateCjkInputState() { + const cjkEl = document.getElementById('cjkInput'); + if (!cjkEl) return; + const settings = this.loadAppSettingsFromStorage(); + const showCjk = this._serverCjkOverride || settings.cjkInputEnabled || false; + cjkEl.style.display = showCjk ? 'block' : 'none'; + if (!showCjk) window.cjkActive = false; + } + handleInit(data) { // Clear the init fallback timer since we got data if (this._initFallbackTimer) { @@ -1307,12 +1325,9 @@ class CodemanApp { } const gen = ++this._initGeneration; - // CJK input form: show/hide based on server env INPUT_CJK_FORM=ON - const cjkEl = document.getElementById('cjkInput'); - if (cjkEl) { - cjkEl.style.display = data.inputCjkForm ? 'block' : 'none'; - if (!data.inputCjkForm) window.cjkActive = false; - } + // CJK input form: controlled by user setting (with server env as override) + this._serverCjkOverride = data.inputCjkForm || false; + this._updateCjkInputState(); // Update version displays (header and toolbar) if (data.version) { @@ -1988,6 +2003,12 @@ class CodemanApp { async selectSession(sessionId) { if (this.activeSessionId === sessionId) return; + // Focus terminal SYNCHRONOUSLY before any await — iOS Safari only honors + // programmatic focus() within the user-gesture call stack (e.g. tab click). + // After the first await the gesture context is lost and focus() is silently + // ignored, leaving the keyboard unable to send input to the terminal. + if (this.terminal) this.terminal.focus(); + const _selStart = performance.now(); const _selName = this.sessions.get(sessionId)?.name || sessionId.slice(0,8); _crashDiag.log(`SELECT: ${_selName}`); diff --git a/src/web/public/index.html b/src/web/public/index.html index 450a4687..f4ba982a 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -871,6 +871,16 @@

App Settings

+
+
+ CJK Input + Dedicated IME input field for CJK languages +
+ +
Header Displays
diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index ca7782b5..1ad1ca9a 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -3,10 +3,36 @@ * * Always-visible textarea below the terminal (in index.html). * The browser handles IME composition natively — we just read - * textarea.value on Enter and send it to PTY. + * textarea.value and send it to PTY. * While this textarea has focus, window.cjkActive = true blocks xterm's onData. * Arrow keys and function keys are forwarded to PTY directly. * + * ## Android IME challenge + * + * Android virtual keyboards (WeChat, Sogou, Gboard in Chinese mode) use + * composition for EVERYTHING — including English prediction and punctuation. + * This means compositionstart fires even for English text, and compositionend + * may not fire until the user explicitly confirms (space, candidate tap). + * + * We use InputEvent.inputType to distinguish: + * - `insertCompositionText`: tentative text, may change (CJK candidates, pinyin) + * - `insertText`: final committed text (confirmed word, punctuation, space) + * + * During composition, `insertText` events are flushed immediately (punctuation, + * English words confirmed by IME). `insertCompositionText` waits for + * compositionend (CJK candidate selection). + * + * ## Phantom character for Android backspace + * + * Android virtual keyboards don't generate key-repeat keydown events for held + * keys. When the textarea is empty, backspace produces no `input` event either + * (nothing to delete). We keep a zero-width space (U+200B) "phantom" in the + * textarea at all times. Backspace deletes the phantom → `input` fires with + * `deleteContentBackward` → we send \x7f to PTY and restore the phantom. + * Long-press backspace generates rapid deleteContentBackward events, each + * handled the same way — giving continuous deletion at the keyboard's native + * repeat rate. + * * @dependency index.html (#cjkInput textarea) * @globals {object} CjkInput — window.cjkActive (boolean) signals app.js to block xterm onData * @loadorder 5.5 of 15 — loaded after keyboard-accessory.js, before app.js @@ -17,10 +43,12 @@ const CjkInput = (() => { let _textarea = null; let _send = null; let _initialized = false; - let _onMousedown = null; - let _onFocus = null; - let _onBlur = null; - let _onKeydown = null; + let _composing = false; + const _listeners = {}; + + // Zero-width space: always present in textarea so Android backspace has + // something to delete, triggering the `input` event we need to detect it. + const PHANTOM = '\u200B'; const PASSTHROUGH_KEYS = { ArrowUp: '\x1b[A', @@ -36,66 +64,176 @@ const CjkInput = (() => { c: '\x03', d: '\x04', l: '\x0c', z: '\x1a', a: '\x01', e: '\x05', }; + /** Strip phantom characters from a string */ + function _strip(str) { + return str.replace(/\u200B/g, ''); + } + + /** Reset textarea to phantom-only state with cursor at end */ + function _resetToPhantom() { + _textarea.value = PHANTOM; + _textarea.setSelectionRange(1, 1); + } + + /** Check if textarea contains only phantom(s) or is empty — no real user text */ + function _isEffectivelyEmpty() { + return !_strip(_textarea.value); + } + + /** Flush textarea: send real text to PTY and reset to phantom */ + function _flush() { + const val = _strip(_textarea.value); + if (val) { + _send(val); + } + _resetToPhantom(); + } + return { init({ send }) { - // Guard against double-init: remove previous listeners if (_initialized) this.destroy(); _send = send; + _composing = false; _textarea = document.getElementById('cjkInput'); if (!_textarea) return this; - _onMousedown = (e) => { e.stopPropagation(); }; - _onFocus = () => { window.cjkActive = true; }; - _onBlur = () => { window.cjkActive = false; }; - _textarea.addEventListener('mousedown', _onMousedown); - _textarea.addEventListener('focus', _onFocus); - _textarea.addEventListener('blur', _onBlur); + // Seed the phantom character + _resetToPhantom(); + + _listeners.mousedown = (e) => { e.stopPropagation(); }; + _listeners.focus = () => { + window.cjkActive = true; + // Restore phantom if textarea was emptied while blurred + if (!_textarea.value) _resetToPhantom(); + }; + _listeners.blur = () => { window.cjkActive = false; }; + _textarea.addEventListener('mousedown', _listeners.mousedown); + _textarea.addEventListener('focus', _listeners.focus); + _textarea.addEventListener('blur', _listeners.blur); - _onKeydown = (e) => { - if (e.isComposing || e.keyCode === 229) return; + // ── Composition tracking ── + _listeners.compositionstart = () => { + _composing = true; + // Clear phantom so IME sees a clean textarea — some IMEs include + // existing text in the composition region which would corrupt input. + if (_textarea.value === PHANTOM) { + _textarea.value = ''; + } + }; + _listeners.compositionend = () => { + _composing = false; + // Defer flush: some Android IMEs haven't committed text to textarea + // when compositionend fires. setTimeout(0) ensures we read the final value. + setTimeout(_flush, 0); + }; + _textarea.addEventListener('compositionstart', _listeners.compositionstart); + _textarea.addEventListener('compositionend', _listeners.compositionend); - // Enter: send accumulated text (or bare Enter if empty) + // ── Keydown: special keys work REGARDLESS of composition state ── + _listeners.keydown = (e) => { + // Enter: flush accumulated text (or bare Enter if empty). + // No isComposing guard — Android IMEs set isComposing=true for English + // prediction, but Enter should ALWAYS send. We preventDefault to stop + // the IME from also handling Enter (which could double-send or do nothing). if (e.key === 'Enter') { e.preventDefault(); - if (_textarea.value) { - _send(_textarea.value + '\r'); - _textarea.value = ''; + _composing = false; + const val = _strip(_textarea.value); + if (val) { + _send(val + '\r'); } else { _send('\r'); } + _resetToPhantom(); return; } - // Escape: clear textarea + // Escape: clear textarea (always works) if (e.key === 'Escape') { e.preventDefault(); - _textarea.value = ''; + _composing = false; + _resetToPhantom(); return; } - // Ctrl combos: forward to PTY + // Ctrl combos: forward to PTY (always works) if (e.ctrlKey && CTRL_KEYS[e.key]) { e.preventDefault(); _send(CTRL_KEYS[e.key]); return; } - // Backspace: delete from textarea if has text, else forward to PTY - if (e.key === 'Backspace' && !_textarea.value) { + // Below: only when NOT composing (composing keystrokes belong to IME) + if (_composing) return; + + // Backspace: forward to PTY when no real text in textarea + // (Desktop path — Android uses the input event + phantom approach) + if (e.key === 'Backspace' && _isEffectivelyEmpty()) { e.preventDefault(); _send('\x7f'); + _resetToPhantom(); return; } - // Arrow/function keys: forward to PTY when textarea is empty - if (PASSTHROUGH_KEYS[e.key] && !_textarea.value) { + // Arrow/function keys: forward to PTY when no real text + if (PASSTHROUGH_KEYS[e.key] && _isEffectivelyEmpty()) { e.preventDefault(); _send(PASSTHROUGH_KEYS[e.key]); return; } + + // Single printable character: send immediately to PTY + // (Desktop keyboards with physical keys — Android sends 'Unidentified') + if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) { + e.preventDefault(); + _send(e.key); + return; + } }; - _textarea.addEventListener('keydown', _onKeydown); + _textarea.addEventListener('keydown', _listeners.keydown); + + // ── Input event: the primary path for Android virtual keyboards ── + // Android sends keyCode 229 + key "Unidentified" for virtual key presses, + // making keydown unreliable. input fires AFTER character insertion and + // carries inputType which tells us whether the text is final or tentative. + _listeners.input = (e) => { + // ── Backspace / delete detection ── + // Android long-press backspace generates rapid deleteContentBackward events. + // The phantom character ensures the textarea is never truly empty, so each + // press/repeat fires an input event that we can catch here. + if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') { + if (_isEffectivelyEmpty()) { + // No real text left — forward backspace to PTY + _send('\x7f'); + _resetToPhantom(); + return; + } + // User is editing their own text in the textarea — let it be. + // Ensure phantom is still present for the NEXT backspace. + if (!_textarea.value.startsWith(PHANTOM)) { + _textarea.value = PHANTOM + _textarea.value; + _textarea.setSelectionRange(1, 1); + } + return; + } + + if (_composing) { + // insertText during composition = IME committed final text + // (e.g., punctuation key inserts 。directly, or IME confirms a word). + // Flush immediately — this text won't change. + if (e.inputType === 'insertText') { + _flush(); + return; + } + // insertCompositionText = IME is still working (pinyin, candidates, + // English prediction). Wait for compositionend to flush. + return; + } + // Outside composition: send immediately + _flush(); + }; + _textarea.addEventListener('input', _listeners.input); _initialized = true; return this; @@ -103,13 +241,13 @@ const CjkInput = (() => { destroy() { if (_textarea) { - if (_onMousedown) _textarea.removeEventListener('mousedown', _onMousedown); - if (_onFocus) _textarea.removeEventListener('focus', _onFocus); - if (_onBlur) _textarea.removeEventListener('blur', _onBlur); - if (_onKeydown) _textarea.removeEventListener('keydown', _onKeydown); + for (const [event, handler] of Object.entries(_listeners)) { + if (handler) _textarea.removeEventListener(event, handler); + } } window.cjkActive = false; - _onMousedown = _onFocus = _onBlur = _onKeydown = null; + _composing = false; + for (const key of Object.keys(_listeners)) delete _listeners[key]; _initialized = false; }, diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index b9ddf2e4..9ac68cfc 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -1147,6 +1147,7 @@ html.mobile-init .file-browser-panel { /* Compact welcome overlay for mobile */ .welcome-content { + max-width: calc(100vw - 1.5rem); padding: 1rem 0.75rem; } diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 18d5b6a5..bf5e287c 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -273,6 +273,11 @@ Object.assign(CodemanApp.prototype, { this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting ${tabCount} Claude session(s) in ${caseName}...\x1b[0m`); this.terminal.writeln(''); + // Focus terminal NOW, in the synchronous user-gesture context (button click). + // iOS Safari ignores programmatic focus() after any await, so this must happen + // before the first async call. The keyboard opens here and stays open through + // the session creation flow; selectSession at the end inherits the focus state. + this.terminal.focus(); try { // Get case path first @@ -493,6 +498,8 @@ Object.assign(CodemanApp.prototype, { this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`); this.terminal.writeln(''); + // Focus in sync gesture context (see runClaude comment) + this.terminal.focus(); try { // Check if OpenCode is available diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 943b9ad6..523935d2 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -364,6 +364,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); + document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? false; document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false; // Claude CLI settings const claudeModeSelect = document.getElementById('appSettingsClaudeMode'); @@ -1180,6 +1181,7 @@ Object.assign(CodemanApp.prototype, { imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, + cjkInputEnabled: document.getElementById('appSettingsCjkInput').checked, tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked, // Claude CLI settings claudeMode: document.getElementById('appSettingsClaudeMode').value, @@ -1296,9 +1298,12 @@ Object.assign(CodemanApp.prototype, { this.renderProjectInsightsPanel(); // Re-render to apply visibility setting this.updateSubagentWindowVisibility(); // Apply subagent window visibility setting + // Apply CJK input visibility immediately + this._updateCjkInputState(); + // Save to server (includes notification prefs for cross-browser persistence) - // Strip device-specific keys — localEchoEnabled is per-platform (touch default differs) - const { localEchoEnabled: _leo, ...serverSettings } = settings; + // Strip device-specific keys — localEchoEnabled/cjkInputEnabled are per-platform + const { localEchoEnabled: _leo, cjkInputEnabled: _cjk, ...serverSettings } = settings; try { await this._apiPut('/api/settings', { ...serverSettings, notificationPreferences: notifPrefsToSave, voiceSettings }); @@ -1682,7 +1687,7 @@ Object.assign(CodemanApp.prototype, { const displayKeys = new Set([ 'showFontControls', 'showSystemStats', 'showTokenCount', 'showCost', 'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents', - 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', + 'subagentActiveTabOnly', 'tabTwoRows', 'localEchoEnabled', 'cjkInputEnabled', ]); // Merge settings: non-display keys always sync from server, // display keys only seed from server when localStorage has no value diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 5d54ff8d..270868fd 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -94,7 +94,11 @@ textarea:focus-visible { outline: none; } -/* xterm.js hidden textarea — must never show focus ring or caret */ +/* xterm.js hidden textarea — must never show focus ring or caret. + * On mobile (touch devices), override xterm.css defaults that position the + * textarea at left:-9999em with width/height:0. iOS Safari ignores keyboard + * input to off-screen zero-size textareas, so we move it on-screen with a + * minimal size while keeping it visually invisible. */ .xterm-helper-textarea { border: none !important; box-shadow: none !important; @@ -102,6 +106,14 @@ textarea:focus-visible { opacity: 0 !important; caret-color: transparent !important; } +.touch-device .xterm .xterm-helper-textarea { + left: 0 !important; + top: 0 !important; + width: 1px !important; + height: 1px !important; + z-index: -1 !important; + font-size: 16px !important; /* prevent iOS auto-zoom on focus */ +} /* Session tab focus */ .session-tab:focus-visible { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 5c9b1a3b..40b91bc4 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -174,12 +174,14 @@ Object.assign(CodemanApp.prototype, { // Accumulate sub-line pixel deltas so slow swipes still scroll let pixelAccum = 0; + let didScroll = false; // track whether touchmove fired (tap vs scroll) container.addEventListener('touchstart', (ev) => { if (ev.touches.length === 1) { touchLastY = ev.touches[0].clientY; velocity = 0; pixelAccum = 0; isTouching = true; + didScroll = false; lastTime = 0; if (scrollFrame) { cancelAnimationFrame(scrollFrame); scrollFrame = null; } } @@ -187,6 +189,7 @@ Object.assign(CodemanApp.prototype, { container.addEventListener('touchmove', (ev) => { if (ev.touches.length === 1 && isTouching) { + didScroll = true; const touchY = ev.touches[0].clientY; const delta = touchLastY - touchY; // positive = scroll down pixelAccum += delta; @@ -207,6 +210,12 @@ Object.assign(CodemanApp.prototype, { if (!scrollFrame && Math.abs(velocity) > 0.3) { scrollFrame = requestAnimationFrame(scrollLoop); } + // Tap (no scroll): refocus xterm's hidden textarea so keyboard input + // routes back to the terminal. Without this, a tap on the terminal area + // consumes the touch event but xterm's textarea never regains focus. + if (!didScroll && this.terminal) { + this.terminal.focus(); + } }, { passive: true }); container.addEventListener('touchcancel', () => { @@ -260,17 +269,6 @@ Object.assign(CodemanApp.prototype, { } 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. @@ -284,6 +282,19 @@ Object.assign(CodemanApp.prototype, { if (!this._lastResizeDims || cols !== this._lastResizeDims.cols || rows !== this._lastResizeDims.rows) { + // Clear viewport + scrollback ONLY when dimensions actually change. + // 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. + // IMPORTANT: Only clear when we're actually sending SIGWINCH (dims changed). + // Clearing without a subsequent Ink redraw leaves the terminal blank. + 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'); + } this._lastResizeDims = { cols, rows }; fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', @@ -1261,6 +1272,10 @@ Object.assign(CodemanApp.prototype, { if (this.fitAddon) this.fitAddon.fit(); const dims = this.getTerminalDimensions(); if (!dims) return; + // Update _lastResizeDims so the throttledResize handler won't redundantly + // clear the terminal for the same dimensions (which would blank the screen + // without a subsequent Ink redraw to repaint it). + this._lastResizeDims = { cols: dims.cols, rows: dims.rows }; // Fast path: WebSocket resize if (this._wsReady && this._wsSessionId === sessionId) { try {