diff --git a/scripts/build.mjs b/scripts/build.mjs index d7236f5f..1fbb34d8 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 3de02b40..c7a48ebb 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3458,6 +3458,8 @@ + + diff --git a/src/web/public/terminal-keycode229-recovery.js b/src/web/public/terminal-keycode229-recovery.js new file mode 100644 index 00000000..491b5ec0 --- /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 bb83cc5a..4555eb86 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 f719ed3c..018ebff9 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 00000000..247d1653 --- /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']); + }); +});