fix(terminal): deliver the whole text of a multi-character key event (#2122)

With a backspace-and-retype input method such as GoNhanh, Vietnamese letters were lost in the terminal: `không` arrived as `khô`. These tools post a retyped string such as `ông` as one key event, but xterm builds its keypress text from `charCode`, which holds only the first character, and cancels the event. Such events are now delivered from the input event's own `data`.

IME ownership is now reset on the next keydown, so the key after a multi-character event is not duplicated. Terminal sessions and the Run console share this handling.

Refs #1325

Co-authored-by: hiennq12 <[email protected]>
Цей коміт міститься в:
hiennq12 authored and GitHub committed 2026-11-08 20:44:10 +08:00
1 джерело 2d6394f203
коміт 49a113c875
2 files changed
+70 -3

No files matched your search

+65 -1
Переглянути файл
@@ -28,7 +28,7 @@ function keydown(target: EventTarget, init: { key: string; keyCode: number; isCo
}
function keypress(target: EventTarget, key: string) {
const e = new KeyboardEvent("keypress", { key, bubbles: true, cancelable: true });
const e = new KeyboardEvent("keypress", { key, charCode: key.charCodeAt(0), bubbles: true, cancelable: true });
target.dispatchEvent(e);
return e;
}
@@ -98,6 +98,26 @@ describe("ownTerminalInput", () => {
expect(sent).toEqual(["123"]);
});
it("sends the whole text of one key event carrying several characters", () => {
// Telex tools on macOS retype `ông` as one synthetic key; xterm's keypress sends only `ô`.
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "ông", keyCode: 65 });
keypress(textarea, "ông");
input(textarea, { inputType: "insertText", data: "ông" });
keyup(textarea, 65);
expect(sent).toEqual(["ông"]);
expect(reached).toEqual(["keydown"]);
});
it("leaves a single-character keypress to xterm", () => {
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "ô", keyCode: 65 });
keypress(textarea, "ô");
input(textarea, { inputType: "insertText", data: "ô" });
expect(sent).toEqual([]);
expect(reached).toEqual(["keydown", "keypress", "insertText"]);
});
it("sends a composition commit reported without compositionstart", () => {
const { textarea, sent } = setup();
input(textarea, { inputType: "insertFromComposition", data: "," });
@@ -202,6 +222,7 @@ describe("ownTerminalInput", () => {
describe("ownTerminalInput with xterm", () => {
type Step =
| ["keydown" | "keyup", string, number, boolean?]
| ["keypress", string]
| ["compositionstart" | "compositionupdate" | "compositionend", string]
| ["input", string, string | null, string];
@@ -221,6 +242,8 @@ describe("ownTerminalInput with xterm", () => {
const e = keydown(textarea, { key: step[1], keyCode: step[2], isComposing: step[3] });
// A browser fires no keypress for a keydown xterm cancelled.
if (!step[3] && !e.defaultPrevented && step[1].length === 1) keypress(textarea, step[1]);
} else if (step[0] === "keypress") {
keypress(textarea, step[1]);
} else if (step[0] === "keyup") {
keyup(textarea, step[2]);
} else if (step[0] === "input") {
@@ -236,6 +259,47 @@ describe("ownTerminalInput with xterm", () => {
return sent;
}
it.each([true, false])("sends a retyped word and following plain keys once (keyup first: %s)", async (release) => {
// Uppercase typing reaches both keypress and input; lowercase keydown is cancelled by xterm.
const sent = await replay([
["keydown", "ông", 65],
["keypress", "ông"],
["input", "insertText", "ông", "ông"],
...(release ? ([["keyup", "ông", 65]] as Step[]) : []),
["keydown", "A", 65],
["input", "insertText", "A", "A"],
["keyup", "A", 65],
["keydown", "b", 66],
["input", "insertText", "b", "Ab"],
["keyup", "b", 66],
]);
expect(sent).toEqual(["ông", "A", "b"]);
});
it.each([
["Enter", 13, "\r"],
["Tab", 9, "\t"],
["Backspace", 8, "\x7f"],
["Escape", 27, "\x1b"],
["ArrowUp", 38, "\x1b[A"],
["ArrowDown", 40, "\x1b[B"],
["ArrowLeft", 37, "\x1b[D"],
["ArrowRight", 39, "\x1b[C"],
["Dead", 0, ""],
["Process", 229, ""],
["Unidentified", 0, ""],
] as const)("leaves %s keydown handling intact after a retyped word", async (key, code, expected) => {
const sent = await replay([
["keydown", "ông", 65],
["keypress", "ông"],
["input", "insertText", "ông", "ông"],
["keyup", "ông", 65],
["keydown", key, code],
["keyup", key, code],
]);
expect(sent.join("")).toBe("ông" + expected);
});
function composeThenSwitch(before: string, shown: string[]): Step[] {
const steps: Step[] = [["compositionstart", ""]];
for (const text of shown) {
+5 -2
Переглянути файл
@@ -60,7 +60,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
let composition = "";
/** The pending keydown went to the input method, so its keypress belongs to it too. */
let imeKey = false;
/** An IME keydown was seen since the last keyup: its commit is ours to deliver. */
/** The pending key belongs to an IME: its commit is ours to deliver. */
let imeDown = false;
const onKeyDown = (e: KeyboardEvent) => {
@@ -70,10 +70,10 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
return;
}
imeKey = isImeKey(e);
imeDown = imeKey;
if (imeKey) {
// The IME owns this key: xterm must not run its deferred textarea diff or send the
// keypress, and the text it commits next is ours.
imeDown = true;
e.stopPropagation();
}
};
@@ -81,6 +81,9 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
// xterm emits a pressed character from keypress; for an IME key that would duplicate the
// text the input event delivers.
const onKeyPress = (e: KeyboardEvent) => {
// Telex tools on macOS post a retyped `ông` as one key event. xterm builds its keypress
// text from `charCode`, which holds only `ô`, so the input event must deliver this one.
if (!composing && e.key.length > 1 && e.key !== "Enter") imeKey = imeDown = true;
if (imeKey) e.stopPropagation();
};