fix(terminal): forward the orphaned input event instead of replaying a guessed key

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.
This commit is contained in:
Aamer Akhter
2026-09-07 19:11:20 -04:00
parent 82b090c74a
commit e8a93ada1f
8 changed files with 500 additions and 443 deletions
+1 -1
View File
@@ -3458,7 +3458,7 @@
<script defer src="notification-manager.js"></script>
<script defer src="keyboard-accessory.js"></script>
<script defer src="input-cjk.js"></script>
<!-- Recovers Android/GBoard keydowns that report keyCode 229 without touching xterm's helper textarea. Must precede terminal-ui.js. -->
<!-- Forwards committed input events that xterm drops on Android/GBoard soft keyboards. Must precede terminal-ui.js. -->
<script defer src="terminal-keycode229-recovery.js"></script>
<!-- Hardened markdown HTML sanitizer (wires DOMPurify). Must precede app.js. -->
<script defer src="sanitize-html.js"></script>
+140 -180
View File
@@ -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;
}
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;
function cancelPending() {
for (const candidate of pending.splice(0)) {
candidate.active = false;
if (candidate.timer !== null) {
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);
clearTimer(candidate.timer);
} catch {
removePending(candidate);
// A broken timer host must not break input handling.
}
});
} 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;
candidate.timer = null;
}
}
claimCanonicalInput(inputData);
}
function resetForCompositionOrFocusLoss() {
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.
}
}
/** 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;
keydownSnapshot = canonicalCount;
}
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 candidate = {
data,
snapshot: keydownSnapshot ?? canonicalCount,
active: true,
timer: null,
};
pending.push(candidate);
try {
candidate.timer = setTimer(() => resolveCandidate(candidate), 0);
} catch {
cancelPending();
}
}
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);
+28 -14
View File
@@ -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);
});
},
/**