Improve mobile terminal input, selection, and resizing (#1607)

* Improve mobile terminal typing and selection

* Address mobile terminal review feedback

* Remove dead native terminal input branch

* Replay bracketed paste in terminal preamble

* Stop per-key terminal resize claims

* Stabilize terminal resize browser test

* Use tracked terminal mode for native paste

* Restore terminal toolbar focus handling

* Adapt native terminal to active visibility state

* Stabilize native mobile terminal interactions

* Add native terminal fallback boundary

* test(app): expect explicit terminal resize reclaim

* fix(app): stabilize terminal buffer coordinates

* fix(app): restrict terminal resize ownership

* fix(terminal): gate restored input modes

* fix(app): recover stalled terminal WebViews

* chore(app): remove obsolete terminal harnesses

* fix(app): make terminal resize claims explicit

* test(app): stabilize native terminal device flows

* test(app): track absolute scrollback coordinates

* fix(app): isolate terminal WebView streams

* fix(terminal): arbitrate size ownership across clients

The daemon now distinguishes explicit ownership claims from passive geometry updates. This keeps the active client authoritative while allowing its split, viewport, and keyboard changes to resize the PTY without idle clients stealing it.

* test(server): make worker resize assertion portable

Query the worker's authoritative terminal snapshot instead of invoking a Unix-only shell command, so the resize regression exercises the same boundary on Windows and Unix.
This commit is contained in:
Mohamed Boudra
2026-08-03 19:01:34 +08:00
committed by GitHub
parent 2948a435ed
commit 04fe30a3e2
119 changed files with 9843 additions and 1032 deletions
@@ -0,0 +1,141 @@
import { useCallback, useMemo } from "react";
import { Pressable, Text, type PressableStateCallbackType } from "react-native";
import { StyleSheet } from "react-native-unistyles";
export interface TerminalPasteActionProps {
hasClipboardText: boolean;
onPaste: () => void;
}
export function TerminalPasteAction({ hasClipboardText, onPaste }: TerminalPasteActionProps) {
return (
<TerminalActionButton
label="Paste"
accessibilityLabel="Paste"
testID="terminal-paste"
disabled={!hasClipboardText}
onPress={onPaste}
variant="key"
/>
);
}
export interface TerminalFloatingCopyActionProps {
hasSelection: boolean;
onCopy: () => void;
}
export function TerminalFloatingCopyAction({
hasSelection,
onCopy,
}: TerminalFloatingCopyActionProps) {
if (!hasSelection) {
return null;
}
return (
<TerminalActionButton
label="Copy"
accessibilityLabel="Copy"
testID="terminal-copy"
onPress={onCopy}
variant="floating"
/>
);
}
interface TerminalActionButtonProps {
label: string;
accessibilityLabel: string;
testID: string;
disabled?: boolean;
onPress: () => void;
variant: "key" | "floating";
}
function TerminalActionButton({
label,
accessibilityLabel,
testID,
disabled = false,
onPress,
variant,
}: TerminalActionButtonProps) {
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
variant === "floating" ? styles.floatingButton : styles.keyButton,
disabled && styles.keyButtonDisabled,
(Boolean(hovered) || pressed) &&
!disabled &&
(variant === "floating" ? styles.floatingButtonHovered : styles.keyButtonHovered),
],
[disabled, variant],
);
const textStyle = useMemo(
() => [variant === "floating" ? styles.floatingButtonText : styles.keyButtonText],
[variant],
);
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
const handlePress = useCallback(() => {
if (!disabled) {
onPress();
}
}, [disabled, onPress]);
return (
<Pressable
accessibilityLabel={accessibilityLabel}
accessibilityRole="button"
accessibilityState={accessibilityState}
disabled={disabled}
testID={testID}
onPress={handlePress}
style={pressableStyle}
>
<Text style={textStyle}>{label}</Text>
</Pressable>
);
}
const styles = StyleSheet.create((theme) => ({
keyButton: {
flex: 1,
minWidth: 0,
height: 34,
borderRadius: theme.borderRadius.md,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: theme.spacing[1],
backgroundColor: theme.colors.surface1,
},
keyButtonHovered: {
backgroundColor: theme.colors.surface2,
},
keyButtonDisabled: {
opacity: 0.45,
},
keyButtonText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
floatingButton: {
borderRadius: theme.borderRadius.lg,
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
backgroundColor: "rgba(32, 32, 32, 0.86)",
},
floatingButtonHovered: {
backgroundColor: "rgba(48, 48, 48, 0.92)",
},
floatingButtonText: {
color: "#f5f5f5",
fontSize: theme.fontSize.xs,
fontWeight: theme.fontWeight.medium,
textAlign: "center",
},
}));
@@ -0,0 +1,70 @@
import type { Ref } from "react";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
claimSize: () => void;
showKeyboard: () => void;
blur: () => void;
}
export interface TerminalEmulatorProps {
dom?: unknown;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
keyboardInset?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: {
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onSelectionChange?: (hasSelection: boolean) => void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,688 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ComponentProps,
} from "react";
import {
Keyboard,
StyleSheet,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import { openExternalUrl } from "../utils/open-external-url";
import type { TerminalEmulatorHandle, TerminalEmulatorProps } from "./terminal-emulator-contract";
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
type BridgeInboundMessage =
| {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "paste"; streamKey: string; text: string }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string; forceClaim: boolean; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| {
type: "resize";
streamKey: string;
rows: number;
cols: number;
shouldClaim?: boolean;
forceClaim?: boolean;
}
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
type WebViewProps = ComponentProps<typeof WebView>;
interface PendingTerminalTap {
startX: number;
startY: number;
moved: boolean;
}
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
}
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
function createMountMessage(input: {
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
return {
type: "mount",
streamKey: input.streamKey,
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
fontFamily: input.fontFamily,
fontSize: input.fontSize,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
}
export default function WebViewTerminalEmulator({
ref,
streamKey,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const webViewRef = useRef<WebView>(null);
const [webViewEpoch, setWebViewEpoch] = useState(0);
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
const bridgeReadyRef = useRef(false);
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountRequestedStreamKeyRef = useRef<string | null>(null);
const rendererReadyStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
});
mountConfigRef.current = {
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
});
callbacksRef.current = {
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
};
const clearBridgeReadyTimeout = useCallback(() => {
if (bridgeReadyTimeoutRef.current === null) return;
clearTimeout(bridgeReadyTimeoutRef.current);
bridgeReadyTimeoutRef.current = null;
}, []);
const clearRendererReadyTimeout = useCallback(() => {
if (rendererReadyTimeoutRef.current === null) return;
clearTimeout(rendererReadyTimeoutRef.current);
rendererReadyTimeoutRef.current = null;
}, []);
const resetWebViewDocument = useCallback(() => {
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountRequestedStreamKeyRef.current = null;
rendererReadyStreamKeyRef.current = null;
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
setWebViewEpoch((value) => value + 1);
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
const scheduleBridgeReadyWatchdog = useCallback(() => {
clearBridgeReadyTimeout();
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
bridgeReadyTimeoutRef.current = setTimeout(() => {
bridgeReadyTimeoutRef.current = null;
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
return;
}
resetWebViewDocument();
}, BRIDGE_READY_TIMEOUT_MS);
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
const scheduleRendererReadyWatchdog = useCallback(() => {
clearRendererReadyTimeout();
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
rendererReadyTimeoutRef.current = setTimeout(() => {
rendererReadyTimeoutRef.current = null;
if (
!shouldResetForMissingRendererReady({
expectedReadyVersion: expectedRendererReadyVersion,
currentReadyVersion: rendererReadyVersionRef.current,
expectedStreamKey: streamKey,
mountRequestedStreamKey: mountRequestedStreamKeyRef.current,
rendererReadyStreamKey: rendererReadyStreamKeyRef.current,
})
) {
return;
}
resetWebViewDocument();
}, RENDERER_READY_TIMEOUT_MS);
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
const flushPendingMessages = useCallback(() => {
if (!bridgeReadyRef.current || !webViewRef.current) return;
const pending = pendingMessagesRef.current.splice(0);
for (const message of pending) {
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}
}, []);
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
if (!bridgeReadyRef.current || !webViewRef.current) {
pendingMessagesRef.current.push(message);
return;
}
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}, []);
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
paste: (text: string) => {
sendToWebView({ type: "paste", streamKey, text });
},
copySelection: async () => "",
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
claimSize: () => {
sendToWebView({ type: "resize", streamKey, forceClaim: true });
},
showKeyboard: () => {
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
webViewRef.current?.requestFocus();
},
blur: () => {
webViewRef.current?.injectJavaScript(
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
);
Keyboard.dismiss();
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
mountRequestedStreamKeyRef.current = streamKey;
rendererReadyStreamKeyRef.current = null;
sendToWebView(mountMessage);
flushPendingMessages();
scheduleRendererReadyWatchdog();
}, [
bridgeReadyVersion,
flushPendingMessages,
scheduleRendererReadyWatchdog,
sendToWebView,
streamKey,
]);
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
}, [fontFamily, fontSize, sendToWebView, streamKey]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
}, [pendingModifiers, sendToWebView, streamKey]);
useEffect(() => {
if (!mountRequestedStreamKeyRef.current) return;
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, forceClaim: true });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, forceClaim: false, shouldClaim: false });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
return () => {
if (mountRequestedStreamKeyRef.current) {
const previousStreamKey = mountRequestedStreamKeyRef.current;
callbacksRef.current.onRendererReadyChange?.({
streamKey: previousStreamKey,
isReady: false,
});
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
}
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountRequestedStreamKeyRef.current = null;
rendererReadyStreamKeyRef.current = null;
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
};
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
const handleLifecycleMessage = useCallback(
(message: BridgeOutboundMessage): boolean => {
if (message.type === "bridgeReady") {
bridgeReadyRef.current = true;
bridgeReadyVersionRef.current += 1;
clearBridgeReadyTimeout();
setBridgeReadyVersion((value) => value + 1);
return true;
}
if (message.type === "rendererReady") {
if (message.streamKey === mountRequestedStreamKeyRef.current) {
rendererReadyStreamKeyRef.current = message.isReady ? message.streamKey : null;
if (message.isReady) {
rendererReadyVersionRef.current += 1;
clearRendererReadyTimeout();
}
}
callbacksRef.current.onRendererReadyChange?.({
streamKey: message.streamKey,
isReady: message.isReady,
});
return true;
}
return false;
},
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const resolveLocalFileLink = useCallback(
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
try {
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
null);
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target,
});
} catch {
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target: null,
});
}
},
[sendToWebView],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
if (message.type === "resolveLocalFileLink") {
void resolveLocalFileLink(message);
return;
}
if (message.type === "openLocalFileLink") {
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
return;
}
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({
rows: message.rows,
cols: message.cols,
shouldClaim: message.shouldClaim !== false,
forceClaim: message.forceClaim,
});
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
key: message.key,
ctrl: message.ctrl,
shift: message.shift,
alt: message.alt,
meta: message.meta,
});
break;
case "pendingModifiersConsumed":
callbacksRef.current.onPendingModifiersConsumed?.();
break;
case "inputModeChange":
callbacksRef.current.onInputModeChange?.(message.state);
break;
case "openExternalUrl":
void openExternalUrl(message.url);
break;
case "swipeLeft":
callbacksRef.current.onSwipeLeft?.();
break;
case "swipeRight":
callbacksRef.current.onSwipeRight?.();
break;
case "debug":
break;
}
},
[resolveLocalFileLink],
);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: BridgeOutboundMessage;
try {
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
} catch {
return;
}
if (message.type === "bridgeReady" || message.type === "rendererReady") {
handleLifecycleMessage(message);
return;
}
if (
!shouldHandleTerminalBridgeMessage(message, {
desiredStreamKey: streamKey,
mountedStreamKey: mountRequestedStreamKeyRef.current,
})
) {
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage, streamKey],
);
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
bridgeReadyRef.current = false;
mountRequestedStreamKeyRef.current = null;
rendererReadyStreamKeyRef.current = null;
scheduleBridgeReadyWatchdog();
}, [scheduleBridgeReadyWatchdog]);
const handleContentProcessDidTerminate = useCallback<
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleRenderProcessGone = useCallback<
NonNullable<WebViewProps["onRenderProcessGone"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
pendingTapRef.current = {
startX: event.nativeEvent.pageX,
startY: event.nativeEvent.pageY,
moved: false,
};
}, []);
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
const pendingTap = pendingTapRef.current;
if (!pendingTap) return;
const dx = event.nativeEvent.pageX - pendingTap.startX;
const dy = event.nativeEvent.pageY - pendingTap.startY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
pendingTap.moved = true;
}
}, []);
const handleWebViewTouchEnd = useCallback(() => {
const pendingTap = pendingTapRef.current;
pendingTapRef.current = null;
if (!pendingTap || pendingTap.moved) {
return;
}
webViewRef.current?.requestFocus();
callbacksRef.current.onFocus?.();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
const handleWebViewTouchCancel = useCallback(() => {
pendingTapRef.current = null;
}, []);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
const rootStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor }],
[backgroundColor],
);
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor }],
[backgroundColor],
);
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webViewContainer, { backgroundColor }],
[backgroundColor],
);
return (
<View style={rootStyle} testID={testId}>
<WebView
key={webViewEpoch}
ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle}
containerStyle={webViewContainerStyle}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled
nestedScrollEnabled
bounces={false}
overScrollMode="never"
keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
textInteractionEnabled
allowsLinkPreview={false}
setSupportMultipleWindows={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
onTouchStart={handleWebViewTouchStart}
onTouchMove={handleWebViewTouchMove}
onTouchEnd={handleWebViewTouchEnd}
onTouchCancel={handleWebViewTouchCancel}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
onRenderProcessGone={handleRenderProcessGone}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
},
webView: {
flex: 1,
},
webViewContainer: {
flex: 1,
},
});
@@ -1,693 +1,56 @@
import {
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ComponentProps,
type Ref,
} from "react";
import {
Keyboard,
StyleSheet,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalEmulatorWebViewHtml } from "../terminal/webview/terminal-emulator-webview-html";
import type { PendingTerminalModifiers } from "../utils/terminal-keys";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { useEffect } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { useAppSettings } from "../hooks/use-settings";
import { resolveTerminalRendererSelection } from "../terminal/native-renderer/terminal-renderer-capability";
import NativeGridTerminalEmulator from "./terminal-emulator-native-grid.native";
import WebViewTerminalEmulator from "./terminal-emulator-webview.native";
import type { TerminalEmulatorProps } from "./terminal-emulator-contract";
export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
blur: () => void;
}
export type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
interface TerminalEmulatorProps {
dom?: unknown;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
onOpenLocalFileLink?: (
target: TerminalLocalFileLinkTarget,
disposition: "main" | "side",
) => Promise<void> | void;
onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
pendingModifiers?: PendingTerminalModifiers;
focusRequestToken?: number;
resizeRequestToken?: number;
}
type BridgeInboundMessage =
| {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setFont"; streamKey: string; fontFamily?: string; fontSize?: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
| { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean }
| {
type: "resolveLocalFileLinkResponse";
streamKey: string;
requestId: number;
target: TerminalLocalFileLinkTarget | null;
};
type BridgeOutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
| {
type: "terminalKey";
streamKey: string;
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta: boolean;
}
| { type: "pendingModifiersConsumed"; streamKey: string }
| { type: "inputModeChange"; streamKey: string; state: TerminalInputModeState }
| { type: "openExternalUrl"; streamKey: string; url: string }
| {
type: "resolveLocalFileLink";
streamKey: string;
requestId: number;
source: TerminalLocalFileLinkSource;
}
| {
type: "openLocalFileLink";
streamKey: string;
target: TerminalLocalFileLinkTarget;
disposition: "main" | "side";
}
| { type: "swipeLeft"; streamKey: string }
| { type: "swipeRight"; streamKey: string }
| { type: "debug"; message: string; details?: unknown };
const TERMINAL_WEBVIEW_SOURCE = { html: terminalEmulatorWebViewHtml };
const TERMINAL_WEBVIEW_ORIGIN_WHITELIST = ["*"];
const BRIDGE_READY_TIMEOUT_MS = 2_500;
const RENDERER_READY_TIMEOUT_MS = 2_500;
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
type WebViewProps = ComponentProps<typeof WebView>;
interface PendingTerminalTap {
startX: number;
startY: number;
moved: boolean;
}
function buildThemeKey(theme: ITheme): string {
return JSON.stringify(theme);
}
function serializeForInjectedJavaScript(message: BridgeInboundMessage): string {
return JSON.stringify(message).replace(/<\/script/gi, "<\\/script");
}
function createMountMessage(input: {
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}): BridgeInboundMessage {
return {
type: "mount",
streamKey: input.streamKey,
initialSnapshot: input.initialSnapshot,
scrollbackLines: input.scrollbackLines,
theme: input.theme,
fontFamily: input.fontFamily,
fontSize: input.fontSize,
pendingModifiers: input.pendingModifiers,
swipeGesturesEnabled: input.swipeGesturesEnabled,
};
}
export default function TerminalEmulator({
ref,
streamKey,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
foreground: "#e6e6e6",
cursor: "#e6e6e6",
},
scrollbackLines,
fontFamily,
fontSize,
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}: TerminalEmulatorProps) {
const webViewRef = useRef<WebView>(null);
const [webViewEpoch, setWebViewEpoch] = useState(0);
const [bridgeReadyVersion, setBridgeReadyVersion] = useState(0);
const bridgeReadyRef = useRef(false);
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingTapRef = useRef<PendingTerminalTap | null>(null);
const mountConfigRef = useRef({
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
});
mountConfigRef.current = {
streamKey,
initialSnapshot,
scrollbackLines,
theme: xtermTheme,
fontFamily,
fontSize,
pendingModifiers,
swipeGesturesEnabled,
};
const callbacksRef = useRef({
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
});
callbacksRef.current = {
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onSwipeLeft,
onSwipeRight,
};
const clearBridgeReadyTimeout = useCallback(() => {
if (bridgeReadyTimeoutRef.current === null) return;
clearTimeout(bridgeReadyTimeoutRef.current);
bridgeReadyTimeoutRef.current = null;
}, []);
const clearRendererReadyTimeout = useCallback(() => {
if (rendererReadyTimeoutRef.current === null) return;
clearTimeout(rendererReadyTimeoutRef.current);
rendererReadyTimeoutRef.current = null;
}, []);
const resetWebViewDocument = useCallback(() => {
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountedStreamKeyRef.current = null;
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
setWebViewEpoch((value) => value + 1);
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, streamKey]);
const scheduleBridgeReadyWatchdog = useCallback(() => {
clearBridgeReadyTimeout();
const expectedBridgeReadyVersion = bridgeReadyVersionRef.current;
bridgeReadyTimeoutRef.current = setTimeout(() => {
bridgeReadyTimeoutRef.current = null;
if (bridgeReadyVersionRef.current !== expectedBridgeReadyVersion || bridgeReadyRef.current) {
return;
}
resetWebViewDocument();
}, BRIDGE_READY_TIMEOUT_MS);
}, [clearBridgeReadyTimeout, resetWebViewDocument]);
const scheduleRendererReadyWatchdog = useCallback(() => {
clearRendererReadyTimeout();
const expectedRendererReadyVersion = rendererReadyVersionRef.current;
rendererReadyTimeoutRef.current = setTimeout(() => {
rendererReadyTimeoutRef.current = null;
if (
rendererReadyVersionRef.current !== expectedRendererReadyVersion ||
mountedStreamKeyRef.current === streamKey
) {
return;
}
resetWebViewDocument();
}, RENDERER_READY_TIMEOUT_MS);
}, [clearRendererReadyTimeout, resetWebViewDocument, streamKey]);
const flushPendingMessages = useCallback(() => {
if (!bridgeReadyRef.current || !webViewRef.current) return;
const pending = pendingMessagesRef.current.splice(0);
for (const message of pending) {
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}
}, []);
const sendToWebView = useCallback((message: BridgeInboundMessage) => {
if (!bridgeReadyRef.current || !webViewRef.current) {
pendingMessagesRef.current.push(message);
return;
}
const payload = serializeForInjectedJavaScript(message);
webViewRef.current.injectJavaScript(
`window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ && window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__(${payload}); true;`,
);
}, []);
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
blur: () => {
webViewRef.current?.injectJavaScript(
"window.__PASEO_TERMINAL_WEBVIEW_BLUR__ && window.__PASEO_TERMINAL_WEBVIEW_BLUR__(); true;",
);
Keyboard.dismiss();
},
}),
[sendToWebView, streamKey],
);
function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: TerminalEmulatorProps) {
const { t } = useTranslation();
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
mountedStreamKeyRef.current = streamKey;
sendToWebView(mountMessage);
flushPendingMessages();
scheduleRendererReadyWatchdog();
}, [
bridgeReadyVersion,
flushPendingMessages,
scheduleRendererReadyWatchdog,
sendToWebView,
streamKey,
]);
const themeKey = useMemo(() => buildThemeKey(xtermTheme), [xtermTheme]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setTheme", streamKey, theme: xtermTheme });
}, [sendToWebView, streamKey, themeKey, xtermTheme]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setScrollback", streamKey, lines: scrollbackLines });
}, [scrollbackLines, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setFont", streamKey, fontFamily, fontSize });
}, [fontFamily, fontSize, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setPendingModifiers", streamKey, pendingModifiers });
}, [pendingModifiers, sendToWebView, streamKey]);
useEffect(() => {
if (!mountedStreamKeyRef.current) return;
sendToWebView({ type: "setSwipeGesturesEnabled", streamKey, enabled: swipeGesturesEnabled });
}, [sendToWebView, streamKey, swipeGesturesEnabled]);
useEffect(() => {
if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => {
if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey, shouldClaim: true });
}, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => {
return () => {
if (mountedStreamKeyRef.current) {
const previousStreamKey = mountedStreamKeyRef.current;
callbacksRef.current.onRendererReadyChange?.({
streamKey: previousStreamKey,
isReady: false,
});
sendToWebView({ type: "unmount", streamKey: previousStreamKey });
}
bridgeReadyRef.current = false;
pendingMessagesRef.current = [];
mountedStreamKeyRef.current = null;
clearBridgeReadyTimeout();
clearRendererReadyTimeout();
};
}, [clearBridgeReadyTimeout, clearRendererReadyTimeout, sendToWebView]);
const handleLifecycleMessage = useCallback(
(message: BridgeOutboundMessage): boolean => {
if (message.type === "bridgeReady") {
bridgeReadyRef.current = true;
bridgeReadyVersionRef.current += 1;
clearBridgeReadyTimeout();
setBridgeReadyVersion((value) => value + 1);
return true;
}
if (message.type === "rendererReady") {
mountedStreamKeyRef.current = message.isReady ? message.streamKey : null;
if (message.isReady) {
rendererReadyVersionRef.current += 1;
clearRendererReadyTimeout();
}
callbacksRef.current.onRendererReadyChange?.({
streamKey: message.streamKey,
isReady: message.isReady,
});
return true;
}
return false;
},
[clearBridgeReadyTimeout, clearRendererReadyTimeout],
);
const resolveLocalFileLink = useCallback(
async (message: Extract<BridgeOutboundMessage, { type: "resolveLocalFileLink" }>) => {
try {
const target = await (callbacksRef.current.onResolveLocalFileLink?.(message.source) ??
null);
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target,
});
} catch {
sendToWebView({
type: "resolveLocalFileLinkResponse",
streamKey: message.streamKey,
requestId: message.requestId,
target: null,
});
}
},
[sendToWebView],
);
const handleTerminalMessage = useCallback(
(
message: Exclude<BridgeOutboundMessage, { type: "bridgeReady" } | { type: "rendererReady" }>,
) => {
if (message.type === "resolveLocalFileLink") {
void resolveLocalFileLink(message);
return;
}
if (message.type === "openLocalFileLink") {
callbacksRef.current.onOpenLocalFileLink?.(message.target, message.disposition);
return;
}
switch (message.type) {
case "input":
callbacksRef.current.onInput?.(message.data);
break;
case "resize":
callbacksRef.current.onResize?.({
rows: message.rows,
cols: message.cols,
shouldClaim: message.shouldClaim !== false,
});
break;
case "terminalKey":
callbacksRef.current.onTerminalKey?.({
key: message.key,
ctrl: message.ctrl,
shift: message.shift,
alt: message.alt,
meta: message.meta,
});
break;
case "pendingModifiersConsumed":
callbacksRef.current.onPendingModifiersConsumed?.();
break;
case "inputModeChange":
callbacksRef.current.onInputModeChange?.(message.state);
break;
case "openExternalUrl":
void openExternalUrl(message.url);
break;
case "swipeLeft":
callbacksRef.current.onSwipeLeft?.();
break;
case "swipeRight":
callbacksRef.current.onSwipeRight?.();
break;
case "debug":
break;
}
},
[resolveLocalFileLink],
);
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
let message: BridgeOutboundMessage;
try {
message = JSON.parse(event.nativeEvent.data) as BridgeOutboundMessage;
} catch {
return;
}
if (message.type === "bridgeReady" || message.type === "rendererReady") {
handleLifecycleMessage(message);
return;
}
handleTerminalMessage(message);
},
[handleLifecycleMessage, handleTerminalMessage],
);
const handleLoadStart = useCallback<NonNullable<WebViewProps["onLoadStart"]>>(() => {
bridgeReadyRef.current = false;
mountedStreamKeyRef.current = null;
scheduleBridgeReadyWatchdog();
}, [scheduleBridgeReadyWatchdog]);
const handleContentProcessDidTerminate = useCallback<
NonNullable<WebViewProps["onContentProcessDidTerminate"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleRenderProcessGone = useCallback<
NonNullable<WebViewProps["onRenderProcessGone"]>
>(() => {
resetWebViewDocument();
}, [resetWebViewDocument]);
const handleWebViewTouchStart = useCallback((event: GestureResponderEvent) => {
pendingTapRef.current = {
startX: event.nativeEvent.pageX,
startY: event.nativeEvent.pageY,
moved: false,
};
}, []);
const handleWebViewTouchMove = useCallback((event: GestureResponderEvent) => {
const pendingTap = pendingTapRef.current;
if (!pendingTap) return;
const dx = event.nativeEvent.pageX - pendingTap.startX;
const dy = event.nativeEvent.pageY - pendingTap.startY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
pendingTap.moved = true;
}
}, []);
const handleWebViewTouchEnd = useCallback(() => {
const pendingTap = pendingTapRef.current;
pendingTapRef.current = null;
if (!pendingTap || pendingTap.moved) {
return;
}
webViewRef.current?.requestFocus();
callbacksRef.current.onFocus?.();
sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]);
const handleWebViewTouchCancel = useCallback(() => {
pendingTapRef.current = null;
}, []);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
const rootStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.root, { backgroundColor }],
[backgroundColor],
);
const webViewStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webView, { backgroundColor }],
[backgroundColor],
);
const webViewContainerStyle = useMemo<StyleProp<ViewStyle>>(
() => [styles.webViewContainer, { backgroundColor }],
[backgroundColor],
);
onRendererReadyChange?.({ streamKey, isReady: false });
}, [onRendererReadyChange, streamKey]);
return (
<View style={rootStyle} testID={testId}>
<WebView
key={webViewEpoch}
ref={webViewRef}
source={TERMINAL_WEBVIEW_SOURCE}
style={webViewStyle}
containerStyle={webViewContainerStyle}
originWhitelist={TERMINAL_WEBVIEW_ORIGIN_WHITELIST}
scrollEnabled
nestedScrollEnabled
bounces={false}
overScrollMode="never"
keyboardDisplayRequiresUserAction={false}
automaticallyAdjustContentInsets={false}
contentInsetAdjustmentBehavior="never"
textInteractionEnabled
allowsLinkPreview={false}
setSupportMultipleWindows={false}
setBuiltInZoomControls={false}
setDisplayZoomControls={false}
textZoom={100}
onTouchStart={handleWebViewTouchStart}
onTouchMove={handleWebViewTouchMove}
onTouchEnd={handleWebViewTouchEnd}
onTouchCancel={handleWebViewTouchCancel}
onMessage={handleMessage}
onLoadStart={handleLoadStart}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
onRenderProcessGone={handleRenderProcessGone}
/>
<View style={styles.unsupportedContainer}>
<Text style={styles.unsupportedText}>{t("workspace.terminal.updateHost")}</Text>
</View>
);
}
const styles = StyleSheet.create({
root: {
export default function TerminalEmulator(props: TerminalEmulatorProps) {
const { settings } = useAppSettings();
const { useLegacyTerminalRenderer } = settings;
const rendererSelection = resolveTerminalRendererSelection({
useLegacyTerminalRenderer,
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
});
if (rendererSelection === "update-host") {
return <UnsupportedNativeTerminal {...props} />;
}
const Renderer =
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
return <Renderer key={rendererSelection} {...props} />;
}
const styles = StyleSheet.create((theme) => ({
unsupportedContainer: {
flex: 1,
minHeight: 0,
minWidth: 0,
overflow: "hidden",
alignItems: "center",
justifyContent: "center",
padding: 24,
},
webView: {
flex: 1,
unsupportedText: {
color: theme.colors.foregroundMuted,
textAlign: "center",
},
webViewContainer: {
flex: 1,
},
});
}));
@@ -23,10 +23,12 @@ import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import { encodeTerminalPaste } from "../terminal/runtime/terminal-paste";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import type { TerminalClipboardWriter } from "../terminal/native-renderer/terminal-selection";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -42,7 +44,11 @@ export interface TerminalEmulatorHandle {
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
paste: (text: string) => void;
copySelection: (clipboard: TerminalClipboardWriter) => Promise<string>;
clear: () => void;
claimSize: () => void;
showKeyboard: () => void;
blur: () => void;
}
@@ -93,18 +99,25 @@ interface TerminalEmulatorProps {
dom?: DOMProps;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
supportsTerminalInputModeReplay: boolean;
testId?: string;
xtermTheme?: ITheme;
scrollbackLines: number;
fontFamily?: string;
fontSize?: number;
keyboardInset?: number;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onResize?: (input: {
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}) => Promise<void> | void;
onTerminalKey?: (input: {
key: string;
ctrl: boolean;
@@ -114,6 +127,7 @@ interface TerminalEmulatorProps {
}) => Promise<void> | void;
onPendingModifiersConsumed?: () => Promise<void> | void;
onInputModeChange?: (state: TerminalInputModeState) => Promise<void> | void;
onSelectionChange?: (hasSelection: boolean) => void;
onResolveLocalFileLink?: (
source: TerminalLocalFileLinkSource,
) => Promise<TerminalLocalFileLinkTarget | null> | TerminalLocalFileLinkTarget | null;
@@ -211,6 +225,18 @@ export default function TerminalEmulator({
const dropActiveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const domBridgeRef = useRef<DOMImperativeFactory | null>(null);
const pasteText = useCallback((text: string) => {
if (text.length === 0) {
return;
}
mountCallbacksRef.current.onInput?.(
encodeTerminalPaste({
text,
bracketedPaste: runtimeRef.current?.getInputModeState().bracketedPaste ?? false,
}),
);
}, []);
useDOMImperativeHandle(
domBridgeRef,
(): DOMImperativeFactory => ({
@@ -230,14 +256,28 @@ export default function TerminalEmulator({
runtimeRef.current?.renderSnapshot({ state });
}
},
paste: (...args) => {
const text = args[0];
if (typeof text === "string" && text.length > 0) {
pasteText(text);
}
},
copySelection: async () => "",
clear: () => {
runtimeRef.current?.clear();
},
claimSize: () => {
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
},
showKeyboard: () => {
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
runtimeRef.current?.focus();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
[pasteText],
);
useImperativeHandle(
ref,
@@ -251,14 +291,25 @@ export default function TerminalEmulator({
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
paste: (text: string) => {
pasteText(text);
},
copySelection: async () => "",
clear: () => {
runtimeRef.current?.clear();
},
claimSize: () => {
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
},
showKeyboard: () => {
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
runtimeRef.current?.focus();
},
blur: () => {
runtimeRef.current?.blur();
},
}),
[],
[pasteText],
);
useEffect(() => {
@@ -458,7 +509,7 @@ export default function TerminalEmulator({
if (focusRequestToken <= 0) {
return () => {};
}
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.resize({ forceClaim: true, shouldClaim: true });
return focusWithRetries({
focus: () => {
runtimeRef.current?.focus();
@@ -478,7 +529,7 @@ export default function TerminalEmulator({
if (resizeRequestToken <= 0) {
return;
}
runtimeRef.current?.resize({ force: true, shouldClaim: true });
runtimeRef.current?.resize({ forceClaim: false, shouldClaim: false });
}, [resizeRequestToken]);
const showTerminalContextMenu = useCallback(() => {
@@ -3,6 +3,7 @@ import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
reconcileFocusClaim,
resolveTerminalResizeClaim,
settleFocusClaim,
type FocusClaimState,
} from "./terminal-pane-focus-claim";
@@ -15,9 +16,124 @@ function request(
}
describe("terminal pane focus claim", () => {
it("forwards resize callbacks only from the active focused ready candidate", () => {
const readiness = {
isWorkspaceFocused: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: true,
isRendererReady: true,
};
const candidates = [
resolveTerminalResizeClaim({
size: { rows: 30, cols: 90 },
previousSentSize: null,
shouldClaim: true,
forceClaim: false,
supportsTerminalSizeOwnership: true,
readiness: { ...readiness, isPaneFocused: false },
}),
resolveTerminalResizeClaim({
size: { rows: 42, cols: 120 },
previousSentSize: null,
shouldClaim: true,
forceClaim: false,
supportsTerminalSizeOwnership: true,
readiness: { ...readiness, isPaneFocused: true },
}),
];
expect(candidates).toEqual([
{ shouldSend: false, intent: "claim" },
{ shouldSend: true, intent: "claim" },
]);
});
it("sends passive refits as owner-only updates and lets interaction reclaim the same size", () => {
const readiness = {
isWorkspaceFocused: true,
isPaneFocused: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: true,
isRendererReady: true,
};
const size = { rows: 42, cols: 120 };
const passiveRefit = resolveTerminalResizeClaim({
size,
previousSentSize: size,
shouldClaim: false,
forceClaim: false,
supportsTerminalSizeOwnership: true,
readiness,
});
const ordinarySameSizeMeasurement = resolveTerminalResizeClaim({
size,
previousSentSize: size,
shouldClaim: true,
forceClaim: false,
supportsTerminalSizeOwnership: true,
readiness,
});
const explicitReclaim = resolveTerminalResizeClaim({
size,
previousSentSize: size,
shouldClaim: true,
forceClaim: true,
supportsTerminalSizeOwnership: true,
readiness,
});
expect(passiveRefit).toEqual({ shouldSend: false, intent: "update" });
expect(ordinarySameSizeMeasurement).toEqual({ shouldSend: true, intent: "claim" });
expect(explicitReclaim.shouldSend).toBe(true);
});
it("lets the current owner update after pane focus moves without transferring ownership", () => {
const result = resolveTerminalResizeClaim({
size: { rows: 20, cols: 100 },
previousSentSize: { rows: 40, cols: 100 },
shouldClaim: false,
forceClaim: false,
supportsTerminalSizeOwnership: true,
readiness: {
isWorkspaceFocused: true,
isPaneFocused: false,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: true,
isRendererReady: true,
},
});
expect(result).toEqual({ shouldSend: true, intent: "update" });
});
it("keeps passive refits local against legacy daemons", () => {
const result = resolveTerminalResizeClaim({
size: { rows: 20, cols: 100 },
previousSentSize: { rows: 40, cols: 100 },
shouldClaim: false,
forceClaim: false,
supportsTerminalSizeOwnership: false,
readiness: {
isWorkspaceFocused: true,
isPaneFocused: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: true,
isRendererReady: true,
},
});
expect(result).toEqual({ shouldSend: false, intent: "update" });
});
it("waits for both the client and renderer before requesting a claim", () => {
const withoutClient = canRequestFocusClaim({
isWorkspaceFocused: true,
isPaneFocused: true,
isAppActivelyVisible: true,
isClientReady: false,
isConnected: true,
@@ -25,6 +141,7 @@ describe("terminal pane focus claim", () => {
});
const withoutRenderer = canRequestFocusClaim({
isWorkspaceFocused: true,
isPaneFocused: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: true,
@@ -37,6 +154,7 @@ describe("terminal pane focus claim", () => {
it("does not deliver a requested claim after the host disconnects", () => {
const disconnected = canRequestFocusClaim({
isWorkspaceFocused: true,
isPaneFocused: true,
isAppActivelyVisible: true,
isClientReady: true,
isConnected: false,
@@ -8,14 +8,69 @@ export interface FocusClaimStep {
shouldRequest: boolean;
}
interface FocusClaimReadiness {
export interface FocusClaimReadiness {
isWorkspaceFocused: boolean;
isPaneFocused: boolean;
isAppActivelyVisible: boolean;
isClientReady: boolean;
isConnected: boolean;
isRendererReady: boolean;
}
interface TerminalSize {
rows: number;
cols: number;
}
export function resolveTerminalResizeClaim(input: {
size: TerminalSize;
previousSentSize: TerminalSize | null;
shouldClaim: boolean;
forceClaim: boolean;
supportsTerminalSizeOwnership: boolean;
readiness: FocusClaimReadiness;
}): { shouldSend: boolean; intent: "claim" | "update" } {
const intent = input.shouldClaim ? "claim" : "update";
if (intent === "claim") {
if (!canRequestFocusClaim(input.readiness)) {
return { shouldSend: false, intent };
}
if (input.supportsTerminalSizeOwnership) {
return { shouldSend: true, intent };
}
return {
shouldSend:
input.forceClaim ||
input.previousSentSize === null ||
input.previousSentSize.rows !== input.size.rows ||
input.previousSentSize.cols !== input.size.cols,
intent,
};
}
if (!input.supportsTerminalSizeOwnership || !canRequestOwnedSizeUpdate(input.readiness)) {
return { shouldSend: false, intent };
}
return {
shouldSend:
input.previousSentSize === null ||
input.previousSentSize.rows !== input.size.rows ||
input.previousSentSize.cols !== input.size.cols,
intent,
};
}
function canRequestOwnedSizeUpdate(input: FocusClaimReadiness): boolean {
return (
input.isWorkspaceFocused &&
input.isAppActivelyVisible &&
input.isClientReady &&
input.isConnected &&
input.isRendererReady
);
}
export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
claimedKey: null,
requestedKey: null,
@@ -24,6 +79,7 @@ export const EMPTY_FOCUS_CLAIM_STATE: FocusClaimState = {
export function canRequestFocusClaim(input: FocusClaimReadiness): boolean {
return (
input.isWorkspaceFocused &&
input.isPaneFocused &&
input.isAppActivelyVisible &&
input.isClientReady &&
input.isConnected &&
+325 -149
View File
@@ -1,11 +1,16 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useRetainedPanelActive } from "@/components/retained-panel";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import * as Clipboard from "expo-clipboard";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { encodeTerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import { Keyboard as KeyboardIcon, KeyboardOff as KeyboardOffIcon } from "lucide-react-native";
import type { TerminalKeyInput } from "@getpaseo/protocol/terminal-key-input";
import {
DEFAULT_TERMINAL_INPUT_MODE_STATE,
type TerminalInputModeState,
} from "@getpaseo/protocol/terminal-input-mode";
import { useTranslation } from "react-i18next";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
@@ -13,14 +18,28 @@ import { useAppActivelyVisible } from "@/hooks/use-app-visible";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
hasPendingTerminalModifiers,
normalizeTerminalTransportKey,
resolvePendingModifierDataInput,
} from "@/utils/terminal-keys";
import {
createTerminalKeyInput,
dispatchTerminalKeyInput,
EMPTY_TERMINAL_KEY_MODIFIERS,
type TerminalKeyModifierState,
} from "@/terminal/runtime/terminal-key-dispatch";
import {
getTerminalVirtualKeyboardControlId,
shouldShowTerminalFloatingCopyAction,
shouldShowTerminalPasteAction,
TERMINAL_VIRTUAL_KEYBOARD_ROWS,
type TerminalVirtualKeyboardControl,
} from "@/terminal/runtime/terminal-virtual-keyboard";
import { pasteTerminalClipboard } from "@/terminal/runtime/terminal-paste";
import { getWorkspaceTerminalSession } from "@/terminal/runtime/workspace-terminal-session";
import {
EMPTY_FOCUS_CLAIM_STATE,
canRequestFocusClaim,
reconcileFocusClaim,
resolveTerminalResizeClaim,
settleFocusClaim,
} from "./terminal-pane-focus-claim";
import {
@@ -29,12 +48,16 @@ import {
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useBlockMobilePanelOpenGestures } from "@/mobile-panels/provider";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
applyTerminalRendererReadyChange,
resolveTerminalStreamTarget,
shouldReplayTerminalSnapshotForRenderer,
shouldShowTerminalLoadingOverlay,
type TerminalRendererReadyChange,
@@ -69,23 +92,9 @@ const MODIFIER_LABELS = {
alt: "Alt",
} as const;
const KEY_BUTTONS = {
esc: { id: "esc", label: "Esc", key: "Escape" },
tab: { id: "tab", label: "Tab", key: "Tab" },
up: { id: "up", label: "↑", key: "ArrowUp" },
down: { id: "down", label: "↓", key: "ArrowDown" },
left: { id: "left", label: "←", key: "ArrowLeft" },
right: { id: "right", label: "→", key: "ArrowRight" },
enter: { id: "enter", label: "Enter", key: "Enter" },
backspace: { id: "backspace", label: "⌫", key: "Backspace" },
space: { id: "space", label: "Space", key: " " },
} as const;
const EMPTY_MODIFIERS = EMPTY_TERMINAL_KEY_MODIFIERS;
interface ModifierState {
ctrl: boolean;
shift: boolean;
alt: boolean;
}
type ModifierState = TerminalKeyModifierState;
type PendingTerminalInput =
| {
@@ -94,21 +103,9 @@ type PendingTerminalInput =
}
| {
type: "key";
input: {
key: string;
ctrl: boolean;
shift: boolean;
alt: boolean;
meta?: boolean;
};
input: TerminalKeyInput;
};
const EMPTY_MODIFIERS: ModifierState = {
ctrl: false,
shift: false,
alt: false,
};
function terminalScopeKey(input: { serverId: string; cwd: string }): string {
return `${input.serverId}:${input.cwd}`;
}
@@ -163,6 +160,40 @@ function VirtualKeyButton({ id, label, keyValue, onSend }: VirtualKeyButtonProps
);
}
interface KeyboardToggleButtonProps {
isKeyboardVisible: boolean;
iconColor: string;
onToggle: () => void;
}
function KeyboardToggleButton({
isKeyboardVisible,
iconColor,
onToggle,
}: KeyboardToggleButtonProps) {
const label = isKeyboardVisible ? "Hide keyboard" : "Show keyboard";
const Icon = isKeyboardVisible ? KeyboardOffIcon : KeyboardIcon;
const pressableStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
styles.keyButton,
(Boolean(hovered) || pressed) && styles.keyButtonHovered,
],
[],
);
return (
<Pressable
accessibilityLabel={label}
accessibilityRole="button"
testID="terminal-keyboard-toggle"
onPress={onToggle}
style={pressableStyle}
>
<Icon color={iconColor} size={16} />
</Pressable>
);
}
export function TerminalPane({
serverId,
cwd,
@@ -185,11 +216,12 @@ export function TerminalPane({
const isMobile = useIsCompactFormFactor();
const mobileView = usePanelStore((state) => state.mobilePanel.target);
const showMobileAgentList = usePanelStore((state) => state.showMobileAgentList);
const swipeGesturesEnabled = isMobile && mobileView === "agent";
const swipeGesturesEnabled = isMobile;
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
});
const [keyboardInset, setKeyboardInset] = useState(0);
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
@@ -197,6 +229,13 @@ export function TerminalPane({
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const supportsTerminalInputModeReplay = useSessionStore(
(state) =>
state.sessions[serverId]?.serverInfo?.features?.["terminal-input-mode-replay"] === true,
);
const supportsTerminalSizeOwnership = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-size-ownership"] === true,
);
const setFocusedTerminalId = useSessionStore((state) => state.setFocusedTerminalId);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
@@ -214,16 +253,17 @@ export function TerminalPane({
const [streamError, setStreamError] = useState<string | null>(null);
const [rendererReadyStreamKey, setRendererReadyStreamKey] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [hasSelection, setHasSelection] = useState(false);
const [hasClipboardText, setHasClipboardText] = useState(false);
const [isKeyboardToggleVisible, setIsKeyboardToggleVisible] = useState(false);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
useBlockMobilePanelOpenGestures(isMobile && isWorkspaceFocused && isPaneFocused && hasSelection);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const terminalActiveRef = useRef(isTerminalActive);
terminalActiveRef.current = isTerminalActive;
const inputModeRef = useRef<TerminalInputModeState>({
kittyKeyboardFlags: 0,
win32InputMode: false,
});
const inputModeRef = useRef<TerminalInputModeState>(DEFAULT_TERMINAL_INPUT_MODE_STATE);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null);
@@ -232,12 +272,39 @@ export function TerminalPane({
useEffect(() => {
terminalIdRef.current = terminalId;
inputModeRef.current = {
kittyKeyboardFlags: 0,
win32InputMode: false,
};
inputModeRef.current = DEFAULT_TERMINAL_INPUT_MODE_STATE;
setHasSelection(false);
}, [terminalId]);
const refreshClipboardAvailability = useCallback(async () => {
if (!isMobile) {
setHasClipboardText(false);
return;
}
try {
const hasText = await Clipboard.hasStringAsync();
setHasClipboardText(hasText);
} catch {
setHasClipboardText(false);
}
}, [isMobile]);
useEffect(() => {
void refreshClipboardAvailability();
}, [refreshClipboardAvailability, isAppActivelyVisible]);
useEffect(() => {
void refreshClipboardAvailability();
}, [keyboardInset, refreshClipboardAvailability]);
useEffect(() => {
setIsKeyboardToggleVisible(keyboardInset > 0);
}, [keyboardInset]);
const handleSelectionChange = useCallback((nextHasSelection: boolean) => {
setHasSelection(nextHasSelection);
}, []);
const requestTerminalFocus = useCallback(() => {
setFocusRequestToken((current) => current + 1);
}, []);
@@ -282,7 +349,8 @@ export function TerminalPane({
useEffect(() => {
const canRequest = canRequestFocusClaim({
isWorkspaceFocused,
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
@@ -296,13 +364,14 @@ export function TerminalPane({
if (step.shouldRequest) {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
emulatorRef.current?.claimSize();
}
}, [
client,
isAppActivelyVisible,
isConnected,
isPaneFocused,
isWorkspaceFocused,
isTerminalActive,
rendererReadyStreamKey,
requestTerminalReflow,
scopeKey,
@@ -316,6 +385,7 @@ export function TerminalPane({
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
emulatorRef.current?.claimSize();
}, [
isPaneFocused,
isWorkspaceFocused,
@@ -338,26 +408,37 @@ export function TerminalPane({
const pulseKeyboardRefits = useCallback(() => {
clearKeyboardRefitTimeouts();
requestTerminalReflow();
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs, index) =>
setTimeout(() => {
requestTerminalReflow();
if (index === TERMINAL_REFIT_DELAYS_MS.length - 1) {
emulatorRef.current?.claimSize();
}
}, delayMs),
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
const handleKeyboardInsetChange = useCallback(
(nextInset: number) => {
setKeyboardInset(nextInset);
pulseKeyboardRefits();
},
[pulseKeyboardRefits],
);
useEffect(() => {
return () => clearKeyboardRefitTimeouts();
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => isMobile && keyboardShift.value > 0,
() => (isMobile ? Math.round(keyboardShift.value) : 0),
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
runOnJS(handleKeyboardInsetChange)(next);
},
[isMobile, pulseKeyboardRefits],
[isMobile, handleKeyboardInsetChange],
);
useEffect(() => {
@@ -408,7 +489,21 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => lastSentTerminalSizeRef.current,
getPreferredSize: () => {
if (
!canRequestFocusClaim({
isWorkspaceFocused: terminalActiveRef.current,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
})
) {
return null;
}
return measuredTerminalSizeRef.current;
},
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!terminalActiveRef.current || terminalIdRef.current !== outputTerminalId) {
return;
@@ -432,6 +527,14 @@ export function TerminalPane({
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
canClaimSize: canRequestFocusClaim({
isWorkspaceFocused: terminalActiveRef.current,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
}),
size: measuredTerminalSizeRef.current,
});
},
@@ -439,9 +542,6 @@ export function TerminalPane({
});
streamControllerRef.current = controller;
controller.setTerminal({
terminalId: terminalActiveRef.current ? terminalIdRef.current : null,
});
return () => {
controller.dispose();
@@ -453,17 +553,33 @@ export function TerminalPane({
client,
handleStreamControllerStatus,
isConnected,
isAppActivelyVisible,
isPaneFocused,
rendererReadyStreamKey,
supportsTerminalRestoreModes,
terminalStreamKey,
workspaceTerminalSession.snapshots,
]);
useEffect(() => {
pendingTerminalInputRef.current = [];
const nextTerminalId = isTerminalActive ? terminalId : null;
const nextTerminalId = resolveTerminalStreamTarget({
terminalId,
terminalStreamKey,
rendererReadyStreamKey,
isWorkspaceFocused: isTerminalActive,
});
streamControllerRef.current?.setTerminal({
terminalId: nextTerminalId,
});
}, [isTerminalActive, terminalId]);
}, [
client,
isConnected,
isTerminalActive,
rendererReadyStreamKey,
terminalId,
terminalStreamKey,
]);
const enqueuePendingTerminalInput = useCallback((entry: PendingTerminalInput) => {
const queue = pendingTerminalInputRef.current;
@@ -492,15 +608,14 @@ export function TerminalPane({
return true;
}
const encoded = encodeTerminalKeyInput(entry.input, {
dispatchTerminalKeyInput({
keyInput: entry.input,
inputMode: inputModeRef.current,
});
if (encoded.length === 0) {
return true;
}
client.sendTerminalInput(currentTerminalId, {
type: "input",
data: encoded,
sendData: (data) =>
client.sendTerminalInput(currentTerminalId, {
type: "input",
data,
}),
});
return true;
},
@@ -552,26 +667,31 @@ export function TerminalPane({
enqueuePendingTerminalInput({
type: "key",
input: {
key: normalizeTerminalTransportKey(input.key),
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
meta: input.meta,
...createTerminalKeyInput({
key: input.key,
modifiers: {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
},
meta: input.meta,
}),
},
});
return true;
}
const normalizedKey = normalizeTerminalTransportKey(input.key);
const pendingEntry: PendingTerminalInput = {
type: "key",
input: {
key: normalizedKey,
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
input: createTerminalKeyInput({
key: input.key,
modifiers: {
ctrl: input.ctrl,
shift: input.shift,
alt: input.alt,
},
meta: input.meta,
},
}),
};
if (!dispatchTerminalInputEntry(pendingEntry)) {
enqueuePendingTerminalInput(pendingEntry);
@@ -638,7 +758,7 @@ export function TerminalPane({
);
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean }) => {
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
@@ -647,35 +767,34 @@ export function TerminalPane({
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
if (!input.shouldClaim) {
return;
}
let sent = false;
const canSend = canRequestFocusClaim({
isWorkspaceFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: true,
const claim = resolveTerminalResizeClaim({
size: nextSize,
previousSentSize: lastSentTerminalSizeRef.current,
shouldClaim: input.shouldClaim,
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
},
});
if (client && terminalId && canSend) {
const previousSent = lastSentTerminalSizeRef.current;
if (
!previousSent ||
previousSent.rows !== normalizedRows ||
previousSent.cols !== normalizedCols
) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
});
}
let sent = false;
if (client && terminalId && claim.shouldSend) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
intent: claim.intent,
});
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey) {
if (requestedKey && claim.intent === "claim") {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
@@ -695,6 +814,38 @@ export function TerminalPane({
clearPendingModifiers();
}, [clearPendingModifiers]);
const handleTerminalPaste = useCallback(() => {
requestTerminalReflow();
void pasteTerminalClipboard({
clipboard: { readText: () => Clipboard.getStringAsync() },
terminal: { paste: (text) => emulatorRef.current?.paste(text) },
}).then(() => refreshClipboardAvailability());
}, [requestTerminalReflow, refreshClipboardAvailability]);
const handleTerminalCopy = useCallback(() => {
void emulatorRef.current
?.copySelection({
writeText: async (text: string) => {
await Clipboard.setStringAsync(text);
},
})
.then(() => refreshClipboardAvailability());
}, [refreshClipboardAvailability]);
const handleKeyboardToggle = useCallback(() => {
if (isKeyboardToggleVisible) {
setIsKeyboardToggleVisible(false);
emulatorRef.current?.blur();
requestTerminalReflow();
return;
}
setIsKeyboardToggleVisible(true);
emulatorRef.current?.showKeyboard();
requestTerminalFocus();
requestTerminalReflow();
}, [isKeyboardToggleVisible, requestTerminalFocus, requestTerminalReflow]);
const handleInputModeChange = useCallback((state: TerminalInputModeState) => {
inputModeRef.current = state;
}, []);
@@ -739,8 +890,9 @@ export function TerminalPane({
(modifier: keyof ModifierState) => {
setModifiers((current) => ({ ...current, [modifier]: !current[modifier] }));
requestTerminalFocus();
requestTerminalReflow();
},
[requestTerminalFocus],
[requestTerminalFocus, requestTerminalReflow],
);
const sendVirtualKey = useCallback(
@@ -754,6 +906,7 @@ export function TerminalPane({
});
clearPendingModifiers();
requestTerminalFocus();
requestTerminalReflow();
},
[
clearPendingModifiers,
@@ -761,6 +914,7 @@ export function TerminalPane({
modifiers.ctrl,
modifiers.shift,
requestTerminalFocus,
requestTerminalReflow,
sendTerminalKey,
],
);
@@ -781,6 +935,54 @@ export function TerminalPane({
emulatorRef.current?.blur();
onOpenFileExplorer();
}, [swipeGesturesEnabled, onOpenFileExplorer]);
const showPasteAction = shouldShowTerminalPasteAction({ isNative });
const showFloatingCopyAction = shouldShowTerminalFloatingCopyAction({
hasSelection,
isNative,
});
const keyboardToggleIconColor = theme.colors.foregroundMuted;
const renderVirtualKeyboardControl = (control: TerminalVirtualKeyboardControl) => {
const controlId = getTerminalVirtualKeyboardControlId(control);
switch (control.type) {
case "key":
return (
<VirtualKeyButton
key={controlId}
id={control.button.id}
label={control.button.label}
keyValue={control.button.key}
onSend={sendVirtualKey}
/>
);
case "modifier":
return (
<ModifierButton
key={controlId}
modifier={control.modifier}
active={modifiers[control.modifier]}
onToggle={toggleModifier}
/>
);
case "paste":
return showPasteAction ? (
<TerminalPasteAction
key={controlId}
hasClipboardText={hasClipboardText}
onPaste={handleTerminalPaste}
/>
) : null;
case "keyboardToggle":
return (
<KeyboardToggleButton
key={controlId}
iconColor={keyboardToggleIconColor}
isKeyboardVisible={isKeyboardToggleVisible}
onToggle={handleKeyboardToggle}
/>
);
}
};
const showLoadingOverlay = shouldShowTerminalLoadingOverlay({
isWorkspaceFocused: isTerminalActive,
hasStreamError: Boolean(streamError),
@@ -805,11 +1007,13 @@ export function TerminalPane({
ref={emulatorRef}
dom={TERMINAL_EMULATOR_DOM_PROPS}
streamKey={terminalStreamKey}
supportsTerminalInputModeReplay={supportsTerminalInputModeReplay}
testId="terminal-surface"
xtermTheme={xtermTheme}
scrollbackLines={settings.terminalScrollbackLines}
fontFamily={terminalFontFamily}
fontSize={settings.codeFontSize}
keyboardInset={keyboardInset}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onRendererReadyChange={handleRendererReadyChange}
@@ -820,6 +1024,7 @@ export function TerminalPane({
onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange}
onSelectionChange={handleSelectionChange}
onResolveLocalFileLink={handleResolveLocalFileLink}
onOpenLocalFileLink={handleOpenLocalFileLink}
onPendingModifiersConsumed={handlePendingModifiersConsumed}
@@ -834,6 +1039,12 @@ export function TerminalPane({
<LoadingSpinner size="small" color={theme.colors.foregroundMuted} />
</View>
) : null}
{showFloatingCopyAction ? (
<View pointerEvents="box-none" style={styles.floatingCopyContainer}>
<TerminalFloatingCopyAction hasSelection={hasSelection} onCopy={handleTerminalCopy} />
</View>
) : null}
</View>
{streamError ? (
@@ -847,55 +1058,14 @@ export function TerminalPane({
{isMobile ? (
<View style={styles.keyboardContainer} testID="terminal-virtual-keyboard">
<View style={styles.keyboardRows}>
<View style={styles.keyboardRow}>
{[KEY_BUTTONS.esc, KEY_BUTTONS.tab].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
<ModifierButton modifier="ctrl" active={modifiers.ctrl} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.up.id}
label={KEY_BUTTONS.up.label}
keyValue={KEY_BUTTONS.up.key}
onSend={sendVirtualKey}
/>
<ModifierButton modifier="shift" active={modifiers.shift} onToggle={toggleModifier} />
<VirtualKeyButton
id={KEY_BUTTONS.backspace.id}
label={KEY_BUTTONS.backspace.label}
keyValue={KEY_BUTTONS.backspace.key}
onSend={sendVirtualKey}
/>
</View>
<View style={styles.keyboardRow}>
<ModifierButton modifier="alt" active={modifiers.alt} onToggle={toggleModifier} />
{[
KEY_BUTTONS.space,
KEY_BUTTONS.left,
KEY_BUTTONS.down,
KEY_BUTTONS.right,
KEY_BUTTONS.enter,
].map((button) => (
<VirtualKeyButton
key={button.id}
id={button.id}
label={button.label}
keyValue={button.key}
onSend={sendVirtualKey}
/>
))}
</View>
{TERMINAL_VIRTUAL_KEYBOARD_ROWS.map((row) => (
<View
key={row.map(getTerminalVirtualKeyboardControlId).join(":")}
style={styles.keyboardRow}
>
{row.map(renderVirtualKeyboardControl)}
</View>
))}
</View>
</View>
) : null}
@@ -925,6 +1095,12 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: "center",
backgroundColor: "rgba(0, 0, 0, 0.16)",
},
floatingCopyContainer: {
position: "absolute",
right: theme.spacing[3],
bottom: theme.spacing[12],
zIndex: 2,
},
errorRow: {
paddingHorizontal: theme.spacing[3],
paddingVertical: theme.spacing[1],
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { shouldHandleTerminalBridgeMessage } from "./terminal-webview-message-routing";
describe("terminal WebView message routing", () => {
it("rejects terminal messages from a stale stream", () => {
expect(
shouldHandleTerminalBridgeMessage(
{ type: "input", streamKey: "terminal-a" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(false);
expect(
shouldHandleTerminalBridgeMessage(
{ type: "resize", streamKey: "terminal-a" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(false);
});
it("rejects the old WebView stream while React is switching callbacks", () => {
expect(
shouldHandleTerminalBridgeMessage(
{ type: "input", streamKey: "terminal-a" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-a" },
),
).toBe(false);
});
it("accepts messages from the mounted stream and unscoped diagnostics", () => {
expect(
shouldHandleTerminalBridgeMessage(
{ type: "input", streamKey: "terminal-b" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(true);
expect(
shouldHandleTerminalBridgeMessage(
{ type: "debug" },
{ desiredStreamKey: "terminal-b", mountedStreamKey: "terminal-b" },
),
).toBe(true);
});
});
@@ -0,0 +1,17 @@
interface TerminalBridgeMessageIdentity {
type: string;
streamKey?: string;
}
export function shouldHandleTerminalBridgeMessage(
message: TerminalBridgeMessageIdentity,
stream: {
desiredStreamKey: string;
mountedStreamKey: string | null;
},
): boolean {
return (
message.streamKey === undefined ||
(message.streamKey === stream.desiredStreamKey && message.streamKey === stream.mountedStreamKey)
);
}
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { shouldResetForMissingRendererReady } from "./terminal-webview-readiness";
describe("legacy terminal WebView readiness", () => {
it("resets when a requested mount never reports renderer readiness", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-a",
rendererReadyStreamKey: null,
}),
).toBe(true);
});
it("keeps the document after the requested renderer confirms readiness", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-a",
rendererReadyStreamKey: "terminal-a",
}),
).toBe(false);
});
it("ignores a watchdog from an earlier ready generation", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 1,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-a",
rendererReadyStreamKey: null,
}),
).toBe(false);
});
it("does not let readiness from a stale stream satisfy the requested mount", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-b",
mountRequestedStreamKey: "terminal-b",
rendererReadyStreamKey: "terminal-a",
}),
).toBe(true);
});
it("does not reset a newer mount from an expired watchdog", () => {
expect(
shouldResetForMissingRendererReady({
expectedReadyVersion: 0,
currentReadyVersion: 0,
expectedStreamKey: "terminal-a",
mountRequestedStreamKey: "terminal-b",
rendererReadyStreamKey: null,
}),
).toBe(false);
});
});
@@ -0,0 +1,13 @@
export function shouldResetForMissingRendererReady(input: {
expectedReadyVersion: number;
currentReadyVersion: number;
expectedStreamKey: string;
mountRequestedStreamKey: string | null;
rendererReadyStreamKey: string | null;
}): boolean {
return (
input.currentReadyVersion === input.expectedReadyVersion &&
input.mountRequestedStreamKey === input.expectedStreamKey &&
input.rendererReadyStreamKey !== input.expectedStreamKey
);
}