Skip to content

fix: Korean/CJK IME composition events not captured#120

Open
hongsw wants to merge 4 commits into
coder:mainfrom
hongsw:fix/korean-ime-composition-events
Open

fix: Korean/CJK IME composition events not captured#120
hongsw wants to merge 4 commits into
coder:mainfrom
hongsw:fix/korean-ime-composition-events

Conversation

@hongsw
Copy link
Copy Markdown

@hongsw hongsw commented Jan 25, 2026

Summary

  • Fix IME composition events not being captured for Korean, Chinese, Japanese input
  • Fix spaces appearing between CJK characters when copying text from terminal

Fixes #119

Problem

IME composition events (compositionstart, compositionupdate, compositionend) fire on the focused element. When using a hidden textarea for keyboard input (as ghostty-web does), the textarea receives focus, but composition event listeners were attached to the container element. This caused all IME events to be missed.

Current:
  Canvas click → textarea.focus()     (focus goes to textarea)
  IME listeners → attached to container  ❌ MISMATCH

Fixed:
  Canvas click → textarea.focus()
  IME listeners → attached to textarea  ✅

Additionally, when copying CJK text from the terminal, spaces appeared between characters because the continuation cells (width=0) for wide characters were being treated as empty spaces.

Changes

  1. input-handler.ts: Attach composition events to inputElement (textarea) if available
  2. terminal.ts: Focus textarea instead of container in focus() method
  3. selection-manager.ts: Skip wide character continuation cells when extracting text

Test Plan

  • Type Korean text (e.g., "안녕하세요") - characters appear correctly
  • Type Chinese text (e.g., "你好") - characters should appear correctly
  • Type Japanese text (e.g., "こんにちは") - characters should appear correctly
  • Select and copy CJK text - no extra spaces between characters
  • English typing still works normally

Known Limitation

Fast Korean typing may occasionally insert extra spaces due to composition event timing. This is tracked as a separate issue.

🤖 Generated with Claude Code

IME composition events (compositionstart, compositionupdate, compositionend)
fire on the focused element. When using a hidden textarea for input,
the textarea receives focus, but composition event listeners were attached
to the container element, causing the events to be missed.

Changes:
- input-handler.ts: Attach composition events to inputElement (textarea)
  if available, otherwise fall back to container
- terminal.ts: Focus textarea instead of container in focus() method
- selection-manager.ts: Skip wide character continuation cells when
  extracting selection text (fixes spaces between CJK characters)

This fixes Korean, Chinese, Japanese and other IME input methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f572fa14f7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/terminal.ts
Comment on lines +721 to +725
// Focus the textarea for keyboard/IME input.
// The textarea is the actual input element that receives keyboard events
// and IME composition events. Focusing the container doesn't work for IME
// because composition events fire on the focused element.
const target = this.textarea || this.element;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep blur behavior aligned with textarea focus

The new focus path now targets the hidden textarea when it exists, but blur() still calls this.element.blur(). When the terminal is open (default case creates the textarea), calling terminal.blur() will leave the textarea focused, so keyboard/IME input continues to be captured even though callers expect blur to release focus. This is a regression introduced by switching focus to the textarea. Consider blurring the same target (this.textarea || this.element) or explicitly blurring the textarea when present.

Useful? React with 👍 / 👎.

@hongsw
Copy link
Copy Markdown
Author

hongsw commented Jan 25, 2026

#119

aibaryonai-ctrl and others added 3 commits January 26, 2026 04:26
Display a small "조합중: X" indicator in the top-right corner during
IME composition. This helps users see what character is being composed
before it's finalized.

Korean is a compositional writing system where characters are built
step-by-step (e.g., ㅅ → 세 → 셰). This visual feedback makes the
composition process clearer.

Closes #2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
When pressing space or period to end Korean IME composition, the keydown
event fires before compositionend. This caused the triggering character
to be output before the composed text (e.g., "세요" becomes "세 요").

Added compositionJustEnded flag that blocks the first keydown after
composition ends, preventing the character order reversal.

Fixes #1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- Remove contenteditable from container (causes IME to insert text as DOM nodes)
- Set container tabindex="-1" so it's not focusable
- Add focus redirection from container to textarea
- Queue composition-ending key (space, period) to process after compositionend
- This ensures correct character order: "세요 " instead of "세 요"

Key changes:
1. input-handler.ts: Add pendingKeyAfterComposition to queue the terminating key
2. terminal.ts: Remove contenteditable, set tabindex="-1", add focus redirection

Fixes Korean, Chinese, and Japanese IME input.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@hongsw
Copy link
Copy Markdown
Author

