From 29b265380196418c7a049f633d254d1b8b5cd199 Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sun, 12 Apr 2026 10:01:29 -0400 Subject: [PATCH] fix: prevent double character input on Android Shift+key On Android tablets, pressing Shift+A produces "AA" because the input event listener re-sends characters that xterm already processed via its keydown handler. Track keydown timestamps and skip input events that fire within 50ms of a handled keydown. Only affects touch devices (listener gated by isTouchDevice()). --- src/web/public/terminal-ui.js | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d7e09610..78b9cc7a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -122,21 +122,45 @@ Object.assign(CodemanApp.prototype, { // 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. This listener catches those orphaned input events. + // 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; Promise.resolve().then(() => { + // If xterm cleared the textarea, it processed the input -- skip. const val = xtermTextarea.value; if (!val || val.trim() === '') 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 = ''; }); });