fix(ui): add opt-in mobile keyboard resize mode and stabilize touch terminal input (#1107)
* fix(ui): add opt-in mobile keyboard resize mode * fix(ui): stabilize touch terminal input and tab layout * fix(ui): narrow touch terminal handling to mobile and tablet * fix(ui): refine touch terminal input handling * fix(ui): re-key terminal viewport on session id * fix(ui): avoid touch-laptop terminal overlay regression * fix(ui): hide ghostty system caret surfaces * fix(settings): normalize mobile keyboard mode sanitization * fix(ui): honor terminal quick keys toggle on mobile * fix(ui): gate mobile keyboard resize mode on iOS --------- Co-authored-by: vhqtvn <8930337+vhqtvn@users.noreply.github.com> Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
vhqtvn
Bohdan Triapitsyn
parent
63b4a5b996
commit
962ee41bba
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Ghostty, Terminal as GhosttyTerminal, FitAddon } from 'ghostty-web';
|
||||
|
||||
import { isMobileDeviceViaCSS } from '@/lib/device';
|
||||
import type { TerminalTheme } from '@/lib/terminalTheme';
|
||||
import { getGhosttyTerminalOptions } from '@/lib/terminalTheme';
|
||||
import type { TerminalChunk } from '@/stores/useTerminalStore';
|
||||
@@ -77,6 +76,7 @@ interface TerminalViewportProps {
|
||||
className?: string;
|
||||
enableTouchScroll?: boolean;
|
||||
autoFocus?: boolean;
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportProps>(
|
||||
@@ -92,6 +92,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
className,
|
||||
enableTouchScroll,
|
||||
autoFocus = true,
|
||||
isVisible = true,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -117,37 +118,72 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
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 cursorBlinkStateRef = React.useRef<boolean | null>(null);
|
||||
const focusArmedRef = React.useRef(!enableTouchScroll);
|
||||
const previousVisibleRef = React.useRef(isVisible);
|
||||
const [, forceRender] = React.useReducer((x) => x + 1, 0);
|
||||
const [terminalReadyVersion, bumpTerminalReady] = React.useReducer((x) => x + 1, 0);
|
||||
|
||||
inputHandlerRef.current = onInput;
|
||||
resizeHandlerRef.current = onResize;
|
||||
|
||||
const isAndroid = typeof navigator !== 'undefined' && /Android/i.test(navigator.userAgent);
|
||||
const prefersTouchOnlyPointer = typeof window !== 'undefined'
|
||||
&& typeof window.matchMedia === 'function'
|
||||
&& window.matchMedia('(pointer: coarse)').matches
|
||||
&& window.matchMedia('(hover: none)').matches;
|
||||
const useHiddenInputOverlay = Boolean(enableTouchScroll)
|
||||
&& (isAndroid || isMobileDeviceViaCSS() || prefersTouchOnlyPointer);
|
||||
// Touch devices need a dedicated editable surface so special keys like
|
||||
// Backspace and arrows are captured reliably without relying on Ghostty's
|
||||
// internal mobile text handling.
|
||||
const useHiddenInputOverlay = Boolean(enableTouchScroll);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!enableTouchScroll) {
|
||||
focusArmedRef.current = true;
|
||||
previousVisibleRef.current = isVisible;
|
||||
return;
|
||||
}
|
||||
|
||||
const becameVisible = !previousVisibleRef.current && isVisible;
|
||||
if (becameVisible) {
|
||||
focusArmedRef.current = false;
|
||||
}
|
||||
previousVisibleRef.current = isVisible;
|
||||
}, [enableTouchScroll, isVisible]);
|
||||
|
||||
const disableTerminalTextareas = React.useCallback(() => {
|
||||
if (!useHiddenInputOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
const hiddenInput = hiddenInputRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const editableNodes = Array.from(container.querySelectorAll<HTMLElement>('[contenteditable="true"]'));
|
||||
const editableNodes: HTMLElement[] = [];
|
||||
if (container.getAttribute('contenteditable') === 'true') {
|
||||
editableNodes.push(container);
|
||||
}
|
||||
editableNodes.push(...Array.from(container.querySelectorAll<HTMLElement>('[contenteditable="true"]')));
|
||||
editableNodes.forEach((node) => {
|
||||
node.setAttribute('data-terminal-disabled-contenteditable', 'true');
|
||||
node.setAttribute('contenteditable', 'false');
|
||||
node.setAttribute('aria-hidden', 'true');
|
||||
node.tabIndex = -1;
|
||||
node.style.setProperty('caret-color', 'transparent');
|
||||
node.style.color = 'transparent';
|
||||
node.style.setProperty('-webkit-text-fill-color', 'transparent');
|
||||
node.style.background = 'transparent';
|
||||
node.style.outline = 'none';
|
||||
node.style.boxShadow = 'none';
|
||||
node.style.textShadow = 'none';
|
||||
node.style.setProperty('user-select', 'none');
|
||||
node.style.setProperty('-webkit-user-select', 'none');
|
||||
});
|
||||
|
||||
container.tabIndex = -1;
|
||||
container.removeAttribute('role');
|
||||
container.removeAttribute('aria-multiline');
|
||||
|
||||
const textareas = Array.from(container.querySelectorAll('textarea')) as HTMLTextAreaElement[];
|
||||
textareas.forEach((textarea) => {
|
||||
textarea.style.setProperty('caret-color', 'transparent');
|
||||
@@ -161,10 +197,6 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
textarea.style.fontSize = '0';
|
||||
textarea.style.lineHeight = '0';
|
||||
|
||||
if (!useHiddenInputOverlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textarea === hiddenInput) {
|
||||
return;
|
||||
}
|
||||
@@ -706,7 +738,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
if (wasTap) {
|
||||
if (useHiddenInputOverlay) {
|
||||
focusHiddenInput(event.clientX, event.clientY);
|
||||
} else {
|
||||
} else if (enableTouchScroll && !focusArmedRef.current) {
|
||||
focusArmedRef.current = true;
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
@@ -860,7 +893,8 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
const point = event.changedTouches?.[0];
|
||||
if (useHiddenInputOverlay) {
|
||||
focusHiddenInput(point?.clientX, point?.clientY);
|
||||
} else {
|
||||
} else if (enableTouchScroll && !focusArmedRef.current) {
|
||||
focusArmedRef.current = true;
|
||||
terminalRef.current?.focus();
|
||||
}
|
||||
return;
|
||||
@@ -929,6 +963,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
let localTextareaObserver: MutationObserver | null = null;
|
||||
let localDisposables: Array<{ dispose: () => void }> = [];
|
||||
let restorePatchedScrollToBottom: (() => void) | null = null;
|
||||
let restoreContainerFocus: (() => void) | null = null;
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
@@ -937,6 +972,20 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
|
||||
container.tabIndex = useHiddenInputOverlay ? -1 : 0;
|
||||
|
||||
if (useHiddenInputOverlay) {
|
||||
const originalFocus = container.focus.bind(container);
|
||||
const patchedContainer = container as HTMLDivElement & {
|
||||
focus: typeof container.focus;
|
||||
};
|
||||
patchedContainer.focus = ((...args: Parameters<HTMLElement['focus']>) => {
|
||||
void args;
|
||||
focusHiddenInput();
|
||||
}) as typeof container.focus;
|
||||
restoreContainerFocus = () => {
|
||||
patchedContainer.focus = originalFocus as typeof container.focus;
|
||||
};
|
||||
}
|
||||
|
||||
const handleTerminalTextareaFocus = () => {
|
||||
setTerminalCursorBlink(true);
|
||||
};
|
||||
@@ -948,6 +997,20 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
const handleDocumentFocusIn = (event: FocusEvent) => {
|
||||
const target = event.target as Node | null;
|
||||
if (target && container.contains(target)) {
|
||||
if (useHiddenInputOverlay && target instanceof HTMLElement) {
|
||||
window.setTimeout(() => {
|
||||
target.blur();
|
||||
}, 0);
|
||||
setTerminalCursorBlink(false);
|
||||
return;
|
||||
}
|
||||
if (enableTouchScroll && !focusArmedRef.current && target instanceof HTMLElement) {
|
||||
window.setTimeout(() => {
|
||||
target.blur();
|
||||
}, 0);
|
||||
setTerminalCursorBlink(false);
|
||||
return;
|
||||
}
|
||||
setTerminalCursorBlink(true);
|
||||
return;
|
||||
}
|
||||
@@ -972,6 +1035,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
const terminal = new GhosttyTerminal(options);
|
||||
followOutputRef.current = true;
|
||||
|
||||
if (useHiddenInputOverlay) {
|
||||
terminal.focus = () => {};
|
||||
}
|
||||
|
||||
const terminalWithViewport = terminal as unknown as TerminalWithViewport;
|
||||
if (typeof terminalWithViewport.scrollToBottom === 'function') {
|
||||
const originalScrollToBottom = terminalWithViewport.scrollToBottom.bind(terminalWithViewport);
|
||||
@@ -1011,7 +1078,12 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
localTextareaObserver = new MutationObserver(() => {
|
||||
disableTerminalTextareas();
|
||||
});
|
||||
localTextareaObserver.observe(container, { childList: true, subtree: true });
|
||||
localTextareaObserver.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['contenteditable', 'role', 'tabindex', 'aria-label'],
|
||||
});
|
||||
}
|
||||
|
||||
const viewport = findScrollableViewport(container);
|
||||
@@ -1088,6 +1160,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
localResizeObserver?.disconnect();
|
||||
localTextareaObserver?.disconnect();
|
||||
restoreContainerFocus?.();
|
||||
|
||||
localTerminal?.dispose();
|
||||
terminalRef.current = null;
|
||||
@@ -1097,7 +1170,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
cursorBlinkStateRef.current = null;
|
||||
resetWriteState();
|
||||
};
|
||||
}, [disableTerminalTextareas, fitTerminal, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
|
||||
}, [disableTerminalTextareas, enableTouchScroll, fitTerminal, focusHiddenInput, fontFamily, fontSize, setupTouchScroll, theme, resetWriteState, setTerminalCursorBlink, useHiddenInputOverlay]);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -1192,23 +1265,25 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const container = containerRef.current;
|
||||
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);
|
||||
}
|
||||
const isInsideTerminal = Boolean(related && container?.contains(related));
|
||||
|
||||
// Respect explicit focus changes to external editable controls (chat input, settings inputs, etc.)
|
||||
// so terminal focus logic doesn't fight user intent and trigger keyboard show/hide loops.
|
||||
if (isInput && !isHiddenInput && !isInsideTerminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Do not auto-refocus here. In hidden-input mode, forcing focus back can
|
||||
// create a focus ping-pong with Ghostty's internal textbox on mobile.
|
||||
void isInsideTerminal;
|
||||
void isHiddenInput;
|
||||
},
|
||||
[useHiddenInputOverlay, focusHiddenInput]
|
||||
[useHiddenInputOverlay]
|
||||
);
|
||||
|
||||
const handleHiddenBeforeInput = React.useCallback(
|
||||
@@ -1333,6 +1408,26 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
return;
|
||||
}
|
||||
|
||||
const specialKeySequences: Record<string, string> = {
|
||||
ArrowUp: '\u001b[A',
|
||||
ArrowDown: '\u001b[B',
|
||||
ArrowRight: '\u001b[C',
|
||||
ArrowLeft: '\u001b[D',
|
||||
Escape: '\u001b',
|
||||
Tab: '\t',
|
||||
Delete: '\u001b[3~',
|
||||
Home: '\u001b[H',
|
||||
End: '\u001b[F',
|
||||
};
|
||||
|
||||
const specialKeySequence = specialKeySequences[event.key];
|
||||
if (specialKeySequence) {
|
||||
event.preventDefault();
|
||||
inputHandlerRef.current(specialKeySequence);
|
||||
clearEditableValue(event.currentTarget as HTMLElement);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const nativeEvent = event.nativeEvent as KeyboardEvent | undefined;
|
||||
if (nativeEvent?.isComposing) {
|
||||
@@ -1466,6 +1561,7 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn('relative h-full w-full terminal-viewport-container', className)}
|
||||
data-hidden-input-overlay-active={useHiddenInputOverlay ? 'true' : undefined}
|
||||
style={{ backgroundColor: theme.background }}
|
||||
onTouchStart={(event) => {
|
||||
if (!useHiddenInputOverlay || enableTouchScroll) {
|
||||
@@ -1477,10 +1573,10 @@ const TerminalViewport = React.forwardRef<TerminalController, TerminalViewportPr
|
||||
}
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
if (useHiddenInputOverlay) {
|
||||
if (enableTouchScroll) {
|
||||
return;
|
||||
}
|
||||
if (hasCopyableSelectionInViewport()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user