Skip to content

Commit b5d72f5

Browse files
spikeclaude
andcommitted
Fix Korean IME data being eaten on fast input and make composition view visible
xterm.js's CompositionHelper sends committed composed text via setTimeout(0) after compositionend, so the data can arrive AFTER a new compositionstart for the next character has already fired. The previous suppression logic checked isComposing with a 20ms post-compositionend window, which was too narrow and the wrong signal — late-arriving composed text from the previous character was dropped, causing characters to silently disappear during fast Korean typing. Track expected post-composition data with a pendingComposedData counter that is incremented on compositionend and consumed on the next data event, regardless of isComposing state. A 500ms safety timeout decays the counter if the expected data never arrives (e.g. cancelled/empty composition). Also override the xterm composition view styling (which has a known position TODO and uses hardcoded black/white at z-index 1) so users can actually see the in-progress IME text. Themed to terminal colors with a clear border and bumped z-index above other helpers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9cadde7 commit b5d72f5

2 files changed

Lines changed: 48 additions & 5 deletions

File tree

frontend/app/view/term/term.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@
133133
.terminal {
134134
width: 100%;
135135

136+
// Make the xterm IME composition view actually visible. xterm.js renders this small
137+
// box at the cursor position to show the in-progress IME text (e.g. Korean ㅎ → 하 → 한).
138+
// The default styles are barely visible and z-index 1 lets it sit under other helpers.
139+
// We theme it to match the terminal and bump z-index so the user can clearly see what
140+
// they are composing.
141+
.xterm-helpers {
142+
z-index: 10;
143+
}
144+
.composition-view {
145+
background: var(--main-bg-color, #000) !important;
146+
color: var(--main-text-color, #fff) !important;
147+
border: 1px solid var(--accent-color, #58c4dc);
148+
border-radius: 2px;
149+
padding: 0 3px;
150+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.6);
151+
z-index: 20 !important;
152+
}
153+
136154
.xterm-viewport {
137155
&::-webkit-scrollbar {
138156
width: 6px; /* this needs to match fitAddon.scrollbarWidth in termwrap.ts */

frontend/app/view/term/termwrap.ts

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,11 @@ export class TermWrap {
100100
lastCompositionEnd: number = 0;
101101
lastComposedText: string = "";
102102
firstDataAfterCompositionSent: boolean = false;
103+
// Counter of compositionend events whose committed text we still expect to receive via
104+
// xterm.js's CompositionHelper setTimeout(0) callback. Decremented on first matching data
105+
// event. Auto-decremented after a safety timeout if data never arrives (e.g. empty
106+
// composition cancelled with Escape).
107+
pendingComposedData: number = 0;
103108

104109
// Paste deduplication
105110
// xterm.js paste() method triggers onData event, which can cause duplicate sends
@@ -313,6 +318,7 @@ export class TermWrap {
313318
this.lastComposedText = "";
314319
this.lastCompositionEnd = 0;
315320
this.firstDataAfterCompositionSent = false;
321+
this.pendingComposedData = 0;
316322
}
317323

318324
private handleCompositionStart = (e: CompositionEvent) => {
@@ -332,6 +338,21 @@ export class TermWrap {
332338
this.lastComposedText = e.data || "";
333339
this.lastCompositionEnd = Date.now();
334340
this.firstDataAfterCompositionSent = false;
341+
// xterm.js's CompositionHelper schedules a setTimeout(0) after compositionend that
342+
// reads the committed text from the textarea and dispatches it via onData. Track that
343+
// we are expecting that data so handleTermData lets it through even if a new
344+
// compositionstart for the next character has already fired.
345+
if (e.data && e.data.length > 0) {
346+
this.pendingComposedData++;
347+
const expected = this.pendingComposedData;
348+
setTimeout(() => {
349+
// safety net: if the expected data never arrived, decay the counter so it
350+
// does not stay elevated forever and allow normal data through wrongly.
351+
if (this.pendingComposedData >= expected) {
352+
this.pendingComposedData--;
353+
}
354+
}, 500);
355+
}
335356
};
336357

337358
async initTerminal() {
@@ -436,13 +457,17 @@ export class TermWrap {
436457
return;
437458
}
438459

439-
// IME fix: suppress isComposing=true events unless they immediately follow
440-
// a compositionend (within 20ms). This handles CapsLock input method switching
441-
// where the composition buffer gets flushed as a spurious isComposing=true event
442-
if (this.isComposing) {
460+
// IME handling. xterm.js's CompositionHelper sends the committed composed text via a
461+
// setTimeout(0) after compositionend, so the data may arrive AFTER a new
462+
// compositionstart for the next character has already fired (isComposing=true again).
463+
// We track expected post-composition data with pendingComposedData and let it through
464+
// even when isComposing is true.
465+
if (this.pendingComposedData > 0) {
466+
this.pendingComposedData--;
467+
} else if (this.isComposing) {
443468
const timeSinceCompositionEnd = Date.now() - this.lastCompositionEnd;
444469
if (timeSinceCompositionEnd > IMEDedupWindowMs) {
445-
dlog("Suppressed IME data (composing, not near compositionend):", data);
470+
dlog("Suppressed IME data (composing, no pending composed data):", data);
446471
return;
447472
}
448473
}

0 commit comments

Comments
 (0)