дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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]>
Цей коміт міститься в:
4 files changed
+194
-3
No files matched your search
@@ -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,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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача