Skip to content

Commit e21fd9e

Browse files
authored
Korean/CJK IME input drops characters in the terminal (#30)
* fix: forward WebKit IME composition events so Korean/CJK input works * test: cover IME replacement bridge; extract pure computeImeDelta
1 parent 4eff061 commit e21fd9e

4 files changed

Lines changed: 380 additions & 0 deletions

File tree

src/components/workspace/AuxTerminal.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
1717
import { openUrl } from "@tauri-apps/plugin-opener";
1818
import { loadTerminalRenderer } from "@/lib/terminalRenderer";
1919
import { registerTerminalDropTarget } from "@/lib/terminalDrop";
20+
import { setupImeReplacementBridge } from "@/lib/ime";
2021
import * as ipc from "@/lib/ipc";
2122
import { loginShell } from "@/lib/loginShell";
2223
import { usePrefs, currentTerminalStack, currentTerminalTheme, currentColorFgBg } from "@/store/prefs";
@@ -92,6 +93,19 @@ export function AuxTerminal({ wsPath, active, onExited, onTitle }: { wsPath: str
9293
term.loadAddon(unicode11);
9394
term.unicode.activeVersion = "11";
9495
term.open(hostRef.current);
96+
// Korean/CJK IME (WKWebView). WebKit composes via textarea `input` events
97+
// (insertText + insertReplacementText), not compositionstart/end, and
98+
// xterm drops the replacement events — so input gets mangled (안녕 → ㅇㄴ).
99+
// setupImeReplacementBridge fills the gap; see src/lib/ime.ts. The
100+
// keyCode-229 guard below keeps xterm's keydown path inert during IME so
101+
// only the input-event bridge drives composition. Mirrors TerminalPane.
102+
term.attachCustomKeyEventHandler((e) => {
103+
if (e.type === "keydown" && (e.isComposing || e.keyCode === 229)) {
104+
return false;
105+
}
106+
return true;
107+
});
108+
const disposeImeBridge = setupImeReplacementBridge(hostRef.current, () => ptyRef.current, ipc.ptyWrite);
95109
termRef.current = term;
96110
// Hold a ref to the WebGL addon so the cleanup path can dispose it BEFORE
97111
// term.dispose(). Without that, the addon's pending render frame fires
@@ -172,6 +186,7 @@ export function AuxTerminal({ wsPath, active, onExited, onTitle }: { wsPath: str
172186
cancelled = true;
173187
ro.disconnect();
174188
unregisterDrop();
189+
disposeImeBridge();
175190
unlistenData?.(); unlistenExit?.();
176191
if (ptyRef.current) ipc.ptyKill(ptyRef.current).catch(() => {});
177192
// Dispose the renderer addon FIRST so its render loop can't fire

src/components/workspace/TerminalPane.tsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { SearchAddon } from "@xterm/addon-search";
1919
import { loadTerminalRenderer } from "@/lib/terminalRenderer";
2020
import { IS_MAC, bindingMatches, type ShortcutId } from "@/lib/shortcuts";
2121
import { registerTerminalDropTarget } from "@/lib/terminalDrop";
22+
import { setupImeReplacementBridge } from "@/lib/ime";
2223
import { sendMessageToPty } from "@/lib/agentSend";
2324
import type { TerminalTab, Workspace, SandboxMode } from "@/lib/types";
2425
import { effectiveSandboxMode } from "@/lib/types";
@@ -358,6 +359,47 @@ export function TerminalPane({ ws, tab, active }: Props) {
358359
termRef.current = term;
359360
fitRef.current = fit;
360361

362+
// ── Korean/CJK IME fix for WKWebView ───────────────────────────────
363+
// WebKit drives CJK composition NOT through compositionstart/update/end
364+
// (those never fire here; isComposing stays false, keyCode is always
365+
// 229) but through `input` events on the helper textarea:
366+
// • insertText → a fresh jamo is appended (syllable start)
367+
// • insertReplacementText → the composing syllable is refined
368+
// xterm's _inputEvent only forwards inputType === 'insertText', so every
369+
// replacement is DROPPED and only the leading jamo of each syllable
370+
// reaches the PTY (안녕 → ㅇㄴ). We fill the gap: on a replacement event,
371+
// diff the textarea against its previous value and emit backspaces + the
372+
// new tail so the PTY line tracks the textarea exactly — this also
373+
// handles Korean's final-consonant migration (안 + ㅏ → 아나), since the
374+
// whole composing value is diffed, not just the last char. `prevTaVal`
375+
// is synced on EVERY input event (including the insertText ones xterm
376+
// forwards) so the diff baseline stays correct across syllable
377+
// boundaries. English/control keys route through keypress/keydown and
378+
// never hit the replacement branch, so they're untouched. See the
379+
// keyCode-229 guard above, which keeps the keydown path inert for IME.
380+
const disposeImeBridge = setupImeReplacementBridge(host, () => ptyRef.current, ipc.ptyWrite);
381+
382+
// ── IME diagnostic (opt-in) ────────────────────────────────────────
383+
// Toggle with `localStorage.imeDebug = "1"`, type Korean, read the dev
384+
// log (console.warn is forwarded by vite; console.log is not). Kept for
385+
// debugging future IME regressions on other WebKit builds / layouts.
386+
if ((() => { try { return localStorage.getItem("imeDebug") === "1"; } catch { return false; } })()) {
387+
const ta = host.querySelector(".xterm-helper-textarea") as HTMLTextAreaElement | null;
388+
const tag = `[ime ${ws.name}/${tab.cli}]`;
389+
if (ta) {
390+
ta.addEventListener("keydown", (e) => {
391+
console.warn(`${tag} keydown key=${JSON.stringify(e.key)} code=${e.code} keyCode=${e.keyCode} isComposing=${e.isComposing} taValue=${JSON.stringify(ta.value)}`);
392+
}, true);
393+
ta.addEventListener("compositionstart", (e) => console.warn(`${tag} compositionstart data=${JSON.stringify((e as CompositionEvent).data)} taValue=${JSON.stringify(ta.value)}`));
394+
ta.addEventListener("compositionupdate", (e) => console.warn(`${tag} compositionupdate data=${JSON.stringify((e as CompositionEvent).data)} taValue=${JSON.stringify(ta.value)}`));
395+
ta.addEventListener("compositionend", (e) => console.warn(`${tag} compositionend data=${JSON.stringify((e as CompositionEvent).data)} taValue=${JSON.stringify(ta.value)}`));
396+
ta.addEventListener("input", (e) => console.warn(`${tag} input inputType=${(e as InputEvent).inputType} data=${JSON.stringify((e as InputEvent).data)} isComposing=${(e as InputEvent).isComposing} taValue=${JSON.stringify(ta.value)}`));
397+
console.warn(`${tag} IME diagnostic attached.`);
398+
} else {
399+
console.warn(`${tag} IME diagnostic: helper textarea not found.`);
400+
}
401+
}
402+
361403
// Drop target: dragging a file (screenshot, etc.) onto this terminal
362404
// inserts the file's escaped path at the prompt — like macOS Terminal.
363405
// The getter reads ptyRef lazily so a Restart (fresh pty id) still works.
@@ -395,6 +437,20 @@ export function TerminalPane({ ws, tab, active }: Props) {
395437
"file-finder", "find-in-files", "broadcast", "open-shortcuts", "open-settings",
396438
];
397439
term.attachCustomKeyEventHandler((e) => {
440+
// IME composition guard (Korean/Japanese/Chinese). xterm's
441+
// CompositionHelper.keydown() decides "still composing?" purely by
442+
// `keyCode === 229`. Chromium sets that for every composition keystroke,
443+
// but WKWebView (WebKit) reports the real jamo key instead, so xterm
444+
// finalizes the composition on EVERY keystroke — committing the partial
445+
// syllable and resetting (안녕하세요 → ㅇㄴㅎ세). Returning false here
446+
// short-circuits xterm's keydown BEFORE its composition handler runs and,
447+
// crucially, without preventDefault — so the native textarea + xterm's
448+
// own compositionstart/update/end listeners assemble the full syllable
449+
// and emit it once on compositionend. `isComposing` covers continuation
450+
// keystrokes; `keyCode === 229` covers the one that starts composition.
451+
if (e.type === "keydown" && (e.isComposing || e.keyCode === 229)) {
452+
return false;
453+
}
398454
if (e.type === "keydown") {
399455
const binds = usePrefs.getState().shortcuts;
400456
if (PASS_TO_APP.some(id => bindingMatches(e, binds[id]))) {
@@ -1274,6 +1330,7 @@ export function TerminalPane({ ws, tab, active }: Props) {
12741330
cancelled = true;
12751331
ro.disconnect();
12761332
unregisterDrop();
1333+
disposeImeBridge();
12771334
unlistenDataRef.current?.();
12781335
unlistenExitRef.current?.();
12791336
if (ptyRef.current) ipc.ptyKill(ptyRef.current).catch(() => {});

src/lib/ime.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// @vitest-environment happy-dom
2+
import { describe, it, expect, vi } from "vitest";
3+
import {
4+
computeImeDelta,
5+
isForwardedInputType,
6+
setupImeReplacementBridge,
7+
} from "@/lib/ime";
8+
9+
const DEL = 0x7f;
10+
const enc = (s: string) => Array.from(new TextEncoder().encode(s));
11+
12+
// ── computeImeDelta ────────────────────────────────────────────────────
13+
// Diffs the textarea value (code-point aware) into DEL backspaces + the new
14+
// tail. This is the core of the WebKit IME fix.
15+
16+
describe("computeImeDelta", () => {
17+
it("encodes a fresh append from empty", () => {
18+
expect(computeImeDelta("", "아")).toEqual(enc("아"));
19+
});
20+
21+
it("returns nothing when unchanged", () => {
22+
expect(computeImeDelta("안", "안")).toEqual([]);
23+
expect(computeImeDelta("", "")).toEqual([]);
24+
});
25+
26+
it("replaces the composing syllable (ㅇ -> 아)", () => {
27+
expect(computeImeDelta("ㅇ", "아")).toEqual([DEL, ...enc("아")]);
28+
});
29+
30+
it("keeps the common prefix and only rewrites the tail (안ㄴ -> 안녀)", () => {
31+
// common prefix "안"; backspace the trailing ㄴ, send 녀.
32+
expect(computeImeDelta("안ㄴ", "안녀")).toEqual([DEL, ...enc("녀")]);
33+
});
34+
35+
it("handles Korean final-consonant migration (안 -> 아나)", () => {
36+
// The trailing ㄴ of 안 migrates to start the next syllable. Diffing the
37+
// whole value (not just the last char) gets this right: DEL 안, send 아나.
38+
expect(computeImeDelta("안", "아나")).toEqual([DEL, ...enc("아나")]);
39+
});
40+
41+
it("emits a bare backspace when the value shrinks", () => {
42+
expect(computeImeDelta("안녕", "안")).toEqual([DEL]);
43+
});
44+
45+
it("counts a surrogate-pair grapheme as ONE backspace", () => {
46+
// 👍 and 👋 are each a single code point spanning two UTF-16 units. A
47+
// naive .length diff would emit two backspaces; code-point diffing emits one.
48+
expect(computeImeDelta("👍", "👋")).toEqual([DEL, ...enc("👋")]);
49+
});
50+
});
51+
52+
// ── isForwardedInputType ───────────────────────────────────────────────
53+
// xterm forwards only "insertText"; everything composition-related it drops,
54+
// so those are the events the bridge must forward.
55+
56+
describe("isForwardedInputType", () => {
57+
it("does NOT forward insertText (xterm handles it)", () => {
58+
expect(isForwardedInputType("insertText")).toBe(false);
59+
});
60+
61+
it("does NOT forward paste or line breaks (xterm/keydown handle them)", () => {
62+
expect(isForwardedInputType("insertFromPaste")).toBe(false);
63+
expect(isForwardedInputType("insertLineBreak")).toBe(false);
64+
expect(isForwardedInputType("insertParagraph")).toBe(false);
65+
});
66+
67+
it("forwards composition refinements and composition deletes", () => {
68+
expect(isForwardedInputType("insertReplacementText")).toBe(true);
69+
expect(isForwardedInputType("insertCompositionText")).toBe(true);
70+
expect(isForwardedInputType("deleteContentBackward")).toBe(true);
71+
});
72+
});
73+
74+
// ── setupImeReplacementBridge (DOM) ────────────────────────────────────
75+
76+
function mountTerminal() {
77+
const host = document.createElement("div");
78+
host.className = "xterm";
79+
const ta = document.createElement("textarea");
80+
ta.className = "xterm-helper-textarea";
81+
host.appendChild(ta);
82+
document.body.appendChild(host);
83+
return { host, ta };
84+
}
85+
86+
function fireInput(ta: HTMLTextAreaElement, inputType: string, value: string, data: string | null) {
87+
ta.value = value;
88+
ta.dispatchEvent(new InputEvent("input", { inputType, data, bubbles: true }));
89+
}
90+
91+
describe("setupImeReplacementBridge", () => {
92+
const PID = "pty-1";
93+
94+
it("forwards only the dropped events while typing 안녕, reconstructing it on the PTY", () => {
95+
const { host, ta } = mountTerminal();
96+
const write = vi.fn();
97+
setupImeReplacementBridge(host, () => PID, write);
98+
99+
// The exact WebKit event sequence captured from the live app.
100+
fireInput(ta, "insertText", "ㅇ", "ㅇ"); // xterm forwards → bridge skips
101+
fireInput(ta, "insertReplacementText", "아", "아"); // dropped → bridge forwards
102+
fireInput(ta, "insertReplacementText", "안", "안");
103+
fireInput(ta, "insertText", "안ㄴ", "ㄴ"); // xterm forwards → bridge skips
104+
fireInput(ta, "insertReplacementText", "안녀", "녀");
105+
fireInput(ta, "insertReplacementText", "안녕", "녕");
106+
107+
// Only the 4 replacement events produce writes.
108+
expect(write).toHaveBeenCalledTimes(4);
109+
expect(write.mock.calls.map(c => c[1])).toEqual([
110+
[DEL, ...enc("아")], // ㅇ -> 아
111+
[DEL, ...enc("안")], // 아 -> 안
112+
[DEL, ...enc("녀")], // 안ㄴ -> 안녀
113+
[DEL, ...enc("녕")], // 안녀 -> 안녕
114+
]);
115+
116+
// Replaying xterm's insertText sends + the bridge's writes onto a model
117+
// PTY line yields exactly "안녕".
118+
const xtermSends = ["ㅇ", "ㄴ"]; // what xterm forwards for the insertText events
119+
let line = [...Array.from(xtermSends[0])];
120+
// Apply: bridge(아), bridge(안), xterm(ㄴ), bridge(녀), bridge(녕)
121+
const apply = (bytes: number[]) => {
122+
const text = new TextDecoder().decode(Uint8Array.from(bytes.filter(b => b !== DEL)));
123+
const backs = bytes.filter(b => b === DEL).length;
124+
for (let i = 0; i < backs; i++) line.pop();
125+
line.push(...Array.from(text));
126+
};
127+
apply([DEL, ...enc("아")]);
128+
apply([DEL, ...enc("안")]);
129+
line.push(...Array.from("ㄴ")); // xterm insertText
130+
apply([DEL, ...enc("녀")]);
131+
apply([DEL, ...enc("녕")]);
132+
expect(line.join("")).toBe("안녕");
133+
134+
host.remove();
135+
});
136+
137+
it("resets its baseline on Enter so the next composition does not backspace the old line", () => {
138+
const { host, ta } = mountTerminal();
139+
const write = vi.fn();
140+
setupImeReplacementBridge(host, () => PID, write);
141+
142+
fireInput(ta, "insertText", "안녕", "녕"); // baseline := "안녕"
143+
write.mockClear();
144+
145+
// Enter: xterm sends \r and clears the textarea WITHOUT an input event.
146+
ta.value = "";
147+
ta.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
148+
149+
// Next composition starts fresh.
150+
fireInput(ta, "insertReplacementText", "하", "하");
151+
// No DEL for the (gone) old line — just the new char.
152+
expect(write).toHaveBeenCalledTimes(1);
153+
expect(write.mock.calls[0][1]).toEqual([...enc("하")]);
154+
155+
host.remove();
156+
});
157+
158+
it("stops forwarding after cleanup", () => {
159+
const { host, ta } = mountTerminal();
160+
const write = vi.fn();
161+
const dispose = setupImeReplacementBridge(host, () => PID, write);
162+
163+
dispose();
164+
fireInput(ta, "insertReplacementText", "아", "아");
165+
expect(write).not.toHaveBeenCalled();
166+
167+
host.remove();
168+
});
169+
170+
it("does not write when there is no PTY yet", () => {
171+
const { host, ta } = mountTerminal();
172+
const write = vi.fn();
173+
setupImeReplacementBridge(host, () => null, write);
174+
175+
fireInput(ta, "insertReplacementText", "아", "아");
176+
expect(write).not.toHaveBeenCalled();
177+
178+
host.remove();
179+
});
180+
});

0 commit comments

Comments
 (0)