Merge pull request #327 from dignfei/fix/terminal-ime-punctuation

fix(terminal): preserve IME punctuation input
This commit is contained in:
Ark0N
2026-08-21 20:23:26 +02:00
committed by GitHub
4 changed files with 145 additions and 107 deletions
+27 -25
View File
@@ -27,8 +27,8 @@
*
* Solution: outside composition, flush is DEBOUNCED (200ms). The entire
* delete→reinsert cycle collapses into one flush of the final textarea value.
* Keyboard typing of single printable characters still goes through the
* keydown handler (immediate, no debounce).
* Physical-keyboard commits are flushed immediately after the input event
* exposes the final browser/IME text; keydown never guesses that text.
*
* ## Phantom character for Android backspace
*
@@ -56,8 +56,7 @@ const CjkInput = (() => {
let _compositionFlushTimer = null;
let _dictationActive = false;
let _dictationDecayTimer = null;
let _keydownSentAt = 0;
let _keydownSentText = '';
let _printableKeydownAt = null;
const _listeners = {};
const PHANTOM = '​';
@@ -197,6 +196,7 @@ const CjkInput = (() => {
_send = send;
_composing = false;
_printableKeydownAt = null;
_flushTimer = null;
_textarea = document.getElementById('cjkInput');
if (!_textarea) return this;
@@ -234,6 +234,7 @@ const CjkInput = (() => {
};
_listeners.blur = () => {
_t(`blur composing=${_composing} ${_vdesc(_textarea.value)}`);
_printableKeydownAt = null;
// Keep cjkActive while CJK input is visible — iOS dictation and system
// UI may steal focus temporarily, and clearing the flag during that
// window lets xterm's onData process duplicated input.
@@ -253,6 +254,7 @@ const CjkInput = (() => {
_listeners.compositionstart = () => {
_t(`compstart ${_vdesc(_textarea.value)}`);
_composing = true;
_printableKeydownAt = null;
_cancelDebouncedFlush();
// Leave textarea.value untouched — programmatic changes during
// compositionstart cancel the IME composition on iOS Safari.
@@ -277,6 +279,7 @@ const CjkInput = (() => {
// ── Keydown: special keys work REGARDLESS of composition state ──
_listeners.keydown = (e) => {
_t(`keydown ${_kdesc(e.key)} kc=${e.keyCode} ic=${e.isComposing} c=${_composing}`);
_printableKeydownAt = null;
if (e.key === 'Enter') {
e.preventDefault();
_composing = false;
@@ -325,16 +328,11 @@ const CjkInput = (() => {
return;
}
// Single printable character: send immediately to PTY.
// Third-party IMEs on iOS may ignore preventDefault, so the char
// still enters the textarea and fires an input event — _keydownSentAt
// tells the input handler to skip that echo.
// A printable KeyboardEvent.key is the physical key, not necessarily
// the committed text. Let the browser/IME produce the input event so
// full-width punctuation and other layout transforms are preserved.
if (e.key.length === 1 && !e.ctrlKey && !e.altKey && !e.metaKey && _isEffectivelyEmpty()) {
e.preventDefault();
_send(e.key);
_keydownSentAt = performance.now();
_keydownSentText = e.key;
_resetToPhantom();
_printableKeydownAt = performance.now();
return;
}
};
@@ -343,6 +341,8 @@ const CjkInput = (() => {
// ── Input event: primary path for virtual keyboards + dictation ──
_listeners.input = (e) => {
_t(`input ${e.inputType || '?'} ic=${e.isComposing} c=${_composing} ${_vdesc(_textarea.value)}`);
const printableKeydownAt = _printableKeydownAt;
_printableKeydownAt = null;
// ── Stuck-composition recovery ──
// Some IMEs (WeChat/Sogou keyboards) fire compositionstart without a
// matching compositionend. A stale _composing=true blocks every flush
@@ -388,18 +388,18 @@ const CjkInput = (() => {
if (_composing) return;
// Keydown handler already sent this character — clear the textarea
// echo that the IME inserted despite preventDefault. Content-checked:
// only a value matching the sent char is an echo. Anything else (e.g.
// an IME committing CJK text right after a keydown-sent char) is real
// input and must flow through to the debounced flush, not be dropped.
if (performance.now() - _keydownSentAt < 100) {
const cur = _strip(_textarea.value);
if (cur === '' || cur === _keydownSentText) {
_t('echo-drop');
_resetToPhantom();
return;
}
// A recent physical printable key makes this insertText a keyboard
// commit, so keep the old zero-latency path. Send the textarea's final
// Unicode value, never KeyboardEvent.key, because the IME may have
// transformed punctuation or the active layout may differ.
if (
e.inputType === 'insertText' &&
printableKeydownAt !== null &&
performance.now() - printableKeydownAt < 100
) {
_cancelDebouncedFlush();
_flush();
return;
}
// Outside composition: keyboard typing or voice dictation.
@@ -425,6 +425,7 @@ const CjkInput = (() => {
clearTimeout(_compositionFlushTimer);
_compositionFlushTimer = null;
_composing = false;
_printableKeydownAt = null;
_resetToPhantom();
},
@@ -446,6 +447,7 @@ const CjkInput = (() => {
}
window.cjkActive = false;
_composing = false;
_printableKeydownAt = null;
for (const key of Object.keys(_listeners)) delete _listeners[key];
_initialized = false;
},
+5 -70
View File
@@ -287,11 +287,12 @@ Object.assign(CodemanApp.prototype, {
this._installMobileTapMouseGuard();
this._installTouchSelectionFocusGuard();
// Suppress xterm key handling during CJK IME composition.
// Without this, xterm processes raw keyDown events (e.g., "Process" key)
// during composition, causing duplicate or garbled input.
// Let xterm's CompositionHelper own IME key events. In particular, a
// non-composing keyCode 229 is how an active IME commits numbers and
// punctuation; returning false here would stop xterm before it can diff
// the helper textarea and emit the committed Unicode text.
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
if (ev.isComposing || ev.key === 'Process' || ev.keyCode === 229) return true;
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
@@ -399,72 +400,6 @@ Object.assign(CodemanApp.prototype, {
return true;
});
// Android virtual keyboard fix: catch non-composition input events.
// On Android Chrome, typing symbols (e.g., "/" from Gboard's symbol keyboard)
// sends keyCode 229 + input event WITHOUT compositionstart/end wrapping.
// The custom key handler above returns false for keyCode 229, telling xterm
// to ignore the keydown. xterm.js expects the character to arrive via
// composition events, but since there's no composition, the character is lost.
// This listener catches those orphaned input events and forwards them to onData.
{
const xtermTextarea = container.querySelector('.xterm-helper-textarea');
if (xtermTextarea && MobileDetection.isTouchDevice()) {
let composing = false;
let lastKeydownHandled = 0;
xtermTextarea.addEventListener('compositionstart', () => { composing = true; });
xtermTextarea.addEventListener('compositionend', () => { composing = false; });
// Track when xterm handles a keydown normally (non-229 keyCode).
// If xterm processed the keydown, it will emit onData itself --
// the input event handler below must NOT re-send the character.
xtermTextarea.addEventListener('keydown', (e) => {
if (!e.isComposing && e.keyCode !== 229) {
lastKeydownHandled = Date.now();
}
});
xtermTextarea.addEventListener('input', (e) => {
// Only handle insertText events outside of composition -- these are
// the ones xterm.js misses on Android virtual keyboards.
if (composing || e.isComposing) return;
if (e.inputType !== 'insertText' || !e.data) return;
// If xterm just handled a keydown (within 50ms), it already sent the
// char via onData. Skip to avoid double-send (e.g., Shift+A => AA).
if (Date.now() - lastKeydownHandled < 50) return;
// xterm.js may have already processed this via its own input handler.
// Check if the textarea was cleared by xterm (value is empty or just
// whitespace) -- if so, xterm handled it and we should not double-send.
// Use a microtask to check after xterm's own handlers have run.
const data = e.data;
const pendingBefore = this._localEchoOverlay?.pendingText || '';
Promise.resolve().then(() => {
if (
this._lastTerminalData?.data === data &&
performance.now() - this._lastTerminalData.time < 100
) {
xtermTextarea.value = '';
return;
}
const pendingAfter = this._localEchoOverlay?.pendingText || '';
if (
this._localEchoEnabled &&
pendingAfter.length > pendingBefore.length &&
pendingAfter.endsWith(data)
) {
xtermTextarea.value = '';
return;
}
// If xterm cleared the textarea, it processed the input -- skip.
const val = xtermTextarea.value;
if (!val || (val.trim() === '' && data !== ' ')) return;
// xterm didn't process it -- forward to terminal as if typed.
// Emit via onData path by writing to terminal's input handler.
this.terminal._core.coreService.triggerDataEvent(data, true);
// Clear the textarea to prevent xterm from processing it later.
xtermTextarea.value = '';
});
});
}
}
// WebGL renderer for GPU-accelerated terminal rendering.
// Previously caused "page unresponsive" crashes from synchronous GPU stalls,
// but the mode-aware 32/64KB frame cap in flushPendingWrites() now prevents