Skip to content

Commit f572fa1

Browse files
fix: Korean/CJK IME composition events not captured
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>
1 parent 03ead6e commit f572fa1

3 files changed

Lines changed: 34 additions & 16 deletions

File tree

lib/input-handler.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -288,14 +288,19 @@ export class InputHandler {
288288
this.inputElement.addEventListener('beforeinput', this.beforeInputListener);
289289
}
290290

291+
// Attach composition events to inputElement (textarea) if available.
292+
// IME composition events fire on the focused element, and when using a hidden
293+
// textarea for input (as ghostty-web does), the textarea receives focus,
294+
// not the container. This fixes Korean/Chinese/Japanese IME input.
295+
const compositionTarget = this.inputElement || this.container;
291296
this.compositionStartListener = this.handleCompositionStart.bind(this);
292-
this.container.addEventListener('compositionstart', this.compositionStartListener);
297+
compositionTarget.addEventListener('compositionstart', this.compositionStartListener);
293298

294299
this.compositionUpdateListener = this.handleCompositionUpdate.bind(this);
295-
this.container.addEventListener('compositionupdate', this.compositionUpdateListener);
300+
compositionTarget.addEventListener('compositionupdate', this.compositionUpdateListener);
296301

297302
this.compositionEndListener = this.handleCompositionEnd.bind(this);
298-
this.container.addEventListener('compositionend', this.compositionEndListener);
303+
compositionTarget.addEventListener('compositionend', this.compositionEndListener);
299304

300305
// Mouse event listeners (for terminal mouse tracking)
301306
this.mousedownListener = this.handleMouseDown.bind(this);
@@ -1059,18 +1064,20 @@ export class InputHandler {
10591064
this.beforeInputListener = null;
10601065
}
10611066

1067+
// Remove composition listeners from the same element they were attached to
1068+
const compositionTarget = this.inputElement || this.container;
10621069
if (this.compositionStartListener) {
1063-
this.container.removeEventListener('compositionstart', this.compositionStartListener);
1070+
compositionTarget.removeEventListener('compositionstart', this.compositionStartListener);
10641071
this.compositionStartListener = null;
10651072
}
10661073

10671074
if (this.compositionUpdateListener) {
1068-
this.container.removeEventListener('compositionupdate', this.compositionUpdateListener);
1075+
compositionTarget.removeEventListener('compositionupdate', this.compositionUpdateListener);
10691076
this.compositionUpdateListener = null;
10701077
}
10711078

10721079
if (this.compositionEndListener) {
1073-
this.container.removeEventListener('compositionend', this.compositionEndListener);
1080+
compositionTarget.removeEventListener('compositionend', this.compositionEndListener);
10741081
this.compositionEndListener = null;
10751082
}
10761083

lib/selection-manager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,12 @@ export class SelectionManager {
180180
if (char.trim()) {
181181
lastNonEmpty = lineText.length;
182182
}
183-
} else {
183+
} else if (!cell || cell.width !== 0) {
184+
// Only add space for truly empty cells, not wide character continuation cells.
185+
// Wide characters (like CJK) occupy 2 terminal cells:
186+
// - First cell: has codepoint, width=2
187+
// - Second cell: codepoint=0, width=0 (continuation marker)
188+
// We skip continuation cells to avoid inserting spaces between characters.
184189
lineText += ' ';
185190
}
186191
}

lib/terminal.ts

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -717,15 +717,21 @@ export class Terminal implements ITerminalCore {
717717
* Focus terminal input
718718
*/
719719
focus(): void {
720-
if (this.isOpen && this.element) {
721-
// Focus immediately for immediate keyboard/wheel event handling
722-
this.element.focus();
723-
724-
// Also schedule a delayed focus as backup to ensure it sticks
725-
// (some browsers may need this if DOM isn't fully settled)
726-
setTimeout(() => {
727-
this.element?.focus();
728-
}, 0);
720+
if (this.isOpen) {
721+
// Focus the textarea for keyboard/IME input.
722+
// The textarea is the actual input element that receives keyboard events
723+
// and IME composition events. Focusing the container doesn't work for IME
724+
// because composition events fire on the focused element.
725+
const target = this.textarea || this.element;
726+
if (target) {
727+
target.focus();
728+
729+
// Also schedule a delayed focus as backup to ensure it sticks
730+
// (some browsers may need this if DOM isn't fully settled)
731+
setTimeout(() => {
732+
target?.focus();
733+
}, 0);
734+
}
729735
}
730736
}
731737

0 commit comments

Comments
 (0)