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:
vhqtvn
2026-05-05 12:26:34 +03:00
committed by GitHub
co-authored by vhqtvn Bohdan Triapitsyn
parent 63b4a5b996
commit 962ee41bba
25 changed files with 604 additions and 128 deletions
@@ -91,11 +91,13 @@ export const TerminalView: React.FC = () => {
const terminalFontSize = useUIStore(state => state.terminalFontSize);
const bottomTerminalHeight = useUIStore((state) => state.bottomTerminalHeight);
const isBottomTerminalExpanded = useUIStore((state) => state.isBottomTerminalExpanded);
const { isMobile, hasTouchInput } = useDeviceInfo();
const { isMobile, isTablet, hasTouchOnlyPointer } = useDeviceInfo();
const isTouchTerminal = isMobile || isTablet;
const useTouchTerminalInput = (isTouchTerminal || hasTouchOnlyPointer) && runtime.platform === 'web';
// Tabs are supported for web + desktop runtimes, including mobile (not VSCode).
const enableTabs = runtime.platform !== 'vscode';
const showTerminalQuickKeysOnDesktop = useUIStore((state) => state.showTerminalQuickKeysOnDesktop);
const showQuickKeys = isMobile || showTerminalQuickKeysOnDesktop;
const showQuickKeys = isTouchTerminal || showTerminalQuickKeysOnDesktop;
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -176,11 +178,21 @@ export const TerminalView: React.FC = () => {
const rehydratedSnapshotTakenRef = React.useRef(false);
const focusTerminalWhenWindowActive = React.useCallback(() => {
if (useTouchTerminalInput) {
return;
}
if (typeof document !== 'undefined' && !document.hasFocus()) {
return;
}
terminalControllerRef.current?.focus();
}, []);
}, [useTouchTerminalInput]);
const focusTerminalController = React.useCallback(() => {
if (useTouchTerminalInput) {
return;
}
terminalControllerRef.current?.focus();
}, [useTouchTerminalInput]);
React.useEffect(() => {
if (!terminalHydrated) {
@@ -546,7 +558,7 @@ export const TerminalView: React.FC = () => {
]);
React.useEffect(() => {
if (!isTerminalVisible) {
if (!isTerminalVisible || useTouchTerminalInput) {
return;
}
@@ -562,7 +574,7 @@ export const TerminalView: React.FC = () => {
return () => {
window.cancelAnimationFrame(rafId);
};
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible]);
}, [activeTabId, focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput]);
const handleRestart = React.useCallback(async () => {
if (!effectiveDirectory) return;
@@ -675,10 +687,10 @@ export const TerminalView: React.FC = () => {
if (modifierConsumed) {
setActiveModifier(null);
terminalControllerRef.current?.focus();
focusTerminalController();
}
},
[activeModifier, isReconnectPending, setActiveModifier, t, terminal]
[activeModifier, focusTerminalController, isReconnectPending, setActiveModifier, t, terminal]
);
const handleViewportResize = React.useCallback(
@@ -699,9 +711,9 @@ export const TerminalView: React.FC = () => {
const handleModifierToggle = React.useCallback(
(modifier: Modifier) => {
setActiveModifier((current) => (current === modifier ? null : modifier));
terminalControllerRef.current?.focus();
focusTerminalController();
},
[setActiveModifier]
[focusTerminalController, setActiveModifier]
);
const handleMobileKeyPress = React.useCallback(
@@ -712,9 +724,9 @@ export const TerminalView: React.FC = () => {
}
handleViewportInput(sequence);
setActiveModifier(null);
terminalControllerRef.current?.focus();
focusTerminalController();
},
[activeModifier, handleViewportInput, setActiveModifier]
[activeModifier, focusTerminalController, handleViewportInput, setActiveModifier]
);
React.useEffect(() => {
@@ -778,7 +790,7 @@ export const TerminalView: React.FC = () => {
event.stopPropagation();
handleViewportInput(controlCode);
setActiveModifier(null);
terminalControllerRef.current?.focus();
focusTerminalController();
}
}
};
@@ -791,6 +803,7 @@ export const TerminalView: React.FC = () => {
activeModifier,
handleMobileKeyPress,
handleViewportInput,
focusTerminalController,
showQuickKeys,
setActiveModifier,
terminalSessionId,
@@ -817,20 +830,19 @@ export const TerminalView: React.FC = () => {
const xtermTheme = React.useMemo(() => convertThemeToXterm(currentTheme), [currentTheme]);
const terminalSessionKey = React.useMemo(() => {
const terminalViewportKey = React.useMemo(() => {
const directoryPart = effectiveDirectory ?? 'no-dir';
const tabPart = activeTabId ?? 'no-tab';
const terminalPart = terminalSessionId ?? `pending-${tabPart}`;
const terminalPart = terminalSessionId ?? 'no-terminal';
return `${directoryPart}::${tabPart}::${terminalPart}`;
}, [effectiveDirectory, activeTabId, terminalSessionId]);
const viewportSessionKey = React.useMemo(() => {
const base = terminalSessionId ?? terminalSessionKey;
return `${base}::layout-${viewportLayoutVersion}`;
}, [terminalSessionId, terminalSessionKey, viewportLayoutVersion]);
return `${terminalViewportKey}::layout-${viewportLayoutVersion}`;
}, [terminalViewportKey, viewportLayoutVersion]);
React.useEffect(() => {
if (isMobile || !isBottomTerminalOpen || !isTerminalVisible) {
if (useTouchTerminalInput || !isBottomTerminalOpen || !isTerminalVisible) {
return;
}
@@ -846,10 +858,10 @@ export const TerminalView: React.FC = () => {
return () => {
window.clearTimeout(timeoutId);
};
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isTerminalVisible, useTouchTerminalInput]);
React.useEffect(() => {
if (!isTerminalVisible) {
if (!isTerminalVisible || useTouchTerminalInput) {
return;
}
const controller = terminalControllerRef.current;
@@ -871,10 +883,10 @@ export const TerminalView: React.FC = () => {
};
}
fitOnce();
}, [focusTerminalWhenWindowActive, isTerminalVisible, terminalSessionKey, terminalSessionId]);
}, [focusTerminalWhenWindowActive, isTerminalVisible, useTouchTerminalInput, terminalViewportKey, terminalSessionId]);
React.useEffect(() => {
if (isMobile || !isTerminalVisible || !isBottomTerminalOpen) {
if (useTouchTerminalInput || !isTerminalVisible || !isBottomTerminalOpen) {
return;
}
@@ -899,7 +911,7 @@ export const TerminalView: React.FC = () => {
}
fitOnce();
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isMobile, isTerminalVisible]);
}, [bottomTerminalHeight, isBottomTerminalExpanded, isBottomTerminalOpen, isTerminalVisible, useTouchTerminalInput]);
if (!hasActiveContext) {
return (
@@ -924,8 +936,8 @@ export const TerminalView: React.FC = () => {
}
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
const showBottomDockControls = !isMobile && isBottomTerminalOpen && !isTerminalActive;
const shouldRenderViewport = hasOpenedTerminalViewport;
const showBottomDockControls = !isTouchTerminal && isBottomTerminalOpen && !isTerminalActive;
const quickKeysControls = (
<>
<Button
@@ -1033,10 +1045,10 @@ export const TerminalView: React.FC = () => {
return (
<div className="flex h-full flex-col overflow-hidden bg-[var(--surface-background)]">
<div className={cn('app-region-no-drag sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isMobile ? 'pl-3 pr-1.5 py-1' : 'pl-3 pr-1.5 py-1')}>
<div className={cn('app-region-no-drag sticky top-0 z-20 shrink-0 bg-[var(--surface-background)] text-xs', isTouchTerminal ? 'px-3 py-1.5' : 'pl-3 pr-1.5 py-1')}>
{enableTabs && directoryTerminalState ? (
<div className="flex items-center gap-2 pl-1 pr-1">
<div className={cn('min-w-0 flex-1', isMobile ? 'h-8' : 'h-7')}>
<div className={cn('min-w-0 flex-1', isTouchTerminal ? 'h-8' : 'h-7')}>
<SortableTabsStrip
items={terminalTabItems}
activeId={activeTabId}
@@ -1052,11 +1064,11 @@ export const TerminalView: React.FC = () => {
type="button"
size="xs"
variant="ghost"
className={cn('shrink-0', isMobile ? 'h-8 w-8 p-0' : 'h-7 w-7 p-0')}
className={cn('shrink-0', isTouchTerminal ? 'h-8 w-8 p-0' : 'h-7 w-7 p-0')}
onClick={handleCreateTab}
title={t('terminalView.tabs.newTabTitle')}
>
<RiAddLine size={isMobile ? 18 : 16} />
<RiAddLine size={isTouchTerminal ? 18 : 16} />
</Button>
<div className="flex shrink-0 items-center gap-1 overflow-visible">
@@ -1126,7 +1138,6 @@ export const TerminalView: React.FC = () => {
<div className="h-full w-full box-border pl-4 pr-1.5 pt-3 pb-4">
{shouldRenderViewport ? (
<TerminalViewport
key={viewportSessionKey}
ref={(controller) => {
terminalControllerRef.current = controller;
}}
@@ -1137,8 +1148,9 @@ export const TerminalView: React.FC = () => {
theme={xtermTheme}
fontFamily={resolvedFontStack}
fontSize={terminalFontSize}
enableTouchScroll={hasTouchInput}
autoFocus={isTerminalVisible}
enableTouchScroll={useTouchTerminalInput}
autoFocus={!useTouchTerminalInput && isTerminalVisible}
isVisible={isTerminalVisible}
/>
) : null}
</div>