diff --git a/packages/app/package.json b/packages/app/package.json
index 34cd6414d..42e509fed 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -5,9 +5,10 @@
"main": "index.ts",
"scripts": {
"start": "cross-env APP_VARIANT=development expo start",
+ "build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"reset-project": "node ./scripts/reset-project.js",
"build:workspace-deps": "npm run build --workspace=@getpaseo/highlight && npm run build --workspace=@getpaseo/expo-two-way-audio",
- "eas-build-post-install": "npm run build:workspace-deps",
+ "eas-build-post-install": "npm run build:workspace-deps && npm run build:terminal-webview",
"android": "npm run android:development",
"android:development": "cross-env APP_VARIANT=development expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"android:production": "cross-env APP_VARIANT=production expo prebuild --platform android --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
diff --git a/packages/app/scripts/build-terminal-webview-html.mjs b/packages/app/scripts/build-terminal-webview-html.mjs
new file mode 100644
index 000000000..d3b02336b
--- /dev/null
+++ b/packages/app/scripts/build-terminal-webview-html.mjs
@@ -0,0 +1,89 @@
+import esbuild from "esbuild";
+import fs from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const appRoot = path.resolve(__dirname, "..");
+const repoRoot = path.resolve(appRoot, "../..");
+const entry = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-entry.ts");
+const output = path.join(appRoot, "src/terminal/webview/terminal-emulator-webview-html.ts");
+
+async function resolveTsPath(basePath) {
+ const candidates = [
+ basePath,
+ `${basePath}.ts`,
+ `${basePath}.tsx`,
+ `${basePath}.js`,
+ `${basePath}.jsx`,
+ path.join(basePath, "index.ts"),
+ path.join(basePath, "index.tsx"),
+ path.join(basePath, "index.js"),
+ path.join(basePath, "index.jsx"),
+ ];
+ for (const candidate of candidates) {
+ try {
+ const stat = await fs.stat(candidate);
+ if (stat.isFile()) return candidate;
+ } catch {
+ // try next candidate
+ }
+ }
+ return basePath;
+}
+
+const aliasPlugin = {
+ name: "paseo-alias",
+ setup(build) {
+ build.onResolve({ filter: /^@\// }, async (args) => ({
+ path: await resolveTsPath(path.join(appRoot, "src", args.path.slice(2))),
+ }));
+ build.onResolve({ filter: /^@server\// }, async (args) => ({
+ path: await resolveTsPath(
+ path.join(repoRoot, "packages/server/src", args.path.slice("@server/".length)),
+ ),
+ }));
+ },
+};
+
+const result = await esbuild.build({
+ entryPoints: [entry],
+ bundle: true,
+ write: false,
+ format: "iife",
+ platform: "browser",
+ target: ["ios15", "chrome100"],
+ loader: {
+ ".css": "text",
+ },
+ plugins: [aliasPlugin],
+ logLevel: "info",
+});
+
+const js = result.outputFiles[0]?.text;
+if (!js) {
+ throw new Error("terminal webview bundle produced no JavaScript");
+}
+
+const html = `
+
+
+
+
+
+
+
+
+`;
+
+const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
+// Do not edit by hand.
+
+export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
+`;
+
+await fs.writeFile(output, contents);
+console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx
new file mode 100644
index 000000000..036667e73
--- /dev/null
+++ b/packages/app/src/components/terminal-emulator.native.tsx
@@ -0,0 +1,510 @@
+import {
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+ useState,
+ type ComponentProps,
+ type Ref,
+} from "react";
+import { StyleSheet, View, 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 "@server/shared/messages";
+import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
+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";
+
+export interface TerminalEmulatorHandle {
+ writeOutput: (text: string) => void;
+ renderSnapshot: (state: TerminalState | null) => void;
+ clear: () => void;
+}
+
+interface TerminalEmulatorProps {
+ dom?: unknown;
+ ref: Ref;
+ streamKey: string;
+ testId?: string;
+ xtermTheme?: ITheme;
+ scrollbackLines: number;
+ swipeGesturesEnabled?: boolean;
+ onSwipeLeft?: () => void;
+ onSwipeRight?: () => void;
+ initialSnapshot?: TerminalState | null;
+ onInput?: (data: string) => Promise | void;
+ onResize?: (input: { rows: number; cols: number }) => Promise | void;
+ onTerminalKey?: (input: {
+ key: string;
+ ctrl: boolean;
+ shift: boolean;
+ alt: boolean;
+ meta: boolean;
+ }) => Promise | void;
+ onPendingModifiersConsumed?: () => Promise | void;
+ onInputModeChange?: (state: TerminalInputModeState) => Promise | void;
+ onRendererReadyChange?: (change: TerminalRendererReadyChange) => void;
+ pendingModifiers?: PendingTerminalModifiers;
+ focusRequestToken?: number;
+ resizeRequestToken?: number;
+}
+
+type BridgeInboundMessage =
+ | {
+ type: "mount";
+ streamKey: string;
+ initialSnapshot: TerminalState | null;
+ scrollbackLines: number;
+ theme: ITheme;
+ pendingModifiers: PendingTerminalModifiers;
+ swipeGesturesEnabled: boolean;
+ }
+ | { type: "unmount"; streamKey: string }
+ | { type: "writeOutput"; streamKey: string; text: string }
+ | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
+ | { type: "clear"; streamKey: string }
+ | { type: "focus"; streamKey: string }
+ | { type: "resize"; streamKey: string }
+ | { type: "setTheme"; streamKey: string; theme: ITheme }
+ | { type: "setScrollback"; streamKey: string; lines: number }
+ | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
+ | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
+
+type BridgeOutboundMessage =
+ | { type: "bridgeReady" }
+ | { type: "rendererReady"; streamKey: string; isReady: boolean }
+ | { type: "input"; streamKey: string; data: string }
+ | { type: "resize"; streamKey: string; rows: number; cols: number }
+ | {
+ 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: "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;
+type WebViewProps = ComponentProps;
+
+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;
+ pendingModifiers: PendingTerminalModifiers;
+ swipeGesturesEnabled: boolean;
+}): BridgeInboundMessage {
+ return {
+ type: "mount",
+ streamKey: input.streamKey,
+ initialSnapshot: input.initialSnapshot,
+ scrollbackLines: input.scrollbackLines,
+ theme: input.theme,
+ pendingModifiers: input.pendingModifiers,
+ swipeGesturesEnabled: input.swipeGesturesEnabled,
+ };
+}
+
+export default function TerminalEmulator({
+ ref,
+ streamKey,
+ testId = "terminal-surface",
+ xtermTheme = {
+ background: "#0b0b0b",
+ foreground: "#e6e6e6",
+ cursor: "#e6e6e6",
+ },
+ scrollbackLines,
+ swipeGesturesEnabled = false,
+ onSwipeLeft,
+ onSwipeRight,
+ initialSnapshot = null,
+ onInput,
+ onResize,
+ onTerminalKey,
+ onPendingModifiersConsumed,
+ onInputModeChange,
+ onRendererReadyChange,
+ pendingModifiers = { ctrl: false, shift: false, alt: false },
+ focusRequestToken = 0,
+ resizeRequestToken = 0,
+}: TerminalEmulatorProps) {
+ const webViewRef = useRef(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([]);
+ const mountedStreamKeyRef = useRef(null);
+ const bridgeReadyTimeoutRef = useRef | null>(null);
+ const rendererReadyTimeoutRef = useRef | null>(null);
+ const mountConfigRef = useRef({
+ streamKey,
+ initialSnapshot,
+ scrollbackLines,
+ theme: xtermTheme,
+ pendingModifiers,
+ swipeGesturesEnabled,
+ });
+ mountConfigRef.current = {
+ streamKey,
+ initialSnapshot,
+ scrollbackLines,
+ theme: xtermTheme,
+ pendingModifiers,
+ swipeGesturesEnabled,
+ };
+ const callbacksRef = useRef({
+ onInput,
+ onResize,
+ onTerminalKey,
+ onPendingModifiersConsumed,
+ onInputModeChange,
+ onRendererReadyChange,
+ onSwipeLeft,
+ onSwipeRight,
+ });
+ callbacksRef.current = {
+ onInput,
+ onResize,
+ onTerminalKey,
+ onPendingModifiersConsumed,
+ onInputModeChange,
+ onRendererReadyChange,
+ 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: (text: string) => {
+ sendToWebView({ type: "writeOutput", streamKey, text });
+ },
+ renderSnapshot: (state: TerminalState | null) => {
+ sendToWebView({ type: "renderSnapshot", streamKey, state });
+ },
+ clear: () => {
+ sendToWebView({ type: "clear", streamKey });
+ },
+ }),
+ [sendToWebView, 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: "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 });
+ sendToWebView({ type: "focus", streamKey });
+ webViewRef.current?.requestFocus();
+ }, [focusRequestToken, sendToWebView, streamKey]);
+
+ useEffect(() => {
+ if (resizeRequestToken <= 0) return;
+ sendToWebView({ type: "resize", streamKey });
+ }, [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 handleTerminalMessage = useCallback(
+ (
+ message: Exclude,
+ ) => {
+ switch (message.type) {
+ case "input":
+ callbacksRef.current.onInput?.(message.data);
+ break;
+ case "resize":
+ callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols });
+ 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;
+ }
+ },
+ [],
+ );
+
+ 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>(() => {
+ bridgeReadyRef.current = false;
+ mountedStreamKeyRef.current = null;
+ scheduleBridgeReadyWatchdog();
+ }, [scheduleBridgeReadyWatchdog]);
+
+ const handleContentProcessDidTerminate = useCallback<
+ NonNullable
+ >(() => {
+ resetWebViewDocument();
+ }, [resetWebViewDocument]);
+
+ const handleRenderProcessGone = useCallback<
+ NonNullable
+ >(() => {
+ resetWebViewDocument();
+ }, [resetWebViewDocument]);
+
+ const webViewStyle = useMemo>(
+ () => [styles.webView, { backgroundColor: xtermTheme.background ?? "#0b0b0b" }],
+ [xtermTheme.background],
+ );
+
+ return (
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ root: {
+ flex: 1,
+ minHeight: 0,
+ minWidth: 0,
+ overflow: "hidden",
+ backgroundColor: "#0b0b0b",
+ },
+ webView: {
+ flex: 1,
+ backgroundColor: "#0b0b0b",
+ },
+ webViewContainer: {
+ flex: 1,
+ backgroundColor: "#0b0b0b",
+ },
+});
diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx
index fa3ab1333..be2681780 100644
--- a/packages/app/src/screens/workspace/workspace-screen.tsx
+++ b/packages/app/src/screens/workspace/workspace-screen.tsx
@@ -714,13 +714,12 @@ const MobileMountedTabSlot = memo(function MobileMountedTabSlot({
[buildPaneContentModel, paneId, tabDescriptor],
);
- const slotStyle = useMemo(
- () => ({ display: isVisible ? ("flex" as const) : ("none" as const), flex: 1 }),
- [isVisible],
- );
+ const slotStyle = isVisible
+ ? styles.mobileMountedTabSlotVisible
+ : styles.mobileMountedTabSlotHidden;
return (
-
+
tabs.map((tab) => tab.tabId), [tabs]);
const focusedPaneTabDescriptorMap = useStableTabDescriptorMap(tabs);
const { mountedTabIds: mountedFocusedPaneTabIdsSet } = useMountedTabSet({
- activeTabId: activeTabDescriptor?.tabId ?? null,
+ activeTabId,
allTabIds: focusedPaneTabIds,
cap: 3,
});
@@ -3654,6 +3653,15 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
backgroundColor: theme.colors.surface0,
+ position: "relative",
+ },
+ mobileMountedTabSlotVisible: {
+ ...StyleSheet.absoluteFillObject,
+ opacity: 1,
+ },
+ mobileMountedTabSlotHidden: {
+ ...StyleSheet.absoluteFillObject,
+ opacity: 0,
},
contentPlaceholder: {
flex: 1,
diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts
new file mode 100644
index 000000000..20b7f4226
--- /dev/null
+++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts
@@ -0,0 +1,301 @@
+import type { ITheme } from "@xterm/xterm";
+import xtermCss from "@xterm/xterm/css/xterm.css";
+import type { TerminalState } from "@server/shared/messages";
+import type { TerminalInputModeState } from "@server/shared/terminal-input-mode";
+import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
+import { TerminalEmulatorRuntime } from "../runtime/terminal-emulator-runtime";
+
+interface MountMessage {
+ type: "mount";
+ streamKey: string;
+ initialSnapshot: TerminalState | null;
+ scrollbackLines: number;
+ theme: ITheme;
+ pendingModifiers: PendingTerminalModifiers;
+ swipeGesturesEnabled: boolean;
+}
+
+type InboundMessage =
+ | MountMessage
+ | { type: "unmount"; streamKey: string }
+ | { type: "writeOutput"; streamKey: string; text: string }
+ | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
+ | { type: "clear"; streamKey: string }
+ | { type: "focus"; streamKey: string }
+ | { type: "resize"; streamKey: string }
+ | { type: "setTheme"; streamKey: string; theme: ITheme }
+ | { type: "setScrollback"; streamKey: string; lines: number }
+ | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
+ | { type: "setSwipeGesturesEnabled"; streamKey: string; enabled: boolean };
+
+type OutboundMessage =
+ | { type: "bridgeReady" }
+ | { type: "rendererReady"; streamKey: string; isReady: boolean }
+ | { type: "input"; streamKey: string; data: string }
+ | { type: "resize"; streamKey: string; rows: number; cols: number }
+ | {
+ 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: "swipeLeft"; streamKey: string }
+ | { type: "swipeRight"; streamKey: string }
+ | { type: "debug"; message: string; details?: unknown };
+
+declare global {
+ interface Window {
+ ReactNativeWebView?: {
+ postMessage?: (data: string) => void;
+ };
+ __PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void;
+ }
+}
+
+const sendToNative = (message: OutboundMessage): void => {
+ window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
+};
+
+const installStyles = (): void => {
+ const style = document.createElement("style");
+ style.textContent = `
+${xtermCss}
+html,
+body,
+#terminal-root {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ padding: 0;
+ overflow: hidden;
+ overscroll-behavior: none;
+ background: #0b0b0b;
+}
+#terminal-root {
+ display: flex;
+ min-width: 0;
+ min-height: 0;
+}
+#terminal-host {
+ flex: 1;
+ min-width: 0;
+ min-height: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+}
+[data-terminal-scrollbar-root="true"] .xterm-viewport {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+[data-terminal-scrollbar-root="true"] .xterm-viewport::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+}
+`;
+ document.head.appendChild(style);
+};
+
+class TerminalWebViewBridge {
+ private runtime: TerminalEmulatorRuntime | null = null;
+ private streamKey: string | null = null;
+ private swipeGesturesEnabled = false;
+ private trackingSwipe = false;
+ private activePointerId: number | null = null;
+ private startX = 0;
+ private startY = 0;
+ private firedSwipe = false;
+
+ constructor(
+ private readonly root: HTMLDivElement,
+ private readonly host: HTMLDivElement,
+ ) {
+ this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
+ this.root.addEventListener("pointermove", this.handlePointerMove, { passive: false });
+ this.root.addEventListener("pointerup", this.handlePointerUp, { passive: true });
+ this.root.addEventListener("pointercancel", this.handlePointerUp, { passive: true });
+ }
+
+ receive = (message: InboundMessage): void => {
+ try {
+ this.receiveUnsafe(message);
+ } catch (error) {
+ sendToNative({
+ type: "debug",
+ message: "terminal webview receive failed",
+ details: error instanceof Error ? { message: error.message, stack: error.stack } : error,
+ });
+ }
+ };
+
+ private receiveUnsafe(message: InboundMessage): void {
+ if (message.type === "mount") {
+ this.mount(message);
+ return;
+ }
+ if (message.type === "unmount") {
+ this.unmount(message.streamKey);
+ return;
+ }
+ if (!this.matches(message.streamKey)) {
+ return;
+ }
+ this.receiveMounted(message);
+ }
+
+ private receiveMounted(
+ message: Exclude,
+ ): void {
+ switch (message.type) {
+ case "writeOutput":
+ this.runtime?.write({ text: message.text });
+ break;
+ case "renderSnapshot":
+ this.runtime?.renderSnapshot({ state: message.state });
+ break;
+ case "clear":
+ this.runtime?.clear();
+ break;
+ case "focus":
+ this.runtime?.focus();
+ break;
+ case "resize":
+ this.runtime?.resize({ force: true });
+ break;
+ case "setTheme":
+ this.runtime?.setTheme({ theme: message.theme });
+ break;
+ case "setScrollback":
+ this.runtime?.setScrollback({ lines: message.lines });
+ break;
+ case "setPendingModifiers":
+ this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
+ break;
+ case "setSwipeGesturesEnabled":
+ this.swipeGesturesEnabled = message.enabled;
+ break;
+ }
+ }
+
+ private mount(message: MountMessage): void {
+ this.unmount(this.streamKey);
+ this.streamKey = message.streamKey;
+ this.swipeGesturesEnabled = message.swipeGesturesEnabled;
+ document.body.style.backgroundColor = message.theme.background ?? "#0b0b0b";
+
+ const runtime = new TerminalEmulatorRuntime();
+ this.runtime = runtime;
+ runtime.setCallbacks({
+ callbacks: {
+ onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
+ onResize: ({ rows, cols }) =>
+ sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }),
+ onTerminalKey: (input) =>
+ sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
+ onPendingModifiersConsumed: () =>
+ sendToNative({ type: "pendingModifiersConsumed", streamKey: message.streamKey }),
+ onInputModeChange: (state) =>
+ sendToNative({ type: "inputModeChange", streamKey: message.streamKey, state }),
+ onOpenExternalUrl: (url) =>
+ sendToNative({ type: "openExternalUrl", streamKey: message.streamKey, url }),
+ },
+ });
+ runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
+ runtime.mount({
+ root: this.root,
+ host: this.host,
+ initialSnapshot: message.initialSnapshot,
+ scrollback: message.scrollbackLines,
+ theme: message.theme,
+ });
+ sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
+ }
+
+ private unmount(streamKey: string | null): void {
+ if (!this.runtime) {
+ return;
+ }
+ const previousStreamKey = this.streamKey;
+ this.runtime.unmount();
+ this.runtime = null;
+ this.streamKey = null;
+ if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
+ sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
+ }
+ }
+
+ private matches(streamKey: string): boolean {
+ return this.streamKey === streamKey;
+ }
+
+ private handlePointerDown = (event: PointerEvent): void => {
+ if (!this.swipeGesturesEnabled || !event.isPrimary) {
+ return;
+ }
+ this.trackingSwipe = true;
+ this.firedSwipe = false;
+ this.activePointerId = event.pointerId;
+ this.startX = event.clientX;
+ this.startY = event.clientY;
+ };
+
+ private handlePointerMove = (event: PointerEvent): void => {
+ if (!this.trackingSwipe || this.firedSwipe || !this.streamKey) {
+ return;
+ }
+ if (this.activePointerId !== null && event.pointerId !== this.activePointerId) {
+ return;
+ }
+
+ const dx = event.clientX - this.startX;
+ const dy = event.clientY - this.startY;
+ const absDx = Math.abs(dx);
+ const absDy = Math.abs(dy);
+ if (absDy >= 12 && absDy > absDx) {
+ this.resetSwipe();
+ return;
+ }
+ if (absDx < 22 || (absDy !== 0 && absDx / absDy < 1.2)) {
+ return;
+ }
+
+ this.firedSwipe = true;
+ sendToNative({ type: dx > 0 ? "swipeRight" : "swipeLeft", streamKey: this.streamKey });
+ if (event.cancelable) event.preventDefault();
+ };
+
+ private handlePointerUp = (event: PointerEvent): void => {
+ if (this.activePointerId !== null && event.pointerId !== this.activePointerId) {
+ return;
+ }
+ this.resetSwipe();
+ };
+
+ private resetSwipe(): void {
+ this.trackingSwipe = false;
+ this.activePointerId = null;
+ this.startX = 0;
+ this.startY = 0;
+ this.firedSwipe = false;
+ }
+}
+
+installStyles();
+
+const root = document.createElement("div");
+root.id = "terminal-root";
+root.dataset.terminalScrollbarRoot = "true";
+const host = document.createElement("div");
+host.id = "terminal-host";
+root.appendChild(host);
+document.body.appendChild(root);
+
+const bridge = new TerminalWebViewBridge(root, host);
+window.__PASEO_TERMINAL_WEBVIEW_RECEIVE__ = bridge.receive;
+sendToNative({ type: "bridgeReady" });
diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts
new file mode 100644
index 000000000..6ff3a7554
--- /dev/null
+++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts
@@ -0,0 +1,5 @@
+// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
+// Do not edit by hand.
+
+export const terminalEmulatorWebViewHtml =
+ '\n\n \n \n \n \n \n \n \n';