Skip to content

fix(@wterm/dom): support IME composition (Japanese/Chinese/Korean input)#50

Open
sakaritoru wants to merge 3 commits into
vercel-labs:mainfrom
sakaritoru:fix/ime-composition
Open

fix(@wterm/dom): support IME composition (Japanese/Chinese/Korean input)#50
sakaritoru wants to merge 3 commits into
vercel-labs:mainfrom
sakaritoru:fix/ime-composition

Conversation

@sakaritoru
Copy link
Copy Markdown

Summary

Adds IME (Input Method Editor) composition support to @wterm/dom, fixing three independent issues that broke Japanese / Chinese / Korean text entry:

  1. Garbled first keystroke — the IME's initial keydown (which fires with keyCode === 229 before compositionstart) was forwarded to the PTY as a raw character, so commits like `こんにちは` produced `kこんにちは`.
  2. Off-screen composition preview — uncommitted IME text rendered into the textarea, but the textarea was positioned at `left: -9999px`, so users couldn't see what they were typing.
  3. Misanchored overlay in TUI mode — TUI apps that hide the cursor (`\x1b[?25l`, e.g. Claude Code, vim, less) drop the `.term-cursor` element, so the composition overlay had no anchor and snapped to `(0,0)`.

Changes

Three atomic commits, all scoped to `packages/@wterm/dom/src/input.ts` and its tests:

  • `fix: bail keydown handler during IME composition` — early-return when `composing`, `isComposing`, or `keyCode === 229`
  • `feat: render uncommitted IME text via composition overlay` — adds a `term-composition` span that mirrors the xterm.js compositionView pattern; textarea is repositioned to the cursor (still visually transparent) so the IME's native popup anchors correctly
  • `fix: keep IME overlay anchored when TUI hides the cursor` — falls back to `bridge.getCursor()` + `.term-row[N]` + measured char width when `.term-cursor` is absent; coalesces `MutationObserver` fires through `requestAnimationFrame` to avoid layout thrashing during fast streams

No API surface change. No new dependencies. No other packages touched.

Test plan

  • `pnpm --filter @wterm/dom test` — 79 tests pass (11 new IME tests across keydown gating, overlay lifecycle, and TUI-mode fallback)
  • `pnpm test` (turbo, full monorepo) — 12/12 tasks pass; React/Vue wrappers unaffected
  • `pnpm exec tsc --noEmit` — clean
  • `pnpm exec prettier --check` — clean
  • Manual: typed `こんにちは` in a Next.js + `@wterm/dom` app running Claude Code in PTY; composition preview visible inline at the prompt, commits arrive once on confirm, cursor stays anchored when the TUI hides the cursor

Notes

  • Docs updates skipped: IME support is a behavior fix with no public API / option / theme change. Happy to add a line to a changelog or docs page if maintainers prefer.
  • An unrelated rendering glitch (DEC SCS `\x1b(0` → box-drawing characters) was observed during testing of TUI apps but is not part of this PR — looks like a separate codepath in the parser.

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.
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 <span class="term-composition">
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.
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.
@vercel
Copy link
Copy Markdown

vercel Bot commented Apr 26, 2026

@sakaritoru is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

nzinfo added a commit to coreseekdev/wterm that referenced this pull request May 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant