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) } }