fix(app): remove terminal debug instrumentation
This commit is contained in:
@@ -144,6 +144,8 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
? mobileView === "agent-list"
|
? mobileView === "agent-list"
|
||||||
: desktopAgentListOpen
|
: desktopAgentListOpen
|
||||||
: false;
|
: false;
|
||||||
|
const openGestureEnabled =
|
||||||
|
chromeEnabled && isMobile && mobileView === "agent";
|
||||||
|
|
||||||
useGlobalKeyboardNav({
|
useGlobalKeyboardNav({
|
||||||
enabled: chromeEnabled,
|
enabled: chromeEnabled,
|
||||||
@@ -169,7 +171,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
const openGesture = useMemo(
|
const openGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.enabled(chromeEnabled && isMobile && !isOpen)
|
.enabled(openGestureEnabled)
|
||||||
.manualActivation(true)
|
.manualActivation(true)
|
||||||
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
// Fail if 10px vertical movement happens first (allow vertical scroll)
|
||||||
.failOffsetY([-10, 10])
|
.failOffsetY([-10, 10])
|
||||||
@@ -225,9 +227,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
chromeEnabled,
|
openGestureEnabled,
|
||||||
isMobile,
|
|
||||||
isOpen,
|
|
||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef } from "react";
|
|||||||
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
import { View, Text, Pressable, Platform, useWindowDimensions } from "react-native";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
useAnimatedReaction,
|
|
||||||
useAnimatedStyle,
|
useAnimatedStyle,
|
||||||
useSharedValue,
|
useSharedValue,
|
||||||
runOnJS,
|
runOnJS,
|
||||||
@@ -25,29 +24,16 @@ import { FileExplorerPane } from "./file-explorer-pane";
|
|||||||
import { TerminalPane } from "./terminal-pane";
|
import { TerminalPane } from "./terminal-pane";
|
||||||
|
|
||||||
const MIN_CHAT_WIDTH = 400;
|
const MIN_CHAT_WIDTH = 400;
|
||||||
|
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
|
||||||
|
|
||||||
function isTerminalDebugEnabled(): boolean {
|
function resolveKeyboardShift(rawHeight: number, inset: number): number {
|
||||||
const explicit = (
|
"worklet";
|
||||||
globalThis as {
|
// iOS can report a small accessory/prediction bar height during touch focus.
|
||||||
__PASEO_TERMINAL_DEBUG?: unknown;
|
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
|
||||||
}
|
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
|
||||||
).__PASEO_TERMINAL_DEBUG;
|
return 0;
|
||||||
if (typeof explicit === "boolean") {
|
|
||||||
return explicit;
|
|
||||||
}
|
}
|
||||||
const devFlag = (globalThis as { __DEV__?: unknown }).__DEV__;
|
return Math.max(0, rawHeight - inset);
|
||||||
return devFlag === true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function logTerminalDebug(message: string, payload?: Record<string, unknown>): void {
|
|
||||||
if (!isTerminalDebugEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload) {
|
|
||||||
console.log("[TerminalDebug][ExplorerSidebar] " + message, payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("[TerminalDebug][ExplorerSidebar] " + message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ExplorerSidebarProps {
|
interface ExplorerSidebarProps {
|
||||||
@@ -69,10 +55,10 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
const setExplorerTab = usePanelStore((state) => state.setExplorerTab);
|
||||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||||
const { width: viewportWidth } = useWindowDimensions();
|
const { width: viewportWidth } = useWindowDimensions();
|
||||||
const terminalDebugEnabled = isTerminalDebugEnabled();
|
|
||||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||||
const bottomInset = useSharedValue(insets.bottom);
|
const bottomInset = useSharedValue(insets.bottom);
|
||||||
const closeGestureLastLogX = useSharedValue(0);
|
const closeTouchStartX = useSharedValue(0);
|
||||||
|
const closeTouchStartY = useSharedValue(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
bottomInset.value = insets.bottom;
|
bottomInset.value = insets.bottom;
|
||||||
@@ -112,38 +98,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
closeToAgent();
|
closeToAgent();
|
||||||
}, [closeToAgent]);
|
}, [closeToAgent]);
|
||||||
|
|
||||||
const logCloseGesture = useCallback(
|
const enableSidebarCloseGesture = isMobile && isOpen;
|
||||||
(phase: string, payload?: Record<string, unknown>) => {
|
|
||||||
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 handleTabPress = useCallback(
|
const handleTabPress = useCallback(
|
||||||
(tab: ExplorerTab) => {
|
(tab: ExplorerTab) => {
|
||||||
@@ -152,47 +107,52 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
[setExplorerTab]
|
[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)
|
// Swipe gesture to close (swipe right on mobile)
|
||||||
const closeGesture = useMemo(
|
const closeGesture = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Gesture.Pan()
|
Gesture.Pan()
|
||||||
.withRef(closeGestureRef)
|
.withRef(closeGestureRef)
|
||||||
.enabled(isMobile && isOpen)
|
.enabled(enableSidebarCloseGesture)
|
||||||
// Only activate on rightward swipe (positive X), fail on leftward or vertical
|
// Use manual activation so child views (e.g. WebView terminals) keep touch streams
|
||||||
// This allows ScrollViews using waitFor to scroll left normally
|
// unless we detect an intentional right-swipe close.
|
||||||
.activeOffsetX(15)
|
.manualActivation(true)
|
||||||
.failOffsetX(-10)
|
.onTouchesDown((event) => {
|
||||||
.failOffsetY([-10, 10])
|
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(() => {
|
.onStart(() => {
|
||||||
isGesturing.value = true;
|
isGesturing.value = true;
|
||||||
if (terminalDebugEnabled) {
|
|
||||||
closeGestureLastLogX.value = 0;
|
|
||||||
runOnJS(logCloseGesture)("start", { windowWidth: Math.round(windowWidth) });
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.onUpdate((event) => {
|
.onUpdate((event) => {
|
||||||
// Right sidebar: swipe right to close (positive translationX)
|
// Right sidebar: swipe right to close (positive translationX)
|
||||||
@@ -200,30 +160,11 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
translateX.value = newTranslateX;
|
translateX.value = newTranslateX;
|
||||||
const progress = 1 - newTranslateX / windowWidth;
|
const progress = 1 - newTranslateX / windowWidth;
|
||||||
backdropOpacity.value = Math.max(0, Math.min(1, progress));
|
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) => {
|
.onEnd((event) => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
const shouldClose =
|
const shouldClose =
|
||||||
event.translationX > windowWidth / 3 || event.velocityX > 500;
|
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) {
|
if (shouldClose) {
|
||||||
animateToClose();
|
animateToClose();
|
||||||
runOnJS(handleClose)();
|
runOnJS(handleClose)();
|
||||||
@@ -233,25 +174,19 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
})
|
})
|
||||||
.onFinalize(() => {
|
.onFinalize(() => {
|
||||||
isGesturing.value = false;
|
isGesturing.value = false;
|
||||||
if (terminalDebugEnabled) {
|
|
||||||
runOnJS(logCloseGesture)("finalize");
|
|
||||||
}
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
isMobile,
|
enableSidebarCloseGesture,
|
||||||
isOpen,
|
|
||||||
explorerTab,
|
|
||||||
windowWidth,
|
windowWidth,
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
animateToOpen,
|
animateToOpen,
|
||||||
animateToClose,
|
animateToClose,
|
||||||
handleClose,
|
handleClose,
|
||||||
logCloseGesture,
|
|
||||||
isGesturing,
|
isGesturing,
|
||||||
closeGestureRef,
|
closeGestureRef,
|
||||||
closeGestureLastLogX,
|
closeTouchStartX,
|
||||||
terminalDebugEnabled,
|
closeTouchStartY,
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -295,7 +230,7 @@ export function ExplorerSidebar({ serverId, agentId, cwd }: ExplorerSidebarProps
|
|||||||
|
|
||||||
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
|
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
|
||||||
const absoluteHeight = Math.abs(keyboardHeight.value);
|
const absoluteHeight = Math.abs(keyboardHeight.value);
|
||||||
const shift = Math.max(0, absoluteHeight - bottomInset.value);
|
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
|
||||||
return {
|
return {
|
||||||
paddingBottom: bottomInset.value + shift,
|
paddingBottom: bottomInset.value + shift,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ import { FitAddon } from "@xterm/addon-fit";
|
|||||||
import { Terminal } from "@xterm/xterm";
|
import { Terminal } from "@xterm/xterm";
|
||||||
import type { DOMProps } from "expo/dom";
|
import type { DOMProps } from "expo/dom";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
|
import {
|
||||||
|
type PendingTerminalModifiers,
|
||||||
|
isTerminalModifierDomKey,
|
||||||
|
mergeTerminalModifiers,
|
||||||
|
normalizeDomTerminalKey,
|
||||||
|
normalizeTerminalTransportKey,
|
||||||
|
shouldInterceptDomTerminalKey,
|
||||||
|
} from "../utils/terminal-keys";
|
||||||
|
|
||||||
interface TerminalEmulatorProps {
|
interface TerminalEmulatorProps {
|
||||||
dom?: DOMProps;
|
dom?: DOMProps;
|
||||||
@@ -16,6 +24,16 @@ interface TerminalEmulatorProps {
|
|||||||
cursorColor?: string;
|
cursorColor?: string;
|
||||||
onInput?: (data: string) => Promise<void> | void;
|
onInput?: (data: string) => Promise<void> | void;
|
||||||
onResize?: (rows: number, cols: number) => Promise<void> | void;
|
onResize?: (rows: number, cols: number) => Promise<void> | void;
|
||||||
|
onTerminalKey?: (input: {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta: boolean;
|
||||||
|
}) => Promise<void> | void;
|
||||||
|
onPendingModifiersConsumed?: () => Promise<void> | void;
|
||||||
|
pendingModifiers?: PendingTerminalModifiers;
|
||||||
|
focusRequestToken?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
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<string, unknown>): void {
|
|
||||||
if (!isTerminalDebugEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload) {
|
|
||||||
console.log(`[TerminalDebug][DOM] ${message}`, payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log(`[TerminalDebug][DOM] ${message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function TerminalEmulator({
|
export default function TerminalEmulator({
|
||||||
streamKey,
|
streamKey,
|
||||||
outputText,
|
outputText,
|
||||||
@@ -57,6 +51,10 @@ export default function TerminalEmulator({
|
|||||||
cursorColor = "#e6e6e6",
|
cursorColor = "#e6e6e6",
|
||||||
onInput,
|
onInput,
|
||||||
onResize,
|
onResize,
|
||||||
|
onTerminalKey,
|
||||||
|
onPendingModifiersConsumed,
|
||||||
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
|
focusRequestToken = 0,
|
||||||
}: TerminalEmulatorProps) {
|
}: TerminalEmulatorProps) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -65,6 +63,11 @@ export default function TerminalEmulator({
|
|||||||
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
|
const onInputRef = useRef<TerminalEmulatorProps["onInput"]>(onInput);
|
||||||
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
|
const onResizeRef = useRef<TerminalEmulatorProps["onResize"]>(onResize);
|
||||||
|
const onTerminalKeyRef = useRef<TerminalEmulatorProps["onTerminalKey"]>(onTerminalKey);
|
||||||
|
const onPendingModifiersConsumedRef = useRef<
|
||||||
|
TerminalEmulatorProps["onPendingModifiersConsumed"]
|
||||||
|
>(onPendingModifiersConsumed);
|
||||||
|
const pendingModifiersRef = useRef<PendingTerminalModifiers>(pendingModifiers);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onInputRef.current = onInput;
|
onInputRef.current = onInput;
|
||||||
@@ -74,6 +77,18 @@ export default function TerminalEmulator({
|
|||||||
onResizeRef.current = onResize;
|
onResizeRef.current = onResize;
|
||||||
}, [onResize]);
|
}, [onResize]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTerminalKeyRef.current = onTerminalKey;
|
||||||
|
}, [onTerminalKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onPendingModifiersConsumedRef.current = onPendingModifiersConsumed;
|
||||||
|
}, [onPendingModifiersConsumed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingModifiersRef.current = pendingModifiers;
|
||||||
|
}, [pendingModifiers]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
const root = rootRef.current;
|
const root = rootRef.current;
|
||||||
@@ -81,12 +96,6 @@ export default function TerminalEmulator({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logTerminalDebug("mount", {
|
|
||||||
streamKey,
|
|
||||||
hasOnInput: Boolean(onInputRef.current),
|
|
||||||
hasOnResize: Boolean(onResizeRef.current),
|
|
||||||
});
|
|
||||||
|
|
||||||
renderedOutputRef.current = "";
|
renderedOutputRef.current = "";
|
||||||
lastSizeRef.current = null;
|
lastSizeRef.current = null;
|
||||||
host.innerHTML = "";
|
host.innerHTML = "";
|
||||||
@@ -146,6 +155,7 @@ export default function TerminalEmulator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
const viewportElement = host.querySelector<HTMLElement>(".xterm-viewport");
|
||||||
|
const screenElement = host.querySelector<HTMLElement>(".xterm-screen");
|
||||||
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
|
const previousViewportOverscroll = viewportElement?.style.overscrollBehavior ?? "";
|
||||||
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
|
const previousViewportTouchAction = viewportElement?.style.touchAction ?? "";
|
||||||
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
|
const previousViewportOverflowY = viewportElement?.style.overflowY ?? "";
|
||||||
@@ -154,13 +164,19 @@ export default function TerminalEmulator({
|
|||||||
const previousViewportWebkitOverflowScrolling =
|
const previousViewportWebkitOverflowScrolling =
|
||||||
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
|
viewportElement?.style.getPropertyValue("-webkit-overflow-scrolling") ?? "";
|
||||||
if (viewportElement) {
|
if (viewportElement) {
|
||||||
viewportElement.style.overscrollBehavior = "contain";
|
viewportElement.style.overscrollBehavior = "none";
|
||||||
viewportElement.style.touchAction = "pan-y";
|
viewportElement.style.touchAction = "pan-y";
|
||||||
viewportElement.style.overflowY = "auto";
|
viewportElement.style.overflowY = "auto";
|
||||||
viewportElement.style.overflowX = "hidden";
|
viewportElement.style.overflowX = "hidden";
|
||||||
viewportElement.style.pointerEvents = "auto";
|
viewportElement.style.pointerEvents = "auto";
|
||||||
viewportElement.style.setProperty("-webkit-overflow-scrolling", "touch");
|
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;
|
terminalRef.current = terminal;
|
||||||
window.__paseoTerminal = terminal;
|
window.__paseoTerminal = terminal;
|
||||||
@@ -174,7 +190,6 @@ export default function TerminalEmulator({
|
|||||||
try {
|
try {
|
||||||
fitAddon.fit();
|
fitAddon.fit();
|
||||||
} catch {
|
} catch {
|
||||||
logTerminalDebug("fit failed");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,14 +201,6 @@ export default function TerminalEmulator({
|
|||||||
}
|
}
|
||||||
|
|
||||||
lastSizeRef.current = { rows, cols };
|
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);
|
void handler(rows, cols);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,64 +211,156 @@ export default function TerminalEmulator({
|
|||||||
if (!handler) {
|
if (!handler) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logTerminalDebug("input", {
|
|
||||||
length: data.length,
|
|
||||||
preview: data.slice(0, 20),
|
|
||||||
});
|
|
||||||
void handler(data);
|
void handler(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
let lastScrollLogTs = 0;
|
terminal.attachCustomKeyEventHandler((event) => {
|
||||||
let lastWheelLogTs = 0;
|
if (event.type !== "keydown" || event.isComposing) {
|
||||||
let lastTouchMoveLogTs = 0;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const viewportScrollHandler = () => {
|
const normalizedKey = normalizeDomTerminalKey(event.key);
|
||||||
const now = Date.now();
|
if (!normalizedKey || isTerminalModifierDomKey(event.key)) {
|
||||||
if (now - lastScrollLogTs < 120) {
|
return true;
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
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 });
|
const pending = pendingModifiersRef.current;
|
||||||
viewportElement?.addEventListener("wheel", viewportWheelHandler, { passive: true });
|
if (
|
||||||
viewportElement?.addEventListener("touchstart", viewportTouchStartHandler, {
|
!shouldInterceptDomTerminalKey({
|
||||||
passive: true,
|
key: normalizedKey,
|
||||||
});
|
ctrlKey: event.ctrlKey,
|
||||||
viewportElement?.addEventListener("touchmove", viewportTouchMoveHandler, {
|
altKey: event.altKey,
|
||||||
passive: true,
|
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<HTMLElement>(".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(() => {
|
const resizeObserver = new ResizeObserver(() => {
|
||||||
fitAndEmitResize();
|
fitAndEmitResize();
|
||||||
});
|
});
|
||||||
@@ -293,10 +392,10 @@ export default function TerminalEmulator({
|
|||||||
window.removeEventListener("resize", windowResizeHandler);
|
window.removeEventListener("resize", windowResizeHandler);
|
||||||
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
|
||||||
window.clearInterval(fitInterval);
|
window.clearInterval(fitInterval);
|
||||||
viewportElement?.removeEventListener("scroll", viewportScrollHandler);
|
root.removeEventListener("touchstart", rootTouchStartHandler);
|
||||||
viewportElement?.removeEventListener("wheel", viewportWheelHandler);
|
root.removeEventListener("touchmove", rootTouchMoveHandler);
|
||||||
viewportElement?.removeEventListener("touchstart", viewportTouchStartHandler);
|
root.removeEventListener("touchend", rootTouchEndHandler);
|
||||||
viewportElement?.removeEventListener("touchmove", viewportTouchMoveHandler);
|
root.removeEventListener("touchcancel", rootTouchCancelHandler);
|
||||||
|
|
||||||
fitAddon.dispose();
|
fitAddon.dispose();
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
@@ -328,12 +427,14 @@ export default function TerminalEmulator({
|
|||||||
previousViewportWebkitOverflowScrolling
|
previousViewportWebkitOverflowScrolling
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (screenElement) {
|
||||||
|
screenElement.style.pointerEvents = previousScreenPointerEvents;
|
||||||
|
}
|
||||||
|
|
||||||
terminalRef.current = null;
|
terminalRef.current = null;
|
||||||
if (window.__paseoTerminal === terminal) {
|
if (window.__paseoTerminal === terminal) {
|
||||||
window.__paseoTerminal = undefined;
|
window.__paseoTerminal = undefined;
|
||||||
}
|
}
|
||||||
logTerminalDebug("unmount", { streamKey });
|
|
||||||
renderedOutputRef.current = "";
|
renderedOutputRef.current = "";
|
||||||
lastSizeRef.current = null;
|
lastSizeRef.current = null;
|
||||||
};
|
};
|
||||||
@@ -366,6 +467,13 @@ export default function TerminalEmulator({
|
|||||||
renderedOutputRef.current = outputText;
|
renderedOutputRef.current = outputText;
|
||||||
}, [outputText]);
|
}, [outputText]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (focusRequestToken <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
terminalRef.current?.focus();
|
||||||
|
}, [focusRequestToken]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
@@ -384,7 +492,6 @@ export default function TerminalEmulator({
|
|||||||
overscrollBehavior: "none",
|
overscrollBehavior: "none",
|
||||||
}}
|
}}
|
||||||
onPointerDown={() => {
|
onPointerDown={() => {
|
||||||
logTerminalDebug("root pointerdown", { streamKey });
|
|
||||||
terminalRef.current?.focus();
|
terminalRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,17 +2,19 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type LayoutChangeEvent,
|
|
||||||
Pressable,
|
Pressable,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
Text,
|
Text,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Plus, RefreshCw } from "lucide-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 { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
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";
|
import TerminalEmulator from "./terminal-emulator";
|
||||||
|
|
||||||
interface TerminalPaneProps {
|
interface TerminalPaneProps {
|
||||||
@@ -46,48 +48,21 @@ type ModifierState = {
|
|||||||
alt: boolean;
|
alt: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const EMPTY_MODIFIERS: ModifierState = {
|
||||||
|
ctrl: false,
|
||||||
|
shift: false,
|
||||||
|
alt: false,
|
||||||
|
};
|
||||||
|
|
||||||
function terminalScopeKey(serverId: string, cwd: string): string {
|
function terminalScopeKey(serverId: string, cwd: string): string {
|
||||||
return `${serverId}:${cwd}`;
|
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<string, unknown>): void {
|
|
||||||
if (!isTerminalDebugEnabled()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (payload) {
|
|
||||||
console.log("[TerminalDebug][Pane] " + message, payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
console.log("[TerminalDebug][Pane] " + message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
|
||||||
// Optional when rendered inside the mobile explorer sidebar gesture context.
|
|
||||||
let closeGestureRef: React.MutableRefObject<any> | undefined;
|
|
||||||
try {
|
|
||||||
const animation = useExplorerSidebarAnimation();
|
|
||||||
closeGestureRef = animation.closeGestureRef;
|
|
||||||
} catch {
|
|
||||||
// Terminal pane can render outside explorer sidebar during isolated tests.
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
|
||||||
const isConnected = useSessionStore(
|
const isConnected = useSessionStore(
|
||||||
@@ -108,11 +83,8 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>({
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
ctrl: false,
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
shift: false,
|
|
||||||
alt: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const terminalsQuery = useQuery({
|
const terminalsQuery = useQuery({
|
||||||
queryKey: ["terminals", serverId, cwd] as const,
|
queryKey: ["terminals", serverId, cwd] as const,
|
||||||
@@ -287,48 +259,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
[terminals, selectedTerminalId]
|
[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
|
const currentOutput = selectedTerminalId
|
||||||
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
? (outputByTerminalId.get(selectedTerminalId) ?? "")
|
||||||
: "";
|
: "";
|
||||||
@@ -341,20 +271,82 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
createTerminalMutation.mutate();
|
createTerminalMutation.mutate();
|
||||||
}, [createTerminalMutation]);
|
}, [createTerminalMutation]);
|
||||||
|
|
||||||
const handleTerminalData = useCallback(
|
const requestTerminalFocus = useCallback(() => {
|
||||||
async (data: string) => {
|
setFocusRequestToken((current) => current + 1);
|
||||||
if (!client || activeStreamId === null || data.length === 0) {
|
}, []);
|
||||||
return;
|
|
||||||
|
const clearPendingModifiers = useCallback(() => {
|
||||||
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendTerminalKey = useCallback(
|
||||||
|
(
|
||||||
|
input: {
|
||||||
|
key: string;
|
||||||
|
ctrl: boolean;
|
||||||
|
shift: boolean;
|
||||||
|
alt: boolean;
|
||||||
|
meta?: boolean;
|
||||||
}
|
}
|
||||||
logTerminalDebug("send input", {
|
): boolean => {
|
||||||
length: data.length,
|
if (!client || activeStreamId === null) {
|
||||||
preview: data.slice(0, 24),
|
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]
|
[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(
|
const handleTerminalResize = useCallback(
|
||||||
async (rows: number, cols: number) => {
|
async (rows: number, cols: number) => {
|
||||||
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
if (!client || !selectedTerminalId || rows <= 0 || cols <= 0) {
|
||||||
@@ -370,11 +362,6 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logTerminalDebug("send resize", {
|
|
||||||
rows: normalizedRows,
|
|
||||||
cols: normalizedCols,
|
|
||||||
selectedTerminalId,
|
|
||||||
});
|
|
||||||
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols };
|
||||||
client.sendTerminalInput(selectedTerminalId, {
|
client.sendTerminalInput(selectedTerminalId, {
|
||||||
type: "resize",
|
type: "resize",
|
||||||
@@ -385,30 +372,51 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
[client, selectedTerminalId]
|
[client, selectedTerminalId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const toggleModifier = useCallback((modifier: keyof ModifierState) => {
|
const handleTerminalKey = useCallback(
|
||||||
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
|
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(
|
const sendVirtualKey = useCallback(
|
||||||
(key: string) => {
|
(key: string) => {
|
||||||
if (!client || activeStreamId === null) {
|
sendTerminalKey({
|
||||||
return;
|
|
||||||
}
|
|
||||||
client.sendTerminalStreamKey(activeStreamId, {
|
|
||||||
key: key.length === 1 ? key.toLowerCase() : key,
|
|
||||||
ctrl: modifiers.ctrl,
|
|
||||||
shift: modifiers.shift,
|
|
||||||
alt: modifiers.alt,
|
|
||||||
});
|
|
||||||
logTerminalDebug("send virtual key", {
|
|
||||||
key,
|
key,
|
||||||
ctrl: modifiers.ctrl,
|
ctrl: modifiers.ctrl,
|
||||||
shift: modifiers.shift,
|
shift: modifiers.shift,
|
||||||
alt: modifiers.alt,
|
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) {
|
if (!client || !isConnected) {
|
||||||
@@ -492,32 +500,34 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.outputContainer} onLayout={handleOutputLayout}>
|
<View style={styles.outputContainer}>
|
||||||
{selectedTerminal ? (
|
{selectedTerminal ? (
|
||||||
<NativeViewGestureHandler
|
<View style={styles.terminalGestureContainer}>
|
||||||
disallowInterruption={false}
|
<TerminalEmulator
|
||||||
waitFor={waitForCloseGesture ? closeGestureRef : undefined}
|
dom={{
|
||||||
>
|
style: { flex: 1 },
|
||||||
<View style={styles.terminalGestureContainer} onLayout={handleTerminalLayout}>
|
matchContents: false,
|
||||||
<TerminalEmulator
|
scrollEnabled: true,
|
||||||
dom={{
|
nestedScrollEnabled: true,
|
||||||
style: { flex: 1 },
|
overScrollMode: "never",
|
||||||
matchContents: false,
|
bounces: false,
|
||||||
scrollEnabled: true,
|
automaticallyAdjustContentInsets: false,
|
||||||
nestedScrollEnabled: true,
|
contentInsetAdjustmentBehavior: "never",
|
||||||
overScrollMode: "never",
|
}}
|
||||||
}}
|
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
|
||||||
streamKey={`${scopeKey}:${selectedTerminal.id}:${activeStreamId ?? "none"}`}
|
outputText={currentOutput}
|
||||||
outputText={currentOutput}
|
testId="terminal-surface"
|
||||||
testId="terminal-surface"
|
backgroundColor={theme.colors.background}
|
||||||
backgroundColor={theme.colors.background}
|
foregroundColor={theme.colors.foreground}
|
||||||
foregroundColor={theme.colors.foreground}
|
cursorColor={theme.colors.foreground}
|
||||||
cursorColor={theme.colors.foreground}
|
onInput={handleTerminalData}
|
||||||
onInput={handleTerminalData}
|
onResize={handleTerminalResize}
|
||||||
onResize={handleTerminalResize}
|
onTerminalKey={handleTerminalKey}
|
||||||
/>
|
onPendingModifiersConsumed={handlePendingModifiersConsumed}
|
||||||
</View>
|
pendingModifiers={modifiers}
|
||||||
</NativeViewGestureHandler>
|
focusRequestToken={focusRequestToken}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View style={styles.centerState}>
|
<View style={styles.centerState}>
|
||||||
<Text style={styles.stateText}>No terminal selected</Text>
|
<Text style={styles.stateText}>No terminal selected</Text>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user