diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 0ae68e115..5b5e45579 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -144,6 +144,8 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { ? mobileView === "agent-list" : desktopAgentListOpen : false; + const openGestureEnabled = + chromeEnabled && isMobile && mobileView === "agent"; useGlobalKeyboardNav({ enabled: chromeEnabled, @@ -169,7 +171,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { const openGesture = useMemo( () => Gesture.Pan() - .enabled(chromeEnabled && isMobile && !isOpen) + .enabled(openGestureEnabled) .manualActivation(true) // Fail if 10px vertical movement happens first (allow vertical scroll) .failOffsetY([-10, 10]) @@ -225,9 +227,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) { isGesturing.value = false; }), [ - chromeEnabled, - isMobile, - isOpen, + openGestureEnabled, windowWidth, translateX, backdropOpacity, diff --git a/packages/app/src/components/explorer-sidebar.tsx b/packages/app/src/components/explorer-sidebar.tsx index 2ddb95a92..936c776ba 100644 --- a/packages/app/src/components/explorer-sidebar.tsx +++ b/packages/app/src/components/explorer-sidebar.tsx @@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import Animated, { - useAnimatedReaction, useAnimatedStyle, useSharedValue, runOnJS, @@ -25,29 +24,16 @@ import { FileExplorerPane } from "./file-explorer-pane"; import { TerminalPane } from "./terminal-pane"; const MIN_CHAT_WIDTH = 400; +const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120; -function isTerminalDebugEnabled(): boolean { - const explicit = ( - globalThis as { - __PASEO_TERMINAL_DEBUG?: unknown; - } - ).__PASEO_TERMINAL_DEBUG; - if (typeof explicit === "boolean") { - return explicit; +function resolveKeyboardShift(rawHeight: number, inset: number): number { + "worklet"; + // iOS can report a small accessory/prediction bar height during touch focus. + // Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout. + if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) { + return 0; } - const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__; - return devFlag === true; -} - -function logTerminalDebug(message: string, payload?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (payload) { - console.log("[TerminalDebug][ExplorerSidebar] " + message, payload); - return; - } - console.log("[TerminalDebug][ExplorerSidebar] " + message); + return Math.max(0, rawHeight - inset); } interface ExplorerSidebarProps { @@ -69,10 +55,10 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps const setExplorerTab = usePanelStore((state) => state.setExplorerTab); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const { width: viewportWidth } = useWindowDimensions(); - const terminalDebugEnabled = isTerminalDebugEnabled(); const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const bottomInset = useSharedValue(insets.bottom); - const closeGestureLastLogX = useSharedValue(0); + const closeTouchStartX = useSharedValue(0); + const closeTouchStartY = useSharedValue(0); useEffect(() => { bottomInset.value = insets.bottom; @@ -112,38 +98,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps closeToAgent(); }, [closeToAgent]); - const logCloseGesture = useCallback( - (phase: string, payload?: Record) => { - logTerminalDebug("close gesture " + phase, { - isMobile, - isOpen, - explorerTab, - ...(payload ?? {}), - }); - }, - [explorerTab, isMobile, isOpen] - ); - - const logKeyboardInset = useCallback( - (rawHeight: number, shift: number, inset: number) => { - logTerminalDebug("keyboard inset", { - rawHeight: Math.round(rawHeight), - shift: Math.round(shift), - inset: Math.round(inset), - isMobile, - isOpen, - explorerTab, - }); - }, - [explorerTab, isMobile, isOpen] - ); - - useEffect(() => { - if (!terminalDebugEnabled) { - return; - } - logTerminalDebug("close gesture config", { isMobile, isOpen, explorerTab }); - }, [explorerTab, isMobile, isOpen, terminalDebugEnabled]); + const enableSidebarCloseGesture = isMobile && isOpen; const handleTabPress = useCallback( (tab: ExplorerTab) => { @@ -152,47 +107,52 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps [setExplorerTab] ); - useAnimatedReaction( - () => { - const rawHeight = Math.abs(keyboardHeight.value); - return { - rawHeight, - shift: Math.max(0, rawHeight - bottomInset.value), - inset: bottomInset.value, - }; - }, - (next, previous) => { - if (!terminalDebugEnabled) { - return; - } - if ( - previous && - Math.abs(previous.shift - next.shift) < 4 && - Math.abs(previous.rawHeight - next.rawHeight) < 4 - ) { - return; - } - runOnJS(logKeyboardInset)(next.rawHeight, next.shift, next.inset); - } - ); - // Swipe gesture to close (swipe right on mobile) const closeGesture = useMemo( () => Gesture.Pan() .withRef(closeGestureRef) - .enabled(isMobile && isOpen) - // Only activate on rightward swipe (positive X), fail on leftward or vertical - // This allows ScrollViews using waitFor to scroll left normally - .activeOffsetX(15) - .failOffsetX(-10) - .failOffsetY([-10, 10]) + .enabled(enableSidebarCloseGesture) + // Use manual activation so child views (e.g. WebView terminals) keep touch streams + // unless we detect an intentional right-swipe close. + .manualActivation(true) + .onTouchesDown((event) => { + const touch = event.changedTouches[0]; + if (!touch) { + return; + } + closeTouchStartX.value = touch.absoluteX; + closeTouchStartY.value = touch.absoluteY; + }) + .onTouchesMove((event, stateManager) => { + const touch = event.changedTouches[0]; + if (!touch || event.numberOfTouches !== 1) { + stateManager.fail(); + return; + } + + const deltaX = touch.absoluteX - closeTouchStartX.value; + const deltaY = touch.absoluteY - closeTouchStartY.value; + const absDeltaX = Math.abs(deltaX); + const absDeltaY = Math.abs(deltaY); + + // Fail quickly on clear leftward or vertical intent so child views keep control. + if (deltaX <= -10) { + stateManager.fail(); + return; + } + if (absDeltaY > 10 && absDeltaY > absDeltaX) { + stateManager.fail(); + return; + } + + // Activate only on intentional rightward movement. + if (deltaX >= 15 && absDeltaX > absDeltaY) { + stateManager.activate(); + } + }) .onStart(() => { isGesturing.value = true; - if (terminalDebugEnabled) { - closeGestureLastLogX.value = 0; - runOnJS(logCloseGesture)("start", { windowWidth: Math.round(windowWidth) }); - } }) .onUpdate((event) => { // Right sidebar: swipe right to close (positive translationX) @@ -200,30 +160,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps translateX.value = newTranslateX; const progress = 1 - newTranslateX / windowWidth; backdropOpacity.value = Math.max(0, Math.min(1, progress)); - - if ( - terminalDebugEnabled && - Math.abs(newTranslateX - closeGestureLastLogX.value) >= 80 - ) { - closeGestureLastLogX.value = newTranslateX; - runOnJS(logCloseGesture)("update", { - translationX: Math.round(event.translationX), - appliedTranslateX: Math.round(newTranslateX), - velocityX: Math.round(event.velocityX), - }); - } }) .onEnd((event) => { isGesturing.value = false; const shouldClose = event.translationX > windowWidth / 3 || event.velocityX > 500; - if (terminalDebugEnabled) { - runOnJS(logCloseGesture)("end", { - translationX: Math.round(event.translationX), - velocityX: Math.round(event.velocityX), - shouldClose, - }); - } if (shouldClose) { animateToClose(); runOnJS(handleClose)(); @@ -233,25 +174,19 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps }) .onFinalize(() => { isGesturing.value = false; - if (terminalDebugEnabled) { - runOnJS(logCloseGesture)("finalize"); - } }), [ - isMobile, - isOpen, - explorerTab, + enableSidebarCloseGesture, windowWidth, translateX, backdropOpacity, animateToOpen, animateToClose, handleClose, - logCloseGesture, isGesturing, closeGestureRef, - closeGestureLastLogX, - terminalDebugEnabled, + closeTouchStartX, + closeTouchStartY, ] ); @@ -295,7 +230,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps const mobileKeyboardInsetStyle = useAnimatedStyle(() => { const absoluteHeight = Math.abs(keyboardHeight.value); - const shift = Math.max(0, absoluteHeight - bottomInset.value); + const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value); return { paddingBottom: bottomInset.value + shift, }; diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 165870c43..fb917e62a 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -5,6 +5,14 @@ import { FitAddon } from "@xterm/addon-fit"; import { Terminal } from "@xterm/xterm"; import type { DOMProps } from "expo/dom"; import "@xterm/xterm/css/xterm.css"; +import { + type PendingTerminalModifiers, + isTerminalModifierDomKey, + mergeTerminalModifiers, + normalizeDomTerminalKey, + normalizeTerminalTransportKey, + shouldInterceptDomTerminalKey, +} from "../utils/terminal-keys"; interface TerminalEmulatorProps { dom?: DOMProps; @@ -16,6 +24,16 @@ interface TerminalEmulatorProps { cursorColor?: string; onInput?: (data: string) => Promise | void; onResize?: (rows: number, cols: number) => Promise | void; + onTerminalKey?: (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + }) => Promise | void; + onPendingModifiersConsumed?: () => Promise | void; + pendingModifiers?: PendingTerminalModifiers; + focusRequestToken?: number; } declare global { @@ -24,30 +42,6 @@ declare global { } } -function isTerminalDebugEnabled(): boolean { - const explicit = ( - globalThis as { - __PASEO_TERMINAL_DEBUG?: unknown; - } - ).__PASEO_TERMINAL_DEBUG; - if (typeof explicit === "boolean") { - return explicit; - } - const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__; - return devFlag === true; -} - -function logTerminalDebug(message: string, payload?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (payload) { - console.log(`[TerminalDebug][DOM] ${message}`, payload); - return; - } - console.log(`[TerminalDebug][DOM] ${message}`); -} - export default function TerminalEmulator({ streamKey, outputText, @@ -57,6 +51,10 @@ export default function TerminalEmulator({ cursorColor = "#e6e6e6", onInput, onResize, + onTerminalKey, + onPendingModifiersConsumed, + pendingModifiers = { ctrl: false, shift: false, alt: false }, + focusRequestToken = 0, }: TerminalEmulatorProps) { const rootRef = useRef(null); const hostRef = useRef(null); @@ -65,6 +63,11 @@ export default function TerminalEmulator({ const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null); const onInputRef = useRef(onInput); const onResizeRef = useRef(onResize); + const onTerminalKeyRef = useRef(onTerminalKey); + const onPendingModifiersConsumedRef = useRef< + TerminalEmulatorProps["onPendingModifiersConsumed"] + >(onPendingModifiersConsumed); + const pendingModifiersRef = useRef(pendingModifiers); useEffect(() => { onInputRef.current = onInput; @@ -74,6 +77,18 @@ export default function TerminalEmulator({ onResizeRef.current = onResize; }, [onResize]); + useEffect(() => { + onTerminalKeyRef.current = onTerminalKey; + }, [onTerminalKey]); + + useEffect(() => { + onPendingModifiersConsumedRef.current = onPendingModifiersConsumed; + }, [onPendingModifiersConsumed]); + + useEffect(() => { + pendingModifiersRef.current = pendingModifiers; + }, [pendingModifiers]); + useEffect(() => { const host = hostRef.current; const root = rootRef.current; @@ -81,12 +96,6 @@ export default function TerminalEmulator({ return; } - logTerminalDebug("mount", { - streamKey, - hasOnInput: Boolean(onInputRef.current), - hasOnResize: Boolean(onResizeRef.current), - }); - renderedOutputRef.current = ""; lastSizeRef.current = null; host.innerHTML = ""; @@ -146,6 +155,7 @@ export default function TerminalEmulator({ } const viewportElement = host.querySelector(".xterm-viewport"); + const screenElement = host.querySelector(".xterm-screen"); const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? ""; const previousViewportTouchAction = viewportElement?.style.touchAction ?? ""; const previousViewportOverflowY = viewportElement?.style.overflowY ?? ""; @@ -154,13 +164,19 @@ export default function TerminalEmulator({ const previousViewportWebkitOverflowScrolling = viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? ""; if (viewportElement) { - viewportElement.style.overscrollBehavior = "contain"; + viewportElement.style.overscrollBehavior = "none"; viewportElement.style.touchAction = "pan-y"; viewportElement.style.overflowY = "auto"; viewportElement.style.overflowX = "hidden"; viewportElement.style.pointerEvents = "auto"; viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch"); } + const previousScreenPointerEvents = screenElement?.style.pointerEvents ?? ""; + if (screenElement) { + // xterm renders the screen layer above the viewport. Disable hit-testing on that layer + // so touch drags can reach the scrollable viewport on mobile. + screenElement.style.pointerEvents = "none"; + } terminalRef.current = terminal; window.__paseoTerminal = terminal; @@ -174,7 +190,6 @@ export default function TerminalEmulator({ try { fitAddon.fit(); } catch { - logTerminalDebug("fit failed"); return; } @@ -186,14 +201,6 @@ export default function TerminalEmulator({ } lastSizeRef.current = { rows, cols }; - const rootRect = root.getBoundingClientRect(); - logTerminalDebug("fit+resize", { - force, - rows, - cols, - rootWidth: Math.round(rootRect.width), - rootHeight: Math.round(rootRect.height), - }); void handler(rows, cols); }; @@ -204,64 +211,156 @@ export default function TerminalEmulator({ if (!handler) { return; } - logTerminalDebug("input", { - length: data.length, - preview: data.slice(0, 20), - }); void handler(data); }); - let lastScrollLogTs = 0; - let lastWheelLogTs = 0; - let lastTouchMoveLogTs = 0; + terminal.attachCustomKeyEventHandler((event) => { + if (event.type !== "keydown" || event.isComposing) { + return true; + } - const viewportScrollHandler = () => { - const now = Date.now(); - if (now - lastScrollLogTs < 120) { - return; + const normalizedKey = normalizeDomTerminalKey(event.key); + if (!normalizedKey || isTerminalModifierDomKey(event.key)) { + return true; } - lastScrollLogTs = now; - logTerminalDebug("viewport scroll", { - baseY: terminal.buffer.active.baseY, - viewportY: terminal.buffer.active.viewportY, - }); - }; - const viewportWheelHandler = (event: WheelEvent) => { - const now = Date.now(); - if (now - lastWheelLogTs < 120) { - return; - } - lastWheelLogTs = now; - logTerminalDebug("viewport wheel", { - deltaY: event.deltaY, - deltaX: event.deltaX, - }); - }; - const viewportTouchStartHandler = (event: TouchEvent) => { - logTerminalDebug("viewport touchstart", { - touches: event.touches.length, - }); - }; - const viewportTouchMoveHandler = (event: TouchEvent) => { - const now = Date.now(); - if (now - lastTouchMoveLogTs < 120) { - return; - } - lastTouchMoveLogTs = now; - logTerminalDebug("viewport touchmove", { - touches: event.touches.length, - }); - }; - viewportElement?.addEventListener("scroll", viewportScrollHandler, { passive: true }); - viewportElement?.addEventListener("wheel", viewportWheelHandler, { passive: true }); - viewportElement?.addEventListener("touchstart", viewportTouchStartHandler, { - passive: true, - }); - viewportElement?.addEventListener("touchmove", viewportTouchMoveHandler, { - passive: true, + const pending = pendingModifiersRef.current; + if ( + !shouldInterceptDomTerminalKey({ + key: normalizedKey, + ctrlKey: event.ctrlKey, + altKey: event.altKey, + pendingModifiers: pending, + }) + ) { + return true; + } + + const modifiers = mergeTerminalModifiers({ + pendingModifiers: pending, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + }); + + const keyPayload = { + key: normalizeTerminalTransportKey(normalizedKey), + ...modifiers, + }; + onTerminalKeyRef.current?.(keyPayload); + + if (pending.ctrl || pending.shift || pending.alt) { + onPendingModifiersConsumedRef.current?.(); + } + + event.preventDefault(); + event.stopPropagation(); + return false; }); + let touchScrollRemainderPx = 0; + const touchScrollLineHeightPx = (() => { + const row = host.querySelector(".xterm-rows > div"); + const measured = row?.getBoundingClientRect().height; + return measured && measured > 0 ? measured : 18; + })(); + const activeTouchRef: { + identifier: number; + startX: number; + startY: number; + lastX: number; + lastY: number; + mode: "vertical" | "horizontal" | null; + } = { + identifier: -1, + startX: 0, + startY: 0, + lastX: 0, + lastY: 0, + mode: null, + }; + const rootTouchStartHandler = (event: TouchEvent) => { + if (event.touches.length !== 1) { + touchScrollRemainderPx = 0; + activeTouchRef.identifier = -1; + activeTouchRef.mode = null; + return; + } + const touch = event.touches[0]; + if (!touch) { + touchScrollRemainderPx = 0; + activeTouchRef.identifier = -1; + activeTouchRef.mode = null; + return; + } + activeTouchRef.identifier = touch.identifier; + activeTouchRef.startX = touch.clientX; + activeTouchRef.startY = touch.clientY; + activeTouchRef.lastX = touch.clientX; + activeTouchRef.lastY = touch.clientY; + activeTouchRef.mode = null; + touchScrollRemainderPx = 0; + }; + const rootTouchMoveHandler = (event: TouchEvent) => { + if (event.touches.length !== 1) { + return; + } + const touch = Array.from(event.touches).find( + (candidate) => candidate.identifier === activeTouchRef.identifier + ); + if (!touch) { + return; + } + + const totalDeltaX = touch.clientX - activeTouchRef.startX; + const totalDeltaY = touch.clientY - activeTouchRef.startY; + if (activeTouchRef.mode === null) { + const absX = Math.abs(totalDeltaX); + const absY = Math.abs(totalDeltaY); + if (absX > 8 || absY > 8) { + activeTouchRef.mode = absY >= absX ? "vertical" : "horizontal"; + } + } + + const deltaY = touch.clientY - activeTouchRef.lastY; + activeTouchRef.lastX = touch.clientX; + activeTouchRef.lastY = touch.clientY; + + if (activeTouchRef.mode !== "vertical") { + return; + } + + // Manual vertical touch scrolling fallback for xterm's layered DOM. + touchScrollRemainderPx += deltaY; + const lineDelta = Math.trunc(touchScrollRemainderPx / touchScrollLineHeightPx); + if (lineDelta !== 0) { + const appliedLineDelta = -lineDelta; + terminal.scrollLines(appliedLineDelta); + touchScrollRemainderPx -= lineDelta * touchScrollLineHeightPx; + } + event.preventDefault(); + }; + const rootTouchEndHandler = (event: TouchEvent) => { + const changed = Array.from(event.changedTouches).some( + (touch) => touch.identifier === activeTouchRef.identifier + ); + if (changed || event.touches.length === 0) { + touchScrollRemainderPx = 0; + activeTouchRef.identifier = -1; + activeTouchRef.mode = null; + } + }; + const rootTouchCancelHandler = () => { + touchScrollRemainderPx = 0; + activeTouchRef.identifier = -1; + activeTouchRef.mode = null; + }; + root.addEventListener("touchstart", rootTouchStartHandler, { passive: true }); + root.addEventListener("touchmove", rootTouchMoveHandler, { passive: false }); + root.addEventListener("touchend", rootTouchEndHandler, { passive: true }); + root.addEventListener("touchcancel", rootTouchCancelHandler, { passive: true }); + const resizeObserver = new ResizeObserver(() => { fitAndEmitResize(); }); @@ -293,10 +392,10 @@ export default function TerminalEmulator({ window.removeEventListener("resize", windowResizeHandler); visualViewport?.removeEventListener("resize", visualViewportResizeHandler); window.clearInterval(fitInterval); - viewportElement?.removeEventListener("scroll", viewportScrollHandler); - viewportElement?.removeEventListener("wheel", viewportWheelHandler); - viewportElement?.removeEventListener("touchstart", viewportTouchStartHandler); - viewportElement?.removeEventListener("touchmove", viewportTouchMoveHandler); + root.removeEventListener("touchstart", rootTouchStartHandler); + root.removeEventListener("touchmove", rootTouchMoveHandler); + root.removeEventListener("touchend", rootTouchEndHandler); + root.removeEventListener("touchcancel", rootTouchCancelHandler); fitAddon.dispose(); terminal.dispose(); @@ -328,12 +427,14 @@ export default function TerminalEmulator({ previousViewportWebkitOverflowScrolling ); } + if (screenElement) { + screenElement.style.pointerEvents = previousScreenPointerEvents; + } terminalRef.current = null; if (window.__paseoTerminal === terminal) { window.__paseoTerminal = undefined; } - logTerminalDebug("unmount", { streamKey }); renderedOutputRef.current = ""; lastSizeRef.current = null; }; @@ -366,6 +467,13 @@ export default function TerminalEmulator({ renderedOutputRef.current = outputText; }, [outputText]); + useEffect(() => { + if (focusRequestToken <= 0) { + return; + } + terminalRef.current?.focus(); + }, [focusRequestToken]); + return (
{ - logTerminalDebug("root pointerdown", { streamKey }); terminalRef.current?.focus(); }} > diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 83c4e2750..40fefbaac 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -2,17 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ActivityIndicator, - type LayoutChangeEvent, Pressable, ScrollView, Text, View, } from "react-native"; import { Plus, RefreshCw } from "lucide-react-native"; -import { NativeViewGestureHandler } from "react-native-gesture-handler"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { useSessionStore } from "@/stores/session-store"; -import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animation-context"; +import { + hasPendingTerminalModifiers, + mapTerminalDataToKey, + normalizeTerminalTransportKey, +} from "@/utils/terminal-keys"; import TerminalEmulator from "./terminal-emulator"; interface TerminalPaneProps { @@ -46,48 +48,21 @@ type ModifierState = { alt: boolean; }; +const EMPTY_MODIFIERS: ModifierState = { + ctrl: false, + shift: false, + alt: false, +}; + function terminalScopeKey(serverId: string, cwd: string): string { return `${serverId}:${cwd}`; } -function isTerminalDebugEnabled(): boolean { - const explicit = ( - globalThis as { - __PASEO_TERMINAL_DEBUG?: unknown; - } - ).__PASEO_TERMINAL_DEBUG; - if (typeof explicit === "boolean") { - return explicit; - } - const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__; - return devFlag === true; -} - -function logTerminalDebug(message: string, payload?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (payload) { - console.log("[TerminalDebug][Pane] " + message, payload); - return; - } - console.log("[TerminalDebug][Pane] " + message); -} - export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { const { theme } = useUnistyles(); const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; - // Optional when rendered inside the mobile explorer sidebar gesture context. - let closeGestureRef: React.MutableRefObject | undefined; - try { - const animation = useExplorerSidebarAnimation(); - closeGestureRef = animation.closeGestureRef; - } catch { - // Terminal pane can render outside explorer sidebar during isolated tests. - } - const queryClient = useQueryClient(); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const isConnected = useSessionStore( @@ -108,11 +83,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { } | null>(null); const [isAttaching, setIsAttaching] = useState(false); const [streamError, setStreamError] = useState(null); - const [modifiers, setModifiers] = useState({ - ctrl: false, - shift: false, - alt: false, - }); + const [modifiers, setModifiers] = useState(EMPTY_MODIFIERS); + const [focusRequestToken, setFocusRequestToken] = useState(0); const terminalsQuery = useQuery({ queryKey: ["terminals", serverId, cwd] as const, @@ -287,48 +259,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { [terminals, selectedTerminalId] ); - const waitForCloseGesture = Boolean(isMobile && closeGestureRef?.current); - - useEffect(() => { - logTerminalDebug("render state", { - scopeKey, - isMobile, - terminals: terminals.length, - selectedTerminalId, - activeStreamId, - isAttaching, - waitForCloseGesture, - }); - }, [ - activeStreamId, - isAttaching, - isMobile, - scopeKey, - selectedTerminalId, - terminals.length, - waitForCloseGesture, - ]); - - const handleOutputLayout = useCallback( - (event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - logTerminalDebug("output layout", { - width: Math.round(width), - height: Math.round(height), - selectedTerminalId, - }); - }, - [selectedTerminalId] - ); - - const handleTerminalLayout = useCallback((event: LayoutChangeEvent) => { - const { width, height } = event.nativeEvent.layout; - logTerminalDebug("terminal container layout", { - width: Math.round(width), - height: Math.round(height), - }); - }, []); - const currentOutput = selectedTerminalId ? (outputByTerminalId.get(selectedTerminalId) ?? "") : ""; @@ -341,20 +271,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { createTerminalMutation.mutate(); }, [createTerminalMutation]); - const handleTerminalData = useCallback( - async (data: string) => { - if (!client || activeStreamId === null || data.length === 0) { - return; + const requestTerminalFocus = useCallback(() => { + setFocusRequestToken((current) => current + 1); + }, []); + + const clearPendingModifiers = useCallback(() => { + setModifiers({ ...EMPTY_MODIFIERS }); + }, []); + + const sendTerminalKey = useCallback( + ( + input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta?: boolean; } - logTerminalDebug("send input", { - length: data.length, - preview: data.slice(0, 24), + ): boolean => { + if (!client || activeStreamId === null) { + return false; + } + + const normalizedKey = normalizeTerminalTransportKey(input.key); + client.sendTerminalStreamKey(activeStreamId, { + key: normalizedKey, + ctrl: input.ctrl, + shift: input.shift, + alt: input.alt, + meta: input.meta, }); - client.sendTerminalStreamInput(activeStreamId, data); + return true; }, [client, activeStreamId] ); + const handleTerminalData = useCallback( + async (data: string) => { + if (data.length === 0) { + return; + } + + if (hasPendingTerminalModifiers(modifiers)) { + const mappedKey = mapTerminalDataToKey(data); + if ( + mappedKey && + sendTerminalKey( + { + key: mappedKey, + ctrl: modifiers.ctrl, + shift: modifiers.shift, + alt: modifiers.alt, + meta: false, + } + ) + ) { + clearPendingModifiers(); + return; + } + } + + if (!client || activeStreamId === null) { + return; + } + client.sendTerminalStreamInput(activeStreamId, data); + }, + [ + activeStreamId, + clearPendingModifiers, + client, + modifiers.alt, + modifiers.ctrl, + modifiers.shift, + sendTerminalKey, + ] + ); + const handleTerminalResize = useCallback( async (rows: number, cols: number) => { if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) { @@ -370,11 +362,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { ) { return; } - logTerminalDebug("send resize", { - rows: normalizedRows, - cols: normalizedCols, - selectedTerminalId, - }); lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; client.sendTerminalInput(selectedTerminalId, { type: "resize", @@ -385,30 +372,51 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { [client, selectedTerminalId] ); - const toggleModifier = useCallback((modifier: keyof ModifierState) => { - setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); - }, []); + const handleTerminalKey = useCallback( + async (input: { + key: string; + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; + }) => { + sendTerminalKey(input); + }, + [sendTerminalKey] + ); + + const handlePendingModifiersConsumed = useCallback(() => { + clearPendingModifiers(); + }, [clearPendingModifiers]); + + const toggleModifier = useCallback( + (modifier: keyof ModifierState) => { + setModifiers((current) => ({ ...current, [modifier]: !current[modifier] })); + requestTerminalFocus(); + }, + [requestTerminalFocus] + ); const sendVirtualKey = useCallback( (key: string) => { - if (!client || activeStreamId === null) { - return; - } - client.sendTerminalStreamKey(activeStreamId, { - key: key.length === 1 ? key.toLowerCase() : key, - ctrl: modifiers.ctrl, - shift: modifiers.shift, - alt: modifiers.alt, - }); - logTerminalDebug("send virtual key", { + sendTerminalKey({ key, ctrl: modifiers.ctrl, shift: modifiers.shift, alt: modifiers.alt, + meta: false, }); - setModifiers({ ctrl: false, shift: false, alt: false }); + clearPendingModifiers(); + requestTerminalFocus(); }, - [client, activeStreamId, modifiers.alt, modifiers.ctrl, modifiers.shift] + [ + clearPendingModifiers, + modifiers.alt, + modifiers.ctrl, + modifiers.shift, + requestTerminalFocus, + sendTerminalKey, + ] ); if (!client || !isConnected) { @@ -492,32 +500,34 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) { - + {selectedTerminal ? ( - - - - - + + + ) : ( No terminal selected diff --git a/packages/app/src/utils/terminal-keys.test.ts b/packages/app/src/utils/terminal-keys.test.ts new file mode 100644 index 000000000..bd01fe99a --- /dev/null +++ b/packages/app/src/utils/terminal-keys.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from "vitest"; +import { + hasPendingTerminalModifiers, + isTerminalModifierDomKey, + mapTerminalDataToKey, + mergeTerminalModifiers, + normalizeDomTerminalKey, + normalizeTerminalTransportKey, + shouldInterceptDomTerminalKey, +} from "./terminal-keys"; + +describe("terminal key helpers", () => { + it("normalizes supported DOM keys", () => { + expect(normalizeDomTerminalKey("Esc")).toBe("Escape"); + expect(normalizeDomTerminalKey(" ")).toBe(" "); + expect(normalizeDomTerminalKey("ArrowUp")).toBe("ArrowUp"); + expect(normalizeDomTerminalKey("F12")).toBe("F12"); + }); + + it("filters unsupported and composing DOM keys", () => { + expect(normalizeDomTerminalKey("Dead")).toBeNull(); + expect(normalizeDomTerminalKey("Unidentified")).toBeNull(); + expect(normalizeDomTerminalKey("MediaPlayPause")).toBeNull(); + }); + + it("detects modifier DOM keys", () => { + expect(isTerminalModifierDomKey("Control")).toBe(true); + expect(isTerminalModifierDomKey("Shift")).toBe(true); + expect(isTerminalModifierDomKey("a")).toBe(false); + }); + + it("lowercases printable transport keys", () => { + expect(normalizeTerminalTransportKey("C")).toBe("c"); + expect(normalizeTerminalTransportKey("Escape")).toBe("Escape"); + }); + + it("merges pending modifiers with native key modifiers", () => { + expect( + mergeTerminalModifiers({ + pendingModifiers: { ctrl: true, shift: false, alt: true }, + ctrlKey: false, + shiftKey: true, + altKey: false, + metaKey: false, + }) + ).toEqual({ + ctrl: true, + shift: true, + alt: true, + meta: false, + }); + }); + + it("intercepts special keys and modifier combos", () => { + expect( + shouldInterceptDomTerminalKey({ + key: "Escape", + ctrlKey: false, + altKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + }) + ).toBe(true); + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: true, + altKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + }) + ).toBe(true); + expect( + shouldInterceptDomTerminalKey({ + key: "c", + ctrlKey: false, + altKey: false, + pendingModifiers: { ctrl: false, shift: false, alt: false }, + }) + ).toBe(false); + }); + + it("detects pending modifier state", () => { + expect(hasPendingTerminalModifiers({ ctrl: false, shift: false, alt: false })).toBe( + false + ); + expect(hasPendingTerminalModifiers({ ctrl: true, shift: false, alt: false })).toBe( + true + ); + }); + + it("maps onData bytes to terminal keys for modifier fallback", () => { + expect(mapTerminalDataToKey("c")).toBe("c"); + expect(mapTerminalDataToKey("\r")).toBe("Enter"); + expect(mapTerminalDataToKey("\t")).toBe("Tab"); + expect(mapTerminalDataToKey("\x7f")).toBe("Backspace"); + expect(mapTerminalDataToKey("\x1b")).toBe("Escape"); + expect(mapTerminalDataToKey("\x03")).toBeNull(); + expect(mapTerminalDataToKey("")).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/terminal-keys.ts b/packages/app/src/utils/terminal-keys.ts new file mode 100644 index 000000000..2cb612f04 --- /dev/null +++ b/packages/app/src/utils/terminal-keys.ts @@ -0,0 +1,159 @@ +export type PendingTerminalModifiers = { + ctrl: boolean; + shift: boolean; + alt: boolean; +}; + +const MODIFIER_DOM_KEYS = new Set([ + "Control", + "Shift", + "Alt", + "Meta", + "AltGraph", + "OS", +]); + +const DOM_KEY_ALIASES: Record = { + Esc: "Escape", + Left: "ArrowLeft", + Right: "ArrowRight", + Up: "ArrowUp", + Down: "ArrowDown", + Del: "Delete", + Spacebar: " ", + Space: " ", +}; + +const SUPPORTED_SPECIAL_KEYS = new Set([ + "Enter", + "Tab", + "Backspace", + "Escape", + "ArrowUp", + "ArrowDown", + "ArrowLeft", + "ArrowRight", + "Home", + "End", + "Insert", + "Delete", + "PageUp", + "PageDown", + "F1", + "F2", + "F3", + "F4", + "F5", + "F6", + "F7", + "F8", + "F9", + "F10", + "F11", + "F12", +]); + +export function isTerminalModifierDomKey(rawKey: string): boolean { + return MODIFIER_DOM_KEYS.has(rawKey); +} + +export function normalizeDomTerminalKey(rawKey: string): string | null { + if (!rawKey) { + return null; + } + + const key = DOM_KEY_ALIASES[rawKey] ?? rawKey; + if ( + key === "Unidentified" || + key === "Dead" || + key === "Compose" || + key === "Process" + ) { + return null; + } + + if (key.length === 1) { + return key; + } + + if (SUPPORTED_SPECIAL_KEYS.has(key)) { + return key; + } + + return null; +} + +export function normalizeTerminalTransportKey(key: string): string { + if (key.length === 1) { + return key.toLowerCase(); + } + return key; +} + +export function hasPendingTerminalModifiers( + modifiers: PendingTerminalModifiers +): boolean { + return modifiers.ctrl || modifiers.shift || modifiers.alt; +} + +export function shouldInterceptDomTerminalKey(args: { + key: string; + ctrlKey: boolean; + altKey: boolean; + pendingModifiers: PendingTerminalModifiers; +}): boolean { + return ( + args.key.length > 1 || + args.ctrlKey || + args.altKey || + hasPendingTerminalModifiers(args.pendingModifiers) + ); +} + +export function mergeTerminalModifiers(args: { + pendingModifiers: PendingTerminalModifiers; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; + metaKey: boolean; +}): { + ctrl: boolean; + shift: boolean; + alt: boolean; + meta: boolean; +} { + const { pendingModifiers, ctrlKey, shiftKey, altKey, metaKey } = args; + return { + ctrl: ctrlKey || pendingModifiers.ctrl, + shift: shiftKey || pendingModifiers.shift, + alt: altKey || pendingModifiers.alt, + meta: metaKey, + }; +} + +export function mapTerminalDataToKey(data: string): string | null { + if (!data || data.length !== 1) { + return null; + } + + if (data === "\r" || data === "\n") { + return "Enter"; + } + if (data === "\t") { + return "Tab"; + } + if (data === "\x7f" || data === "\b") { + return "Backspace"; + } + if (data === "\x1b") { + return "Escape"; + } + + const code = data.charCodeAt(0); + // Only map printable ASCII for modifier fallback; keep control bytes raw. + if (code >= 0x20 && code <= 0x7e) { + return data; + } + + return null; +}