From 82b090c74a256d15cbef47a10c1d9022ea43ed91 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 7 Sep 2026 12:27:36 -0400 Subject: [PATCH 1/2] fix(terminal): recover dropped keyCode 229 input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android/GBoard-style keyboards fire keydown with keyCode 229 and, on some paths, never mutate xterm's helper textarea. xterm has nothing to diff, so it emits no data and the typed character is silently dropped: it never reaches the PTY and never appears on screen. terminal-keycode229-recovery.js is a standalone controller that re-emits exactly those keys, and only once. xterm stays authoritative throughout: - Only an explicit keyCode 229 keydown carrying a single printable key (or Enter) is eligible; Process/Unidentified/Dead, modifiers, AltGraph and a live composition are all left alone. - The re-emit is scheduled from a microtask and then a zero-delay timer, so xterm's own textarea diff always gets the first opportunity; canonical data for the same key cancels the pending fallback. - compositionstart and blur drop every pending candidate, so a real IME composition lifecycle is never second-guessed. - After a recovery, one late canonical value attributed to that key token (via beforeinput/input on the helper textarea) is suppressed so the character cannot be delivered twice; the record expires after 250ms and an unattributed byte is never suppressed. terminal-ui.js wires it at the two existing choke points β€” the custom key handler and the onData registration, the latter now a named handler so the recovery path can re-enter it β€” with both hooks wrapped so a failure in the fallback can never break canonical input. Unit coverage drives the module directly in a vm; the wiring itself is covered end-to-end in the (browser-only) terminal-copy-shortcut suite. --- scripts/build.mjs | 2 + src/web/public/index.html | 2 + .../public/terminal-keycode229-recovery.js | 227 +++++++++++++++ src/web/public/terminal-ui.js | 40 ++- test/terminal-copy-shortcut.test.ts | 84 ++++++ test/terminal-keycode229-recovery.test.ts | 265 ++++++++++++++++++ 6 files changed, 618 insertions(+), 2 deletions(-) create mode 100644 src/web/public/terminal-keycode229-recovery.js create mode 100644 test/terminal-keycode229-recovery.test.ts diff --git a/scripts/build.mjs b/scripts/build.mjs index d7236f5f6..1fbb34d85 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -83,6 +83,7 @@ appendFileSync( // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); +run('minify terminal-keycode229-recovery.js', 'npx esbuild dist/web/public/terminal-keycode229-recovery.js --minify --outfile=dist/web/public/terminal-keycode229-recovery.js --allow-overwrite'); run('minify i18n.js', 'npx esbuild dist/web/public/i18n.js --minify --outfile=dist/web/public/i18n.js --allow-overwrite'); run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); @@ -110,6 +111,7 @@ console.log('\n[build] content-hash cache busting'); 'notification-manager.js', 'keyboard-accessory.js', 'input-cjk.js', + 'terminal-keycode229-recovery.js', 'sanitize-html.js', 'app.js', 'tab-rail-resize.js', diff --git a/src/web/public/index.html b/src/web/public/index.html index 3de02b409..c7a48ebb4 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3458,6 +3458,8 @@

Read My Mind

+ + diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js new file mode 100644 index 000000000..491b5ec0b --- /dev/null +++ b/src/web/public/terminal-keycode229-recovery.js @@ -0,0 +1,227 @@ +/** + * Recover explicit keyCode 229 terminal input when a browser reports a key but + * never mutates xterm's helper textarea. xterm remains authoritative whenever + * it emits canonical data or the browser enters a real composition lifecycle. + */ +(function (global) { + 'use strict'; + + const LATE_INPUT_WINDOW_MS = 250; + const MAX_RECOVERED_RECORDS = 32; + + function explicitTerminalDataForEvent(event) { + if (!event || event.type !== 'keydown' || event.isComposing) return null; + if (event.ctrlKey || event.altKey || event.metaKey) return null; + try { + if (event.getModifierState?.('AltGraph')) return null; + } catch { + return null; + } + + const key = event.key; + if (key === 'Enter') return '\r'; + if (key === 'Process' || key === 'Unidentified' || key === 'Dead') return null; + if (typeof key !== 'string' || Array.from(key).length !== 1) return null; + const codePoint = key.codePointAt(0); + if (codePoint === undefined || codePoint < 32 || codePoint === 127) return null; + return key; + } + + function terminalDataForEvent(event) { + if (event?.keyCode !== 229) return null; + return explicitTerminalDataForEvent(event); + } + + function create(options) { + const textarea = options?.textarea; + const emitRecovered = options?.emitRecovered; + if (!textarea?.addEventListener || !textarea?.removeEventListener || typeof emitRecovered !== 'function') { + return null; + } + + const enqueueMicrotask = options.queueMicrotask || global.queueMicrotask.bind(global); + const setTimer = options.setTimer || global.setTimeout.bind(global); + const clearTimer = options.clearTimer || global.clearTimeout.bind(global); + const now = options.now || (() => global.performance?.now?.() ?? Date.now()); + + let destroyed = false; + let keySequence = 0; + let activeKey = null; + let beforeInputClaim = null; + const pending = []; + const recovered = []; + + function removePending(candidate) { + const index = pending.indexOf(candidate); + if (index !== -1) pending.splice(index, 1); + if (candidate.timer !== null) { + try { + clearTimer(candidate.timer); + } catch {} + candidate.timer = null; + } + candidate.active = false; + } + + function cancelPending(predicate = () => true) { + for (const candidate of [...pending]) { + if (predicate(candidate)) removePending(candidate); + } + } + + function pruneRecovered() { + const current = now(); + for (let index = recovered.length - 1; index >= 0; index -= 1) { + if (recovered[index].expiresAt < current) recovered.splice(index, 1); + } + } + + function handleKeyEvent(event) { + if (destroyed || event?.type !== 'keydown') return; + const record = { + sequence: ++keySequence, + data: explicitTerminalDataForEvent(event), + candidate: null, + }; + activeKey = record; + const data = terminalDataForEvent(event); + const candidate = data === null ? null : { sequence: record.sequence, data, active: true, timer: null }; + if (candidate) { + record.candidate = candidate; + pending.push(candidate); + } + try { + // The custom key handler runs before xterm's CompositionHelper. Queueing + // our timer from a microtask places it after xterm's own zero-delay + // textarea diff, while keeping the recovery delay to one browser task. + enqueueMicrotask(() => { + if (activeKey === record) activeKey = null; + if (destroyed || !candidate?.active) return; + try { + candidate.timer = setTimer(() => { + if (destroyed || !candidate.active) return; + removePending(candidate); + try { + emitRecovered(candidate.data); + } catch { + // No dedupe record is retained when delivery fails. A later + // canonical xterm value must remain free to pass through. + return; + } + pruneRecovered(); + recovered.push({ + sequence: candidate.sequence, + data: candidate.data, + expiresAt: now() + LATE_INPUT_WINDOW_MS, + claimedByInput: false, + }); + if (recovered.length > MAX_RECOVERED_RECORDS) { + recovered.splice(0, recovered.length - MAX_RECOVERED_RECORDS); + } + }, 0); + } catch { + removePending(candidate); + } + }); + } catch { + if (activeKey === record) activeKey = null; + if (candidate) removePending(candidate); + } + } + + function claimCanonicalInput(data) { + if (activeKey?.data === data) { + if (activeKey.candidate?.active) removePending(activeKey.candidate); + return; + } + const matchingPending = pending.find((candidate) => candidate.active && candidate.data === data); + if (matchingPending) { + removePending(matchingPending); + return; + } + const matchingRecovery = recovered.find((record) => !record.claimedByInput && record.data === data); + if (matchingRecovery) matchingRecovery.claimedByInput = true; + } + + function onCanonicalInput(event) { + if (destroyed) return; + const inputData = typeof event?.data === 'string' ? event.data : null; + if (inputData === null) return; + if (event.type === 'input' && beforeInputClaim?.data === inputData) { + beforeInputClaim = null; + return; + } + if (event.type === 'beforeinput') { + const claim = { data: inputData }; + beforeInputClaim = claim; + try { + enqueueMicrotask(() => { + if (beforeInputClaim === claim) beforeInputClaim = null; + }); + } catch { + beforeInputClaim = null; + } + } + claimCanonicalInput(inputData); + } + + function resetForCompositionOrFocusLoss() { + if (destroyed) return; + keySequence += 1; + activeKey = null; + beforeInputClaim = null; + cancelPending(); + recovered.splice(0); + } + + function consumeTerminalData(data) { + if (destroyed) return false; + pruneRecovered(); + + if (activeKey?.data === data) { + if (activeKey.candidate?.active) removePending(activeKey.candidate); + return false; + } + + const canonical = pending.find((candidate) => candidate.active && candidate.data === data); + if (canonical) { + removePending(canonical); + return false; + } + + const duplicateIndex = recovered.findIndex((record) => record.data === data && record.claimedByInput); + if (duplicateIndex === -1) return false; + recovered.splice(duplicateIndex, 1); + return true; + } + + function destroy() { + if (destroyed) return; + destroyed = true; + activeKey = null; + beforeInputClaim = null; + cancelPending(); + recovered.splice(0); + try { + textarea.removeEventListener('beforeinput', onCanonicalInput, true); + textarea.removeEventListener('input', onCanonicalInput, true); + textarea.removeEventListener('compositionstart', resetForCompositionOrFocusLoss, true); + textarea.removeEventListener('blur', resetForCompositionOrFocusLoss, true); + } catch {} + } + + try { + textarea.addEventListener('beforeinput', onCanonicalInput, true); + textarea.addEventListener('input', onCanonicalInput, true); + textarea.addEventListener('compositionstart', resetForCompositionOrFocusLoss, true); + textarea.addEventListener('blur', resetForCompositionOrFocusLoss, true); + } catch { + destroy(); + return null; + } + + return Object.freeze({ handleKeyEvent, consumeTerminalData, destroy }); + } + + global.CodemanKeyCode229Recovery = Object.freeze({ create, terminalDataForEvent }); +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index bb83cc5ae..4555eb862 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -232,12 +232,22 @@ Object.assign(CodemanApp.prototype, { // Terminal Setup β€” xterm.js config and input handling // ═══════════════════════════════════════════════════════════════ + _destroyKeyCode229Recovery() { + try { + this._keyCode229Recovery?.destroy?.(); + } catch { + // Recovery is optional; terminal replacement must continue. + } + this._keyCode229Recovery = null; + }, + initTerminal() { // Load scrollback setting from localStorage, treating DEFAULT_SCROLLBACK as a floor // so users who picked up the previous (smaller) default get the new minimum on upgrade. const stored = parseInt(localStorage.getItem('codeman-scrollback')); const scrollback = Number.isFinite(stored) && stored > 0 ? Math.max(stored, DEFAULT_SCROLLBACK) : DEFAULT_SCROLLBACK; + this._destroyKeyCode229Recovery(); this.terminal = new Terminal({ theme: { ...window.codemanCurrentXtermTheme() }, fontFamily: window.CodemanTerminalFont.resolve(this.loadAppSettingsFromStorage?.().terminalFontFamily), @@ -292,6 +302,11 @@ Object.assign(CodemanApp.prototype, { // punctuation; returning false here would stop xterm before it can diff // the helper textarea and emit the committed Unicode text. this.terminal.attachCustomKeyEventHandler((ev) => { + try { + this._keyCode229Recovery?.handleKeyEvent?.(ev); + } catch { + // The fallback must never interfere with xterm's canonical handler. + } if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true; // Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler @@ -1026,7 +1041,14 @@ Object.assign(CodemanApp.prototype, { // mobile connections. The overlay + localStorage persistence ensure input // survives tab switches and reconnects. - this.terminal.onData((data) => { + const handleTerminalData = (data, { recovered = false } = {}) => { + if (!recovered) { + try { + if (this._keyCode229Recovery?.consumeTerminalData?.(data)) return; + } catch { + // A broken dedupe guard must fail open to canonical xterm data. + } + } // Mouse SGR reports (tap-to-position) are NOT IME input β€” they must reach // the PTY even while the CJK input field owns focus. Without this exception // tapping to move the cursor silently does nothing whenever Chinese input @@ -1348,7 +1370,21 @@ Object.assign(CodemanApp.prototype, { } } } - }); + }; + + // Android/GBoard fires keydown with keyCode 229 and, on some paths, never + // mutates xterm's helper textarea, so the character is silently dropped. + // The controller re-emits exactly those keys, and only after xterm has had + // its own chance to produce the canonical data. + try { + this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ + textarea: this.terminal.textarea, + emitRecovered: (data) => handleTerminalData(data, { recovered: true }), + }); + } catch { + this._keyCode229Recovery = null; + } + this.terminal.onData((data) => handleTerminalData(data)); }, /** diff --git a/test/terminal-copy-shortcut.test.ts b/test/terminal-copy-shortcut.test.ts index f719ed3cd..018ebff92 100644 --- a/test/terminal-copy-shortcut.test.ts +++ b/test/terminal-copy-shortcut.test.ts @@ -218,6 +218,90 @@ describe('terminal Ctrl+C smart copy', () => { expect(res.data.join('')).not.toContain('\x16'); }); + it('recovers explicit keyCode 229 input once when the helper textarea never mutates', async () => { + await setup('KEYCODE-229-RECOVERY', false); + const result = await page.evaluate(async () => { + const app = (window as any).app; + const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement; + const originalSessionId = app.activeSessionId; + const originalLocalEcho = app._localEchoEnabled; + const originalSendInput = app._sendInputAsync; + const originalPendingInput = app._pendingInput; + const originalLastKeystrokeTime = app._lastKeystrokeTime; + const sent: string[] = []; + const dispatch229 = (key: string) => { + for (const type of ['keydown', 'keyup']) { + const event = new KeyboardEvent(type, { + key, + bubbles: true, + cancelable: true, + composed: true, + }); + Object.defineProperties(event, { keyCode: { value: 229 }, which: { value: 229 } }); + textarea.dispatchEvent(event); + } + }; + + try { + app.activeSessionId = 'cod388-browser-regression'; + app._localEchoEnabled = false; + app._pendingInput = ''; + app._lastKeystrokeTime = 0; + app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data); + textarea.focus(); + + dispatch229('x'); + await new Promise((resolveWait) => setTimeout(resolveWait, 30)); + const afterRecovery = [...sent]; + + // A browser that supplies its canonical input late must not duplicate + // the character already recovered for this key token. + textarea.dispatchEvent( + new InputEvent('beforeinput', { data: 'x', inputType: 'insertText', bubbles: true, composed: true }) + ); + textarea.value = 'x'; + textarea.dispatchEvent( + new InputEvent('input', { data: 'x', inputType: 'insertText', bubbles: true, composed: true }) + ); + await new Promise((resolveWait) => setTimeout(resolveWait, 0)); + const afterLateInput = [...sent]; + + dispatch229('Enter'); + await new Promise((resolveWait) => setTimeout(resolveWait, 30)); + const final = [...sent]; + + // Two 229 candidates can overlap while the main thread is busy. A + // canonical value for the first must resolve that candidate without + // cancelling the second candidate's fallback. + const overlapStart = sent.length; + dispatch229('a'); + dispatch229('b'); + const busyUntil = performance.now() + 25; + while (performance.now() < busyUntil) { + // Deliberately hold the browser task so both xterm/fallback timers + // remain queued while canonical input for `a` is prepared. + } + app.terminal._core.coreService.triggerDataEvent('a', true); + await new Promise((resolveWait) => setTimeout(resolveWait, 30)); + return { afterRecovery, afterLateInput, final, overlap: sent.slice(overlapStart) }; + } finally { + app.activeSessionId = originalSessionId; + app._localEchoEnabled = originalLocalEcho; + app._sendInputAsync = originalSendInput; + app._pendingInput = originalPendingInput; + app._lastKeystrokeTime = originalLastKeystrokeTime; + textarea.value = ''; + } + }); + + expect(result).toEqual({ + afterRecovery: ['x'], + afterLateInput: ['x'], + final: ['x', '\r'], + overlap: ['a', 'b'], + }); + }); + it('forwards full-width punctuation after a Chinese IME composition', async () => { await setup('IME-PUNCTUATION', false); const desktopChunks = await captureImeInput(page); diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts new file mode 100644 index 000000000..247d1653c --- /dev/null +++ b/test/terminal-keycode229-recovery.test.ts @@ -0,0 +1,265 @@ +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +type Listener = (event: Record) => void; + +function makeTextarea() { + const listeners = new Map>(); + return { + addEventListener(type: string, listener: Listener) { + const bucket = listeners.get(type) ?? new Set(); + bucket.add(listener); + listeners.set(type, bucket); + }, + removeEventListener(type: string, listener: Listener) { + listeners.get(type)?.delete(listener); + }, + fire(type: string, event: Record = {}) { + for (const listener of listeners.get(type) ?? []) listener({ type, ...event }); + }, + listenerCount() { + return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0); + }, + }; +} + +function key(overrides: Record = {}) { + return { + type: 'keydown', + key: 'x', + keyCode: 229, + isComposing: false, + ctrlKey: false, + altKey: false, + metaKey: false, + getModifierState: () => false, + ...overrides, + }; +} + +function harness({ emitThrows = false } = {}) { + const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); + const exposed: Record = {}; + vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' }); + + const textarea = makeTextarea(); + const emitted: string[] = []; + const microtasks: Array<() => void> = []; + const timers = new Map void>(); + let timerId = 0; + let now = 1_000; + const controller = exposed.CodemanKeyCode229Recovery.create({ + textarea, + emitRecovered: (data: string) => { + if (emitThrows) throw new Error('recovery callback failed'); + emitted.push(data); + }, + queueMicrotask: (callback: () => void) => microtasks.push(callback), + setTimer: (callback: () => void) => { + const id = ++timerId; + timers.set(id, callback); + return id; + }, + clearTimer: (id: number) => timers.delete(id), + now: () => now, + }); + + return { + controller, + emitted, + textarea, + advance(ms: number) { + now += ms; + }, + flushMicrotasks() { + while (microtasks.length) microtasks.shift()!(); + }, + flushTimers() { + for (const [id, callback] of [...timers]) { + timers.delete(id); + callback(); + } + }, + pendingTimers: () => timers.size, + }; +} + +describe('keyCode 229 terminal input recovery', () => { + it('recovers an explicit printable key and Enter after xterm gets the first opportunity', () => { + const h = harness(); + + h.controller.handleKeyEvent(key()); + expect(h.emitted).toEqual([]); + h.flushMicrotasks(); + expect(h.emitted).toEqual([]); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + + h.controller.handleKeyEvent(key({ key: 'Enter' })); + h.flushMicrotasks(); + h.flushTimers(); + expect(h.emitted).toEqual(['x', '\r']); + }); + + it('lets matching canonical terminal data win before fallback', () => { + const h = harness(); + h.controller.handleKeyEvent(key()); + + expect(h.controller.consumeTerminalData('x')).toBe(false); + h.flushMicrotasks(); + h.flushTimers(); + + expect(h.emitted).toEqual([]); + }); + + it('cancels fallback when the helper textarea receives browser input or composition', () => { + const input = harness(); + input.controller.handleKeyEvent(key()); + input.textarea.fire('input', { data: 'x' }); + input.flushMicrotasks(); + input.flushTimers(); + expect(input.emitted).toEqual([]); + + const composition = harness(); + composition.controller.handleKeyEvent(key()); + composition.textarea.fire('compositionstart'); + composition.flushMicrotasks(); + composition.flushTimers(); + expect(composition.emitted).toEqual([]); + }); + + it('suppresses one delayed matching canonical value from the recovered key token', () => { + const h = harness(); + h.controller.handleKeyEvent(key()); + h.flushMicrotasks(); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + + h.textarea.fire('beforeinput', { data: 'x' }); + h.flushMicrotasks(); + expect(h.controller.consumeTerminalData('x')).toBe(true); + expect(h.controller.consumeTerminalData('x')).toBe(false); + }); + + it('does not suppress an unattributed same byte after recovery', () => { + const h = harness(); + h.controller.handleKeyEvent(key()); + h.flushMicrotasks(); + h.flushTimers(); + + expect(h.controller.consumeTerminalData('x')).toBe(false); + }); + + it('does not let an immediate ordinary same-character key cancel pending recovery', () => { + const h = harness(); + h.controller.handleKeyEvent(key()); + h.controller.handleKeyEvent(key({ keyCode: 88 })); + + expect(h.controller.consumeTerminalData('x')).toBe(false); + h.flushMicrotasks(); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + }); + + it('resolves overlapping eligible candidates independently of the latest keydown', () => { + const h = harness(); + h.controller.handleKeyEvent(key({ key: 'x' })); + h.controller.handleKeyEvent(key({ key: 'y' })); + + expect(h.controller.consumeTerminalData('x')).toBe(false); + h.flushMicrotasks(); + h.flushTimers(); + expect(h.emitted).toEqual(['y']); + }); + + it('suppresses a claimed recovery even after a newer ordinary keydown', () => { + const h = harness(); + h.controller.handleKeyEvent(key({ key: 'x' })); + h.flushMicrotasks(); + h.flushTimers(); + h.textarea.fire('beforeinput', { data: 'x' }); + + h.controller.handleKeyEvent(key({ key: 'y', keyCode: 89 })); + expect(h.controller.consumeTerminalData('x')).toBe(true); + }); + + it('fails open when the recovery callback throws', () => { + const h = harness({ emitThrows: true }); + h.controller.handleKeyEvent(key()); + h.flushMicrotasks(); + h.flushTimers(); + h.textarea.fire('beforeinput', { data: 'x' }); + + expect(h.controller.consumeTerminalData('x')).toBe(false); + expect(h.emitted).toEqual([]); + }); + + it('keeps rapid repeated 229 keys and an ordinary same-character key distinct', () => { + const h = harness(); + h.controller.handleKeyEvent(key()); + h.flushMicrotasks(); + h.flushTimers(); + + h.controller.handleKeyEvent(key()); + h.textarea.fire('input', { data: 'x' }); + expect(h.controller.consumeTerminalData('x')).toBe(false); + h.flushMicrotasks(); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + + h.controller.handleKeyEvent(key({ keyCode: 88 })); + h.textarea.fire('input', { data: 'x' }); + expect(h.controller.consumeTerminalData('x')).toBe(false); + }); + + it('never recovers an ordinary keydown that xterm already handles', () => { + const h = harness(); + h.controller.handleKeyEvent(key({ keyCode: 88 })); + h.flushMicrotasks(); + h.flushTimers(); + + expect(h.emitted).toEqual([]); + }); + + it('does not recover real composition, unidentified keys, modifiers, or keyup', () => { + const h = harness(); + for (const event of [ + key({ isComposing: true }), + key({ key: 'Process' }), + key({ key: 'Unidentified' }), + key({ key: 'Dead' }), + key({ ctrlKey: true }), + key({ altKey: true }), + key({ metaKey: true }), + key({ getModifierState: (name: string) => name === 'AltGraph' }), + key({ type: 'keyup' }), + key({ key: 'ArrowLeft' }), + ]) { + h.controller.handleKeyEvent(event); + } + h.flushMicrotasks(); + h.flushTimers(); + expect(h.emitted).toEqual([]); + }); + + it('expires deduplication and destroys listeners and scheduled work', () => { + const h = harness(); + expect(h.textarea.listenerCount()).toBeGreaterThan(0); + h.controller.handleKeyEvent(key()); + h.flushMicrotasks(); + h.flushTimers(); + h.advance(500); + h.textarea.fire('input', { data: 'x' }); + expect(h.controller.consumeTerminalData('x')).toBe(false); + + h.controller.handleKeyEvent(key({ key: 'y' })); + h.flushMicrotasks(); + expect(h.pendingTimers()).toBe(1); + h.controller.destroy(); + expect(h.pendingTimers()).toBe(0); + expect(h.textarea.listenerCount()).toBe(0); + h.flushTimers(); + expect(h.emitted).toEqual(['x']); + }); +}); From e8a93ada1f36d200cb70faf384ed3efa5bc3ac0f Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Mon, 7 Sep 2026 19:11:20 -0400 Subject: [PATCH 2/2] fix(terminal): forward the orphaned input event instead of replaying a guessed key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous shape guessed the character from `event.key` on keydown, re-emitted it, and then tried to suppress a late canonical copy with a 250 ms character-keyed dedupe. Review found three defects in that, all reproducible: the dedupe matched on the character alone with nothing scoping a candidate to the keydown that created it, so the same character typed twice inside the window had its second, real byte swallowed; anything whose committed text differed from `event.key` (Enter, IME punctuation) was delivered twice, because the dedupe could never match it; and the trigger ignored `key === 'Unidentified'`, which is what a soft keyboard reports, so it may never have fired where it was needed. The input event already carries the committed text in `ev.data` β€” exactly what xterm itself would have forwarded β€” so nothing has to be guessed. The controller now only decides WHETHER to forward, by asking whether xterm produced canonical data since the keydown that began the keystroke. No character-keyed matching survives, so the first two defects are structurally impossible rather than defended against, and nothing reads `key`/`keyCode`, so the third cannot recur. Three details are load-bearing and each has a test that fails without it: - The "did xterm speak?" snapshot is taken at KEYDOWN, not at the input event. `_keyPress` emits and sets `_keyPressHandled` before `input` fires, so a snapshot read at input time already contains that emission, reads it as silence, and delivers the character twice. - Our `input` listener is registered with `capture: true`. The target is visited twice in the event path, so a capture listener calling `stopPropagation()` stops later BUBBLE listeners on that same target; xterm's `cancel()` runs exactly in the branch where it handled the input, so on bubble we would never observe handled events, and whether we observed them at all would hang off `options.cancelEvents`. Measured in jsdom and headless chromium; the table is in the module header. - Enter is deliberately no longer special-cased. That mapping is what made the committed text differ from the re-emitted value in the first place. The scope is also narrower than the old name suggests, and the browser test now proves it rather than assuming it. For a keydown that reports keyCode 229 xterm ALREADY self-rescues, via `CompositionHelper._handleAnyTextareaChanges()` diffing the helper textarea on a 0 ms timer. A test asserting "we recovered it" there passes while xterm does all the work, so the browser tests assert WHO delivered the byte: zero canonical emissions for the genuinely orphaned case, exactly one delivery for the case xterm rescues itself. Also addresses review notes: the module gains an `@fileoverview` with `@dependency`/`@loadorder` and an entry in the load-order list and module inventory, and the wiring test moves out of the Ctrl+C smart-copy file into its own. The keydown hook deliberately still runs for every key event rather than moving behind the 229 gate: gating it would reinstate exactly the blindness described above, and it is now a single counter assignment. --- CLAUDE.md | 4 +- config/test-suites.ts | 1 + src/web/public/index.html | 2 +- .../public/terminal-keycode229-recovery.js | 298 ++++++++--------- src/web/public/terminal-ui.js | 42 ++- test/terminal-copy-shortcut.test.ts | 84 ----- ...rminal-keycode229-recovery.browser.test.ts | 153 +++++++++ test/terminal-keycode229-recovery.test.ts | 313 +++++++++--------- 8 files changed, 477 insertions(+), 420 deletions(-) create mode 100644 test/terminal-keycode229-recovery.browser.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6ba242f78..4f9e83626 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -169,7 +169,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph | **Attachments** | `src/attachment-registry.ts`, `attachment-magic`, `generated-artifact-attachments`, `session-attachment-history`, `document-preview-cache`, `document-thumbnailer`, `document-conversion-limiter`, `config/attachment-guard` | See Key Patterns | | **Plan** | `src/plan-orchestrator.ts`, `src/prompts/*.ts`, `src/templates/` (`claude-md.ts` + `case-template.md`) | `templates/` holds the CLAUDE.md scaffold generated into new cases | | **Web** | `src/web/server.ts` β˜…, `sse-events.ts`, `routes/*.ts` (25 modules + barrel; `session-routes.ts` β˜…), `route-helpers.ts`, `ports/*.ts`, `middleware/auth.ts`, `schemas.ts`, `self-update.ts`, `plan-usage-latest.ts`, `ws-connection-registry.ts`, `heic-jpeg-converter.ts` + `heic-jpeg-worker.ts` | | -| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 31 modules + `sw.js` | See Frontend section for the load order, which is authoritative | +| **Frontend** | `src/web/public/app.js` (~6.7K lines, core) + 32 modules + `sw.js` | See Frontend section for the load order, which is authoritative | | **Types** | `src/types/index.ts` (barrel) β†’ 22 domain files; also `src/types.ts` root re-export | See `@fileoverview` in index.ts | β˜… = Large, central file (>50KB) β€” read its `@fileoverview` first. All files have `@fileoverview` JSDoc β€” read that before diving in. Discovery aid: `grep -l '@fileoverview' src/web/routes/*.ts` lists all route modules; same grep works for `src/types/`, `src/web/public/*.js`. @@ -286,7 +286,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Frontend -Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) β†’ `i18n.js`(1.5) β†’ `mobile-handlers.js`(2) β†’ `voice-input.js`(3) β†’ `notification-manager.js`(4) β†’ `keyboard-accessory.js`(5) β†’ `input-cjk.js`(5.5) β†’ `sanitize-html.js`(5.6) β†’ `app.js`(6) β†’ `tab-rail-resize.js`(6.5) β†’ `terminal-ui.js`(7) β†’ `respawn-ui.js`(8) β†’ `ralph-panel.js`(9) β†’ `orchestrator-panel.js`(9.5) β†’ `cron-ui.js`(9.7) β†’ `settings-ui.js`(10) β†’ `panels-ui.js`(11) β†’ `readmymind-ui.js`(11.3) β†’ `ultracode-panel.js`(11.5) β†’ `approvals-ui.js`(11.6) β†’ `admin-ui.js`(11.7) β†’ `session-ui.js`(12) β†’ `webview-tabs.js`(12.5) β†’ `mobile-overview.js`(12.55) β†’ `home-sessions.js`(12.56) β†’ `entrance-animations.js`(12.6) β†’ `ralph-wizard.js`(13) β†’ `api-client.js`(14) β†’ `subagent-windows.js`(15) β†’ `ultracode-windows.js`(15.5) β†’ `session-lineage.js`(15.6) β†’ `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). +Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) β†’ `i18n.js`(1.5) β†’ `mobile-handlers.js`(2) β†’ `voice-input.js`(3) β†’ `notification-manager.js`(4) β†’ `keyboard-accessory.js`(5) β†’ `input-cjk.js`(5.5) β†’ `terminal-keycode229-recovery.js`(5.55) β†’ `sanitize-html.js`(5.6) β†’ `app.js`(6) β†’ `tab-rail-resize.js`(6.5) β†’ `terminal-ui.js`(7) β†’ `respawn-ui.js`(8) β†’ `ralph-panel.js`(9) β†’ `orchestrator-panel.js`(9.5) β†’ `cron-ui.js`(9.7) β†’ `settings-ui.js`(10) β†’ `panels-ui.js`(11) β†’ `readmymind-ui.js`(11.3) β†’ `ultracode-panel.js`(11.5) β†’ `approvals-ui.js`(11.6) β†’ `admin-ui.js`(11.7) β†’ `session-ui.js`(12) β†’ `webview-tabs.js`(12.5) β†’ `mobile-overview.js`(12.55) β†’ `home-sessions.js`(12.56) β†’ `entrance-animations.js`(12.6) β†’ `ralph-wizard.js`(13) β†’ `api-client.js`(14) β†’ `subagent-windows.js`(15) β†’ `ultracode-windows.js`(15.5) β†’ `session-lineage.js`(15.6) β†’ `image-input.js`(16). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. **Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings β†’ Appearance, full per-surface lab at `?animlab=1`. diff --git a/config/test-suites.ts b/config/test-suites.ts index cef400cce..02cc154b2 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -26,6 +26,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/opencode-resize.test.ts', 'test/webgl-fallback.test.ts', 'test/terminal-copy-shortcut.test.ts', + 'test/terminal-keycode229-recovery.browser.test.ts', 'test/codex-predictive-echo.test.ts', // also needs a real codex binary ]; diff --git a/src/web/public/index.html b/src/web/public/index.html index c7a48ebb4..0ed862237 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3458,7 +3458,7 @@

Read My Mind

- + diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js index 491b5ec0b..4acf32a44 100644 --- a/src/web/public/terminal-keycode229-recovery.js +++ b/src/web/public/terminal-keycode229-recovery.js @@ -1,37 +1,44 @@ /** - * Recover explicit keyCode 229 terminal input when a browser reports a key but - * never mutates xterm's helper textarea. xterm remains authoritative whenever - * it emits canonical data or the browser enters a real composition lifecycle. + * @fileoverview Orphaned-input forwarder for xterm's helper textarea. + * + * xterm's `CoreBrowserTerminal._inputEvent` only forwards an `insertText` + * input event while `(!ev.composed || !this._keyDownSeen)` holds. A soft + * keyboard that delivers a `composed: true` input event after a keydown fails + * that guard, so xterm returns without emitting and the committed character is + * silently dropped. + * + * ⚠ The gap is NARROWER than "keyCode 229", and assuming otherwise produces a + * controller that looks useful while doing nothing. For a keydown that really + * does report `keyCode: 229`, xterm ALREADY self-rescues: `CompositionHelper + * .keydown()` calls `_handleAnyTextareaChanges()`, which snapshots + * `textarea.value` and diffs it on a 0 ms timer, emitting the difference + * itself. Measured in headless chromium against a real terminal: for a 229 + * keydown xterm emits and this controller correctly stands down. What is left + * unrescued is a refused `insertText` where NO 229 diff was scheduled β€” that is + * the case this module exists for, and the case its browser test asserts by + * checking WHO delivered the byte rather than merely that one arrived. + * + * The recovery never guesses the character: the `input` event already carries + * the real committed text in `ev.data`, which is exactly what xterm itself + * would have forwarded. We only decide WHETHER to forward it, by asking + * whether xterm produced any canonical data since the keydown that started the + * keystroke. That snapshot must be taken at KEYDOWN, not at the input event: + * xterm's `_keyPress` emits and sets `_keyPressHandled` before `input` fires, + * so a snapshot read at input time would already contain that emission and the + * character would be delivered twice. + * + * Listener registration is load-bearing, in BOTH phase and order. xterm + * registers its own `input` listener in `terminal.open()` with `capture: + * true`, and ours is added afterwards, so at-target it runs second. It must + * also be a CAPTURE listener; see the measured table at the addEventListener + * call below. + * + * @dependency none (standalone IIFE; consumed by terminal-ui.js) + * @loadorder 5.55 (before app.js/terminal-ui.js, which create the controller) */ (function (global) { 'use strict'; - const LATE_INPUT_WINDOW_MS = 250; - const MAX_RECOVERED_RECORDS = 32; - - function explicitTerminalDataForEvent(event) { - if (!event || event.type !== 'keydown' || event.isComposing) return null; - if (event.ctrlKey || event.altKey || event.metaKey) return null; - try { - if (event.getModifierState?.('AltGraph')) return null; - } catch { - return null; - } - - const key = event.key; - if (key === 'Enter') return '\r'; - if (key === 'Process' || key === 'Unidentified' || key === 'Dead') return null; - if (typeof key !== 'string' || Array.from(key).length !== 1) return null; - const codePoint = key.codePointAt(0); - if (codePoint === undefined || codePoint < 32 || codePoint === 127) return null; - return key; - } - - function terminalDataForEvent(event) { - if (event?.keyCode !== 229) return null; - return explicitTerminalDataForEvent(event); - } - function create(options) { const textarea = options?.textarea; const emitRecovered = options?.emitRecovered; @@ -39,189 +46,142 @@ return null; } - const enqueueMicrotask = options.queueMicrotask || global.queueMicrotask.bind(global); + const isScreenReaderMode = options.isScreenReaderMode; const setTimer = options.setTimer || global.setTimeout.bind(global); const clearTimer = options.clearTimer || global.clearTimeout.bind(global); - const now = options.now || (() => global.performance?.now?.() ?? Date.now()); let destroyed = false; - let keySequence = 0; - let activeKey = null; - let beforeInputClaim = null; + // Number of canonical data events xterm has emitted, bumped by the caller's + // onData hook. Only its ORDER relative to a keydown matters. + let canonicalCount = 0; + let keydownSnapshot = null; + let composing = false; const pending = []; - const recovered = []; - function removePending(candidate) { - const index = pending.indexOf(candidate); - if (index !== -1) pending.splice(index, 1); - if (candidate.timer !== null) { - try { - clearTimer(candidate.timer); - } catch {} - candidate.timer = null; + function cancelPending() { + for (const candidate of pending.splice(0)) { + candidate.active = false; + if (candidate.timer !== null) { + try { + clearTimer(candidate.timer); + } catch { + // A broken timer host must not break input handling. + } + candidate.timer = null; + } } - candidate.active = false; } - function cancelPending(predicate = () => true) { - for (const candidate of [...pending]) { - if (predicate(candidate)) removePending(candidate); + function resolveCandidate(candidate) { + const index = pending.indexOf(candidate); + if (index !== -1) pending.splice(index, 1); + candidate.timer = null; + if (!candidate.active || destroyed) return; + candidate.active = false; + // xterm (or its keypress path) spoke for this keystroke β€” it is already + // on its way to the PTY, so there is nothing to recover. + if (canonicalCount > candidate.snapshot) return; + try { + emitRecovered(candidate.data); + } catch { + // Recovery is best effort; a failed delivery must never throw into the + // browser's input handling. } } - function pruneRecovered() { - const current = now(); - for (let index = recovered.length - 1; index >= 0; index -= 1) { - if (recovered[index].expiresAt < current) recovered.splice(index, 1); - } + /** Called from xterm's onData hook: xterm produced canonical data. */ + function notifyCanonicalData() { + canonicalCount += 1; } + /** + * Snapshot the canonical counter at every keydown. This deliberately reads + * NOTHING else off the event β€” not `key`, not `keyCode`. Gating it on + * keyCode 229 would make the recovery inert on exactly the devices it + * exists for, whose keydowns report `key: 'Unidentified'`. It is a single + * assignment, so running it for every keydown costs nothing. + */ function handleKeyEvent(event) { if (destroyed || event?.type !== 'keydown') return; - const record = { - sequence: ++keySequence, - data: explicitTerminalDataForEvent(event), - candidate: null, - }; - activeKey = record; - const data = terminalDataForEvent(event); - const candidate = data === null ? null : { sequence: record.sequence, data, active: true, timer: null }; - if (candidate) { - record.candidate = candidate; - pending.push(candidate); - } - try { - // The custom key handler runs before xterm's CompositionHelper. Queueing - // our timer from a microtask places it after xterm's own zero-delay - // textarea diff, while keeping the recovery delay to one browser task. - enqueueMicrotask(() => { - if (activeKey === record) activeKey = null; - if (destroyed || !candidate?.active) return; - try { - candidate.timer = setTimer(() => { - if (destroyed || !candidate.active) return; - removePending(candidate); - try { - emitRecovered(candidate.data); - } catch { - // No dedupe record is retained when delivery fails. A later - // canonical xterm value must remain free to pass through. - return; - } - pruneRecovered(); - recovered.push({ - sequence: candidate.sequence, - data: candidate.data, - expiresAt: now() + LATE_INPUT_WINDOW_MS, - claimedByInput: false, - }); - if (recovered.length > MAX_RECOVERED_RECORDS) { - recovered.splice(0, recovered.length - MAX_RECOVERED_RECORDS); - } - }, 0); - } catch { - removePending(candidate); - } - }); - } catch { - if (activeKey === record) activeKey = null; - if (candidate) removePending(candidate); - } + keydownSnapshot = canonicalCount; } - function claimCanonicalInput(data) { - if (activeKey?.data === data) { - if (activeKey.candidate?.active) removePending(activeKey.candidate); - return; - } - const matchingPending = pending.find((candidate) => candidate.active && candidate.data === data); - if (matchingPending) { - removePending(matchingPending); + function onInput(event) { + if (destroyed || composing || event?.isComposing) return; + if (event.inputType !== 'insertText') return; + const data = event.data; + if (typeof data !== 'string' || data === '') return; + try { + if (isScreenReaderMode?.()) return; + } catch { return; } - const matchingRecovery = recovered.find((record) => !record.claimedByInput && record.data === data); - if (matchingRecovery) matchingRecovery.claimedByInput = true; - } - function onCanonicalInput(event) { - if (destroyed) return; - const inputData = typeof event?.data === 'string' ? event.data : null; - if (inputData === null) return; - if (event.type === 'input' && beforeInputClaim?.data === inputData) { - beforeInputClaim = null; - return; - } - if (event.type === 'beforeinput') { - const claim = { data: inputData }; - beforeInputClaim = claim; - try { - enqueueMicrotask(() => { - if (beforeInputClaim === claim) beforeInputClaim = null; - }); - } catch { - beforeInputClaim = null; - } + const candidate = { + data, + snapshot: keydownSnapshot ?? canonicalCount, + active: true, + timer: null, + }; + pending.push(candidate); + try { + candidate.timer = setTimer(() => resolveCandidate(candidate), 0); + } catch { + cancelPending(); } - claimCanonicalInput(inputData); } - function resetForCompositionOrFocusLoss() { + function onCompositionStart() { if (destroyed) return; - keySequence += 1; - activeKey = null; - beforeInputClaim = null; + composing = true; cancelPending(); - recovered.splice(0); } - function consumeTerminalData(data) { - if (destroyed) return false; - pruneRecovered(); - - if (activeKey?.data === data) { - if (activeKey.candidate?.active) removePending(activeKey.candidate); - return false; - } - - const canonical = pending.find((candidate) => candidate.active && candidate.data === data); - if (canonical) { - removePending(canonical); - return false; - } - - const duplicateIndex = recovered.findIndex((record) => record.data === data && record.claimedByInput); - if (duplicateIndex === -1) return false; - recovered.splice(duplicateIndex, 1); - return true; + function onCompositionEnd() { + if (destroyed) return; + composing = false; } function destroy() { if (destroyed) return; destroyed = true; - activeKey = null; - beforeInputClaim = null; cancelPending(); - recovered.splice(0); try { - textarea.removeEventListener('beforeinput', onCanonicalInput, true); - textarea.removeEventListener('input', onCanonicalInput, true); - textarea.removeEventListener('compositionstart', resetForCompositionOrFocusLoss, true); - textarea.removeEventListener('blur', resetForCompositionOrFocusLoss, true); - } catch {} + textarea.removeEventListener('input', onInput, true); + textarea.removeEventListener('compositionstart', onCompositionStart, true); + textarea.removeEventListener('compositionend', onCompositionEnd, true); + } catch { + // Teardown is best effort; the terminal is being replaced anyway. + } } + // capture: true, not bubble. The target (the textarea) is visited TWICE in + // the event path, so a capture-phase listener on it calling + // stopPropagation() still stops later BUBBLE-phase listeners on that same + // target. xterm's `_inputEvent` calls `this.cancel(ev)` (preventDefault + + // stopPropagation) exactly in the branch where it HANDLED the input, so on + // bubble we would never see handled events β€” and whether we saw them at + // all would hang off xterm's `options.cancelEvents`, which Codeman does not + // set. Measured (jsdom and headless chromium agree): + // + // capture-then-BUBBLE, no stop: xterm -> ours + // capture-then-BUBBLE, stopPropagation: xterm (ours never fires) + // capture-then-CAPTURE, no stop: xterm -> ours + // capture-then-CAPTURE, stopPropagation: xterm -> ours (still fires) + // + // On capture we therefore observe EVERY input event uniformly, and the + // canonicalCount snapshot alone decides whether to forward. try { - textarea.addEventListener('beforeinput', onCanonicalInput, true); - textarea.addEventListener('input', onCanonicalInput, true); - textarea.addEventListener('compositionstart', resetForCompositionOrFocusLoss, true); - textarea.addEventListener('blur', resetForCompositionOrFocusLoss, true); + textarea.addEventListener('input', onInput, true); + textarea.addEventListener('compositionstart', onCompositionStart, true); + textarea.addEventListener('compositionend', onCompositionEnd, true); } catch { destroy(); return null; } - return Object.freeze({ handleKeyEvent, consumeTerminalData, destroy }); + return Object.freeze({ handleKeyEvent, notifyCanonicalData, destroy }); } - global.CodemanKeyCode229Recovery = Object.freeze({ create, terminalDataForEvent }); + global.CodemanKeyCode229Recovery = Object.freeze({ create }); })(typeof window !== 'undefined' ? window : globalThis); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 4555eb862..27ebcf801 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -303,6 +303,11 @@ Object.assign(CodemanApp.prototype, { // the helper textarea and emit the committed Unicode text. this.terminal.attachCustomKeyEventHandler((ev) => { try { + // Deliberately runs for EVERY keydown, not just keyCode 229: the + // controller snapshots a counter and reads nothing off the event, and + // the devices this exists for report `key: 'Unidentified'` with no + // reliable identity to gate on. Gating it would make recovery inert + // exactly where it is needed. Cost is one assignment. this._keyCode229Recovery?.handleKeyEvent?.(ev); } catch { // The fallback must never interfere with xterm's canonical handler. @@ -1041,14 +1046,7 @@ Object.assign(CodemanApp.prototype, { // mobile connections. The overlay + localStorage persistence ensure input // survives tab switches and reconnects. - const handleTerminalData = (data, { recovered = false } = {}) => { - if (!recovered) { - try { - if (this._keyCode229Recovery?.consumeTerminalData?.(data)) return; - } catch { - // A broken dedupe guard must fail open to canonical xterm data. - } - } + const handleTerminalData = (data) => { // Mouse SGR reports (tap-to-position) are NOT IME input β€” they must reach // the PTY even while the CJK input field owns focus. Without this exception // tapping to move the cursor silently does nothing whenever Chinese input @@ -1372,19 +1370,35 @@ Object.assign(CodemanApp.prototype, { } }; - // Android/GBoard fires keydown with keyCode 229 and, on some paths, never - // mutates xterm's helper textarea, so the character is silently dropped. - // The controller re-emits exactly those keys, and only after xterm has had - // its own chance to produce the canonical data. + // Chrome on Android delivers a `composed: true` input event preceded by a + // keydown, which is exactly the shape xterm's _inputEvent refuses to + // forward, so the committed character is silently dropped. The controller + // forwards the input event's own `data` when xterm produced nothing for + // that keystroke. Created AFTER terminal.open() on purpose: for an event + // targeting the textarea, at-target listeners run in registration order, + // so xterm's listener (added in open()) still runs first. The controller + // registers its own listener with `capture: true`; on bubble xterm's + // `cancel()` (stopPropagation) would swallow exactly the handled events β€” + // see the measured table in terminal-keycode229-recovery.js. try { this._keyCode229Recovery = window.CodemanKeyCode229Recovery?.create?.({ textarea: this.terminal.textarea, - emitRecovered: (data) => handleTerminalData(data, { recovered: true }), + emitRecovered: (data) => handleTerminalData(data), + isScreenReaderMode: () => this.terminal?.options?.screenReaderMode === true, }); } catch { this._keyCode229Recovery = null; } - this.terminal.onData((data) => handleTerminalData(data)); + this.terminal.onData((data) => { + // Canonical xterm data. Telling the controller is what lets it know a + // keystroke was already delivered and needs no recovery. + try { + this._keyCode229Recovery?.notifyCanonicalData?.(); + } catch { + // Bookkeeping must never block real input. + } + handleTerminalData(data); + }); }, /** diff --git a/test/terminal-copy-shortcut.test.ts b/test/terminal-copy-shortcut.test.ts index 018ebff92..f719ed3cd 100644 --- a/test/terminal-copy-shortcut.test.ts +++ b/test/terminal-copy-shortcut.test.ts @@ -218,90 +218,6 @@ describe('terminal Ctrl+C smart copy', () => { expect(res.data.join('')).not.toContain('\x16'); }); - it('recovers explicit keyCode 229 input once when the helper textarea never mutates', async () => { - await setup('KEYCODE-229-RECOVERY', false); - const result = await page.evaluate(async () => { - const app = (window as any).app; - const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement; - const originalSessionId = app.activeSessionId; - const originalLocalEcho = app._localEchoEnabled; - const originalSendInput = app._sendInputAsync; - const originalPendingInput = app._pendingInput; - const originalLastKeystrokeTime = app._lastKeystrokeTime; - const sent: string[] = []; - const dispatch229 = (key: string) => { - for (const type of ['keydown', 'keyup']) { - const event = new KeyboardEvent(type, { - key, - bubbles: true, - cancelable: true, - composed: true, - }); - Object.defineProperties(event, { keyCode: { value: 229 }, which: { value: 229 } }); - textarea.dispatchEvent(event); - } - }; - - try { - app.activeSessionId = 'cod388-browser-regression'; - app._localEchoEnabled = false; - app._pendingInput = ''; - app._lastKeystrokeTime = 0; - app._sendInputAsync = (_sessionId: string, data: string) => sent.push(data); - textarea.focus(); - - dispatch229('x'); - await new Promise((resolveWait) => setTimeout(resolveWait, 30)); - const afterRecovery = [...sent]; - - // A browser that supplies its canonical input late must not duplicate - // the character already recovered for this key token. - textarea.dispatchEvent( - new InputEvent('beforeinput', { data: 'x', inputType: 'insertText', bubbles: true, composed: true }) - ); - textarea.value = 'x'; - textarea.dispatchEvent( - new InputEvent('input', { data: 'x', inputType: 'insertText', bubbles: true, composed: true }) - ); - await new Promise((resolveWait) => setTimeout(resolveWait, 0)); - const afterLateInput = [...sent]; - - dispatch229('Enter'); - await new Promise((resolveWait) => setTimeout(resolveWait, 30)); - const final = [...sent]; - - // Two 229 candidates can overlap while the main thread is busy. A - // canonical value for the first must resolve that candidate without - // cancelling the second candidate's fallback. - const overlapStart = sent.length; - dispatch229('a'); - dispatch229('b'); - const busyUntil = performance.now() + 25; - while (performance.now() < busyUntil) { - // Deliberately hold the browser task so both xterm/fallback timers - // remain queued while canonical input for `a` is prepared. - } - app.terminal._core.coreService.triggerDataEvent('a', true); - await new Promise((resolveWait) => setTimeout(resolveWait, 30)); - return { afterRecovery, afterLateInput, final, overlap: sent.slice(overlapStart) }; - } finally { - app.activeSessionId = originalSessionId; - app._localEchoEnabled = originalLocalEcho; - app._sendInputAsync = originalSendInput; - app._pendingInput = originalPendingInput; - app._lastKeystrokeTime = originalLastKeystrokeTime; - textarea.value = ''; - } - }); - - expect(result).toEqual({ - afterRecovery: ['x'], - afterLateInput: ['x'], - final: ['x', '\r'], - overlap: ['a', 'b'], - }); - }); - it('forwards full-width punctuation after a Chinese IME composition', async () => { await setup('IME-PUNCTUATION', false); const desktopChunks = await captureImeInput(page); diff --git a/test/terminal-keycode229-recovery.browser.test.ts b/test/terminal-keycode229-recovery.browser.test.ts new file mode 100644 index 000000000..8f980fc03 --- /dev/null +++ b/test/terminal-keycode229-recovery.browser.test.ts @@ -0,0 +1,153 @@ +/** + * Wiring for the orphaned-input recovery controller, in a real browser. + * + * The controller's decision logic is unit-tested in + * test/terminal-keycode229-recovery.test.ts. What can only be proven with a + * real xterm instance is the wiring: + * + * - our `input` listener is registered AFTER xterm's, so xterm's `cancel()` + * (stopPropagation, not stopImmediatePropagation) does not silence it; + * - a `composed: true` insertText preceded by a keydown β€” the shape Chrome on + * Android delivers β€” is dropped by xterm and recovered by us, exactly once; + * - a keystroke xterm DOES handle is delivered exactly once, not twice. + * + * Browser-driven, so it is excluded from `npm run test:ci` like the other + * Playwright suites. Run locally: + * npm run test:browser -- test/terminal-keycode229-recovery.browser.test.ts + * + * Port: 3186 (per CLAUDE.md, ports 3150+ for tests) + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PORT = 3186; +const BASE_URL = `http://localhost:${PORT}`; + +describe('orphaned terminal input recovery wiring', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(PORT, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage(); + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + await page.waitForFunction(() => (window as any).app?._keyCode229Recovery, null, { timeout: 30000 }); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** + * Drive one keystroke through the real textarea and report what reached the + * PTY send path. `dispatchInput` mirrors GBoard: a keydown with no usable key + * identity, then a `composed: true` insertText that xterm refuses to forward. + */ + async function keystroke(options: { data: string; dispatchInput: boolean; keyCode: number }) { + return page.evaluate(async ({ data, dispatchInput, keyCode }) => { + const app = (window as any).app; + const textarea = document.querySelector('.xterm-helper-textarea') as HTMLTextAreaElement; + const originalSessionId = app.activeSessionId; + const originalLocalEcho = app._localEchoEnabled; + const originalSendInput = app._sendInputAsync; + const originalPendingInput = app._pendingInput; + const originalLastKeystrokeTime = app._lastKeystrokeTime; + const sent: string[] = []; + let xtermEmitted = 0; + const rec = app._keyCode229Recovery; + + try { + app.activeSessionId = 'cod388-browser-regression'; + app._localEchoEnabled = false; + app._pendingInput = ''; + app._lastKeystrokeTime = 0; + app._sendInputAsync = (_sessionId: string, chunk: string) => sent.push(chunk); + // The controller object is Object.freeze()d, so count xterm's own + // canonical emissions by swapping the (writable) property on app. + app._keyCode229Recovery = { + handleKeyEvent: (e: any) => rec.handleKeyEvent(e), + notifyCanonicalData: () => { + xtermEmitted += 1; + return rec.notifyCanonicalData(); + }, + destroy: () => rec.destroy(), + }; + textarea.focus(); + + const down = new KeyboardEvent('keydown', { + key: 'Unidentified', + bubbles: true, + cancelable: true, + composed: true, + }); + Object.defineProperties(down, { keyCode: { value: keyCode }, which: { value: keyCode } }); + textarea.dispatchEvent(down); + + if (dispatchInput) { + textarea.value = data; + textarea.dispatchEvent( + new InputEvent('input', { data, inputType: 'insertText', bubbles: true, composed: true }) + ); + } + + await new Promise((resolve) => setTimeout(resolve, 60)); + return { sent, xtermEmitted }; + } finally { + app.activeSessionId = originalSessionId; + app._localEchoEnabled = originalLocalEcho; + app._sendInputAsync = originalSendInput; + app._pendingInput = originalPendingInput; + app._lastKeystrokeTime = originalLastKeystrokeTime; + app._keyCode229Recovery = rec; + textarea.value = ''; + } + }, options); + } + + /** + * ⚠ The gap this controller actually fills is NARROWER than "keyCode 229", + * and that matters for what these tests can prove. + * + * xterm already self-recovers keyCode 229: `CompositionHelper.keydown()` + * calls `_handleAnyTextareaChanges()`, which snapshots `textarea.value` and + * diffs it on a 0 ms timer, emitting the difference itself. So for a 229 + * keydown there is nothing orphaned to recover, and a test asserting "we + * recovered it" would pass while xterm did all the work β€” measured: xterm + * emits, our controller correctly stands down. + * + * The real gap is an `insertText` input event that xterm's `_inputEvent` + * refuses (`composed: true` with a keydown seen) where NO 229 diff was + * scheduled to rescue it. These tests therefore assert WHO delivered the + * byte, via `xtermEmitted`, not merely that a byte arrived. + */ + it('recovers a composed insertText that xterm dropped and did not self-rescue', async () => { + const { sent, xtermEmitted } = await keystroke({ data: 'x', dispatchInput: true, keyCode: 65 }); + expect(xtermEmitted).toBe(0); // xterm delivered nothing: genuinely orphaned + expect(sent.join('')).toBe('x'); // ...so this byte is ours + }); + + it('does not duplicate a keystroke xterm self-rescued via its own 0 ms diff', async () => { + const { sent, xtermEmitted } = await keystroke({ data: 'y', dispatchInput: true, keyCode: 229 }); + expect(xtermEmitted).toBe(1); // xterm's 229 textarea diff spoke + expect(sent.join('')).toBe('y'); // exactly once β€” we must not add a second copy + }); + + it('recovers the same character twice when both keystrokes are orphaned', async () => { + const first = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 }); + const second = await keystroke({ data: 'z', dispatchInput: true, keyCode: 65 }); + expect(first.sent.join('')).toBe('z'); + expect(second.sent.join('')).toBe('z'); + }); + + it('sends nothing for a keydown that produces no input event', async () => { + const { sent } = await keystroke({ data: 'q', dispatchInput: false, keyCode: 65 }); + expect(sent).toEqual([]); + }); +}); diff --git a/test/terminal-keycode229-recovery.test.ts b/test/terminal-keycode229-recovery.test.ts index 247d1653c..25cf87dd7 100644 --- a/test/terminal-keycode229-recovery.test.ts +++ b/test/terminal-keycode229-recovery.test.ts @@ -1,3 +1,15 @@ +/** + * Orphaned-input recovery for xterm's helper textarea (PR #388 / COD-27). + * + * xterm's CoreBrowserTerminal._inputEvent only forwards an `insertText` input + * event when `(!ev.composed || !this._keyDownSeen)`. Chrome-on-Android's soft + * keyboard produces `composed: true` input events preceded by a keydown, so + * that guard is false and the committed character is silently dropped. + * + * The controller under test forwards the event's own `data` when β€” and only + * when β€” xterm produced no canonical data for that keystroke. These tests + * drive it with synthetic events and an injected timer; no browser is needed. + */ import { readFileSync } from 'node:fs'; import vm from 'node:vm'; import { describe, expect, it } from 'vitest'; @@ -6,74 +18,66 @@ type Listener = (event: Record) => void; function makeTextarea() { const listeners = new Map>(); + const registrations: Array<{ type: string; capture: unknown }> = []; return { - addEventListener(type: string, listener: Listener) { + addEventListener(type: string, listener: Listener, capture?: unknown) { const bucket = listeners.get(type) ?? new Set(); bucket.add(listener); listeners.set(type, bucket); + registrations.push({ type, capture }); }, removeEventListener(type: string, listener: Listener) { listeners.get(type)?.delete(listener); }, fire(type: string, event: Record = {}) { - for (const listener of listeners.get(type) ?? []) listener({ type, ...event }); + for (const listener of [...(listeners.get(type) ?? [])]) listener({ type, ...event }); }, listenerCount() { return [...listeners.values()].reduce((total, bucket) => total + bucket.size, 0); }, + registrations() { + return [...registrations]; + }, }; } -function key(overrides: Record = {}) { - return { - type: 'keydown', - key: 'x', - keyCode: 229, - isComposing: false, - ctrlKey: false, - altKey: false, - metaKey: false, - getModifierState: () => false, - ...overrides, - }; +/** A committed-text `input` event of the shape Chrome-on-Android delivers. */ +function inputEvent(data: string, overrides: Record = {}) { + return { data, inputType: 'insertText', isComposing: false, ...overrides }; } -function harness({ emitThrows = false } = {}) { +function harness({ screenReader = false } = {}) { const source = readFileSync(new URL('../src/web/public/terminal-keycode229-recovery.js', import.meta.url), 'utf8'); const exposed: Record = {}; vm.runInNewContext(source, { window: exposed, globalThis: exposed }, { filename: 'terminal-keycode229-recovery.js' }); const textarea = makeTextarea(); const emitted: string[] = []; - const microtasks: Array<() => void> = []; const timers = new Map void>(); let timerId = 0; - let now = 1_000; + const controller = exposed.CodemanKeyCode229Recovery.create({ textarea, - emitRecovered: (data: string) => { - if (emitThrows) throw new Error('recovery callback failed'); - emitted.push(data); - }, - queueMicrotask: (callback: () => void) => microtasks.push(callback), + emitRecovered: (data: string) => emitted.push(data), + isScreenReaderMode: () => screenReader, setTimer: (callback: () => void) => { const id = ++timerId; timers.set(id, callback); return id; }, clearTimer: (id: number) => timers.delete(id), - now: () => now, }); return { controller, emitted, textarea, - advance(ms: number) { - now += ms; + /** A keydown that carries NO usable key identity, exactly like GBoard's. */ + keydown(overrides: Record = {}) { + controller.handleKeyEvent({ type: 'keydown', key: 'Unidentified', keyCode: 229, ...overrides }); }, - flushMicrotasks() { - while (microtasks.length) microtasks.shift()!(); + input(data: string, overrides: Record = {}) { + textarea.fire('input', inputEvent(data, overrides)); }, flushTimers() { for (const [id, callback] of [...timers]) { @@ -85,181 +89,190 @@ function harness({ emitThrows = false } = {}) { }; } -describe('keyCode 229 terminal input recovery', () => { - it('recovers an explicit printable key and Enter after xterm gets the first opportunity', () => { +describe('orphaned terminal input recovery', () => { + it('forwards the committed text when xterm stayed silent', () => { const h = harness(); - - h.controller.handleKeyEvent(key()); - expect(h.emitted).toEqual([]); - h.flushMicrotasks(); + h.keydown(); + h.input('x'); expect(h.emitted).toEqual([]); h.flushTimers(); expect(h.emitted).toEqual(['x']); - - h.controller.handleKeyEvent(key({ key: 'Enter' })); - h.flushMicrotasks(); - h.flushTimers(); - expect(h.emitted).toEqual(['x', '\r']); }); - it('lets matching canonical terminal data win before fallback', () => { + it('forwards nothing when xterm emitted canonical data after the keydown', () => { + // The "xterm handled it" case is decided by the COUNTER, never by assuming + // the input event does not reach us. On capture it always does (xterm's + // cancel() only stops later BUBBLE listeners), so this test dispatches the + // real input event AND has xterm emit canonical data for that keystroke. const h = harness(); - h.controller.handleKeyEvent(key()); - - expect(h.controller.consumeTerminalData('x')).toBe(false); - h.flushMicrotasks(); + h.keydown(); + h.input('x'); + h.controller.notifyCanonicalData(); h.flushTimers(); - expect(h.emitted).toEqual([]); }); - it('cancels fallback when the helper textarea receives browser input or composition', () => { - const input = harness(); - input.controller.handleKeyEvent(key()); - input.textarea.fire('input', { data: 'x' }); - input.flushMicrotasks(); - input.flushTimers(); - expect(input.emitted).toEqual([]); - - const composition = harness(); - composition.controller.handleKeyEvent(key()); - composition.textarea.fire('compositionstart'); - composition.flushMicrotasks(); - composition.flushTimers(); - expect(composition.emitted).toEqual([]); - }); - - it('suppresses one delayed matching canonical value from the recovered key token', () => { + it('registers the input listener in the CAPTURE phase', () => { + // Measured in jsdom and headless chromium: a capture-phase listener on the + // TARGET calling stopPropagation() (which is what xterm's cancel() does in + // the branch where it handled the input) stops later BUBBLE listeners on + // that same target, because the target is visited twice in the event path. + // + // capture-then-BUBBLE, stopPropagation: ours NEVER fires + // capture-then-CAPTURE, stopPropagation: ours still fires + // + // So this must not be "tidied" to bubble: on bubble we would silently stop + // seeing exactly the events xterm handled, and whether we saw them at all + // would depend on xterm's `options.cancelEvents`, which Codeman never sets. const h = harness(); - h.controller.handleKeyEvent(key()); - h.flushMicrotasks(); - h.flushTimers(); - expect(h.emitted).toEqual(['x']); - - h.textarea.fire('beforeinput', { data: 'x' }); - h.flushMicrotasks(); - expect(h.controller.consumeTerminalData('x')).toBe(true); - expect(h.controller.consumeTerminalData('x')).toBe(false); + const input = h.textarea.registrations().filter((entry) => entry.type === 'input'); + expect(input).toHaveLength(1); + expect(input[0].capture).toBe(true); + for (const entry of h.textarea.registrations()) expect(entry.capture).toBe(true); }); - it('does not suppress an unattributed same byte after recovery', () => { + it('forwards nothing on the keypress path, where canonical data precedes the input event', () => { + // xterm's _keyPress calls triggerDataEvent() and sets _keyPressHandled + // BEFORE the input event fires. The "did xterm speak?" snapshot therefore + // has to be taken at keydown; taken at input time it would already include + // this emission and the character would be delivered twice. const h = harness(); - h.controller.handleKeyEvent(key()); - h.flushMicrotasks(); + h.keydown(); + h.controller.notifyCanonicalData(); + h.input('x'); h.flushTimers(); - - expect(h.controller.consumeTerminalData('x')).toBe(false); + expect(h.emitted).toEqual([]); }); - it('does not let an immediate ordinary same-character key cancel pending recovery', () => { + it('does not let a stale candidate swallow a later identical keystroke (defect 1)', () => { const h = harness(); - h.controller.handleKeyEvent(key()); - h.controller.handleKeyEvent(key({ keyCode: 88 })); - expect(h.controller.consumeTerminalData('x')).toBe(false); - h.flushMicrotasks(); + // First keystroke: orphaned, recovered. + h.keydown(); + h.input('x'); h.flushTimers(); expect(h.emitted).toEqual(['x']); - }); - - it('resolves overlapping eligible candidates independently of the latest keydown', () => { - const h = harness(); - h.controller.handleKeyEvent(key({ key: 'x' })); - h.controller.handleKeyEvent(key({ key: 'y' })); - expect(h.controller.consumeTerminalData('x')).toBe(false); - h.flushMicrotasks(); + // Second identical keystroke, handled by xterm itself. + h.keydown(); + h.input('x'); + h.controller.notifyCanonicalData(); h.flushTimers(); - expect(h.emitted).toEqual(['y']); + + // Exactly one recovery total, and the second keystroke's canonical byte was + // never claimed or suppressed by the first one. + expect(h.emitted).toEqual(['x']); }); - it('suppresses a claimed recovery even after a newer ordinary keydown', () => { + it('forwards committed text that no keydown key could describe, exactly once (defect 2)', () => { const h = harness(); - h.controller.handleKeyEvent(key({ key: 'x' })); - h.flushMicrotasks(); + h.keydown({ key: 'Enter' }); + h.input('a longer commit'); h.flushTimers(); - h.textarea.fire('beforeinput', { data: 'x' }); - - h.controller.handleKeyEvent(key({ key: 'y', keyCode: 89 })); - expect(h.controller.consumeTerminalData('x')).toBe(true); - }); - - it('fails open when the recovery callback throws', () => { - const h = harness({ emitThrows: true }); - h.controller.handleKeyEvent(key()); - h.flushMicrotasks(); h.flushTimers(); - h.textarea.fire('beforeinput', { data: 'x' }); - - expect(h.controller.consumeTerminalData('x')).toBe(false); - expect(h.emitted).toEqual([]); + expect(h.emitted).toEqual(['a longer commit']); }); - it('keeps rapid repeated 229 keys and an ordinary same-character key distinct', () => { + it('recovers a GBoard keydown and never reads key or keyCode (defect 3)', () => { const h = harness(); - h.controller.handleKeyEvent(key()); - h.flushMicrotasks(); - h.flushTimers(); - - h.controller.handleKeyEvent(key()); - h.textarea.fire('input', { data: 'x' }); - expect(h.controller.consumeTerminalData('x')).toBe(false); - h.flushMicrotasks(); + const reads: string[] = []; + h.controller.handleKeyEvent({ + type: 'keydown', + get key() { + reads.push('key'); + return 'Unidentified'; + }, + get keyCode() { + reads.push('keyCode'); + return 229; + }, + get which() { + reads.push('which'); + return 229; + }, + }); + h.input('x'); h.flushTimers(); expect(h.emitted).toEqual(['x']); - - h.controller.handleKeyEvent(key({ keyCode: 88 })); - h.textarea.fire('input', { data: 'x' }); - expect(h.controller.consumeTerminalData('x')).toBe(false); + expect(reads).toEqual([]); }); - it('never recovers an ordinary keydown that xterm already handles', () => { + it('ignores input events that are not committed text', () => { const h = harness(); - h.controller.handleKeyEvent(key({ keyCode: 88 })); - h.flushMicrotasks(); + for (const inputType of ['insertCompositionText', 'deleteContentBackward', 'insertLineBreak', 'insertFromPaste']) { + h.keydown(); + h.input('x', { inputType }); + } + h.keydown(); + h.input(''); + h.keydown(); + h.textarea.fire('input', { data: null, inputType: 'insertText' }); h.flushTimers(); - expect(h.emitted).toEqual([]); }); - it('does not recover real composition, unidentified keys, modifiers, or keyup', () => { - const h = harness(); - for (const event of [ - key({ isComposing: true }), - key({ key: 'Process' }), - key({ key: 'Unidentified' }), - key({ key: 'Dead' }), - key({ ctrlKey: true }), - key({ altKey: true }), - key({ metaKey: true }), - key({ getModifierState: (name: string) => name === 'AltGraph' }), - key({ type: 'keyup' }), - key({ key: 'ArrowLeft' }), - ]) { - h.controller.handleKeyEvent(event); - } - h.flushMicrotasks(); + it('ignores composition and cancels pending candidates on compositionstart', () => { + const composing = harness(); + composing.keydown(); + composing.input('x', { isComposing: true }); + composing.flushTimers(); + expect(composing.emitted).toEqual([]); + + const lifecycle = harness(); + lifecycle.textarea.fire('compositionstart'); + lifecycle.keydown(); + lifecycle.input('δΈ­'); + lifecycle.flushTimers(); + expect(lifecycle.emitted).toEqual([]); + + // compositionstart arriving after a candidate is queued must cancel it. + const cancelled = harness(); + cancelled.keydown(); + cancelled.input('x'); + cancelled.textarea.fire('compositionstart'); + cancelled.flushTimers(); + expect(cancelled.emitted).toEqual([]); + + // compositionend releases the gate again. + cancelled.textarea.fire('compositionend'); + cancelled.keydown(); + cancelled.input('y'); + cancelled.flushTimers(); + expect(cancelled.emitted).toEqual(['y']); + }); + + it('stays out of the way in screen reader mode', () => { + const h = harness({ screenReader: true }); + h.keydown(); + h.input('x'); h.flushTimers(); expect(h.emitted).toEqual([]); }); - it('expires deduplication and destroys listeners and scheduled work', () => { + it('recovers an input event that arrives with no preceding keydown', () => { const h = harness(); - expect(h.textarea.listenerCount()).toBeGreaterThan(0); - h.controller.handleKeyEvent(key()); - h.flushMicrotasks(); + h.input('x'); h.flushTimers(); - h.advance(500); - h.textarea.fire('input', { data: 'x' }); - expect(h.controller.consumeTerminalData('x')).toBe(false); + expect(h.emitted).toEqual(['x']); + }); - h.controller.handleKeyEvent(key({ key: 'y' })); - h.flushMicrotasks(); + it('clears timers and listeners on destroy', () => { + const h = harness(); + expect(h.textarea.listenerCount()).toBeGreaterThan(0); + h.keydown(); + h.input('x'); expect(h.pendingTimers()).toBe(1); + h.controller.destroy(); expect(h.pendingTimers()).toBe(0); expect(h.textarea.listenerCount()).toBe(0); + h.flushTimers(); - expect(h.emitted).toEqual(['x']); + expect(h.emitted).toEqual([]); + + // Nothing fires after destroy, even if a stray event is delivered. + h.textarea.fire('input', inputEvent('y')); + h.flushTimers(); + expect(h.emitted).toEqual([]); }); });