fix(terminal): send a Pinyin input-source switch commit once, as typed (#2110)

With macOS Pinyin, switching the input source mid-composition (CapsLock) sent the letters twice with spaces: `awd` reached the shell as `aw daw d`. WebKit reports a keyCode-0 keydown before `compositionend`; xterm took it for a key that ends the composition and sent the text at once, then sent it again on `compositionend`. The IME also commits its displayed text, with spaces between syllables.

`ownTerminalInput` keeps that keydown away from xterm and rewrites a commit that is the raw ASCII composition text to the letters typed. Candidate commits stay as they are.

Sibling covered: the embedded terminal (Run console) had no macOS input handling and now uses `ownTerminalInput` too.

Co-authored-by: StarryKira <[email protected]>
Цей коміт міститься в:
时雨遥 authored and GitHub committed 2026-11-08 20:24:39 +08:00
1 джерело 0bfd4fa3cc
коміт 5ea9a2a3a9
4 files changed
+194 -3

No files matched your search

+21 -1
Переглянути файл
@@ -14,6 +14,7 @@ vi.mock("@xterm/xterm", () => ({
cols = 80;
rows = 24;
options = {};
textarea?: HTMLTextAreaElement;
hasSelection() {
return !!terminal.selection;
}
@@ -24,7 +25,10 @@ vi.mock("@xterm/xterm", () => ({
terminal.handler = handler;
}
loadAddon() {}
open() {}
open(host: HTMLElement) {
this.textarea = document.createElement("textarea");
host.append(this.textarea);
}
dispose() {}
onData() {
return { dispose() {} };
@@ -101,3 +105,19 @@ it("copies embedded Run terminal selections before xterm can send Ctrl+C", () =>
term.dispose();
}
});
it("delivers input-method commits to the owner on macOS as the terminal sessions do", () => {
usePlatform.setState({ os: "macos" });
const host = document.createElement("div");
document.body.append(host);
const onData = vi.fn();
const term = createEmbeddedTerminal(host, { onData, onResize: vi.fn() });
const textarea = host.querySelector("textarea")!;
const keydown = new KeyboardEvent("keydown", { key: "1", bubbles: true });
Object.defineProperty(keydown, "keyCode", { value: 229 });
textarea.dispatchEvent(keydown);
const input = new InputEvent("input", { inputType: "insertText", data: "1", bubbles: true });
textarea.dispatchEvent(input);
expect(onData).toHaveBeenCalledWith("1");
term.dispose();
});
+6
Переглянути файл
@@ -6,6 +6,7 @@
*/
import { installTerminalDrop } from "./drop";
import { installTerminalPaste } from "./paste";
import { ownTerminalInput } from "./imeInput";
import { Terminal, type IDisposable } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import "@xterm/xterm/css/xterm.css";
@@ -13,6 +14,7 @@ import type { EmbeddedTerminal, EmbeddedTerminalOptions } from "@/workbench/cont
import { isImeKey } from "@/platform/ime";
import { terminalKeyAction } from "./keys";
import { useProject } from "@/platform/project";
import { usePlatform } from "@/platform/platformInfo";
import { workflowSettings } from "@/platform/workflowSettings";
import { fitScheduler, settledResize } from "./fit";
import { findTerminalLinks } from "./links";
@@ -89,6 +91,10 @@ export function createEmbeddedTerminal(host: HTMLElement, opts: EmbeddedTerminal
const removeDrop = installTerminalDrop(host, (text) => term.paste(text));
term.open(host);
if (opts["aria-label"]) term.textarea?.setAttribute("aria-label", opts["aria-label"]);
// The same WKWebView input-method fixes as the terminal sessions.
if (term.textarea && usePlatform.getState().os === "macos") {
disposables.push({ dispose: ownTerminalInput({ host, textarea: term.textarea, send: opts.onData }) });
}
const fitter = fitScheduler(() => {
if (!host.isConnected || host.offsetWidth === 0 || host.offsetHeight === 0) return;
+131
Переглянути файл
@@ -1,3 +1,4 @@
import { Terminal } from "@xterm/xterm";
import { beforeEach, describe, expect, it } from "vitest";
import { ownTerminalInput } from "./imeInput";
@@ -51,6 +52,10 @@ function input(target: EventTarget, init: { inputType: string; data?: string; is
return e;
}
function composition(target: EventTarget, type: string, data: string) {
target.dispatchEvent(new CompositionEvent(type, { data, bubbles: true }));
}
describe("ownTerminalInput", () => {
beforeEach(() => {
document.body.replaceChildren();
@@ -134,6 +139,42 @@ describe("ownTerminalInput", () => {
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 });
@@ -157,3 +198,93 @@ describe("ownTerminalInput", () => {
expect(sent).toEqual([]);
});
});
describe("ownTerminalInput with xterm", () => {
type Step =
| ["keydown" | "keyup", string, number, boolean?]
| ["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] === "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;
}
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"]);
});
});
+36 -2
Переглянути файл
@@ -18,6 +18,13 @@
*
* Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`),
* input events for keys xterm already sent, and input with no IME keydown behind it.
*
* A real composition still needs two corrections before xterm sends it:
* - switching the input source mid-composition (CapsLock with macOS Pinyin) reports a
* keyCode-0 keydown before `compositionend`; xterm takes it for a key that ends the
* composition and sends the text at once, then sends it again on `compositionend`;
* - the letters committed by that switch keep the spaces the IME showed between syllables
* (`awd` arrives as `aw d`), so the textarea is rewritten to what was typed.
*/
import { isImeKey } from "@/platform/ime";
@@ -31,6 +38,16 @@ export interface TerminalInputOptions {
send: (data: string) => void;
}
/**
* The text a composition commits, without the syllable spaces an IME displays when it commits
* its raw letters (the commit is the composition text itself and plain ASCII). The user never
* typed those spaces: Space picks a candidate while composing.
*/
function typedCommit(commit: string, composition: string): string {
if (commit !== composition || !/^[\x20-\x7e]+$/.test(commit)) return commit;
return commit.replaceAll(" ", "");
}
/**
* Takes input-method commits away from xterm's textarea handling. Returns a disposer.
*
@@ -39,13 +56,19 @@ export interface TerminalInputOptions {
*/
export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions): () => void {
let composing = false;
/** The composition text the IME displays, from the last `compositionupdate`. */
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. */
let imeDown = false;
const onKeyDown = (e: KeyboardEvent) => {
if (composing) return;
if (composing) {
// The input-source switch, not a key: the commit follows on `compositionend`.
if (e.keyCode === 0) e.stopPropagation();
return;
}
imeKey = isImeKey(e);
if (imeKey) {
// The IME owns this key: xterm must not run its deferred textarea diff or send the
@@ -82,9 +105,18 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
const onCompositionStart = () => {
composing = true;
composition = "";
};
const onCompositionEnd = () => {
const onCompositionUpdate = (e: CompositionEvent) => {
composition = e.data;
};
// Runs before xterm reads the commit from the textarea, which it defers by a task.
const onCompositionEnd = (e: CompositionEvent) => {
composing = false;
const commit = typedCommit(e.data, composition);
if (commit !== e.data && textarea.value.endsWith(e.data)) {
textarea.value = textarea.value.slice(0, -e.data.length) + commit;
}
};
host.addEventListener("keydown", onKeyDown, true);
@@ -92,6 +124,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
host.addEventListener("input", onInput, true);
host.addEventListener("keyup", onKeyUp, true);
textarea.addEventListener("compositionstart", onCompositionStart);
textarea.addEventListener("compositionupdate", onCompositionUpdate);
textarea.addEventListener("compositionend", onCompositionEnd);
return () => {
host.removeEventListener("keydown", onKeyDown, true);
@@ -99,6 +132,7 @@ export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions)
host.removeEventListener("input", onInput, true);
host.removeEventListener("keyup", onKeyUp, true);
textarea.removeEventListener("compositionstart", onCompositionStart);
textarea.removeEventListener("compositionupdate", onCompositionUpdate);
textarea.removeEventListener("compositionend", onCompositionEnd);
};
}