diff --git a/src/features/terminal/imeInput.test.ts b/src/features/terminal/imeInput.test.ts new file mode 100644 index 000000000..8fb77fed4 --- /dev/null +++ b/src/features/terminal/imeInput.test.ts @@ -0,0 +1,159 @@ +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, 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; +} + +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 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("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([]); + }); +}); diff --git a/src/features/terminal/imeInput.ts b/src/features/terminal/imeInput.ts new file mode 100644 index 000000000..695b098ad --- /dev/null +++ b/src/features/terminal/imeInput.ts @@ -0,0 +1,104 @@ +/** + * Delivers the IME commits xterm loses, and leaves everything else to xterm. + * + * `CoreBrowserTerminal._inputEvent` ignores an `insertText` event when it is `composed` and + * a keydown is still pending (`_keyDownSeen`). That is right for keys xterm already sent + * from keydown or keypress (plain typing, Option/AltGr characters), but IMEs that commit + * without composition events (Doubao on macOS) inject their text asynchronously while the + * keydown that started the commit is still waiting for its keyup, so the commit is dropped. + * `CompositionHelper` has the same problem from the other side: it reads a keyCode-229 + * commit from a `setTimeout(0)` snapshot diff of the textarea, which misses text inserted + * after the timer and emits DEL when an IME clears the textarea first. + * + * So only the commits xterm would lose are taken over: + * - a keyCode-229 keydown never reaches xterm, so its deferred textarea diff cannot run; + * - while such a keydown is pending, an `insertText` is sent from the event's own `data` + * (not from a value diff), then the textarea is cleared; + * - composition input that arrives without a `compositionstart` is sent the same way. + * + * Everything else stays xterm's: real compositions (`compositionstart` … `compositionend`), + * input events for keys xterm already sent, and input with no IME keydown behind it. + */ +import { isImeKey } from "@/platform/ime"; + +/** Commit-shaped input events that xterm's own input path ignores entirely. */ +const COMPOSITION_COMMITS = new Set(["insertFromComposition", "insertCompositionText"]); + +export interface TerminalInputOptions { + /** The xterm host; capturing here keeps xterm's textarea listeners out of our commits. */ + host: HTMLElement; + textarea: HTMLTextAreaElement; + send: (data: string) => void; +} + +/** + * Takes input-method commits away from xterm's textarea handling. Returns a disposer. + * + * `send` receives the committed text once per commit, in order; it is never called for + * input a real composition produced (xterm sends that), nor for input xterm handles itself. + */ +export function ownTerminalInput({ host, textarea, send }: TerminalInputOptions): () => void { + let composing = false; + /** 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; + imeKey = isImeKey(e); + 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(); + } + }; + + // xterm emits a pressed character from keypress; for an IME key that would duplicate the + // text the input event delivers. + const onKeyPress = (e: KeyboardEvent) => { + if (imeKey) e.stopPropagation(); + }; + + const onKeyUp = () => { + imeKey = false; + imeDown = false; + }; + + const onInput = (e: InputEvent) => { + if (composing || !e.data) return; + if (e.inputType === "insertText") { + // Without an IME keydown behind it this is xterm's: it drops the event for a key it + // already sent (Option/AltGr, plain typing) and delivers the rest (dictation, emoji). + if (!imeDown) return; + } else if (!COMPOSITION_COMMITS.has(e.inputType)) { + return; + } + e.stopPropagation(); + send(e.data); + textarea.value = ""; + }; + + const onCompositionStart = () => { + composing = true; + }; + const onCompositionEnd = () => { + composing = false; + }; + + host.addEventListener("keydown", onKeyDown, true); + host.addEventListener("keypress", onKeyPress, true); + host.addEventListener("input", onInput, true); + host.addEventListener("keyup", onKeyUp, true); + textarea.addEventListener("compositionstart", onCompositionStart); + textarea.addEventListener("compositionend", onCompositionEnd); + return () => { + host.removeEventListener("keydown", onKeyDown, true); + host.removeEventListener("keypress", onKeyPress, true); + host.removeEventListener("input", onInput, true); + host.removeEventListener("keyup", onKeyUp, true); + textarea.removeEventListener("compositionstart", onCompositionStart); + textarea.removeEventListener("compositionend", onCompositionEnd); + }; +} diff --git a/src/features/terminal/session.ts b/src/features/terminal/session.ts index 70e90bfd1..e44d02ead 100644 --- a/src/features/terminal/session.ts +++ b/src/features/terminal/session.ts @@ -14,10 +14,12 @@ import { fitScheduler, settledResize } from "./fit"; import { installTerminalDrop } from "./drop"; import { installTerminalPaste, pasteTerminalClipboard } from "./paste"; import { ImeCommitGate } from "./imeCommit"; +import { ownTerminalInput } from "./imeInput"; import { findTerminalLinks, resolvePath, type TerminalLink } from "./links"; import { splitCommandLine } from "@/platform/commandLine"; import { useProject } from "@/platform/project"; import { useRemote } from "@/platform/remote"; +import { usePlatform } from "@/platform/platformInfo"; import { workflowSettings, type TerminalSettingsValid } from "@/platform/workflowSettings"; import { editorPrefs } from "@/platform/editorSettings"; import { @@ -184,7 +186,7 @@ export class TerminalSession { private resizeObserver: ResizeObserver | null = null; private readonly fitter = fitScheduler(() => this.doFit()); private readonly ptyResize = settledResize((cols, rows) => this.resizePty(cols, rows)); - private inputRaf = 0; + /** Keystrokes typed while the PTY is still starting; sent as soon as it exists. */ private pendingInput = ""; private lastSize = { cols: 0, rows: 0 }; /** Bumped on restart so late output from a previous PTY is ignored. */ @@ -412,13 +414,22 @@ export class TerminalSession { } /** - * Routes a WebKitGTK / fcitx5 IME commit through `ImeCommitGate`: the platform reports the - * commit's own keystroke (code 229) and its input event, but reports `compositionstart` only - * for composed words — never for the punctuation an IME commits directly. + * Delivers the IME commits xterm loses on macOS, and routes a WebKitGTK / fcitx5 commit + * through `ImeCommitGate` elsewhere (that platform reports the commit's own keystroke code + * 229 and its input event, but reports `compositionstart` only for composed words). */ private watchImeCommit() { const textarea = this.term.textarea; if (!textarea) return; + // WKWebView: xterm drops commits IMEs without composition events inject (Doubao) and + // misses text that lands after its textarea snapshot. Deliver those here. + if (usePlatform.getState().os === "macos") { + this.disposables.push({ + dispose: ownTerminalInput({ host: this.host, textarea, send: (data) => this.send(data) }), + }); + return; + } + // WebKitGTK (Linux) and WebView2 keep the fcitx5 deduplication gate. const onKeyDown = (e: KeyboardEvent) => { if (e.keyCode === 229) this.ime.begin(); else this.ime.keystroke(); @@ -513,6 +524,7 @@ export class TerminalSession { } this.ptyId = info.id; this.update({ status: "running", title: info.title || "Local" }); + this.flushPendingInput(); } catch (e) { this.term.writeln(`\x1b[31mFailed to start terminal: ${e instanceof Error ? e.message : String(e)}\x1b[0m`); this.term.writeln(RESTART_HINT); @@ -557,26 +569,36 @@ export class TerminalSession { restart() { if (this.snapshot.status === "running" || this.snapshot.status === "starting") return; this.term.write("\r\n"); + this.pendingInput = ""; this.update({ status: "idle" }); void this.start(); } - /** Sends input to the PTY, coalescing rapid keystrokes per animation frame. */ + /** + * Sends input to the PTY. Keystrokes are not coalesced: WebKit delivers at most one key + * event per frame already, and a frame-based batch only made input wait for rendering. + */ input(data: string) { + if (!data) return; const status = this.snapshot.status; if (status === "exited" || status === "error") { if (data === "\r") this.restart(); return; } - if (this.ptyId === null) return; - this.pendingInput += data; - if (this.inputRaf) return; - this.inputRaf = requestAnimationFrame(() => { - this.inputRaf = 0; - const chunk = this.pendingInput; - this.pendingInput = ""; - if (chunk && this.ptyId !== null) void commands.termWrite(this.ptyId, chunk); - }); + if (this.ptyId === null) { + // The shell is still being spawned (tens of ms); keep the keystrokes and send them + // once the PTY exists instead of dropping them. + this.pendingInput += data; + return; + } + void commands.termWrite(this.ptyId, data); + } + + /** Sends keystrokes buffered while the PTY was starting. */ + private flushPendingInput() { + const queued = this.pendingInput; + this.pendingInput = ""; + if (queued && this.ptyId !== null) void commands.termWrite(this.ptyId, queued); } /** Writes input immediately (shortcuts translated outside xterm). */ @@ -663,7 +685,7 @@ export class TerminalSession { this.disposed = true; this.fitter.cancel(); this.ptyResize.cancel(); - cancelAnimationFrame(this.inputRaf); + this.pendingInput = ""; this.resizeObserver?.disconnect(); this.host.removeEventListener("mousedown", this.onMiddleClick); if (this.ptyId !== null) void commands.termKill(this.ptyId);