Файли
openidea/src/features/terminal/imeInput.test.ts
T
hiennq12 49a113c875 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]>
2026-11-08 20:44:10 +08:00

355 рядки
13 KiB
TypeScript
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
import { Terminal } from "@xterm/xterm";
import { beforeEach, describe, expect, it } from "vitest";
import { ownTerminalInput } from "./imeInput";
/** What xterm does with the textarea, in miniature: a listener that must stay out of our commits. */
function setup() {
const host = document.createElement("div");
const textarea = document.createElement("textarea");
host.append(textarea);
document.body.append(host);
const sent: string[] = [];
const reached: string[] = [];
textarea.addEventListener("input", (e) => reached.push((e as InputEvent).inputType));
textarea.addEventListener("keydown", () => reached.push("keydown"));
textarea.addEventListener("keypress", () => reached.push("keypress"));
textarea.addEventListener("compositionstart", () => reached.push("compositionstart"));
textarea.addEventListener("compositionend", () => reached.push("compositionend"));
const dispose = ownTerminalInput({ host, textarea, send: (d) => sent.push(d) });
return { host, textarea, sent, reached, dispose };
}
function keydown(target: EventTarget, init: { key: string; keyCode: number; isComposing?: boolean; altKey?: boolean }) {
const e = new KeyboardEvent("keydown", { key: init.key, altKey: init.altKey, bubbles: true, cancelable: true });
Object.defineProperty(e, "keyCode", { value: init.keyCode });
if (init.isComposing) Object.defineProperty(e, "isComposing", { value: true });
target.dispatchEvent(e);
return e;
}
function keypress(target: EventTarget, key: string) {
const e = new KeyboardEvent("keypress", { key, charCode: key.charCodeAt(0), bubbles: true, cancelable: true });
target.dispatchEvent(e);
return e;
}
function keyup(target: EventTarget, keyCode: number) {
const e = new KeyboardEvent("keyup", { key: "", bubbles: true });
Object.defineProperty(e, "keyCode", { value: keyCode });
target.dispatchEvent(e);
return e;
}
function input(target: EventTarget, init: { inputType: string; data?: string; isComposing?: boolean }) {
const e = new Event("input", { bubbles: true, cancelable: true });
Object.defineProperties(e, {
inputType: { value: init.inputType },
data: { value: init.data ?? null },
composed: { value: true },
isComposing: { value: init.isComposing ?? false },
});
target.dispatchEvent(e);
return e;
}
function composition(target: EventTarget, type: string, data: string) {
target.dispatchEvent(new CompositionEvent(type, { data, bubbles: true }));
}
describe("ownTerminalInput", () => {
beforeEach(() => {
document.body.replaceChildren();
});
it("sends a commit that xterm's input-event gate would drop", () => {
// Doubao on macOS: a 229 keydown, then insertText while the keydown is still pending —
// CoreBrowserTerminal._inputEvent ignores exactly that and the character disappears.
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "1", keyCode: 229, isComposing: true });
input(textarea, { inputType: "insertText", data: "1", isComposing: true });
expect(sent).toEqual(["1"]);
expect(reached).toEqual([]);
});
it("leaves a commit the keyup already unblocked to xterm", () => {
// Late injection with no keydown pending: xterm's own _inputEvent accepts it.
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "1", keyCode: 229 });
keyup(textarea, 229);
input(textarea, { inputType: "insertText", data: "1" });
expect(sent).toEqual([]);
expect(reached).toEqual(["insertText"]);
});
it("sends every key of a fast run exactly once", () => {
const { textarea, sent } = setup();
for (const key of "1234567890") {
keydown(textarea, { key, keyCode: 229 });
input(textarea, { inputType: "insertText", data: key });
keyup(textarea, 229);
}
expect(sent.join("")).toBe("1234567890");
});
it("sends a multi-character commit once", () => {
const { textarea, sent } = setup();
keydown(textarea, { key: "1", keyCode: 229 });
input(textarea, { inputType: "insertText", data: "123" });
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: "," });
expect(sent).toEqual([","]);
});
it("leaves keys xterm's keydown path already sent to xterm", () => {
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "a", keyCode: 65 });
input(textarea, { inputType: "insertText", data: "a" });
expect(sent).toEqual([]);
expect(reached).toEqual(["keydown", "insertText"]);
});
it("leaves third-level shift characters to xterm's keypress path", () => {
// Option+key (optionAsMeta off) / AltGr: xterm sends the character from keypress, so the
// input event that follows must not be sent a second time.
const { textarea, sent, reached } = setup();
keydown(textarea, { key: "2", keyCode: 50, altKey: true });
input(textarea, { inputType: "insertText", data: "™" });
expect(sent).toEqual([]);
expect(reached).toEqual(["keydown", "insertText"]);
});
it("leaves input with no keydown behind it to xterm", () => {
// Dictation, an on-screen keyboard or the emoji picker.
const { textarea, sent, reached } = setup();
input(textarea, { inputType: "insertText", data: "😀" });
expect(sent).toEqual([]);
expect(reached).toEqual(["insertText"]);
});
it("keeps out of real compositions", () => {
const { textarea, sent, reached } = setup();
textarea.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true }));
keydown(textarea, { key: "Process", keyCode: 229, isComposing: true });
input(textarea, { inputType: "insertCompositionText", data: "你", isComposing: true });
expect(sent).toEqual([]);
expect(reached).toEqual(["compositionstart", "keydown", "insertCompositionText"]);
});
it("keeps the input-source switch from ending a composition early", () => {
// CapsLock with macOS Pinyin: xterm would send the composition on this keydown and again on
// compositionend.
const { textarea, reached } = setup();
composition(textarea, "compositionstart", "");
keydown(textarea, { key: "CapsLock", keyCode: 20, isComposing: true });
keydown(textarea, { key: "Unidentified", keyCode: 0, isComposing: true });
expect(reached).toEqual(["compositionstart", "keydown"]);
});
it.each([
["aw d", "awd"],
["claude co de", "claudecode"],
["xi'an", "xi'an"],
])("commits the letters typed for %j without the IME's syllable spaces", (shown, typed) => {
const { textarea } = setup();
textarea.value = "ls ";
composition(textarea, "compositionstart", "");
composition(textarea, "compositionupdate", shown);
textarea.value = `ls ${shown}`;
composition(textarea, "compositionend", shown);
expect(textarea.value).toBe(`ls ${typed}`);
});
it.each([
["ni hao", "你好"],
["helloworld", "hello world"],
])("keeps a candidate chosen for %j as committed", (shown, chosen) => {
const { textarea } = setup();
composition(textarea, "compositionstart", "");
composition(textarea, "compositionupdate", shown);
textarea.value = chosen;
composition(textarea, "compositionend", chosen);
expect(textarea.value).toBe(chosen);
});
it("stops an IME keydown and its keypress before xterm sees them", () => {
const { textarea, reached } = setup();
keydown(textarea, { key: "1", keyCode: 229 });
keypress(textarea, "1");
expect(reached).toEqual([]);
});
it("clears the textarea after a commit", () => {
const { textarea } = setup();
textarea.value = "1";
keydown(textarea, { key: "1", keyCode: 229 });
input(textarea, { inputType: "insertText", data: "1" });
expect(textarea.value).toBe("");
});
it("stops listening once disposed", () => {
const { textarea, sent, dispose } = setup();
dispose();
keydown(textarea, { key: "1", keyCode: 229 });
input(textarea, { inputType: "insertText", data: "1" });
expect(sent).toEqual([]);
});
});
describe("ownTerminalInput with xterm", () => {
type Step =
| ["keydown" | "keyup", string, number, boolean?]
| ["keypress", string]
| ["compositionstart" | "compositionupdate" | "compositionend", string]
| ["input", string, string | null, string];
/** Replays a WKWebView event trace on xterm's textarea, setting the value the IME left. */
async function replay(steps: Step[]) {
const host = document.createElement("div");
document.body.append(host);
const term = new Terminal();
term.open(host);
const textarea = term.textarea!;
const sent: string[] = [];
term.onData((d) => sent.push(d));
ownTerminalInput({ host, textarea, send: (d) => sent.push(d) });
const task = () => new Promise((resolve) => setTimeout(resolve, 0));
for (const step of steps) {
if (step[0] === "keydown") {
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") {
textarea.value = step[3];
input(textarea, { inputType: step[1], data: step[2] ?? undefined, isComposing: step[1] !== "insertText" });
} else {
composition(textarea, step[0], step[1]);
}
await task();
}
await task();
term.dispose();
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) {
steps.push(
["compositionupdate", text],
["input", "insertCompositionText", text, before + text],
["keydown", text.at(-1)!, 229, true],
["keyup", text.at(-1)!, text.toUpperCase().charCodeAt(text.length - 1), true],
);
}
const committed = shown.at(-1)!;
steps.push(
["keydown", "CapsLock", 20, true],
["keydown", "Unidentified", 0, true],
["input", "deleteCompositionText", null, before],
["input", "insertFromComposition", committed, before + committed],
["compositionend", committed],
["keyup", "CapsLock", 20],
);
return steps;
}
it("sends what was typed once when macOS Pinyin switches to English mid-composition", async () => {
// Safari trace: `awd` in Pinyin, CapsLock, `cl` in English, CapsLock, `claudecode` in Pinyin,
// CapsLock. Before: "aw d" and "claude code" were each sent twice.
const sent = await replay([
...composeThenSwitch("", ["a", "a w", "aw d"]),
["keydown", "c", 67],
["input", "insertText", "c", "awdc"],
["keyup", "c", 67],
["keydown", "l", 76],
["input", "insertText", "l", "awdcl"],
["keyup", "l", 76],
["keydown", "CapsLock", 20],
["keydown", "Unidentified", 0],
["keyup", "CapsLock", 20],
...composeThenSwitch("awdcl", [
"c",
"c l",
"c la",
"cl au",
"claud",
"claude",
"claude c",
"claude co",
"claude co d",
"claude code",
]),
]);
expect(sent).toEqual(["awd", "c", "l", "claudecode"]);
});
});