From 9ea1a2a7285873147d0c29031de5d90e20cb3336 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 24 Jul 2026 22:48:59 +0300 Subject: [PATCH] fix(ui): Android soft keyboard support in terminal touch input - summon the IME via Capacitor Keyboard.show() on tap-to-focus, since Android WebView only raises the keyboard for native tap-focus - forward beforeinput text/backspace/enter to the terminal because Android IMEs deliver input as beforeinput with keyCode 229 keydowns, which ghostty-web ignores - blur the focused terminal input and hide the keyboard before disposal so tearing down a focused editable mid-composition no longer wedges WebView touch dispatch (app froze on terminal tab close) --- .../components/terminal/TerminalViewport.tsx | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/terminal/TerminalViewport.tsx b/packages/ui/src/components/terminal/TerminalViewport.tsx index 58ffe275..ef0c1ef1 100644 --- a/packages/ui/src/components/terminal/TerminalViewport.tsx +++ b/packages/ui/src/components/terminal/TerminalViewport.tsx @@ -165,6 +165,20 @@ const TerminalViewport = React.forwardRef(({ return () => { disposed = true; + // Removing a focused editable mid-IME-composition wedges Android + // WebView's input dispatch (the whole app stops responding to touch). + // Blur first so the IME detaches cleanly, and hide the soft keyboard + // explicitly on Android before the terminal DOM is torn down. + const active = document.activeElement; + if (active instanceof HTMLElement && container.contains(active)) { + active.blur(); + const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; + if (capacitor?.getPlatform?.() === 'android') { + void import('@capacitor/keyboard') + .then(({ Keyboard }) => Keyboard.hide()) + .catch(() => undefined); + } + } observer?.disconnect(); if (resizeTimeout) clearTimeout(resizeTimeout); if (fitFrame !== null) cancelAnimationFrame(fitFrame); @@ -218,6 +232,36 @@ const TerminalViewport = React.forwardRef(({ return () => cancelAnimationFrame(frame); }, [autoFocus, isVisible, ready, sessionKey]); + React.useEffect(() => { + const container = containerRef.current; + if (!enableTouchScroll || !container) return; + // ghostty-web only reads keydown/composition events and preventDefaults + // beforeinput without consuming it. Android IMEs deliver text via + // beforeinput (their keydown arrives as keyCode 229, which ghostty + // ignores), so forward those payloads to the terminal here. Composition + // updates are skipped: ghostty commits them itself on compositionend. + const handleBeforeInput = (event: Event) => { + const input = event as InputEvent; + if (input.isComposing) return; + switch (input.inputType) { + case 'insertText': + if (input.data) inputRef.current(input.data); + break; + case 'insertLineBreak': + case 'insertParagraph': + inputRef.current('\r'); + break; + case 'deleteContentBackward': + inputRef.current('\x7f'); + break; + default: + break; + } + }; + container.addEventListener('beforeinput', handleBeforeInput); + return () => container.removeEventListener('beforeinput', handleBeforeInput); + }, [enableTouchScroll, ready]); + React.useEffect(() => { const container = containerRef.current; const terminal = terminalRef.current; @@ -231,6 +275,16 @@ const TerminalViewport = React.forwardRef(({ let remainder = 0; let selectionFocus: TerminalCellPosition | null = null; const lineHeight = Math.max(12, fontSize + 2); + // Android WebView only raises the soft keyboard for a native tap-focus; the + // pointer-captured, touch-action:none tap here focuses programmatically, so + // the IME must be summoned explicitly via the Capacitor Keyboard plugin. + const showAndroidSoftKeyboard = () => { + const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor; + if (capacitor?.getPlatform?.() !== 'android') return; + void import('@capacitor/keyboard') + .then(({ Keyboard }) => Keyboard.show()) + .catch(() => undefined); + }; const clearLongPress = () => { if (!longPressTimeout) return; clearTimeout(longPressTimeout); @@ -332,7 +386,10 @@ const TerminalViewport = React.forwardRef(({ pointerId = null; gesture = 'idle'; if (shouldFinishSelection) finishSelection(); - if (shouldFocus) terminal.focus(); + if (shouldFocus) { + terminal.focus(); + showAndroidSoftKeyboard(); + } }; const cancel = (event: PointerEvent) => { if (pointerId !== event.pointerId) return;