From 49a113c875246980d2d6c9badc68db2ae610af05 Mon Sep 17 00:00:00 2001 From: hiennq12 <86833436+hiennq12@users.noreply.github.com> Date: Sun, 8 Nov 2026 19:44:10 +0700 Subject: [PATCH] fix(terminal): deliver the whole text of a multi-character key event (#2122) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 <86833436+hiennq12@users.noreply.github.com> --- src/features/terminal/imeInput.test.ts | 66 +++++++++++++++++++++++++- src/features/terminal/imeInput.ts | 7 ++- 2 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/features/terminal/imeInput.test.ts b/src/features/terminal/imeInput.test.ts index 562e6a591..3ba70ac33 100644 --- a/src/features/terminal/imeInput.test.ts +++ b/src/features/terminal/imeInput.test.ts @@ -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) { diff --git a/src/features/terminal/imeInput.ts b/src/features/terminal/imeInput.ts index 9ae4aa907..297a8645d 100644 --- a/src/features/terminal/imeInput.ts +++ b/src/features/terminal/imeInput.ts @@ -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(); };