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
-84
View File
@@ -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);