diff --git a/src/web/public/app.js b/src/web/public/app.js index c63f12be..b23e29ff 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -61,6 +61,10 @@ const _crashDiag = { _entries: [], _maxEntries: 50, + // Per-page-load id: the server keys beacons by it, so a reload (fresh id) + // archives the previous page's trail instead of overwriting it, and + // concurrent clients (desktop + phone) don't clobber each other. + _pageId: Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8), log(msg) { const entry = `${new Date().toISOString().slice(11,23)} ${msg}`; this._entries.push(entry); @@ -69,22 +73,36 @@ const _crashDiag = { } }; -// Log previous crash breadcrumbs on startup +// Log previous crash breadcrumbs on startup, and re-beacon them under a +// distinct page id — an iOS PWA reload wipes the in-memory trail, and without +// this the fresh page's first beacon would be all the server ever sees. try { const prev = localStorage.getItem('codeman-crash-diag'); - if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev); + if (prev) { + console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev); + navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: prev, id: _crashDiag._pageId + '-prev' })); + } } catch {} _crashDiag.log('PAGE LOAD'); // Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes. -setInterval(() => { +function _crashDiagBeacon() { try { - localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); if (_crashDiag._entries.length > 0) { - navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') })); + navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n'), id: _crashDiag._pageId })); } } catch {} +} +setInterval(() => { + try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); } catch {} + _crashDiagBeacon(); }, 2000); +// iOS suspends JS the instant the app is backgrounded — entries logged since +// the last 2s tick would sit unsent until (if ever) the page resumes. Flush +// immediately on hide so a repro right before an app switch is never lost. +document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') _crashDiagBeacon(); +}); window.addEventListener('error', (e) => { _crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`); @@ -3390,9 +3408,15 @@ class CodemanApp { // Close WebSocket for previous session (new one opens after buffer load) this._disconnectWs(); - // Clear CJK textarea to prevent sending stale text to the wrong session - const cjkEl = document.getElementById('cjkInput'); - if (cjkEl) cjkEl.value = ''; + // Clear CJK input to prevent sending stale text to the wrong session. + // Must go through CjkInput.clear() — a raw value wipe leaves the module's + // pending flush timers armed and drops the phantom char it relies on. + if (typeof CjkInput !== 'undefined') { + CjkInput.clear(); + } else { + const cjkEl = document.getElementById('cjkInput'); + if (cjkEl) cjkEl.value = ''; + } // Clean up flicker filter state when switching sessions this._clearTimer('flickerFilterTimeout'); diff --git a/src/web/public/input-cjk.js b/src/web/public/input-cjk.js index db4f406c..fa5ab85e 100644 --- a/src/web/public/input-cjk.js +++ b/src/web/public/input-cjk.js @@ -53,13 +53,36 @@ const CjkInput = (() => { let _initialized = false; let _composing = false; let _flushTimer = null; + let _compositionFlushTimer = null; let _dictationActive = false; let _dictationDecayTimer = null; let _keydownSentAt = 0; + let _keydownSentText = ''; const _listeners = {}; const PHANTOM = '​'; + // ── Diagnostic trace (intermittent CJK-loss investigation) ── + // In-memory ring buffer of every IME event + flush decision. Mirrored into + // the crash-diag breadcrumbs (app.js), which beacon to the server every 2s — + // after a repro, `GET /api/crash-diag` shows the exact event sequence. + const TRACE_MAX = 200; + const _trace = []; + function _esc(v) { + const s = String(v == null ? '' : v).replace(/​/g, '∅'); + return JSON.stringify(s.length > 24 ? s.slice(0, 24) + '…' + s.length : s); + } + function _t(msg) { + _trace.push(`${Date.now() % 1000000} ${msg}`); + if (_trace.length > TRACE_MAX) _trace.shift(); + try { + // eslint-disable-next-line no-undef + if (typeof _crashDiag !== 'undefined') _crashDiag.log('CJK ' + msg); + } catch { + /* crash-diag unavailable (tests) — ring buffer still records */ + } + } + // Two-tier debounce for non-composition input: // - KEYBOARD: short debounce (third-party IMEs like Doubao may not fire // composition events even for keyboard CJK typing) @@ -93,6 +116,16 @@ const CjkInput = (() => { } function _resetToPhantom() { + // Skip redundant writes: every programmatic value/selection mutation can + // desync an Android IME's input session (InputConnection) — after which + // the keyboard composes in its own UI but NO events ever reach the page. + // Only touch the DOM when the content actually differs. + if (_textarea.value === PHANTOM) { + if (_textarea.selectionStart !== 1 || _textarea.selectionEnd !== 1) { + _textarea.setSelectionRange(1, 1); + } + return; + } _textarea.value = PHANTOM; _textarea.setSelectionRange(1, 1); } @@ -103,7 +136,17 @@ const CjkInput = (() => { /** Flush textarea: send real text to PTY and reset to phantom */ function _flush() { + // Never flush mid-composition: reading the value would send the IME's + // provisional text, and resetting the textarea cancels the in-progress + // composition on iOS Safari — silently eating the character being typed. + // Any committed-but-unflushed text stays in the textarea and is sent + // together by the next compositionend flush. + if (_composing) { + _t('flush SKIP composing'); + return; + } const val = _strip(_textarea.value); + _t(`flush ${val ? 'send ' + _esc(val) : 'empty'}`); if (val) { _send(val); } @@ -150,12 +193,31 @@ const CjkInput = (() => { _resetToPhantom(); + _t('init v2-trace'); + _listeners.mousedown = (e) => { e.stopPropagation(); }; + + // ── Wedged-IME recovery (Android) ── + // Some Android IMEs (esp. 9-key Sogou/Xiaomi/Baidu) can wedge their + // InputConnection: the keyboard composes in its own candidate bar but + // delivers ZERO DOM events to the focused textarea. JS cannot detect + // this (nothing fires) — but re-tapping the already-focused empty field + // is the user's natural "it's stuck" gesture. A blur→focus cycle forces + // the browser to restart the IME input session, which un-wedges it. + _listeners.pointerdown = () => { + if (document.activeElement === _textarea && !_composing && _isEffectivelyEmpty()) { + _t('ime-reset (retap)'); + _textarea.blur(); + setTimeout(() => _textarea.focus(), 0); + } + }; _listeners.focus = () => { + _t(`focus val=${_esc(_textarea.value)}`); window.cjkActive = true; if (!_textarea.value) _resetToPhantom(); }; _listeners.blur = () => { + _t(`blur composing=${_composing} val=${_esc(_textarea.value)}`); // Keep cjkActive while CJK input is visible — iOS dictation and system // UI may steal focus temporarily, and clearing the flag during that // window lets xterm's onData process duplicated input. @@ -168,28 +230,38 @@ const CjkInput = (() => { _composing = false; }; _textarea.addEventListener('mousedown', _listeners.mousedown); + _textarea.addEventListener('pointerdown', _listeners.pointerdown); _textarea.addEventListener('focus', _listeners.focus); _textarea.addEventListener('blur', _listeners.blur); // ── Composition tracking (keyboard IME — works for CJK typing) ── _listeners.compositionstart = () => { + _t(`compstart val=${_esc(_textarea.value)}`); _composing = true; _cancelDebouncedFlush(); // Leave textarea.value untouched — programmatic changes during // compositionstart cancel the IME composition on iOS Safari. }; _listeners.compositionend = () => { + _t(`compend val=${_esc(_textarea.value)}`); _composing = false; _cancelDebouncedFlush(); // 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); + // Tracked so destroy() can cancel it; if the next composition starts + // before it runs, _flush's _composing guard turns it into a no-op. + clearTimeout(_compositionFlushTimer); + _compositionFlushTimer = setTimeout(() => { + _compositionFlushTimer = null; + _flush(); + }, 0); }; _textarea.addEventListener('compositionstart', _listeners.compositionstart); _textarea.addEventListener('compositionend', _listeners.compositionend); // ── Keydown: special keys work REGARDLESS of composition state ── _listeners.keydown = (e) => { + _t(`keydown ${_esc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`); if (e.key === 'Enter') { e.preventDefault(); _composing = false; @@ -246,6 +318,7 @@ const CjkInput = (() => { e.preventDefault(); _send(e.key); _keydownSentAt = performance.now(); + _keydownSentText = e.key; _resetToPhantom(); return; } @@ -254,6 +327,23 @@ const CjkInput = (() => { // ── Input event: primary path for virtual keyboards + dictation ── _listeners.input = (e) => { + _t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} val=${_esc(_textarea.value)}`); + // ── Stuck-composition recovery ── + // Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a + // matching compositionend. A stale _composing=true blocks every flush + // below — committed CJK text piles up in the textarea and never + // reaches the PTY. When the event itself says composition is over + // (isComposing false AND a non-composition inputType), trust it. + if ( + _composing && + e.isComposing === false && + e.inputType !== 'insertCompositionText' && + e.inputType !== 'deleteCompositionText' + ) { + _t('UNSTICK composing'); + _composing = false; + } + // ── Backspace / delete detection ── if (e.inputType === 'deleteContentBackward' || e.inputType === 'deleteWordBackward') { if (_composing) return; @@ -283,11 +373,18 @@ const CjkInput = (() => { if (_composing) return; - // Keydown handler already sent this character — just clear the - // textarea echo that the IME inserted despite preventDefault. + // Keydown handler already sent this character — clear the textarea + // echo that the IME inserted despite preventDefault. Content-checked: + // only a value matching the sent char is an echo. Anything else (e.g. + // an IME committing CJK text right after a keydown-sent char) is real + // input and must flow through to the debounced flush, not be dropped. if (performance.now() - _keydownSentAt < 100) { - _resetToPhantom(); - return; + const cur = _strip(_textarea.value); + if (cur === '' || cur === _keydownSentText) { + _t('echo-drop'); + _resetToPhantom(); + return; + } } // Outside composition: keyboard typing or voice dictation. @@ -301,8 +398,30 @@ const CjkInput = (() => { return this; }, + /** + * Discard pending text and timers (e.g. on session switch, so stale text + * can't flush into the wrong session). Restores the phantom so backspace + * forwarding keeps working — unlike a raw `textarea.value = ''`. + */ + clear() { + if (!_initialized || !_textarea) return; + _t('clear (external)'); + _cancelDebouncedFlush(); + clearTimeout(_compositionFlushTimer); + _compositionFlushTimer = null; + _composing = false; + _resetToPhantom(); + }, + + /** Diagnostic: recent IME event trace (ring buffer). */ + getTrace() { + return _trace.slice(); + }, + destroy() { _cancelDebouncedFlush(); + clearTimeout(_compositionFlushTimer); + _compositionFlushTimer = null; clearTimeout(_dictationDecayTimer); _dictationActive = false; if (_textarea) { diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d44e5dbf..421fcf5d 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -305,6 +305,25 @@ Object.assign(CodemanApp.prototype, { }); } + // ── Focus router ── + // While the CJK field is visible, EVERY terminal.focus() call must land on + // the CJK field instead. Focusing xterm's hidden textarea in CJK mode sends + // the IME's output into a black hole: the keyboard composes normally, but + // onData is gated by cjkActive, so nothing reaches the field OR the PTY. + // Session select / SSE-reconnect restore paths call terminal.focus() and + // were silently stealing focus after every app switch on mobile (the + // intermittent "Chinese input goes nowhere" bug). One chokepoint here + // covers all ~15 call sites plus any future ones. + const _xtermFocus = this.terminal.focus.bind(this.terminal); + this.terminal.focus = () => { + const cjkEl = document.getElementById('cjkInput'); + if (cjkEl?.classList.contains('cjk-input-visible')) { + cjkEl.focus(); + } else { + _xtermFocus(); + } + }; + // On mobile Safari, delay initial fit() to allow layout to settle // This prevents 0-column terminals caused by fit() running before container is sized const isMobileSafari = @@ -627,7 +646,19 @@ Object.assign(CodemanApp.prototype, { // is on, because cjkActive stays true the whole time the field is visible. const isMouseReport = /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data); // CJK input has focus — block xterm from sending keystrokes to PTY - if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) return; + if (!isMouseReport && (window.cjkActive || document.activeElement?.id === 'cjkInput')) { + // Self-heal: if the CJK field is visible but focus drifted to xterm's + // hidden textarea (e.g. something called terminal.focus()), everything + // typed lands HERE and is swallowed — keyboard shows the IME composing + // while both the CJK field and the terminal stay empty. Route focus + // back so the very next keystroke lands in the CJK field again. + const cjkEl = document.getElementById('cjkInput'); + if (cjkEl?.classList.contains('cjk-input-visible') && document.activeElement !== cjkEl) { + _crashDiag.log('CJK regain-focus (onData swallowed input)'); + cjkEl.focus(); + } + return; + } if (this.activeSessionId) { // Filter terminal query replies generated by xterm.js itself. // Forwarding them through the WebSocket injects DA/DSR/CPR replies @@ -1622,7 +1653,11 @@ Object.assign(CodemanApp.prototype, { // CJK textarea already provides visual feedback — bypass local echo // buffering so each composed word reaches the PTY immediately. _handleCjkInput(text) { - if (!this.activeSessionId) return; + if (!this.activeSessionId) { + _crashDiag.log(`CJK send DROP no-session len=${text.length}`); + return; + } + _crashDiag.log(`CJK send→${this.activeSessionId.slice(0, 8)} len=${text.length}`); this._sendInputAsync(this.activeSessionId, text); }, diff --git a/src/web/server.ts b/src/web/server.ts index 428630cc..567e40e2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -834,7 +834,13 @@ export class WebServer extends EventEmitter { // Keep the body as a RAW STRING and parse it inside the handler — a global // text/plain -> JSON parser would let a cross-site "simple request" (no CORS // preflight) submit JSON to any route. See security review C2. - let _crashBreadcrumbs = ''; + // Keyed by the client's per-page-load id so (a) a page reload (fresh id) + // ARCHIVES the previous page's breadcrumbs instead of overwriting them — + // iOS PWA reloads used to wipe the trace of the very bug being chased — + // and (b) concurrent clients (desktop + phone) don't clobber each other. + // Same id replaces in place (each beacon carries the full ring buffer). + const MAX_CRASH_PAGES = 10; + const _crashPages = new Map(); this.app.addContentTypeParser('text/plain;charset=UTF-8', { parseAs: 'string' }, (_req, body, done) => { done(null, body); }); @@ -844,17 +850,28 @@ export class WebServer extends EventEmitter { this.app.post('/api/crash-diag', (req, reply) => { const raw = typeof req.body === 'string' ? req.body : ''; let data = raw; + let pageId = 'legacy'; try { - const parsed = JSON.parse(raw) as { data?: unknown }; + const parsed = JSON.parse(raw) as { data?: unknown; id?: unknown }; if (parsed && typeof parsed.data === 'string') data = parsed.data; + if (parsed && typeof parsed.id === 'string' && parsed.id) pageId = parsed.id.slice(0, 64); } catch { /* not JSON — treat the raw beacon text as the breadcrumbs */ } - _crashBreadcrumbs = String(data || ''); + _crashPages.delete(pageId); // re-insert = move to MRU end + _crashPages.set(pageId, { at: Date.now(), data: String(data || '') }); + if (_crashPages.size > MAX_CRASH_PAGES) { + const oldest = _crashPages.keys().next().value; + if (oldest !== undefined) _crashPages.delete(oldest); + } reply.code(204).send(); }); this.app.get('/api/crash-diag', (_req, reply) => { - reply.code(200).send({ breadcrumbs: _crashBreadcrumbs, timestamp: Date.now() }); + const pages = [..._crashPages.entries()].map(([id, p]) => ({ id, at: p.at, data: p.data })); + const breadcrumbs = pages + .map((p) => `═══ page ${p.id} (last beacon ${new Date(p.at).toISOString()}) ═══\n${p.data}`) + .join('\n\n'); + reply.code(200).send({ breadcrumbs, pages, timestamp: Date.now() }); }); // Register all route modules diff --git a/test/input-cjk.test.ts b/test/input-cjk.test.ts new file mode 100644 index 00000000..9405ae9c --- /dev/null +++ b/test/input-cjk.test.ts @@ -0,0 +1,241 @@ +/** + * @fileoverview Unit tests for the CJK IME input module (input-cjk.js). + * + * Loads the browser module into a vm sandbox (no real DOM) and drives fake + * composition/keydown/input event sequences against a stub textarea. + * Focus: the intermittent "Chinese characters silently lost" failure modes — + * stuck composition state, deferred flush racing the next composition, and + * the keydown-echo suppression window swallowing a real IME commit. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PHANTOM = '​'; + +interface FakeTextarea { + value: string; + valueWrites: number; + blur: () => void; + focus: () => void; + classList: { contains: (c: string) => boolean }; + setSelectionRange: () => void; + addEventListener: (ev: string, fn: (e?: unknown) => void) => void; + removeEventListener: (ev: string, fn: (e?: unknown) => void) => void; + fire: (ev: string, arg?: Record) => void; +} + +function makeTextarea(): FakeTextarea { + const listeners = new Map void>>(); + let val = ''; + return { + get value() { + return val; + }, + set value(v: string) { + this.valueWrites++; + val = v; + }, + valueWrites: 0, + blur: () => {}, + focus: () => {}, + classList: { contains: (c: string) => c === 'cjk-input-visible' }, + setSelectionRange: () => {}, + addEventListener: (ev, fn) => { + const list = listeners.get(ev) ?? []; + listeners.set(ev, [...list, fn]); + }, + removeEventListener: (ev, fn) => { + const list = listeners.get(ev) ?? []; + listeners.set( + ev, + list.filter((f) => f !== fn) + ); + }, + fire(ev, arg = {}) { + const base = { preventDefault: () => {}, stopPropagation: () => {}, ...arg }; + for (const fn of listeners.get(ev) ?? []) fn(base); + }, + }; +} + +function loadCjkHarness() { + const textarea = makeTextarea(); + const sent: string[] = []; + const windowObj: Record = {}; + const documentObj = { + getElementById: (id: string) => (id === 'cjkInput' ? textarea : null), + activeElement: null as unknown, + }; + + // Delegating closures so vitest fake timers (which patch the test realm's + // globals) govern the vm context's timers/clock too. + const context = vm.createContext({ + window: windowObj, + document: documentObj, + setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms), + clearTimeout: (t: ReturnType) => clearTimeout(t), + performance: { now: () => performance.now() }, + console, + }); + + const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/input-cjk.js'), 'utf8'); + vm.runInContext(src, context, { filename: 'input-cjk.js' }); + const CjkInput = vm.runInContext('CjkInput', context); + + CjkInput.init({ send: (text: string) => sent.push(text) }); + textarea.fire('focus'); + + return { CjkInput, textarea, sent, windowObj, documentObj }; +} + +describe('CJK input module', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'Date', 'performance'] }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('sends committed text after a normal composition cycle', () => { + const { textarea, sent } = loadCjkHarness(); + + textarea.fire('compositionstart'); + textarea.value = PHANTOM + '你好'; + textarea.fire('input', { isComposing: true, inputType: 'insertCompositionText' }); + textarea.fire('compositionend'); + vi.advanceTimersByTime(10); + + expect(sent).toEqual(['你好']); + expect(textarea.value).toBe(PHANTOM); + }); + + it('recovers committed text when compositionend never fires (stuck composition)', () => { + const { textarea, sent } = loadCjkHarness(); + + // IME fires compositionstart but never compositionend (WeChat/Sogou quirk). + textarea.fire('compositionstart'); + textarea.value = PHANTOM + '你好'; + // The commit arrives as a plain input event outside composition. + textarea.fire('input', { isComposing: false, inputType: 'insertText' }); + vi.advanceTimersByTime(200); + + expect(sent).toEqual(['你好']); + }); + + it('does not flush or reset the textarea while the next composition is active', () => { + const { textarea, sent } = loadCjkHarness(); + + // Word 1 commits; the deferred flush is queued but has not run yet. + textarea.fire('compositionstart'); + textarea.value = PHANTOM + '你好'; + textarea.fire('compositionend'); + // Word 2 composition begins immediately (fast continuous typing). + textarea.fire('compositionstart'); + textarea.value = PHANTOM + '你好世界'; + + // Deferred flush from word 1 fires now — it must NOT touch the textarea + // (a programmatic reset here cancels the live IME composition on iOS). + vi.advanceTimersByTime(10); + expect(sent).toEqual([]); + expect(textarea.value).toBe(PHANTOM + '你好世界'); + + textarea.fire('compositionend'); + vi.advanceTimersByTime(10); + expect(sent).toEqual(['你好世界']); + }); + + it('does not discard an IME commit landing inside the keydown echo window', () => { + const { textarea, sent } = loadCjkHarness(); + + // English char goes out immediately via keydown. + textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false }); + expect(sent).toEqual(['a']); + + // Within 100ms the IME commits Chinese via a bare input event. + vi.advanceTimersByTime(50); + textarea.value = PHANTOM + '你好'; + textarea.fire('input', { isComposing: false, inputType: 'insertText' }); + vi.advanceTimersByTime(200); + + expect(sent).toEqual(['a', '你好']); + }); + + it('still suppresses the true textarea echo of a keydown-sent character', () => { + const { textarea, sent } = loadCjkHarness(); + + textarea.fire('keydown', { key: 'a', ctrlKey: false, altKey: false, metaKey: false }); + expect(sent).toEqual(['a']); + + // Third-party IME ignored preventDefault — the same char echoes into + // the textarea. It must be dropped, not sent twice. + vi.advanceTimersByTime(10); + textarea.value = PHANTOM + 'a'; + textarea.fire('input', { isComposing: false, inputType: 'insertText' }); + vi.advanceTimersByTime(300); + + expect(sent).toEqual(['a']); + expect(textarea.value).toBe(PHANTOM); + }); + + it('re-tapping the focused empty field restarts the IME session (wedged-IME recovery)', () => { + const { textarea, documentObj } = loadCjkHarness(); + documentObj.activeElement = textarea; + let blurred = 0; + let focused = 0; + textarea.blur = () => { + blurred++; + }; + textarea.focus = () => { + focused++; + }; + + textarea.fire('pointerdown'); + expect(blurred).toBe(1); + vi.advanceTimersByTime(10); + expect(focused).toBe(1); + + // First tap on an unfocused field must NOT cycle (normal focus flow). + documentObj.activeElement = null; + textarea.fire('pointerdown'); + expect(blurred).toBe(1); + }); + + it('skips redundant textarea writes when already reset (Android IME desync guard)', () => { + const { CjkInput, textarea } = loadCjkHarness(); + const before = textarea.valueWrites; + + // Value is already the phantom — external clears (session switch / SSE + // reconnect) must not rewrite it, or they race the IME session setup. + CjkInput.clear(); + CjkInput.clear(); + + expect(textarea.valueWrites).toBe(before); + }); + + it('routes terminal.focus() to the CJK field while it is visible (focus router)', () => { + // Regression guard for the intermittent "Chinese input goes nowhere" bug: + // session-select / SSE-reconnect paths call terminal.focus(), which lands + // on xterm's hidden textarea; with the CJK gate active, everything typed + // there is swallowed. terminal-ui must (a) route focus() to the CJK field + // when visible and (b) self-heal inside the onData gate. + const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + + expect(src).toContain('Focus router'); + expect(src).toMatch(/this\.terminal\.focus = \(\) => \{/); + expect(src).toContain("cjkEl?.classList.contains('cjk-input-visible')"); + expect(src).toContain('CJK regain-focus (onData swallowed input)'); + }); + + it('sends text plus carriage return on Enter', () => { + const { textarea, sent } = loadCjkHarness(); + + textarea.value = PHANTOM + '你好'; + textarea.fire('keydown', { key: 'Enter' }); + + expect(sent).toEqual(['你好\r']); + expect(textarea.value).toBe(PHANTOM); + }); +});