fix(terminal): macOS Vietnamese Telex keeps its capitals in Chrome too
macOS's own Vietnamese Telex types into xterm's textarea with no composition events: a letter is an `insertText`, every later key of the word an `insertReplacementText`, each keydown (keyCode 229) arriving after the change. v2.7.4 forwarded the replacements, but a capital typed with Shift still vanished — xterm drops any `input` that lands while a key is held — and its 229 fallback could resend an edit. Replayed from a real Chrome trace, "Abc xin chào" reached the pane as " x c". lib/imeInput.ts now owns those edits: capture phase on xterm's element, each one sent as DELs plus the new tail, the IME's input and 229 keydowns hidden from xterm. Keys xterm sends from keypress (Space) and real compositions (CJK) stay xterm's. Verified against xterm 5.5 in headless Chrome: Telex → "Abc xin chào"; a plain keyboard sends the same bytes as before. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,9 @@
|
||||
* from its terminal — announced by a hint the textarea is described by;
|
||||
* Cmd+F (macOS) / Ctrl+Shift+F opens a find bar (`@xterm/addon-search`). A screen-reader mode
|
||||
* (`agenthub.terminal.screenReader` = "1" in localStorage) turns on xterm's.
|
||||
* An input method that edits the textarea without composition events (macOS
|
||||
* Vietnamese Telex) is typed through `lib/imeInput.ts` — xterm alone
|
||||
* dropped its capitals and the words it rewrites.
|
||||
* URLs open in a new tab (`@xterm/addon-web-links`); a selection tmux copies
|
||||
* (OSC 52 — tmux's default `set-clipboard external` passes its own
|
||||
* copy-mode selections on; a CLI's OSC 52 inside the pane is not) lands in the browser
|
||||
@@ -49,6 +52,7 @@ import { useTheme } from "../../hooks/useTheme";
|
||||
import { terminalThemeFor } from "../../lib/terminalTheme";
|
||||
import { api } from "../../lib/api";
|
||||
import { notePaneFocus, registerPane } from "../../lib/panePaste";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
|
||||
type DropState = "hint" | "uploading" | "failed";
|
||||
@@ -519,29 +523,11 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
const reassert = takeLead;
|
||||
term.textarea?.addEventListener("focus", reassert);
|
||||
|
||||
// Safari with macOS's own Vietnamese Telex types a word by REPLACING it:
|
||||
// after the first letter every key fires `insertReplacementText` carrying
|
||||
// the whole word ("x" → "xi" → "xin"), before its keydown. xterm forwards
|
||||
// only `insertText`, and its keyCode-229 fallback snapshots the textarea
|
||||
// after the change, so all but each word's first letter was lost (xterm.js
|
||||
// #5887). Send the edit ourselves: a DEL per replaced char, then the tail.
|
||||
let beforeReplace = "";
|
||||
const onBeforeReplace = (e: Event) => {
|
||||
if ((e as InputEvent).inputType === "insertReplacementText") {
|
||||
beforeReplace = term.textarea?.value ?? "";
|
||||
}
|
||||
};
|
||||
const onReplace = (e: Event) => {
|
||||
if ((e as InputEvent).inputType !== "insertReplacementText") return;
|
||||
const before = Array.from(beforeReplace);
|
||||
const after = Array.from(term.textarea?.value ?? "");
|
||||
let same = 0;
|
||||
while (same < before.length && before[same] === after[same]) same++;
|
||||
const edit = "\x7f".repeat(before.length - same) + after.slice(same).join("");
|
||||
if (edit) term.input(edit, true);
|
||||
};
|
||||
term.textarea?.addEventListener("beforeinput", onBeforeReplace);
|
||||
term.textarea?.addEventListener("input", onReplace);
|
||||
// macOS's own Vietnamese Telex (Safari and Chrome) types into the
|
||||
// textarea with no composition events: a capital arrives while Shift is
|
||||
// held and every later key REPLACES the word — both dropped by xterm
|
||||
// (xterm.js #5887). lib/imeInput.ts sends those edits itself.
|
||||
const detachIme = attachImeInput(term);
|
||||
|
||||
const refit = () => {
|
||||
// Never fit a collapsed console — see sendSize.
|
||||
@@ -571,8 +557,7 @@ export function TerminalView({ runId, wsBaseUrl, onExit, autoFocus = true }: Ter
|
||||
resizeObserver.disconnect();
|
||||
document.fonts?.removeEventListener("loadingdone", onFontLoaded);
|
||||
term.textarea?.removeEventListener("focus", reassert);
|
||||
term.textarea?.removeEventListener("beforeinput", onBeforeReplace);
|
||||
term.textarea?.removeEventListener("input", onReplace);
|
||||
detachIme();
|
||||
resizeDisposable.dispose();
|
||||
dataDisposable.dispose();
|
||||
ws?.close();
|
||||
|
||||
@@ -75,6 +75,8 @@ vi.mock("@xterm/xterm", () => ({
|
||||
paste: pasteMock,
|
||||
input: inputMock,
|
||||
textarea: (textarea = document.createElement("textarea")),
|
||||
// xterm's own element, holding its textarea (lib/imeInput listens on it).
|
||||
element: document.createElement("div").appendChild(textarea).parentElement,
|
||||
resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }),
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @file imeInput.test.ts
|
||||
* @description Pins `attachImeInput` against a real event trace of macOS's
|
||||
* Vietnamese Simple Telex input method in Chrome (typing "Abc xin chào"): the
|
||||
* IME writes into xterm's textarea with `insertText` / `insertReplacementText`
|
||||
* and no composition events, its keydown (keyCode 229) arriving AFTER the
|
||||
* change — which stock xterm 5.5 turned into " x c". The pane must receive
|
||||
* exactly the text typed, a word the IME rewrites as backspaces plus the new
|
||||
* tail, and keys xterm already sent from `keypress` (Space) never twice.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { attachImeInput, textEdit } from "../imeInput";
|
||||
|
||||
type Step = [type: string, a: string, b: string, keyCode?: number, shift?: boolean];
|
||||
|
||||
// Recorded in the user's browser, one line per event on the textarea.
|
||||
const TELEX: Step[] = [
|
||||
["keydown", "Shift", "ShiftLeft", 16, true],
|
||||
["input", "A", "insertText"],
|
||||
["keydown", "A", "KeyA", 229, true],
|
||||
["keyup", "A", "KeyA", 65, true],
|
||||
["keyup", "Shift", "ShiftLeft", 16],
|
||||
["input", "Ab", "insertReplacementText"],
|
||||
["keydown", "b", "KeyB", 229],
|
||||
["keyup", "b", "KeyB", 66],
|
||||
["input", "Abc", "insertReplacementText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["keyup", "c", "KeyC", 67],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["keyup", " ", "Space", 32],
|
||||
["input", "x", "insertText"],
|
||||
["keydown", "x", "KeyX", 229],
|
||||
["keyup", "x", "KeyX", 88],
|
||||
["input", "xi", "insertReplacementText"],
|
||||
["keydown", "i", "KeyI", 229],
|
||||
["input", "xin", "insertReplacementText"],
|
||||
["keydown", "n", "KeyN", 229],
|
||||
["keydown", " ", "Space", 32],
|
||||
["keypress", " ", "Space", 32],
|
||||
["input", " ", "insertText"],
|
||||
["input", "c", "insertText"],
|
||||
["keydown", "c", "KeyC", 229],
|
||||
["input", "ch", "insertReplacementText"],
|
||||
["keydown", "h", "KeyH", 229],
|
||||
["input", "cha", "insertReplacementText"],
|
||||
["keydown", "a", "KeyA", 229],
|
||||
["input", "chao", "insertReplacementText"],
|
||||
["keydown", "o", "KeyO", 229],
|
||||
["input", "chào", "insertReplacementText"],
|
||||
["keydown", "f", "KeyF", 229],
|
||||
];
|
||||
|
||||
let cleanup: (() => void) | null = null;
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
/** A terminal stand-in: xterm's element and textarea, `input` collecting what
|
||||
* reaches the pane, and `xterm` what xterm's own textarea listeners saw. */
|
||||
function setup() {
|
||||
const element = document.createElement("div");
|
||||
const textarea = document.createElement("textarea");
|
||||
element.appendChild(textarea);
|
||||
document.body.appendChild(element);
|
||||
let sent = "";
|
||||
const xterm: string[] = [];
|
||||
for (const t of ["keydown", "keypress", "input"])
|
||||
textarea.addEventListener(t, (e) => xterm.push(`${t}:${(e as KeyboardEvent).key ?? ""}`), true);
|
||||
// xterm sends a printable key from its keypress handler (Space, here).
|
||||
textarea.addEventListener("keypress", (e) => (sent += e.key), true);
|
||||
const term = { element, textarea, input: (d: string) => (sent += d) };
|
||||
cleanup = attachImeInput(term);
|
||||
return { textarea, xterm, sent: () => sent };
|
||||
}
|
||||
|
||||
function play(ta: HTMLTextAreaElement, steps: Step[]) {
|
||||
for (const [type, a, b, kc, shift] of steps) {
|
||||
if (type === "input") {
|
||||
const before = ta.value;
|
||||
const next = b === "insertText" ? before + a : before.replace(/\S*$/, "") + a;
|
||||
ta.dispatchEvent(new InputEvent("beforeinput", { data: a, inputType: b, bubbles: true }));
|
||||
ta.value = next;
|
||||
ta.dispatchEvent(new InputEvent("input", { data: a, inputType: b, bubbles: true }));
|
||||
} else {
|
||||
const ev = new KeyboardEvent(type, { key: a, code: b, shiftKey: !!shift, bubbles: true });
|
||||
Object.defineProperty(ev, "keyCode", { get: () => kc });
|
||||
ta.dispatchEvent(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** What a line editor holds after `data` (DEL erases one character). */
|
||||
const typed = (data: string) => {
|
||||
const out: string[] = [];
|
||||
for (const ch of data) {
|
||||
if (ch === "\x7f") out.pop();
|
||||
else out.push(ch);
|
||||
}
|
||||
return out.join("");
|
||||
};
|
||||
|
||||
describe("textEdit", () => {
|
||||
it("is the backspaces and new tail that turn one value into the other", () => {
|
||||
expect(textEdit("Abc xin cha", "Abc xin chao")).toBe("o");
|
||||
expect(textEdit("Abc xin chao", "Abc xin chào")).toBe("\x7f\x7fào");
|
||||
expect(textEdit("x", "x")).toBe("");
|
||||
expect(textEdit("ab", "")).toBe("\x7f\x7f");
|
||||
});
|
||||
|
||||
it("counts characters, not UTF-16 units", () => {
|
||||
expect(textEdit("a😀", "a")).toBe("\x7f");
|
||||
});
|
||||
});
|
||||
|
||||
describe("attachImeInput", () => {
|
||||
it("sends what Telex typed — capitals and rewritten words included", () => {
|
||||
const { textarea, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
expect(typed(sent())).toBe("Abc xin chào");
|
||||
});
|
||||
|
||||
it("leaves xterm the keys it sends itself, and hides the IME's from it", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
play(textarea, TELEX);
|
||||
// Space is xterm's (its keypress sends it, its input event follows); the
|
||||
// 229 keydowns and the IME's own input events never reach it.
|
||||
expect(xterm.filter((x) => x.startsWith("keypress"))).toHaveLength(2);
|
||||
expect(xterm.filter((x) => x.startsWith("input"))).toHaveLength(2);
|
||||
expect(xterm.some((x) => x === "keydown:A" || x === "keydown:b")).toBe(false);
|
||||
expect(sent().split(" ")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not touch a composition (CJK input) — xterm handles that", () => {
|
||||
const { textarea, xterm, sent } = setup();
|
||||
const ev = new InputEvent("input", {
|
||||
data: "に",
|
||||
inputType: "insertCompositionText",
|
||||
bubbles: true,
|
||||
});
|
||||
textarea.value = "に";
|
||||
textarea.dispatchEvent(ev);
|
||||
expect(sent()).toBe("");
|
||||
expect(xterm).toContain("input:");
|
||||
});
|
||||
|
||||
it("stops after dispose", () => {
|
||||
const { textarea, sent } = setup();
|
||||
cleanup?.();
|
||||
cleanup = null;
|
||||
play(textarea, TELEX.slice(0, 2));
|
||||
expect(sent()).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* @file imeInput.ts
|
||||
* @description Typing through an input method that edits xterm's textarea
|
||||
* without composition events — macOS's Vietnamese Telex, in Chrome and
|
||||
* Safari, writes each letter with `insertText` and rewrites the whole word
|
||||
* with `insertReplacementText` ("cha" → "chao" → "chào"), its keydown
|
||||
* (keyCode 229) arriving only AFTER the change. xterm 5.5 drops every
|
||||
* replacement (xterm.js #5887) and every `input` that lands while a key is
|
||||
* held (Shift, for a capital), so "Abc xin chào" reached the pane as " x c".
|
||||
* `attachImeInput` turns each such change into what a line editor needs — a
|
||||
* backspace per replaced character, then the new tail — and hides those
|
||||
* events from xterm so nothing is sent twice. A key xterm sends itself (its
|
||||
* `keypress`, e.g. Space) and a real composition (CJK) stay xterm's.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
/** DEL, the byte a terminal's Backspace sends (xterm's too). */
|
||||
const DEL = "\x7f";
|
||||
|
||||
/** The edits this module answers; anything else stays xterm's. */
|
||||
const EDITS = new Set(["insertText", "insertReplacementText", "deleteContentBackward"]);
|
||||
|
||||
/** The backspaces and new tail that turn `before` into `after` — per
|
||||
* character (code point), as the pane's line editor counts them. */
|
||||
export function textEdit(before: string, after: string): string {
|
||||
const a = Array.from(before);
|
||||
const b = Array.from(after);
|
||||
let same = 0;
|
||||
while (same < a.length && same < b.length && a[same] === b[same]) same++;
|
||||
return DEL.repeat(a.length - same) + b.slice(same).join("");
|
||||
}
|
||||
|
||||
/** The parts of an xterm `Terminal` this needs. */
|
||||
interface ImeTerminal {
|
||||
element?: HTMLElement;
|
||||
textarea?: HTMLTextAreaElement;
|
||||
input: (data: string, wasUserInput?: boolean) => void;
|
||||
}
|
||||
|
||||
/** Sends `term`'s non-composition textarea edits to the pane itself; returns
|
||||
* its dispose. Listens in the capture phase on xterm's element, so it runs
|
||||
* before xterm's own listeners on the textarea and can keep them from it. */
|
||||
export function attachImeInput(term: ImeTerminal): () => void {
|
||||
const root = term.element;
|
||||
const ta = term.textarea;
|
||||
if (!root || !ta) return () => {};
|
||||
// The value before the edit in flight (from `beforeinput`).
|
||||
let before: string | null = null;
|
||||
// xterm sent this key from its keypress: the input event that follows is its.
|
||||
let pressed = false;
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target !== ta) return;
|
||||
pressed = false;
|
||||
// The IME's own key, its edit already in (or about to come as) an input
|
||||
// event: xterm's fallback would diff the textarea and send it again.
|
||||
if (e.keyCode === 229 && !e.isComposing) e.stopPropagation();
|
||||
};
|
||||
const onKeyPress = (e: KeyboardEvent) => {
|
||||
if (e.target === ta) pressed = true;
|
||||
};
|
||||
const onBeforeInput = (e: Event) => {
|
||||
if (e.target === ta) before = ta.value;
|
||||
};
|
||||
const onInput = (e: Event) => {
|
||||
const ev = e as InputEvent;
|
||||
if (ev.target !== ta) return;
|
||||
const was = before;
|
||||
before = null;
|
||||
if (pressed) {
|
||||
pressed = false;
|
||||
return;
|
||||
}
|
||||
if (ev.isComposing || !EDITS.has(ev.inputType) || was === null) return;
|
||||
e.stopPropagation();
|
||||
const data = textEdit(was, ta.value);
|
||||
if (data) term.input(data, true);
|
||||
};
|
||||
|
||||
root.addEventListener("keydown", onKeyDown, true);
|
||||
root.addEventListener("keypress", onKeyPress, true);
|
||||
root.addEventListener("beforeinput", onBeforeInput, true);
|
||||
root.addEventListener("input", onInput, true);
|
||||
return () => {
|
||||
root.removeEventListener("keydown", onKeyDown, true);
|
||||
root.removeEventListener("keypress", onKeyPress, true);
|
||||
root.removeEventListener("beforeinput", onBeforeInput, true);
|
||||
root.removeEventListener("input", onInput, true);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user