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:
2026-10-13 09:20:07 +07:00
parent b3fe10a4f3
commit 69a2c37cd2
5 changed files with 262 additions and 26 deletions
+10 -25
View File
@@ -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 }),
})),
}));