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']);
+ });
+});