From 5a5deb73e63da5d3cf828a6894e567e69aefd268 Mon Sep 17 00:00:00 2001 From: Nexus Dev Date: Mon, 20 Apr 2026 18:08:28 +0800 Subject: [PATCH] fix(terminal): prevent IME double-fire on mobile input Three changes to fix duplicated/garbled characters on mobile IMEs (WeChat, Sogou, etc.), especially in English mode: 1. Unified input path: Android now uses our custom element instead of xterm's internal textarea. The textarea leaks IME composition intermediate states on Android, causing a burst of characters on every keystroke. 2. keydownHandledRef guard: When keydown preventsDefault on a printable char, the browser may still update input.value on some mobile engines. The ref marks "already sent" for one event-loop tick, so handleInputChange can skip the duplicate. 3. Strip zero-width characters: Filters U+200B-U+200F, U+FEFF, U+2060-U+206F that leak from some input methods during composition. Co-Authored-By: Claude --- frontend/src/Terminal.tsx | 41 +++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/frontend/src/Terminal.tsx b/frontend/src/Terminal.tsx index daa6bf9..09d49db 100644 --- a/frontend/src/Terminal.tsx +++ b/frontend/src/Terminal.tsx @@ -404,9 +404,11 @@ export default function Terminal({ token }: Props) { }, [copyToClipboard]) const sendToWs = useCallback((data: string) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send(data) - } + if (wsRef.current?.readyState !== WebSocket.OPEN) return + // Strip zero-width and IME internal marker characters that leak from + // some mobile input methods (WeChat, Sogou) during composition. + const cleaned = data.replace(/[\u200B-\u200F\uFEFF\u2060-\u206F]/g, '') + if (cleaned) wsRef.current.send(cleaned) }, []) useEffect(() => { @@ -1145,19 +1147,12 @@ export default function Terminal({ token }: Props) { if (xtermTa) { xtermTa.inputMode = 'none'; xtermTa.blur() } } else { keyboardVisibleRef.current = true - const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) - if (isIOS) { - // iOS Safari won't reliably show the keyboard for xterm's internal - // textarea (tiny element + restrictive attributes). Use our standard - // instead — iOS handles it correctly. - if (xtermTa) xtermTa.inputMode = 'none' - if (inputRef.current) { inputRef.current.inputMode = 'text'; inputRef.current.focus() } - } else { - // Android / other: focus xterm's own textarea — term.onData handles - // all input natively (letters, numbers, IME/CJK). - if (xtermTa) { xtermTa.inputMode = 'text'; xtermTa.focus() } - if (inputRef.current) inputRef.current.inputMode = 'text' - } + // Unified: use our custom on both iOS and Android. + // xterm's internal textarea leaks IME composition intermediate + // states on Android (WeChat/Sogou), causing duplicated/garbled + // characters. A standard has predictable composition events. + if (xtermTa) xtermTa.inputMode = 'none' + if (inputRef.current) { inputRef.current.inputMode = 'text'; inputRef.current.focus() } } } } @@ -1345,10 +1340,19 @@ export default function Terminal({ token }: Props) { }, [token, activeWindowIndex, wsSessionKey]) const isComposingRef = useRef(false) + const keydownHandledRef = useRef(false) function handleInputChange(e: React.ChangeEvent) { if (isComposingRef.current) return // handled by compositionEnd - // Fallback for Android (keydown fires key='Unidentified', onChange is reliable there) + // If keydown already sent this character (common on mobile where + // preventDefault doesn't fully suppress input.value update), skip + // to avoid double-fire. + if (keydownHandledRef.current) { + e.target.value = '' + keydownHandledRef.current = false + return + } + // Fallback for Android when keydown fires key='Unidentified'. const val = e.target.value if (val) { sendToWs(val); e.target.value = '' } } @@ -1377,7 +1381,10 @@ export default function Terminal({ token }: Props) { // preventDefault stops the browser from updating input.value, so onChange won't // double-fire. This is reliable on iOS/desktop where e.key is always correct. e.preventDefault() + keydownHandledRef.current = true sendToWs(e.key) + // Clear after one event-loop tick so fast typers aren't affected. + setTimeout(() => { keydownHandledRef.current = false }, 0) } }