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
@@ -83,7 +83,7 @@ export const MainLayout: React.FC = () => {
const setMultiRunLauncherOpen = useUIStore((state) => state.setMultiRunLauncherOpen);
const multiRunLauncherPrefillPrompt = useUIStore((state) => state.multiRunLauncherPrefillPrompt);
const { isMobile } = useDeviceInfo();
const { isMobile, isTablet } = useDeviceInfo();
const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []);
const sidebarWidth = useUIStore((state) => state.sidebarWidth);
const rightSidebarWidth = useUIStore((state) => state.rightSidebarWidth);
@@ -289,19 +289,24 @@ export const MainLayout: React.FC = () => {
rightSidebarAutoClosedRef.current = false;
}
const shouldCloseBottomTerminal =
height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT;
const canAutoOpenBottomTerminal =
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
// Touch devices frequently resize when the on-screen keyboard opens.
// Treat bottom-terminal auto-collapse/restore as desktop-only so
// keyboard viewport changes do not churn terminal layout state.
if (!isMobile && !isTablet) {
const shouldCloseBottomTerminal =
height < BOTTOM_TERMINAL_AUTO_CLOSE_HEIGHT;
const canAutoOpenBottomTerminal =
height >= BOTTOM_TERMINAL_AUTO_OPEN_HEIGHT;
if (shouldCloseBottomTerminal) {
if (state.isBottomTerminalOpen) {
setBottomTerminalOpen(false);
bottomTerminalAutoClosedRef.current = true;
if (shouldCloseBottomTerminal) {
if (state.isBottomTerminalOpen) {
setBottomTerminalOpen(false);
bottomTerminalAutoClosedRef.current = true;
}
} else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) {
setBottomTerminalOpen(true);
bottomTerminalAutoClosedRef.current = false;
}
} else if (canAutoOpenBottomTerminal && bottomTerminalAutoClosedRef.current) {
setBottomTerminalOpen(true);
bottomTerminalAutoClosedRef.current = false;
}
};
@@ -324,7 +329,7 @@ export const MainLayout: React.FC = () => {
window.clearTimeout(timeoutId);
}
};
}, [setBottomTerminalOpen, setRightSidebarOpen]);
}, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]);
React.useEffect(() => {
if (typeof window === 'undefined') {
@@ -351,7 +356,7 @@ export const MainLayout: React.FC = () => {
return () => {
unsubscribe();
};
}, []);
}, [isMobile, isTablet, setBottomTerminalOpen, setRightSidebarOpen]);
const secondaryView = React.useMemo(() => {
switch (activeMainTab) {
@@ -115,6 +115,7 @@ const VisualSectionContent: React.FC = () => {
'theme',
'pwaInstallName',
'pwaOrientation',
'mobileKeyboardMode',
'timeFormat',
'weekStart',
'fontSize',
@@ -26,6 +26,7 @@ import { updateDesktopSettings } from '@/lib/persistence';
import { CODE_FONT_OPTIONS, DEFAULT_MONO_FONT, DEFAULT_UI_FONT, UI_FONT_OPTIONS, type MonoFontOption, type UiFontOption } from '@/lib/fontOptions';
import { useI18n, type Locale } from '@/lib/i18n';
import { useConfigStore } from '@/stores/useConfigStore';
import { normalizeMobileKeyboardMode, supportsMobileKeyboardResizeContent, type MobileKeyboardMode } from '@/lib/mobileKeyboardMode';
import {
setDirectoryShowHidden,
useDirectoryShowHidden,
@@ -115,6 +116,19 @@ const PWA_ORIENTATION_OPTIONS: Option<'system' | 'portrait' | 'landscape'>[] = [
},
];
const MOBILE_KEYBOARD_MODE_OPTIONS: Option<MobileKeyboardMode>[] = [
{
id: 'native',
labelKey: 'settings.openchamber.visual.option.mobileKeyboardMode.native.label',
descriptionKey: 'settings.openchamber.visual.option.mobileKeyboardMode.native.description',
},
{
id: 'resize-content',
labelKey: 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.label',
descriptionKey: 'settings.openchamber.visual.option.mobileKeyboardMode.resizeContent.description',
},
];
type PwaInstallNameWindow = Window & {
__OPENCHAMBER_SET_PWA_INSTALL_NAME__?: (value: string) => string;
__OPENCHAMBER_SET_PWA_ORIENTATION__?: (value: 'system' | 'portrait' | 'landscape') => 'system' | 'portrait' | 'landscape';
@@ -220,7 +234,7 @@ const normalizeUserMessageRenderingMode = (mode: unknown): 'markdown' | 'plain'
return mode === 'markdown' ? 'markdown' : 'plain';
};
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
export type VisibleSetting = 'theme' | 'pwaInstallName' | 'pwaOrientation' | 'mobileKeyboardMode' | 'timeFormat' | 'weekStart' | 'fontSize' | 'terminalFontSize' | 'spacing' | 'inputBarOffset' | 'mermaidRendering' | 'userMessageRendering' | 'chatRenderMode' | 'messageTransport' | 'activityRenderMode' | 'stickyUserHeader' | 'wideChatLayout' | 'splitAssistantMessageActions' | 'diffLayout' | 'mobileStatusBar' | 'dotfiles' | 'reasoning' | 'showToolFileIcons' | 'expandedTools' | 'queueMode' | 'terminalQuickKeys' | 'persistDraft' | 'inputSpellcheck' | 'reportUsage';
interface OpenChamberVisualSettingsProps {
/** Which settings to show. If undefined, shows all. */
@@ -260,6 +274,8 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const setPadding = useUIStore(state => state.setPadding);
const inputBarOffset = useUIStore(state => state.inputBarOffset);
const setInputBarOffset = useUIStore(state => state.setInputBarOffset);
const mobileKeyboardMode = useUIStore(state => state.mobileKeyboardMode);
const setMobileKeyboardMode = useUIStore(state => state.setMobileKeyboardMode);
const diffLayoutPreference = useUIStore(state => state.diffLayoutPreference);
const setDiffLayoutPreference = useUIStore(state => state.setDiffLayoutPreference);
const diffViewMode = useUIStore(state => state.diffViewMode);
@@ -490,6 +506,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const showPwaInstallNameSetting = shouldShow('pwaInstallName') && isWebRuntime() && browserTab && !isDesktopShell() && !isVSCode;
const showPwaOrientationSetting = shouldShow('pwaOrientation') && isWebRuntime() && !isDesktopShell() && !isVSCode;
const showMobileKeyboardModeSetting = shouldShow('mobileKeyboardMode') && isWebRuntime() && !isDesktopShell() && !isVSCode && supportsMobileKeyboardResizeContent();
const [pwaInstallName, setPwaInstallName] = React.useState('');
const [pwaOrientation, setPwaOrientation] = React.useState<'system' | 'portrait' | 'landscape'>('system');
const selectedTimeFormatLabel = React.useMemo(() => {
@@ -504,6 +521,10 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const option = PWA_ORIENTATION_OPTIONS.find((item) => item.id === pwaOrientation);
return option ? tUnsafe(option.labelKey) : undefined;
}, [pwaOrientation, tUnsafe]);
const selectedMobileKeyboardModeLabel = React.useMemo(() => {
const option = MOBILE_KEYBOARD_MODE_OPTIONS.find((item) => item.id === mobileKeyboardMode);
return option ? tUnsafe(option.labelKey) : undefined;
}, [mobileKeyboardMode, tUnsafe]);
const applyPwaInstallName = React.useCallback(async (value: string) => {
if (typeof window === 'undefined') {
@@ -547,7 +568,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
}, []);
React.useEffect(() => {
if (typeof window === 'undefined' || (!showPwaInstallNameSetting && !showPwaOrientationSetting)) {
if (typeof window === 'undefined' || (!showPwaInstallNameSetting && !showPwaOrientationSetting && !showMobileKeyboardModeSetting)) {
return;
}
@@ -572,6 +593,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
const raw = typeof settings?.pwaAppName === 'string' ? settings.pwaAppName : '';
const normalized = raw.trim().replace(/\s+/g, ' ').slice(0, 64);
const orientation = normalizePwaOrientation(settings?.pwaOrientation);
const nextMobileKeyboardMode = normalizeMobileKeyboardMode(settings?.mobileKeyboardMode);
if (!cancelled) {
if (showPwaInstallNameSetting) {
@@ -580,6 +602,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
if (showPwaOrientationSetting) {
setPwaOrientation(orientation);
}
if (showMobileKeyboardModeSetting) {
setMobileKeyboardMode(nextMobileKeyboardMode);
}
}
} catch {
if (!cancelled) {
@@ -589,6 +614,9 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
if (showPwaOrientationSetting) {
setPwaOrientation('system');
}
if (showMobileKeyboardModeSetting) {
setMobileKeyboardMode('native');
}
}
}
};
@@ -598,7 +626,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
return () => {
cancelled = true;
};
}, [showPwaInstallNameSetting, showPwaOrientationSetting]);
}, [setMobileKeyboardMode, showMobileKeyboardModeSetting, showPwaInstallNameSetting, showPwaOrientationSetting]);
return (
<div className="space-y-8">
@@ -768,7 +796,7 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</section>
)}
{(showPwaInstallNameSetting || showPwaOrientationSetting) && (
{(showPwaInstallNameSetting || showPwaOrientationSetting || showMobileKeyboardModeSetting) && (
<section className="px-2 pb-2 pt-0 space-y-2">
{showPwaInstallNameSetting && (
@@ -858,6 +886,52 @@ export const OpenChamberVisualSettings: React.FC<OpenChamberVisualSettingsProps>
</div>
</div>
)}
{showMobileKeyboardModeSetting && (
<div className="py-1.5 space-y-1.5">
<div className="flex min-w-0 flex-col">
<span className="typography-ui-label text-foreground">{t('settings.openchamber.visual.field.mobileKeyboardMode')}</span>
<span className="typography-meta text-muted-foreground">{t('settings.openchamber.visual.field.mobileKeyboardModeHint')}</span>
</div>
<div className="flex w-full max-w-[18rem] items-center gap-2">
<Select
value={mobileKeyboardMode}
onValueChange={(value) => {
const mode = normalizeMobileKeyboardMode(value);
setMobileKeyboardMode(mode);
void updateDesktopSettings({ mobileKeyboardMode: mode });
}}
>
<SelectTrigger aria-label={t('settings.openchamber.visual.field.mobileKeyboardModeAria')} className="w-full">
<SelectValue placeholder={t('settings.openchamber.visual.field.selectMobileKeyboardModePlaceholder')}>
{selectedMobileKeyboardModeLabel}
</SelectValue>
</SelectTrigger>
<SelectContent>
{MOBILE_KEYBOARD_MODE_OPTIONS.map((option) => (
<SelectItem key={option.id} value={option.id}>
{tUnsafe(option.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button size="sm"
type="button"
variant="ghost"
onClick={() => {
setMobileKeyboardMode('native');
void updateDesktopSettings({ mobileKeyboardMode: 'native' });
}}
disabled={mobileKeyboardMode === 'native'}
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
aria-label={t('settings.openchamber.visual.actions.resetMobileKeyboardModeAria')}
title={t('settings.common.actions.reset')}
>
<RiRestartLine className="h-3.5 w-3.5" />
</Button>
</div>
</div>
)}
</section>
)}
</div>
@@ -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;
}
@@ -382,7 +382,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
<div
ref={(element) => setTabRef(item.id, element)}
className={cn(
'group flex h-full items-center',
'group flex h-full min-w-0 flex-nowrap items-center',
(isScrollable || useIntrinsicPillSizing)
? 'shrink-0'
: usesActivePillIndicator
@@ -403,8 +403,8 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
onClick={() => onSelect(item.id)}
className={cn(
usesActivePillIndicator
? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 items-center justify-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] text-sm font-medium transition-colors duration-150 !min-h-0'
: 'flex h-full min-w-0 items-center typography-micro',
? 'animated-tabs__button pill-tabs__button relative z-10 flex flex-1 min-w-0 flex-nowrap items-center justify-center rounded-[9px] [corner-shape:squircle] supports-[corner-shape:squircle]:rounded-[50px] text-sm font-medium transition-colors duration-150 !min-h-0'
: 'flex h-full min-w-0 flex-nowrap items-center typography-micro',
usesActivePillIndicator && activePillLowercase ? 'lowercase' : null,
usesActivePillIndicator && (showInactiveIconOnly ? 'gap-0' : 'gap-1.5'),
usesActivePillIndicator
@@ -443,7 +443,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
<span
role="button"
tabIndex={-1}
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
className={cn('absolute inset-0 z-20 flex !min-h-0 !min-w-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
onPointerDown={(event) => {
event.stopPropagation();
}}
@@ -462,7 +462,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
{shouldShowLabel ? <span className="animated-tabs__label truncate">{item.label}</span> : null}
</>
) : (
<span className={cn('flex min-w-0 items-center gap-1.5', !isScrollable && 'justify-center')}>
<span className={cn('flex min-w-0 flex-nowrap items-center gap-1.5', !isScrollable && 'justify-center')}>
{item.icon ? (
<span
className={cn(
@@ -475,7 +475,7 @@ export const SortableTabsStrip: React.FC<SortableTabsStripProps> = ({
<span
role="button"
tabIndex={-1}
className={cn('absolute inset-0 z-20 flex items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
className={cn('absolute inset-0 z-20 flex !min-h-0 !min-w-0 items-center justify-center rounded-sm text-muted-foreground transition-opacity hover:text-foreground', alwaysShowCloseControls ? 'opacity-100' : 'opacity-0 group-hover:opacity-100')}
onPointerDown={(event) => {
event.stopPropagation();
}}
@@ -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>