дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
fix(terminal): deliver the IME commits xterm drops on macOS (#1441)
IMEs that commit without composition events (Doubao on macOS) inject their text asynchronously while the keyCode-229 keydown that started the commit is still pending. xterm ignores an insertText event while a keydown is pending, and its deferred textarea diff misses text that lands after its snapshot, so a fast run of digits lost characters (1234567890 arrived as 138). On macOS the terminal now delivers those commits itself (only commits during a pending IME keydown, and composition input without compositionstart); real compositions, keys xterm already sent and input without an IME keydown stay xterm's. Linux and Windows keep the fcitx5 ImeCommitGate. Keystrokes are written to the PTY as they come instead of per animation frame, and those typed while the shell is still starting are sent once it exists. Co-authored-by: DomKing <[email protected]>
Цей коміт міститься в:
1 джерело
a083571537
коміт
6b89984863
3 files changed
+300
-15
No files matched your search
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Заблокувати користувача