fix(terminal): stabilize Android mobile keyboard input (#325)

* fix(terminal): stabilize android mobile keyboard input

* fix(terminal): add disableStdin option to ghostty config

* refactor(terminal): remove duplicated mobile input handlers
This commit is contained in:
shekohex
2026-02-06 00:39:07 +02:00
committed by GitHub
parent 62b5ddd426
commit b17fb60393
3 changed files with 498 additions and 134 deletions
@@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { createPortal } from 'react-dom';
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web'; import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
import type { TerminalTheme } from '@/lib/terminalTheme'; import type { TerminalTheme } from '@/lib/terminalTheme';
@@ -84,35 +85,83 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null); const viewportDiscoveryTimeoutRef = React.useRef<number | null>(null);
const viewportDiscoveryAttemptsRef = React.useRef(0); const viewportDiscoveryAttemptsRef = React.useRef(0);
const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null); const hiddenInputRef = React.useRef<HTMLTextAreaElement | null>(null);
const textInputRef = React.useRef<HTMLInputElement | null>(null);
const isComposingRef = React.useRef(false);
const ignoreNextInputRef = React.useRef(false);
const lastBeforeInputRef = React.useRef<{ type: string; at: number } | null>(null);
const lastInputEventAtRef = React.useRef<number | null>(null);
const refocusTimeoutRef = React.useRef<number | null>(null);
const keydownProbeTimeoutRef = React.useRef<number | null>(null);
const lastObservedValueRef = React.useRef('');
const [, forceRender] = React.useReducer((x) => x + 1, 0); const [, forceRender] = React.useReducer((x) => x + 1, 0);
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0); const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
inputHandlerRef.current = onInput; inputHandlerRef.current = onInput;
resizeHandlerRef.current = onResize; resizeHandlerRef.current = onResize;
const disableTerminalTextareas = React.useCallback(() => {
if (!enableTouchScroll) {
return;
}
const container = containerRef.current;
const hiddenInput = hiddenInputRef.current;
if (!container) {
return;
}
const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[];
textareas.forEach((textarea) => {
if (textarea === hiddenInput) {
return;
}
if (textarea.getAttribute('data-terminal-disabled-input') === 'true') {
return;
}
textarea.setAttribute('data-terminal-disabled-input', 'true');
textarea.setAttribute('aria-hidden', 'true');
textarea.tabIndex = -1;
textarea.disabled = true;
textarea.style.position = 'absolute';
textarea.style.opacity = '0';
textarea.style.width = '0px';
textarea.style.height = '0px';
textarea.style.pointerEvents = 'none';
textarea.style.zIndex = '-1';
});
}, [enableTouchScroll]);
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
const useTextInput = enableTouchScroll && isAndroid;
const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => { const focusHiddenInput = React.useCallback((clientX?: number, clientY?: number) => {
const input = hiddenInputRef.current; const input = (useTextInput ? textInputRef.current : hiddenInputRef.current) as HTMLElement | null;
const container = containerRef.current; const container = containerRef.current;
if (!input || !container) { if (!input || !container) {
return; return;
} }
// Position the input near the user's tap/cursor so the global keyboard
// avoidance logic can decide whether anything is actually obscured.
const rect = container.getBoundingClientRect(); const rect = container.getBoundingClientRect();
const viewportWidth = typeof window !== 'undefined' ? window.innerWidth : rect.width;
const viewportHeight = typeof window !== 'undefined' ? window.innerHeight : rect.height;
const fallbackX = rect.left + rect.width / 2; const fallbackX = rect.left + rect.width / 2;
const fallbackY = rect.top + rect.height - 12; const fallbackY = rect.top + rect.height - 12;
const x = typeof clientX === 'number' ? clientX : fallbackX; const x = typeof clientX === 'number' ? clientX : fallbackX;
const y = typeof clientY === 'number' ? clientY : fallbackY; const y = typeof clientY === 'number' ? clientY : fallbackY;
const padding = 8; const padding = 8;
const left = Math.max(padding, Math.min(rect.width - padding, x - rect.left)); const left = Math.max(padding, Math.min(viewportWidth - padding, x));
const top = Math.max(padding, Math.min(rect.height - padding, y - rect.top)); const top = Math.max(padding, Math.min(viewportHeight - padding, y));
input.style.left = `${left}px`; input.style.left = `${left}px`;
input.style.top = `${top}px`; input.style.top = `${top}px`;
input.style.bottom = ''; input.style.bottom = '';
if (input instanceof HTMLTextAreaElement || input instanceof HTMLInputElement) {
input.disabled = false;
input.readOnly = false;
input.removeAttribute('disabled');
input.removeAttribute('readonly');
}
try { try {
input.focus({ preventScroll: true }); input.focus({ preventScroll: true });
} catch { } catch {
@@ -120,8 +169,94 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
input.focus(); input.focus();
} catch { /* ignored */ } } catch { /* ignored */ }
} }
}, [useTextInput]);
const readEditableValue = React.useCallback((target: HTMLElement) => {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
return target.value;
}
return target.textContent ?? '';
}, []); }, []);
const clearEditableValue = React.useCallback((target: HTMLElement) => {
if (target instanceof HTMLTextAreaElement || target instanceof HTMLInputElement) {
target.value = '';
return;
}
target.textContent = '';
}, []);
const scheduleKeyProbe = React.useCallback((target: HTMLElement) => {
if (typeof window === 'undefined') {
return;
}
if (useTextInput) {
return;
}
if (keydownProbeTimeoutRef.current !== null) {
window.clearTimeout(keydownProbeTimeoutRef.current);
keydownProbeTimeoutRef.current = null;
}
let attempt = 0;
const maxAttempts = 3;
const runProbe = () => {
keydownProbeTimeoutRef.current = window.setTimeout(() => {
keydownProbeTimeoutRef.current = null;
const value = readEditableValue(target);
if (!value) {
attempt += 1;
if (attempt < maxAttempts) {
runProbe();
return;
}
return;
}
const previous = lastObservedValueRef.current;
lastObservedValueRef.current = value;
const delta = value.startsWith(previous) ? value.slice(previous.length) : value;
if (delta) {
inputHandlerRef.current(delta.replace(/\r\n|\r|\n/g, '\r'));
}
clearEditableValue(target);
lastObservedValueRef.current = '';
}, attempt === 0 ? 0 : 24);
};
runProbe();
}, [clearEditableValue, readEditableValue, useTextInput]);
React.useEffect(() => {
const container = containerRef.current;
if (!enableTouchScroll || !container) {
return;
}
const handleContainerFocusIn = (event: FocusEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
if (target.getAttribute('data-terminal-hidden-input') === 'true') {
return;
}
if (!container.contains(target)) {
return;
}
try {
target.blur();
} catch { /* ignored */ }
focusHiddenInput();
};
container.addEventListener('focusin', handleContainerFocusIn, true);
return () => {
container.removeEventListener('focusin', handleContainerFocusIn, true);
};
}, [enableTouchScroll, focusHiddenInput]);
const copySelectionToClipboard = React.useCallback(async () => { const copySelectionToClipboard = React.useCallback(async () => {
if (typeof window === 'undefined' || typeof document === 'undefined') { if (typeof window === 'undefined' || typeof document === 'undefined') {
return; return;
@@ -134,6 +269,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
if (!text.trim()) { if (!text.trim()) {
return; return;
} }
const container = containerRef.current; const container = containerRef.current;
if (!container) { if (!container) {
return; return;
@@ -147,13 +283,14 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
try { if (navigator.clipboard?.writeText) {
if (navigator.clipboard?.writeText) { const copied = await navigator.clipboard
await navigator.clipboard.writeText(text); .writeText(text)
.then(() => true)
.catch(() => false);
if (copied) {
return; return;
} }
} catch {
// fall through to execCommand
} }
try { try {
@@ -167,7 +304,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(textarea); document.body.removeChild(textarea);
} catch { } catch {
// ignore return;
} }
}, []); }, []);
@@ -657,6 +794,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
let disposed = false; let disposed = false;
let localTerminal: GhosttyTerminal | null = null; let localTerminal: GhosttyTerminal | null = null;
let localResizeObserver: ResizeObserver | null = null; let localResizeObserver: ResizeObserver | null = null;
let localTextareaObserver: MutationObserver | null = null;
let localDisposables: Array<{ dispose: () => void }> = []; let localDisposables: Array<{ dispose: () => void }> = [];
const container = containerRef.current; const container = containerRef.current;
@@ -664,7 +802,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
container.tabIndex = 0; container.tabIndex = enableTouchScroll ? -1 : 0;
const initialize = async () => { const initialize = async () => {
try { try {
@@ -673,7 +811,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
return; return;
} }
const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty); const options = getGhosttyTerminalOptions(fontFamily, fontSize, theme, ghostty, Boolean(enableTouchScroll));
const terminal = new GhosttyTerminal(options); const terminal = new GhosttyTerminal(options);
@@ -687,6 +825,15 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
terminal.open(container); terminal.open(container);
bumpTerminalReady(); bumpTerminalReady();
disableTerminalTextareas();
if (enableTouchScroll && typeof MutationObserver !== 'undefined') {
localTextareaObserver = new MutationObserver(() => {
disableTerminalTextareas();
});
localTextareaObserver.observe(container, { childList: true, subtree: true });
}
const viewport = findScrollableViewport(container); const viewport = findScrollableViewport(container);
if (viewport) { if (viewport) {
viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container'); viewport.classList.add('overlay-scrollbar-target', 'overlay-scrollbar-container');
@@ -698,7 +845,9 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
fitTerminal(); fitTerminal();
setupTouchScroll(); setupTouchScroll();
terminal.focus(); if (!enableTouchScroll) {
terminal.focus();
}
localDisposables = [ localDisposables = [
terminal.onData((data: string) => { terminal.onData((data: string) => {
@@ -730,6 +879,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
localDisposables.forEach((disposable) => disposable.dispose()); localDisposables.forEach((disposable) => disposable.dispose());
localResizeObserver?.disconnect(); localResizeObserver?.disconnect();
localTextareaObserver?.disconnect();
localTerminal?.dispose(); localTerminal?.dispose();
terminalRef.current = null; terminalRef.current = null;
@@ -738,7 +888,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
resetWriteState(); resetWriteState();
}; };
}, [fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]); }, [disableTerminalTextareas, enableTouchScroll, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState]);
React.useEffect(() => { React.useEffect(() => {
@@ -750,8 +900,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
resetWriteState(); resetWriteState();
lastReportedSizeRef.current = null; lastReportedSizeRef.current = null;
fitTerminal(); fitTerminal();
terminal.focus(); if (!enableTouchScroll) {
}, [sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]); terminal.focus();
}
}, [enableTouchScroll, sessionKey, terminalReadyVersion, fitTerminal, resetWriteState]);
React.useEffect(() => { React.useEffect(() => {
setupTouchScroll(); setupTouchScroll();
@@ -819,6 +971,266 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
[enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState] [enableTouchScroll, focusHiddenInput, fitTerminal, resetWriteState]
); );
const handleHiddenInputBlur = React.useCallback(
(event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
if (!enableTouchScroll) {
return;
}
const related = event.relatedTarget as HTMLElement | null;
const relatedTag = related?.tagName;
const isInput = relatedTag === 'INPUT' || relatedTag === 'TEXTAREA' || related?.isContentEditable;
const isHiddenInput = related?.getAttribute('data-terminal-hidden-input') === 'true';
if (isInput && !isHiddenInput) {
if (refocusTimeoutRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(refocusTimeoutRef.current);
}
if (typeof window !== 'undefined') {
refocusTimeoutRef.current = window.setTimeout(() => {
refocusTimeoutRef.current = null;
focusHiddenInput();
}, 0);
}
}
},
[enableTouchScroll, focusHiddenInput]
);
const handleHiddenBeforeInput = React.useCallback(
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const nativeEvent = event.nativeEvent as InputEvent | undefined;
const inputType = nativeEvent?.inputType ?? '';
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : '';
lastInputEventAtRef.current = typeof performance !== 'undefined'
? performance.now()
: Date.now();
if (inputType === 'insertCompositionText') {
isComposingRef.current = true;
return;
}
if (!inputType && data) {
if (isComposingRef.current) {
return;
}
event.preventDefault();
inputHandlerRef.current(data);
lastBeforeInputRef.current = {
type: 'insertText',
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
};
ignoreNextInputRef.current = true;
return;
}
if (inputType === 'insertText' && data) {
if (isComposingRef.current) {
return;
}
event.preventDefault();
inputHandlerRef.current(data);
lastBeforeInputRef.current = {
type: inputType,
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
};
ignoreNextInputRef.current = true;
return;
}
if (inputType === 'insertLineBreak') {
if (isComposingRef.current) {
return;
}
event.preventDefault();
inputHandlerRef.current('\r');
lastBeforeInputRef.current = {
type: inputType,
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
};
ignoreNextInputRef.current = true;
return;
}
if (inputType === 'deleteContentBackward') {
if (isComposingRef.current) {
return;
}
event.preventDefault();
inputHandlerRef.current('\x7f');
lastBeforeInputRef.current = {
type: inputType,
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
};
ignoreNextInputRef.current = true;
}
},
[]
);
const handleHiddenInput = React.useCallback(
(event: React.FormEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const target = event.currentTarget as HTMLElement;
lastInputEventAtRef.current = typeof performance !== 'undefined'
? performance.now()
: Date.now();
if (isComposingRef.current) {
return;
}
if (ignoreNextInputRef.current) {
const lastBeforeInput = lastBeforeInputRef.current;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
if (lastBeforeInput && now - lastBeforeInput.at < 50) {
ignoreNextInputRef.current = false;
clearEditableValue(target);
return;
}
ignoreNextInputRef.current = false;
}
const raw = readEditableValue(target);
if (!raw) {
return;
}
lastObservedValueRef.current = raw;
const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
clearEditableValue(target);
lastObservedValueRef.current = '';
},
[clearEditableValue, readEditableValue]
);
const handleHiddenKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
event.stopPropagation();
const target = event.currentTarget as HTMLElement;
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
if (nativeEvent?.isComposing) {
return;
}
const lastBeforeInput = lastBeforeInputRef.current;
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
const recent = Boolean(lastBeforeInput && now - lastBeforeInput.at < 50);
if (event.key === 'Enter') {
if (recent && lastBeforeInput?.type === 'insertLineBreak') {
return;
}
event.preventDefault();
inputHandlerRef.current('\r');
clearEditableValue(target);
return;
}
if (event.key === 'Backspace') {
event.preventDefault();
if (recent && lastBeforeInput?.type === 'deleteContentBackward') {
return;
}
if (!readEditableValue(target)) {
inputHandlerRef.current('\x7f');
}
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
const lastInputAt = lastInputEventAtRef.current;
const sawInputRecently = Boolean(lastInputAt && now - lastInputAt < 50);
if (!sawInputRecently) {
event.preventDefault();
inputHandlerRef.current(event.key);
ignoreNextInputRef.current = true;
lastBeforeInputRef.current = {
type: 'keydown-text',
at: now,
};
}
}
scheduleKeyProbe(target);
},
[clearEditableValue, readEditableValue, scheduleKeyProbe]
);
const handleHiddenKeyUp = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
event.stopPropagation();
const target = event.currentTarget as HTMLElement;
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
if (nativeEvent?.isComposing) {
return;
}
scheduleKeyProbe(target);
},
[scheduleKeyProbe]
);
const handleHiddenCompositionEnd = React.useCallback(
(event: React.CompositionEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const target = event.currentTarget as HTMLElement;
isComposingRef.current = false;
const data = event.data || readEditableValue(target);
lastInputEventAtRef.current = typeof performance !== 'undefined'
? performance.now()
: Date.now();
if (!data) {
return;
}
const value = data.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
clearEditableValue(target);
lastBeforeInputRef.current = {
type: 'compositionend',
at: typeof performance !== 'undefined' ? performance.now() : Date.now(),
};
ignoreNextInputRef.current = true;
},
[clearEditableValue, readEditableValue]
);
const handleHiddenPaste = React.useCallback(
(event: React.ClipboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
event.stopPropagation();
const text = event.clipboardData?.getData('text') ?? '';
if (!text) {
return;
}
event.preventDefault();
const terminal = terminalRef.current;
const payload = terminal?.hasBracketedPaste?.()
? `\x1b[200~${text}\x1b[201~`
: text;
inputHandlerRef.current(payload);
},
[]
);
const hiddenInputStyle: React.CSSProperties = {
position: 'fixed',
left: 0,
top: 0,
width: 1,
height: 1,
opacity: 0,
zIndex: -1,
background: 'transparent',
color: 'transparent',
caretColor: 'transparent',
resize: 'none',
overflow: 'hidden',
whiteSpace: 'nowrap',
border: '0',
padding: 0,
margin: 0,
outline: 'none',
outlineOffset: 0,
fontSize: 16,
fontWeight: 400,
pointerEvents: 'none',
WebkitUserSelect: 'none',
userSelect: 'none',
};
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@@ -838,121 +1250,76 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
} }
}} }}
onMouseUp={() => { onMouseUp={() => {
void copySelectionToClipboard(); if (!enableTouchScroll) {
void copySelectionToClipboard();
}
}} }}
onTouchEnd={() => { onTouchEnd={() => {
void copySelectionToClipboard(); if (!enableTouchScroll) {
void copySelectionToClipboard();
}
}} }}
> >
{enableTouchScroll ? ( {enableTouchScroll && typeof document !== 'undefined'
<textarea ? createPortal(
ref={hiddenInputRef} <>
inputMode="text" <input
autoCapitalize="off" ref={textInputRef}
autoComplete="off" type="text"
autoCorrect="off" inputMode="text"
spellCheck={false} autoCapitalize="off"
tabIndex={-1} autoComplete="off"
enterKeyHint="send" autoCorrect="off"
style={{ spellCheck={false}
position: 'absolute', tabIndex={0}
left: 0, enterKeyHint="send"
top: 0, data-terminal-hidden-input="true"
// Android IME needs visible, larger dimensions to properly attach placeholder="Terminal input"
width: 30, style={{
height: 30, ...hiddenInputStyle,
// Must be slightly visible for Android IME - clip to hide visually display: useTextInput ? 'block' : 'none',
opacity: 0.011, }}
zIndex: 10, onBlur={handleHiddenInputBlur}
background: 'transparent', onBeforeInput={handleHiddenBeforeInput}
color: 'transparent', onCompositionStart={() => {
caretColor: 'transparent', isComposingRef.current = true;
resize: 'none', }}
overflow: 'hidden', onInput={handleHiddenInput}
whiteSpace: 'nowrap', onKeyDown={handleHiddenKeyDown}
border: 'none', onKeyUp={handleHiddenKeyUp}
padding: 0, onCompositionEnd={handleHiddenCompositionEnd}
margin: 0, onPaste={handleHiddenPaste}
outline: 'none', />
// Prevent iOS zoom on focus - must be 16px+ <textarea
fontSize: 16, ref={hiddenInputRef}
// Ensure pointer events work inputMode="text"
pointerEvents: 'auto', autoCapitalize="off"
// Android needs these for IME to work properly autoComplete="off"
WebkitUserSelect: 'text', autoCorrect="off"
userSelect: 'text', spellCheck={false}
// Transform off-screen but keep focusable tabIndex={0}
transform: 'translateX(-9999px)', enterKeyHint="send"
transformOrigin: 'left top', data-terminal-hidden-input="true"
}} placeholder="Terminal input"
onFocus={(event) => { style={{
// Move back on screen when focused (for Android IME) ...hiddenInputStyle,
event.currentTarget.style.transform = 'translateX(0)'; display: useTextInput ? 'none' : 'block',
}} }}
onBlur={(event) => { onBlur={handleHiddenInputBlur}
// Move off screen when blurred onBeforeInput={handleHiddenBeforeInput}
event.currentTarget.style.transform = 'translateX(-9999px)'; onCompositionStart={() => {
}} isComposingRef.current = true;
onBeforeInput={(event) => { }}
const nativeEvent = event.nativeEvent as unknown as InputEvent | undefined; onInput={handleHiddenInput}
const inputType = nativeEvent?.inputType ?? ''; onKeyDown={handleHiddenKeyDown}
const data = typeof nativeEvent?.data === 'string' ? nativeEvent.data : ''; onKeyUp={handleHiddenKeyUp}
onCompositionEnd={handleHiddenCompositionEnd}
// Handle insertText (iOS) and insertCompositionText (Android Gboard) onPaste={handleHiddenPaste}
if ((inputType === 'insertText' || inputType === 'insertCompositionText') && data) { />
event.preventDefault(); </>,
inputHandlerRef.current(data); document.body
return; )
} : null}
if (inputType === 'insertLineBreak') {
event.preventDefault();
inputHandlerRef.current('\r');
return;
}
if (inputType === 'deleteContentBackward') {
event.preventDefault();
inputHandlerRef.current('\x7f');
}
}}
onInput={(event) => {
// Fallback: capture any text that makes it through to the input value
const raw = String(event.currentTarget.value || '');
if (!raw) {
return;
}
// Some mobile keyboards insert `\n` for Enter; PTY expects CR.
const value = raw.replace(/\r\n|\r|\n/g, '\r');
inputHandlerRef.current(value);
event.currentTarget.value = '';
}}
onKeyDown={(event) => {
// Handle Enter key explicitly for Android
if (event.key === 'Enter') {
event.preventDefault();
inputHandlerRef.current('\r');
event.currentTarget.value = '';
return;
}
if (event.key === 'Backspace') {
// If there's nothing in the input buffer, emulate DEL.
if (!event.currentTarget.value) {
inputHandlerRef.current('\x7f');
}
}
}}
onCompositionEnd={(event) => {
// Android IME sends final text via composition events
const data = event.data;
if (data) {
inputHandlerRef.current(data);
event.currentTarget.value = '';
}
}}
/>
) : null}
{viewportRef.current && !enableTouchScroll ? ( {viewportRef.current && !enableTouchScroll ? (
<OverlayScrollbar <OverlayScrollbar
containerRef={viewportRef} containerRef={viewportRef}
@@ -237,11 +237,6 @@ export const TerminalView: React.FC = () => {
switch (event.type) { switch (event.type) {
case 'connected': { case 'connected': {
if (event.runtime || event.ptyBackend) {
console.log(
`[Terminal] connected runtime=${event.runtime ?? 'unknown'} pty=${event.ptyBackend ?? 'unknown'}`
);
}
setConnecting(directory, tabId, false); setConnecting(directory, tabId, false);
setConnectionError(null); setConnectionError(null);
setIsFatalError(false); setIsFatalError(false);
+3 -1
View File
@@ -98,7 +98,8 @@ export function getGhosttyTerminalOptions(
fontFamily: string, fontFamily: string,
fontSize: number, fontSize: number,
theme: TerminalTheme, theme: TerminalTheme,
ghostty: Ghostty ghostty: Ghostty,
disableStdin = false
) { ) {
const powerlineFallbacks = const powerlineFallbacks =
'"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace'; '"JetBrainsMonoNL Nerd Font", "FiraCode Nerd Font", "Cascadia Code PL", "Fira Code", "JetBrains Mono", "SFMono-Regular", Menlo, Consolas, "Liberation Mono", "Courier New", monospace';
@@ -136,5 +137,6 @@ export function getGhosttyTerminalOptions(
}, },
scrollback: 50_000, scrollback: 50_000,
ghostty, ghostty,
disableStdin,
}; };
} }