hongsw commented Jan 25, 2026

스크린샷 2026-01-26 05-47-46

@sreya
Copy link
Copy Markdown
Contributor

sreya commented Feb 24, 2026

@codex review

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3db97718dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/input-handler.ts
Comment on lines +735 to +738
const key = this.pendingKeyAfterComposition;
this.pendingKeyAfterComposition = null;
// Output the key that ended composition
this.onDataCallback(key);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Encode queued composition key before emitting

processPendingKeyAfterComposition forwards the raw KeyboardEvent.key string to onDataCallback, which is only correct for printable characters. IME sessions are often finalized with non-printable keys (for example Enter), and those keys become literal text like "Enter" instead of terminal control input (\r), so CJK users can end up sending wrong bytes to the PTY when confirming candidates.

Useful? React with 👍 / 👎.

Comment thread lib/input-handler.ts
Comment on lines +389 to 392
// Block the key that triggered composition end if we just processed a pending key
if (this.compositionJustEnded) {
this.compositionJustEnded = false;
return;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set composition-end dedupe flag when pending key is consumed

The new compositionJustEnded guard can never trigger because this patch only reads/resets the flag and never sets it to true. On browsers that emit a follow-up keydown for the same composition-ending key, that duplicate event is not suppressed, so the terminating character can be inserted twice.

Useful? React with 👍 / 👎.

Comment thread lib/terminal.ts
Comment on lines +758 to +760
const target = this.textarea || this.element;
if (target) {
target.focus();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep focus and blur on the same target element

focus() now prioritizes the hidden textarea, but blur() still blurs only the container. After this change, calling terminal.blur() can leave the textarea focused, so keyboard input continues to flow into the terminal in contexts that expect blur to stop capture (e.g., when opening overlays or switching inputs).

Useful? React with 👍 / 👎.

yaoshenwang added a commit to yaoshenwang/remux that referenced this pull request Apr 2, 2026
…hostty-web upstream

Root cause: patchGhosttyIME() was moving the textarea to position:fixed
top:-9999px during composition, fighting the browser's IME mechanism
which depends on the textarea's stable geometry for candidate window
placement. This caused the "blank page during Chinese input" symptom.

Changes:
1. Remove all textarea position/size manipulation from patchGhosttyIME()
   — keep ONLY composition event forwarding (textarea → container)
2. Add scripts/patch-ghostty-ime.mjs to fix ghostty-web at the source:
   move composition event listeners from container to textarea via
   MutationObserver (coder/ghostty-web#120)
3. Add isComposing guard: defer all fitAddon.fit() calls during active
   IME composition to prevent layout thrash
4. Add postinstall hook to auto-apply ghostty-web patch
diegosouzapw added a commit to diegosouzapw/ghostty-web that referenced this pull request May 23, 2026
When the selection range covered text containing wide characters
(CJK, fullwidth Latin, etc.), copying the selection inserted a stray
space between every wide glyph — e.g. "안녕하" came out as "안 녕 하 ".

Root cause: wide characters occupy two terminal cells. The first cell
has the codepoint and width=2; the second cell is a continuation
marker with codepoint=0 and width=0. SelectionManager.getSelection's
empty-cell branch treated both empty cells AND continuation cells the
same way and appended a space.

Fix: skip continuation cells (cell exists with width===0) in the
empty-cell branch. Only truly empty cells (no cell, or cell.width!==0
with codepoint===0) get a space.

Ports only the selection-manager subset of upstream PR coder#120 — the rest
of that PR (IME composition routing, textarea-focus refactor, removal
of contenteditable) needs more analysis around regressions with browser
extensions and is deferred to a separate port.

Adds one regression test asserting that selecting "안녕하" copies as
"안녕하", not "안 녕 하".

Co-authored-by: Seungwoo Hong <ai.baryon.ai@gmail.com>
Inspired-by: coder#120
diegosouzapw added a commit to diegosouzapw/ghostty-web that referenced this pull request May 23, 2026
IME composition events (compositionstart / compositionupdate /
compositionend) fire on the focused element. ghostty-web focuses a
hidden textarea for keyboard input, but composition listeners were
attached to the container element — so every Korean / Chinese / Japanese
input event was missed.

This commit:

- Moves composition listeners from `container` to `inputElement`
  (textarea) when the input element is available. Detach is also
  retargeted to the same element so disposal is symmetric.
- Adds a state machine to handle the "terminating key" of an IME
  composition (space, period, etc.). The key is queued during
  composition and replayed after compositionend so the composed text
  appears before the terminator.
- Removes `contenteditable="true"` from the parent container. Having
  contenteditable on the container caused IME text to be inserted as
  text nodes in the container, bypassing the textarea entirely. The
  textarea is itself a real input element, so most browser extensions
  (Vimium, etc.) leave it alone — this should not regress the
  motivation behind coder#78, but needs verification in real browsers.
- Sets `tabindex="-1"` on the parent so it is no longer click/tab
  focusable. Redirects parent mousedown and focus events to the
  textarea so any focus eventually lands on the input element.
- Updates `Terminal.focus()` to target the textarea instead of the
  container, with the same delayed-focus backup behaviour.

Differences from upstream PR coder#120 (deliberate):

- The composition-preview overlay (a div with hardcoded Korean text
  "조합중:" and `#ffcc00` on dark background) is intentionally NOT
  ported. Native browsers already render IME composition feedback,
  and the upstream overlay was both untranslated and theme-hostile.
- The selection-manager wide-char fix from that PR was already
  shipped separately as #120a.

Co-authored-by: Seungwoo Hong <ai.baryon.ai@gmail.com>
Inspired-by: coder#120
diegosouzapw added a commit to diegosouzapw/ghostty-web that referenced this pull request May 23, 2026
When the selection range covered text containing wide characters
(CJK, fullwidth Latin, etc.), copying the selection inserted a stray
space between every wide glyph — e.g. "안녕하" came out as "안 녕 하 ".

Root cause: wide characters occupy two terminal cells. The first cell
has the codepoint and width=2; the second cell is a continuation
marker with codepoint=0 and width=0. SelectionManager.getSelection's
empty-cell branch treated both empty cells AND continuation cells the
same way and appended a space.

Fix: skip continuation cells (cell exists with width===0) in the
empty-cell branch. Only truly empty cells (no cell, or cell.width!==0
with codepoint===0) get a space.

Ports only the selection-manager subset of upstream PR coder#120 — the rest
of that PR (IME composition routing, textarea-focus refactor, removal
of contenteditable) needs more analysis around regressions with browser
extensions and is deferred to a separate port.

Adds one regression test asserting that selecting "안녕하" copies as
"안녕하", not "안 녕 하".


Inspired-by: coder#120

Co-authored-by: Seungwoo Hong <ai.baryon.ai@gmail.com>
diegosouzapw added a commit to diegosouzapw/ghostty-web that referenced this pull request May 23, 2026
IME composition events (compositionstart / compositionupdate /
compositionend) fire on the focused element. ghostty-web focuses a
hidden textarea for keyboard input, but composition listeners were
attached to the container element — so every Korean / Chinese / Japanese
input event was missed.

This commit:

- Moves composition listeners from `container` to `inputElement`
  (textarea) when the input element is available. Detach is also
  retargeted to the same element so disposal is symmetric.
- Adds a state machine to handle the "terminating key" of an IME
  composition (space, period, etc.). The key is queued during
  composition and replayed after compositionend so the composed text
  appears before the terminator.
- Removes `contenteditable="true"` from the parent container. Having
  contenteditable on the container caused IME text to be inserted as
  text nodes in the container, bypassing the textarea entirely. The
  textarea is itself a real input element, so most browser extensions
  (Vimium, etc.) leave it alone — this should not regress the
  motivation behind coder#78, but needs verification in real browsers.
- Sets `tabindex="-1"` on the parent so it is no longer click/tab
  focusable. Redirects parent mousedown and focus events to the
  textarea so any focus eventually lands on the input element.
- Updates `Terminal.focus()` to target the textarea instead of the
  container, with the same delayed-focus backup behaviour.

Differences from upstream PR coder#120 (deliberate):

- The composition-preview overlay (a div with hardcoded Korean text
  "조합중:" and `#ffcc00` on dark background) is intentionally NOT
  ported. Native browsers already render IME composition feedback,
  and the upstream overlay was both untranslated and theme-hostile.
- The selection-manager wide-char fix from that PR was already
  shipped separately as #120a.


Inspired-by: coder#120

Co-authored-by: Seungwoo Hong <ai.baryon.ai@gmail.com>
@diegosouzapw
Copy link
Copy Markdown

Hi @hongsw! 👋

Your work on this PR inspired commits in my fork diegosouzapw/ghostty-web.
I ported the IME composition routing and wide-character copy fix, and added you as co-author in commit 1 and commit 2 — thank you for the contribution!

I'm working on OmniRoute, a project that provides free access to LLM models, and I'm planning to use ghostty-web as the terminal component there. Your work is part of what makes that possible. 🙏

Feel free to check it out — contributions and feedback are very welcome!

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.

Korean (Hangul) IME input not working

4 participants