fix(app): remove terminal debug instrumentation

This commit is contained in:
Mohamed Boudra
2026-02-14 18:09:07 +07:00
parent 25a6f204a4
commit 86b476e309
6 changed files with 665 additions and 355 deletions
+4 -4
View File
@@ -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,
+54 -119
View File
@@ -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,
}; };
+203 -96
View File
@@ -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();
}} }}
> >
+146 -136
View File
@@ -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();
});
});
+159
View File
@@ -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;
}