From e5cb20437d8c452d7150bf2ed2af485c2adf546d Mon Sep 17 00:00:00 2001 From: sakaritoru Date: Sun, 26 Apr 2026 23:38:15 +0900 Subject: [PATCH 1/3] fix(@wterm/dom): bail keydown handler during IME composition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IME first keystroke (e.g. typing 'k' to compose 'か') fires keydown with keyCode 229 before compositionstart. Without this guard, the raw latin key is sent to the PTY in addition to the eventual composed character, which breaks Japanese/Chinese/Korean input. Also bail when e.isComposing is true (defense in depth for browsers that surface it on keydown). Refs: w3c/uievents-key#23, xtermjs/xterm.js handles this similarly. --- .../@wterm/dom/src/__tests__/input.test.ts | 21 +++++++++++++++++++ packages/@wterm/dom/src/input.ts | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/@wterm/dom/src/__tests__/input.test.ts b/packages/@wterm/dom/src/__tests__/input.test.ts index c0c953d..d00609c 100644 --- a/packages/@wterm/dom/src/__tests__/input.test.ts +++ b/packages/@wterm/dom/src/__tests__/input.test.ts @@ -232,6 +232,27 @@ describe("InputHandler", () => { }); }); + describe("IME composition - keydown gating", () => { + it("ignores keydown events whose keyCode is 229 (IME first keystroke)", () => { + const ta = getTextarea(); + ta.dispatchEvent(createKeyboardEvent("Process", { keyCode: 229 })); + expect(received).toHaveLength(0); + }); + + it("ignores keydown events flagged as isComposing", () => { + const ta = getTextarea(); + ta.dispatchEvent(createKeyboardEvent("a", { isComposing: true })); + expect(received).toHaveLength(0); + }); + + it("ignores ordinary keydown while composing", () => { + const ta = getTextarea(); + ta.dispatchEvent(new CompositionEvent("compositionstart", { data: "" })); + ta.dispatchEvent(createKeyboardEvent("a")); + expect(received).toHaveLength(0); + }); + }); + describe("destroy", () => { it("removes textarea from DOM", () => { handler.destroy(); diff --git a/packages/@wterm/dom/src/input.ts b/packages/@wterm/dom/src/input.ts index e75feab..f2f0848 100644 --- a/packages/@wterm/dom/src/input.ts +++ b/packages/@wterm/dom/src/input.ts @@ -138,7 +138,9 @@ export class InputHandler { } private handleKeyDown(e: KeyboardEvent): void { - if (this.composing) return; + // IME first keystroke fires keydown with keyCode 229 before + // compositionstart; bail early so the raw key isn't sent to the PTY. + if (this.composing || e.isComposing || e.keyCode === 229) return; if ((e.metaKey || e.ctrlKey) && e.key === "c") { const sel = window.getSelection(); From 8bc05816d9aba2ba8e358bf5b9cce788921d375a Mon Sep 17 00:00:00 2001 From: sakaritoru Date: Sun, 26 Apr 2026 23:40:17 +0900 Subject: [PATCH 2/3] feat(@wterm/dom): render uncommitted IME text via composition overlay The hidden textarea sits at left:-9999px which means the OS IME inline preview (the candidate window's pre-edit text) is rendered off-screen. Users can compose Japanese/Chinese/Korean but can't see what they're typing until they commit. Mirror xterm.js's approach: keep the textarea at the cursor as an input antenna, and add a separate overlay that renders the candidate string with the terminal's font and background. The textarea stays opacity:0 so the OS caret never shows through. Lifecycle: compositionstart -> position textarea + show overlay compositionupdate -> overlay textContent = e.data compositionend -> hide overlay + send committed data once A MutationObserver re-positions the textarea when the cursor element moves so subsequent compositions land at the right column. --- .../@wterm/dom/src/__tests__/input.test.ts | 66 +++++++++++ packages/@wterm/dom/src/input.ts | 103 +++++++++++++++++- 2 files changed, 165 insertions(+), 4 deletions(-) diff --git a/packages/@wterm/dom/src/__tests__/input.test.ts b/packages/@wterm/dom/src/__tests__/input.test.ts index d00609c..a3e4463 100644 --- a/packages/@wterm/dom/src/__tests__/input.test.ts +++ b/packages/@wterm/dom/src/__tests__/input.test.ts @@ -253,12 +253,78 @@ describe("InputHandler", () => { }); }); + describe("IME composition - overlay", () => { + function getCompositionView(): HTMLSpanElement { + return container.querySelector(".term-composition") as HTMLSpanElement; + } + + it("creates a hidden composition overlay alongside the textarea", () => { + const view = getCompositionView(); + expect(view).not.toBeNull(); + expect(view.style.display).toBe("none"); + }); + + it("shows the composition overlay on compositionstart", () => { + const ta = getTextarea(); + ta.dispatchEvent(new CompositionEvent("compositionstart", { data: "" })); + expect(getCompositionView().style.display).toBe("inline-block"); + }); + + it("renders uncommitted text into the overlay on compositionupdate", () => { + const ta = getTextarea(); + ta.dispatchEvent(new CompositionEvent("compositionstart", { data: "" })); + ta.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "こん" }), + ); + expect(getCompositionView().textContent).toBe("こん"); + ta.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "こんにちは" }), + ); + expect(getCompositionView().textContent).toBe("こんにちは"); + }); + + it("does not send uncommitted text to onData while composing", () => { + const ta = getTextarea(); + ta.dispatchEvent(new CompositionEvent("compositionstart", { data: "" })); + ta.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "こんにちは" }), + ); + expect(received).toHaveLength(0); + }); + + it("commits final text and hides the overlay on compositionend", () => { + const ta = getTextarea(); + ta.dispatchEvent(new CompositionEvent("compositionstart", { data: "" })); + ta.dispatchEvent( + new CompositionEvent("compositionupdate", { data: "こんにちは" }), + ); + ta.dispatchEvent( + new CompositionEvent("compositionend", { data: "こんにちは" }), + ); + expect(received).toContain("こんにちは"); + expect(getCompositionView().style.display).toBe("none"); + expect(getCompositionView().textContent).toBe(""); + }); + + it("emits no data when composition ends with empty data", () => { + const ta = getTextarea(); + ta.dispatchEvent(new CompositionEvent("compositionstart", { data: "" })); + ta.dispatchEvent(new CompositionEvent("compositionend", { data: "" })); + expect(received).toHaveLength(0); + }); + }); + describe("destroy", () => { it("removes textarea from DOM", () => { handler.destroy(); expect(container.querySelector("textarea")).toBeNull(); }); + it("removes the composition overlay from DOM", () => { + handler.destroy(); + expect(container.querySelector(".term-composition")).toBeNull(); + }); + it("removes focused class", () => { container.classList.add("focused"); handler.destroy(); diff --git a/packages/@wterm/dom/src/input.ts b/packages/@wterm/dom/src/input.ts index f2f0848..295c851 100644 --- a/packages/@wterm/dom/src/input.ts +++ b/packages/@wterm/dom/src/input.ts @@ -41,16 +41,26 @@ const FIXED_KEYS: Record = { F12: "\x1b[24~", }; +interface CursorRect { + left: number; + top: number; + width: number; + height: number; +} + export class InputHandler { private element: HTMLElement; private textarea: HTMLTextAreaElement; + private compositionView: HTMLSpanElement; private onData: (data: string) => void; private getBridge: () => WasmBridge | null; private composing = false; + private _cursorObserver: MutationObserver; private _onKeyDown: (e: KeyboardEvent) => void; private _onPaste: (e: ClipboardEvent) => void; private _onCompositionStart: () => void; + private _onCompositionUpdate: (e: CompositionEvent) => void; private _onCompositionEnd: (e: CompositionEvent) => void; private _onInput: () => void; private _onFocus: () => void; @@ -75,11 +85,12 @@ export class InputHandler { this.textarea.setAttribute("aria-hidden", "true"); const s = this.textarea.style; s.position = "absolute"; - s.left = "-9999px"; + s.left = "0"; s.top = "0"; - s.width = "1px"; - s.height = "1px"; + s.width = "1ch"; + s.height = "1.2em"; s.opacity = "0"; + s.zIndex = "10"; s.overflow = "hidden"; s.border = "0"; s.padding = "0"; @@ -92,12 +103,33 @@ export class InputHandler { s.background = "transparent"; element.appendChild(this.textarea); + this.compositionView = document.createElement("span"); + this.compositionView.className = "term-composition"; + const cs = this.compositionView.style; + cs.position = "absolute"; + cs.font = "inherit"; + cs.color = "inherit"; + cs.background = "var(--term-bg, #1e1e1e)"; + cs.whiteSpace = "pre"; + cs.textDecoration = "underline"; + cs.zIndex = "50"; + cs.pointerEvents = "none"; + cs.padding = "0"; + cs.margin = "0"; + cs.border = "0"; + cs.display = "none"; + element.appendChild(this.compositionView); + this._onKeyDown = this.handleKeyDown.bind(this); this._onPaste = this.handlePaste.bind(this); this._onCompositionStart = this.handleCompositionStart.bind(this); + this._onCompositionUpdate = this.handleCompositionUpdate.bind(this); this._onCompositionEnd = this.handleCompositionEnd.bind(this); this._onInput = this.handleInput.bind(this); - this._onFocus = () => this.element.classList.add("focused"); + this._onFocus = () => { + this.element.classList.add("focused"); + this._positionTextareaAtCursor(); + }; this._onBlur = () => this.element.classList.remove("focused"); this.textarea.addEventListener("keydown", this._onKeyDown); @@ -106,6 +138,10 @@ export class InputHandler { "compositionstart", this._onCompositionStart, ); + this.textarea.addEventListener( + "compositionupdate", + this._onCompositionUpdate as EventListener, + ); this.textarea.addEventListener( "compositionend", this._onCompositionEnd as EventListener, @@ -113,6 +149,35 @@ export class InputHandler { this.textarea.addEventListener("input", this._onInput); this.textarea.addEventListener("focus", this._onFocus); this.textarea.addEventListener("blur", this._onBlur); + + this._cursorObserver = new MutationObserver(() => + this._positionTextareaAtCursor(), + ); + this._cursorObserver.observe(element, { childList: true, subtree: true }); + this._positionTextareaAtCursor(); + } + + private _getCursorRect(): CursorRect | null { + const cursorEl = this.element.querySelector(".term-cursor"); + if (!cursorEl) return null; + const elRect = this.element.getBoundingClientRect(); + const r = cursorEl.getBoundingClientRect(); + return { + left: r.left - elRect.left + this.element.scrollLeft, + top: r.top - elRect.top + this.element.scrollTop, + width: r.width, + height: r.height, + }; + } + + private _positionTextareaAtCursor(): void { + const rect = this._getCursorRect(); + if (!rect) return; + const s = this.textarea.style; + s.left = rect.left + "px"; + s.top = rect.top + "px"; + s.width = Math.max(1, rect.width) + "px"; + s.height = Math.max(1, rect.height) + "px"; } focus(): void { @@ -120,12 +185,17 @@ export class InputHandler { } destroy(): void { + this._cursorObserver?.disconnect(); this.textarea.removeEventListener("keydown", this._onKeyDown); this.textarea.removeEventListener("paste", this._onPaste as EventListener); this.textarea.removeEventListener( "compositionstart", this._onCompositionStart, ); + this.textarea.removeEventListener( + "compositionupdate", + this._onCompositionUpdate as EventListener, + ); this.textarea.removeEventListener( "compositionend", this._onCompositionEnd as EventListener, @@ -135,6 +205,7 @@ export class InputHandler { this.textarea.removeEventListener("blur", this._onBlur); this.element.classList.remove("focused"); this.textarea.remove(); + this.compositionView.remove(); } private handleKeyDown(e: KeyboardEvent): void { @@ -190,14 +261,38 @@ export class InputHandler { private handleCompositionStart(): void { this.composing = true; + this._positionTextareaAtCursor(); + this._showCompositionView(); + } + + private handleCompositionUpdate(e: CompositionEvent): void { + this.compositionView.textContent = e.data || ""; } private handleCompositionEnd(e: CompositionEvent): void { this.composing = false; + this._hideCompositionView(); if (e.data) this.onData(e.data); this.textarea.value = ""; } + private _showCompositionView(): void { + const rect = this._getCursorRect(); + const cs = this.compositionView.style; + if (rect) { + cs.left = rect.left + "px"; + cs.top = rect.top + "px"; + cs.height = rect.height + "px"; + cs.lineHeight = rect.height + "px"; + } + cs.display = "inline-block"; + } + + private _hideCompositionView(): void { + this.compositionView.style.display = "none"; + this.compositionView.textContent = ""; + } + private handleInput(): void { if (this.composing) return; const value = this.textarea.value; From 04314f92677263860d1018bde66c88674369e6ea Mon Sep 17 00:00:00 2001 From: sakaritoru Date: Sun, 26 Apr 2026 23:41:23 +0900 Subject: [PATCH 3/3] fix(@wterm/dom): keep IME overlay anchored when TUI hides the cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full-screen TUIs (Claude Code, vim, less, tmux, etc.) routinely emit \x1b[?25l to hide the cursor while drawing their own UI. The renderer removes the .term-cursor element when the cursor is invisible, so querySelector(".term-cursor") returns null and the composition overlay ends up at the document origin instead of the prompt position. Fall back to bridge.getCursor() and resolve a pixel position from the matching .term-row plus a measured monospace character width. The probe caches its result unconditionally — an unmeasurable layout (jsdom) would otherwise re-probe on every observer fire and recurse forever. --- .../@wterm/dom/src/__tests__/input.test.ts | 13 ++++ packages/@wterm/dom/src/input.ts | 70 ++++++++++++++++--- 2 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/@wterm/dom/src/__tests__/input.test.ts b/packages/@wterm/dom/src/__tests__/input.test.ts index a3e4463..14397e1 100644 --- a/packages/@wterm/dom/src/__tests__/input.test.ts +++ b/packages/@wterm/dom/src/__tests__/input.test.ts @@ -312,6 +312,19 @@ describe("InputHandler", () => { ta.dispatchEvent(new CompositionEvent("compositionend", { data: "" })); expect(received).toHaveLength(0); }); + + it("falls back to bridge.getCursor when .term-cursor is absent", () => { + const row = document.createElement("div"); + row.className = "term-row"; + container.appendChild(row); + bridgeMock = { + getCursor: () => ({ row: 0, col: 0, visible: false }), + } as any; + // Triggers the focus -> _positionTextareaAtCursor cycle that exercises + // the fallback branch. Asserts we don't throw or recurse. + handler.focus(); + expect(getTextarea()).not.toBeNull(); + }); }); describe("destroy", () => { diff --git a/packages/@wterm/dom/src/input.ts b/packages/@wterm/dom/src/input.ts index 295c851..1e97940 100644 --- a/packages/@wterm/dom/src/input.ts +++ b/packages/@wterm/dom/src/input.ts @@ -55,7 +55,9 @@ export class InputHandler { private onData: (data: string) => void; private getBridge: () => WasmBridge | null; private composing = false; + private _charWidth = 0; private _cursorObserver: MutationObserver; + private _positionRaf: number | null = null; private _onKeyDown: (e: KeyboardEvent) => void; private _onPaste: (e: ClipboardEvent) => void; @@ -150,23 +152,67 @@ export class InputHandler { this.textarea.addEventListener("focus", this._onFocus); this.textarea.addEventListener("blur", this._onBlur); - this._cursorObserver = new MutationObserver(() => - this._positionTextareaAtCursor(), - ); + // Renderer flushes many DOM mutations per render; coalesce observer + // callbacks into one rAF tick so we don't force a layout per child. + this._cursorObserver = new MutationObserver(() => { + if (this._positionRaf !== null) return; + this._positionRaf = requestAnimationFrame(() => { + this._positionRaf = null; + this._positionTextareaAtCursor(); + }); + }); this._cursorObserver.observe(element, { childList: true, subtree: true }); this._positionTextareaAtCursor(); } + private _measureCharWidth(): number { + if (this._charWidth) return this._charWidth; + const probe = document.createElement("span"); + const ps = probe.style; + ps.font = "inherit"; + ps.position = "absolute"; + ps.visibility = "hidden"; + ps.whiteSpace = "pre"; + ps.left = "-9999px"; + probe.textContent = "xxxxxxxxxx"; + this.element.appendChild(probe); + const w = probe.getBoundingClientRect().width / 10; + probe.remove(); + // Cache unconditionally; an unmeasurable layout (e.g. jsdom) would + // otherwise re-probe on every observer fire and recurse forever. + this._charWidth = w > 0 ? w : 8; + return this._charWidth; + } + private _getCursorRect(): CursorRect | null { - const cursorEl = this.element.querySelector(".term-cursor"); - if (!cursorEl) return null; const elRect = this.element.getBoundingClientRect(); - const r = cursorEl.getBoundingClientRect(); + const cursorEl = this.element.querySelector(".term-cursor"); + if (cursorEl) { + const r = cursorEl.getBoundingClientRect(); + return { + left: r.left - elRect.left + this.element.scrollLeft, + top: r.top - elRect.top + this.element.scrollTop, + width: r.width, + height: r.height, + }; + } + // TUI apps that hide the cursor (\x1b[?25l) drop the .term-cursor + // element from the DOM. Fall back to the WASM-side cursor position + // so IME composition still anchors at the prompt. + const bridge = this.getBridge(); + const cur = bridge ? bridge.getCursor() : null; + if (!cur) return null; + const rows = this.element.querySelectorAll(".term-row"); + const rowEl = rows[cur.row] as HTMLElement | undefined; + if (!rowEl) return null; + const rRect = rowEl.getBoundingClientRect(); + const charW = this._measureCharWidth(); return { - left: r.left - elRect.left + this.element.scrollLeft, - top: r.top - elRect.top + this.element.scrollTop, - width: r.width, - height: r.height, + left: + rRect.left - elRect.left + this.element.scrollLeft + cur.col * charW, + top: rRect.top - elRect.top + this.element.scrollTop, + width: charW, + height: rRect.height, }; } @@ -186,6 +232,10 @@ export class InputHandler { destroy(): void { this._cursorObserver?.disconnect(); + if (this._positionRaf !== null) { + cancelAnimationFrame(this._positionRaf); + this._positionRaf = null; + } this.textarea.removeEventListener("keydown", this._onKeyDown); this.textarea.removeEventListener("paste", this._onPaste as EventListener); this.textarea.removeEventListener(