From 5ea9a2a3a93a4f06a53791ccabbdc1ec2d939724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=97=B6=E9=9B=A8=E9=81=A5?= <59300016+StarryKira@users.noreply.github.com> Date: Sun, 8 Nov 2026 20:24:39 +0800 Subject: [PATCH] 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 <59300016+StarryKira@users.noreply.github.com> --- src/features/terminal/embedded.test.ts | 22 ++++- src/features/terminal/embedded.ts | 6 ++ src/features/terminal/imeInput.test.ts | 131 +++++++++++++++++++++++++ src/features/terminal/imeInput.ts | 38 ++++++- 4 files changed, 194 insertions(+), 3 deletions(-) diff --git a/src/features/terminal/embedded.test.ts b/src/features/terminal/embedded.test.ts index fb8446fbc..44e4fbc58 100644 --- a/src/features/terminal/embedded.test.ts +++ b/src/features/terminal/embedded.test.ts @@ -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(); +}); diff --git a/src/features/terminal/embedded.ts b/src/features/terminal/embedded.ts index d82fbf1d5..ee81ef5ea 100644 --- a/src/features/terminal/embedded.ts +++ b/src/features/terminal/embedded.ts @@ -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; diff --git a/src/features/terminal/imeInput.test.ts b/src/features/terminal/imeInput.test.ts index 8fb77fed4..562e6a591 100644 --- a/src/features/terminal/imeInput.test.ts +++ b/src/features/terminal/imeInput.test.ts @@ -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"]); + }); +}); diff --git a/src/features/terminal/imeInput.ts b/src/features/terminal/imeInput.ts index 695b098ad..9ae4aa907 100644 --- a/src/features/terminal/imeInput.ts +++ b/src/features/terminal/imeInput.ts @@ -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); }; }