Remove the WebView terminal renderer: xterm on web, native grid on mobile (#7639)

* refactor(terminal): remove the WebView terminal renderer

Native uses the native grid renderer only; web keeps xterm. The legacy
renderer setting, its diagnostics toggle, the esbuild WebView bundle and
its CI drift check are gone.

* test(terminal): target the visible terminal surface in the file paste spec

A second page opening a terminal in the same workspace adds a retained,
hidden surface to the first page, so the unscoped locator matched two
textareas and failed strict mode.

* test(server): wait for the shortstat warm instead of polling 500ms

The background warm shells out to git; on Windows CI that regularly
outlasts the 20x25ms poll and fails the test. Use the completion callback
the warm already exposes and keep the non-blocking check.
This commit is contained in:
Mohamed Boudra
2026-10-22 19:47:28 +07:00
committed by GitHub
parent 9d908d678b
commit 6376f2bf40
30 changed files with 26 additions and 1838 deletions
+1 -3
View File
@@ -415,9 +415,7 @@ jobs:
run: npm run build:app-deps
- name: Run app unit tests
run: |
npm run check:terminal-webview --workspace=@getpaseo/app
npm run test --workspace=@getpaseo/app
run: npm run test --workspace=@getpaseo/app
- uses: actions/setup-java@v4
with:
@@ -73,8 +73,14 @@ function fileRead(bytes: Buffer, location: "upload" | "local"): string {
return `FILE_READ:${digest}:${location}`;
}
// Inactive terminal tabs retain their surfaces, and another page opening a terminal in
// the same workspace adds a tab here too. Only the selected tab's surface is visible.
function terminalSurface(page: Page) {
return page.getByTestId("terminal-surface").filter({ visible: true });
}
function terminalInput(page: Page) {
return page.locator('[data-testid="terminal-surface"] .xterm-helper-textarea');
return terminalSurface(page).locator(".xterm-helper-textarea");
}
async function pasteImage(page: Page, bytes: Buffer): Promise<void> {
@@ -127,8 +133,7 @@ async function dropFiles(page: Page, files: DroppedFile[]): Promise<void> {
/** The shared "Drop files here" overlay of the zone around the terminal. */
function terminalDropOverlay(page: Page) {
return page
.getByTestId("terminal-surface")
return terminalSurface(page)
.locator("xpath=ancestor::*[.//*[@data-testid='file-drop-backdrop']][1]")
.getByTestId("file-drop-backdrop");
}
+1 -3
View File
@@ -7,7 +7,7 @@
"start": "npm run start:expo",
"start:expo": "cross-env APP_VARIANT=development expo start",
"reset-project": "node ./scripts/reset-project.js",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps && npm run build:terminal-webview",
"eas-build-post-install": "npm --prefix ../.. run build:app-deps",
"android": "npm run dev:android",
"android:development": "npm run dev:android",
"android:production": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=production expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=production expo run:android --variant=release",
@@ -35,8 +35,6 @@
"profile:workspace-switching": "node ./scripts/profile-workspace-switching.mjs",
"profile:workspace-tabs": "node ./scripts/profile-workspace-tabs.mjs",
"deploy:web": "npm run build:web && wrangler pages deploy dist --project-name paseo-app --branch main",
"build:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs",
"check:terminal-webview": "node ./scripts/build-terminal-webview-html.mjs --check",
"build:mermaid-runtime": "node ./src/components/markdown/fence/mermaid/build-runtime.mjs",
"dev:android": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo prebuild --platform android --clean --non-interactive && cross-env APP_VARIANT=development expo run:android --variant=debug",
"dev:ios": "npm --prefix ../.. run build:client && cross-env APP_VARIANT=development expo run:ios",
@@ -1,103 +0,0 @@
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({
absWorkingDir: repoRoot,
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 = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover"
/>
</head>
<body>
<script>${js}</script>
</body>
</html>`;
const contents = `// Generated by packages/app/scripts/build-terminal-webview-html.mjs.
// Do not edit by hand.
export const terminalEmulatorWebViewHtml = ${JSON.stringify(html)};
`;
if (process.argv.includes("--check")) {
// Compare parsed modules so repository formatting does not create false drift.
const options = { loader: "ts", format: "esm" };
const expected = await esbuild.transform(contents, options);
const actual = await esbuild.transform(await fs.readFile(output, "utf8"), options);
if (actual.code !== expected.code) {
throw new Error(
"Stale terminal WebView HTML. Run npm run build:terminal-webview --workspace=@getpaseo/app",
);
}
console.log("Terminal WebView HTML is current");
} else {
await fs.writeFile(output, contents);
console.log(`Wrote ${path.relative(repoRoot, output)} (${html.length} bytes)`);
}
@@ -1,724 +0,0 @@
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,
TerminalFindResult,
} from "../terminal/runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../terminal/local-links/terminal-local-link-provider";
import { terminalFileFromBase64 } from "../terminal/file-paste";
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: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
| { type: "clearFind"; streamKey: string }
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
| { 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: "findRequest"; streamKey: string }
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
| {
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: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: 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,
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onRendererReadyChange,
onFilePaste,
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({
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
onSwipeLeft,
onSwipeRight,
});
callbacksRef.current = {
onFindRequest,
onFindResult,
onInput,
onFocus,
onResize,
onTerminalKey,
onPendingModifiersConsumed,
onInputModeChange,
onRendererReadyChange,
onResolveLocalFileLink,
onOpenLocalFileLink,
onFilePaste,
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 => ({
find: {
setWidgetSize: (size) => sendToWebView({ type: "findWidgetSize", streamKey, size }),
search: (query, direction) => sendToWebView({ type: "find", streamKey, query, direction }),
clear: () => sendToWebView({ type: "clearFind", streamKey }),
},
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;
}
if (message.type === "findRequest") {
callbacksRef.current.onFindRequest?.();
return;
}
if (message.type === "findResult") {
callbacksRef.current.onFindResult?.(message.result);
return;
}
if (message.type === "filePaste") {
callbacksRef.current.onFilePaste?.(Promise.resolve(terminalFileFromBase64(message)));
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,
},
});
@@ -2,10 +2,8 @@ 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 type { TerminalEmulatorHandle } from "./terminal-emulator-contract";
@@ -25,10 +23,7 @@ function UnsupportedNativeTerminal({ streamKey, onRendererReadyChange }: Termina
}
export default function TerminalEmulator(props: TerminalEmulatorProps) {
const { settings } = useAppSettings();
const { useLegacyTerminalRenderer } = settings;
const rendererSelection = resolveTerminalRendererSelection({
useLegacyTerminalRenderer,
supportsTerminalInputModeReplay: props.supportsTerminalInputModeReplay,
});
@@ -36,10 +31,7 @@ export default function TerminalEmulator(props: TerminalEmulatorProps) {
return <UnsupportedNativeTerminal {...props} />;
}
const Renderer =
rendererSelection === "webview" ? WebViewTerminalEmulator : NativeGridTerminalEmulator;
return <Renderer key={rendererSelection} {...props} />;
return <NativeGridTerminalEmulator {...props} />;
}
const styles = StyleSheet.create((theme) => ({
@@ -1,43 +0,0 @@
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);
});
});
@@ -1,17 +0,0 @@
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)
);
}
@@ -1,64 +0,0 @@
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);
});
});
@@ -1,13 +0,0 @@
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
);
}
@@ -389,26 +389,6 @@ describe("loadAppSettingsFromStorage", () => {
expect(legacySide.openInSidePane).not.toHaveProperty("pullRequests");
});
it("uses the native terminal renderer by default", async () => {
const deps = makeDeps();
const result = await loadAppSettingsFromStorage(deps);
expect(result.useLegacyTerminalRenderer).toBe(false);
});
it("loads the per-device legacy terminal renderer preference", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
[APP_SETTINGS_KEY]: JSON.stringify({ useLegacyTerminalRenderer: true }),
}),
});
const result = await loadAppSettingsFromStorage(deps);
expect(result.useLegacyTerminalRenderer).toBe(true);
});
it("loads configured terminal scrollback lines from app settings", async () => {
const deps = makeDeps({
storage: createInMemoryKeyValueStorage({
@@ -81,7 +81,6 @@ export interface AppSettings {
sendBehavior: SendBehavior;
serviceUrlBehavior: ServiceUrlBehavior;
terminalScrollbackLines: number;
useLegacyTerminalRenderer: boolean;
uiFontFamily: string; // "" = platform default UI stack
monoFontFamily: string; // "" = platform default mono stack
uiBaseFontSize: number; // clamped px, platform default 14 or 15
@@ -142,7 +141,6 @@ export const DEFAULT_CLIENT_SETTINGS: AppSettings = {
sendBehavior: "steer",
serviceUrlBehavior: "ask",
terminalScrollbackLines: DEFAULT_TERMINAL_SCROLLBACK_LINES,
useLegacyTerminalRenderer: false,
uiFontFamily: "",
monoFontFamily: "",
uiBaseFontSize: DEFAULT_UI_BASE_FONT_SIZE,
@@ -224,7 +222,6 @@ const StoredAppSettingsSchema = z
MIN_TERMINAL_SCROLLBACK_LINES,
MAX_TERMINAL_SCROLLBACK_LINES,
).catch(DEFAULT_TERMINAL_SCROLLBACK_LINES),
useLegacyTerminalRenderer: z.boolean().catch(false),
uiFontFamily: sanitizedFontFamily().catch(""),
monoFontFamily: sanitizedFontFamily().catch(""),
uiBaseFontSize: clampedNumber(MIN_UI_BASE_FONT_SIZE, MAX_UI_BASE_FONT_SIZE)
-3
View File
@@ -369,9 +369,6 @@ describe("translation resources", () => {
it("includes Settings expansion keys for the Batch 3A migration", () => {
expect(en.settings.diagnostics.title).toBe("Diagnostics");
expect(en.settings.diagnostics.legacyTerminalRenderer.label).toBe(
"Use legacy terminal renderer",
);
expect(en.settings.about.title).toBe("About");
expect(en.settings.about.releaseChannel.label).toBe("Release channel");
expect(en.settings.appearance.theme.title).toBe("Theme");
-5
View File
@@ -2332,11 +2332,6 @@ export const ar: TranslationResources = {
},
diagnostics: {
title: "التشخيص",
legacyTerminalRenderer: {
label: "استخدام عارض الطرفية السابق",
description: "استخدام طرفية WebView السابقة بعد إعادة فتح الطرفية",
accessibilityLabel: "استخدام عارض الطرفية السابق",
},
testAudio: "اختبار الصوت",
playTest: "لعب الاختبار",
playing: "جارٍ اللعب...",
-5
View File
@@ -2420,11 +2420,6 @@ export const en = {
},
diagnostics: {
title: "Diagnostics",
legacyTerminalRenderer: {
label: "Use legacy terminal renderer",
description: "Use the previous WebView terminal after reopening a terminal",
accessibilityLabel: "Use legacy terminal renderer",
},
testAudio: "Test audio",
playTest: "Play test",
playing: "Playing...",
-5
View File
@@ -2348,11 +2348,6 @@ export const es: TranslationResources = {
},
diagnostics: {
title: "Diagnóstico",
legacyTerminalRenderer: {
label: "Usar el renderizador de terminal anterior",
description: "Usa el terminal WebView anterior al volver a abrir un terminal",
accessibilityLabel: "Usar el renderizador de terminal anterior",
},
testAudio: "audio de prueba",
playTest: "Prueba de juego",
playing: "Jugando...",
-5
View File
@@ -2346,11 +2346,6 @@ export const fr: TranslationResources = {
},
diagnostics: {
title: "Diagnostic",
legacyTerminalRenderer: {
label: "Utiliser l’ancien rendu du terminal",
description: "Utilise l’ancien terminal WebView après la réouverture d’un terminal",
accessibilityLabel: "Utiliser l’ancien rendu du terminal",
},
testAudio: "Tester le son",
playTest: "Lancer le test",
playing: "Lecture…",
-5
View File
@@ -2316,11 +2316,6 @@ export const ja: TranslationResources = {
},
diagnostics: {
title: "診断",
legacyTerminalRenderer: {
label: "以前のターミナルレンダラーを使用",
description: "ターミナルを開き直した後、以前の WebView ターミナルを使用します",
accessibilityLabel: "以前のターミナルレンダラーを使用",
},
testAudio: "音声をテスト",
playTest: "テスト再生",
playing: "再生中...",
-5
View File
@@ -2307,11 +2307,6 @@ export const ko: TranslationResources = {
},
diagnostics: {
title: "진단",
legacyTerminalRenderer: {
label: "레거시 터미널 렌더러 사용",
description: "터미널을 다시 열 때 이전 WebView 터미널을 사용합니다.",
accessibilityLabel: "레거시 터미널 렌더러 사용",
},
testAudio: "오디오 테스트",
playTest: "테스트 재생",
playing: "재생 중...",
-5
View File
@@ -2331,11 +2331,6 @@ export const ptBR: TranslationResources = {
},
diagnostics: {
title: "Diagnósticos",
legacyTerminalRenderer: {
label: "Usar renderizador de terminal anterior",
description: "Usa o terminal WebView anterior após reabrir um terminal",
accessibilityLabel: "Usar renderizador de terminal anterior",
},
testAudio: "Testar áudio",
playTest: "Reproduzir teste",
playing: "Reproduzindo...",
-5
View File
@@ -2333,11 +2333,6 @@ export const ru: TranslationResources = {
},
diagnostics: {
title: "Диагностика",
legacyTerminalRenderer: {
label: "Использовать прежний рендерер терминала",
description: "Использовать прежний WebView-терминал после повторного открытия",
accessibilityLabel: "Использовать прежний рендерер терминала",
},
testAudio: "Проверка звука",
playTest: "Воспроизвести тестовый звук",
playing: "Воспроизведение...",
-5
View File
@@ -2271,11 +2271,6 @@ export const zhCN: TranslationResources = {
},
diagnostics: {
title: "诊断",
legacyTerminalRenderer: {
label: "使用旧版终端渲染器",
description: "重新打开终端后使用之前的 WebView 终端",
accessibilityLabel: "使用旧版终端渲染器",
},
testAudio: "测试音频",
playTest: "播放测试",
playing: "正在播放...",
+1 -35
View File
@@ -81,7 +81,6 @@ import { KeyboardShortcutsSection } from "@/screens/settings/keyboard-shortcuts-
import { EditorSection } from "@/screens/settings/editor-section";
import { FilesSection } from "@/screens/settings/files-section";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { CommunityLinks } from "@/components/community-links";
import { SegmentedControl } from "@/components/ui/segmented-control";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
@@ -136,7 +135,7 @@ import {
} from "@/utils/host-routes";
import { useLastWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { returnFromSettings, type SettingsView } from "@/navigation/settings-navigation";
import { isNative, isWeb } from "@/constants/platform";
import { isWeb } from "@/constants/platform";
// ---------------------------------------------------------------------------
// View model
@@ -383,8 +382,6 @@ function GeneralSection({ settings, handleLanguageChange }: GeneralSectionProps)
}
interface DiagnosticsSectionProps {
useLegacyTerminalRenderer: boolean;
onUseLegacyTerminalRendererChange: (value: boolean) => void;
voiceAudioEngine: ReturnType<typeof useVoiceAudioEngineOptional>;
isPlaybackTestRunning: boolean;
playbackTestResult: string | null;
@@ -392,8 +389,6 @@ interface DiagnosticsSectionProps {
}
function DiagnosticsSection({
useLegacyTerminalRenderer,
onUseLegacyTerminalRendererChange,
voiceAudioEngine,
isPlaybackTestRunning,
playbackTestResult,
@@ -407,26 +402,6 @@ function DiagnosticsSection({
return (
<SettingsSection title={t("settings.diagnostics.title")}>
<View style={settingsStyles.card}>
{isNative ? (
<View style={settingsStyles.row} testID="legacy-terminal-renderer-row">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>
{t("settings.diagnostics.legacyTerminalRenderer.label")}
</Text>
<Text style={settingsStyles.rowHint}>
{t("settings.diagnostics.legacyTerminalRenderer.description")}
</Text>
</View>
<Switch
value={useLegacyTerminalRenderer}
onValueChange={onUseLegacyTerminalRendererChange}
accessibilityLabel={t(
"settings.diagnostics.legacyTerminalRenderer.accessibilityLabel",
)}
testID="legacy-terminal-renderer-switch"
/>
</View>
) : null}
<View style={settingsStyles.row} testID="app-diagnostic-row">
<View style={settingsStyles.rowContent}>
<Text style={settingsStyles.rowTitle}>{t("settings.diagnostics.app.rowTitle")}</Text>
@@ -1159,13 +1134,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
[updateSettings],
);
const handleUseLegacyTerminalRendererChange = useCallback(
(useLegacyTerminalRenderer: boolean) => {
void updateSettings({ useLegacyTerminalRenderer });
},
[updateSettings],
);
const handlePlaybackTest = useCallback(async () => {
if (!voiceAudioEngine || isPlaybackTestRunning) {
return;
@@ -1390,8 +1358,6 @@ export default function SettingsScreen({ view, openAddHostIntent = null }: Setti
case "diagnostics":
return (
<DiagnosticsSection
useLegacyTerminalRenderer={settings.useLegacyTerminalRenderer}
onUseLegacyTerminalRendererChange={handleUseLegacyTerminalRendererChange}
voiceAudioEngine={voiceAudioEngine}
isPlaybackTestRunning={isPlaybackTestRunning}
playbackTestResult={playbackTestResult}
@@ -1,4 +1,3 @@
// Bundled into the terminal WebView as well as the app: keep i18n and React out.
import { FILE_UPLOAD_MAX_BYTES } from "@/attachments/upload-limit";
import { prepareTerminalPath } from "../drop/terminal-path";
@@ -126,16 +125,6 @@ export function terminalFileFromBase64(input: {
return { fileName: input.fileName, mimeType: input.mimeType, bytes };
}
const BASE64_CHUNK_SIZE = 0x8000;
export function terminalFileToBase64(file: TerminalFile): string {
let binary = "";
for (let offset = 0; offset < file.bytes.length; offset += BASE64_CHUNK_SIZE) {
binary += String.fromCharCode(...file.bytes.subarray(offset, offset + BASE64_CHUNK_SIZE));
}
return btoa(binary);
}
/** File extension for a clipboard image, which arrives as bytes with only a MIME type. */
export function imageExtension(mimeType: string): string {
const subtype = mimeType.split("/")[1]?.split(/[+;]/)[0] ?? "";
@@ -4,29 +4,14 @@ import { resolveTerminalRendererSelection } from "./terminal-renderer-capability
describe("terminal renderer capability", () => {
it("requires input mode replay before attaching the native renderer", () => {
expect(
resolveTerminalRendererSelection({
useLegacyTerminalRenderer: false,
supportsTerminalInputModeReplay: false,
}),
).toBe("update-host");
expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: false })).toBe(
"update-host",
);
});
it("allows the native renderer when the daemon advertises input mode replay", () => {
expect(
resolveTerminalRendererSelection({
useLegacyTerminalRenderer: false,
supportsTerminalInputModeReplay: true,
}),
).toBe("native-grid");
});
it("keeps the explicit legacy renderer available with older daemons", () => {
expect(
resolveTerminalRendererSelection({
useLegacyTerminalRenderer: true,
supportsTerminalInputModeReplay: false,
}),
).toBe("webview");
expect(resolveTerminalRendererSelection({ supportsTerminalInputModeReplay: true })).toBe(
"native-grid",
);
});
});
@@ -1,11 +1,7 @@
export type TerminalRendererSelection = "native-grid" | "webview" | "update-host";
export type TerminalRendererSelection = "native-grid" | "update-host";
export function resolveTerminalRendererSelection(input: {
useLegacyTerminalRenderer: boolean;
supportsTerminalInputModeReplay: boolean;
}): TerminalRendererSelection {
if (input.useLegacyTerminalRenderer) {
return "webview";
}
return input.supportsTerminalInputModeReplay ? "native-grid" : "update-host";
}
@@ -1,593 +0,0 @@
import type { TerminalFindResult } from "../runtime/terminal-emulator-runtime";
import type { ITheme } from "@xterm/xterm";
import xtermCss from "@xterm/xterm/css/xterm.css";
import type { TerminalState } from "@getpaseo/protocol/messages";
import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode";
import type { PendingTerminalModifiers } from "@/utils/terminal-keys";
import {
encodeTerminalOutput,
TerminalEmulatorRuntime,
} from "../runtime/terminal-emulator-runtime";
import type {
TerminalLocalFileLinkSource,
TerminalLocalFileLinkTarget,
} from "../local-links/terminal-local-link-provider";
import { terminalFileToBase64, type TerminalFile } from "../file-paste";
interface MountMessage {
type: "mount";
streamKey: string;
initialSnapshot: TerminalState | null;
scrollbackLines: number;
theme: ITheme;
fontFamily?: string;
fontSize?: number;
pendingModifiers: PendingTerminalModifiers;
swipeGesturesEnabled: boolean;
}
type InboundMessage =
| MountMessage
| { 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: "find"; streamKey: string; query: string; direction?: "next" | "previous" }
| { type: "clearFind"; streamKey: string }
| { type: "findWidgetSize"; streamKey: string; size: { width: number; height: number } }
| { 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 OutboundMessage =
| { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string }
| { type: "findRequest"; streamKey: string }
| { type: "findResult"; streamKey: string; result: TerminalFindResult }
| {
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: "filePaste"; streamKey: string; fileName: string; mimeType: string; data: 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 };
declare global {
interface Window {
ReactNativeWebView?: {
postMessage?: (data: string) => void;
};
__PASEO_TERMINAL_WEBVIEW_RECEIVE__?: (message: InboundMessage) => void;
__PASEO_TERMINAL_WEBVIEW_BLUR__?: () => void;
}
}
const sendToNative = (message: OutboundMessage): void => {
window.ReactNativeWebView?.postMessage?.(JSON.stringify(message));
};
const TERMINAL_BACKGROUND_CSS_VAR = "--paseo-terminal-background";
const DEFAULT_TERMINAL_BACKGROUND = "#0b0b0b";
const TERMINAL_TAP_MOVE_TOLERANCE_PX = 8;
function getTerminalBackground(theme: ITheme): string {
return theme.background ?? DEFAULT_TERMINAL_BACKGROUND;
}
const installStyles = (): void => {
const style = document.createElement("style");
style.textContent = `
${xtermCss}
:root {
${TERMINAL_BACKGROUND_CSS_VAR}: transparent;
}
html,
body,
#terminal-root {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
overscroll-behavior: none;
background: var(${TERMINAL_BACKGROUND_CSS_VAR});
}
#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;
background: var(${TERMINAL_BACKGROUND_CSS_VAR});
}
#terminal-root .xterm,
#terminal-root .xterm-screen,
#terminal-root .xterm-viewport {
background-color: var(${TERMINAL_BACKGROUND_CSS_VAR}) !important;
}
[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;
}
#terminal-root .xterm .xterm-helper-textarea {
opacity: 0.01;
width: 1px;
height: 1px;
min-width: 1px;
min-height: 1px;
color: transparent;
background: transparent;
caret-color: transparent;
z-index: 5 !important;
}
`;
document.head.appendChild(style);
};
class TerminalWebViewBridge {
private runtime: TerminalEmulatorRuntime | null = null;
private streamKey: string | null = null;
private nextLocalFileLinkRequestId = 1;
private readonly pendingLocalFileLinkResolutions = new Map<
number,
(target: TerminalLocalFileLinkTarget | null) => void
>();
private filePastes: Promise<void> = Promise.resolve();
private swipeGesturesEnabled = false;
private trackingSwipe = false;
private activePointerId: number | null = null;
private startX = 0;
private startY = 0;
private firedSwipe = false;
private tapTouchIdentifier: number | null = null;
private tapStartX = 0;
private tapStartY = 0;
private tapMoved = false;
constructor(
private readonly root: HTMLDivElement,
private readonly host: HTMLDivElement,
) {
this.root.addEventListener("pointerdown", this.handlePointerDown, { passive: true });
this.root.addEventListener("touchstart", this.handleTouchStart, { passive: true });
this.root.addEventListener("touchmove", this.handleTouchMove, { passive: true });
this.root.addEventListener("touchend", this.handleTouchEnd, { passive: true });
this.root.addEventListener("touchcancel", this.handleTouchCancel, { 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;
}
if (message.type === "resolveLocalFileLinkResponse") {
this.resolveLocalFileLinkRequest(message.requestId, message.target);
return;
}
if (message.type === "findWidgetSize") {
this.runtime?.find.setWidgetSize(message.size);
return;
}
if (message.type === "find") {
this.runtime?.find.search(message.query, message.direction);
return;
}
if (message.type === "clearFind") {
this.runtime?.find.clear();
return;
}
this.receiveMounted(message);
}
private receiveMounted(
message: Exclude<
InboundMessage,
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
>,
): void {
if (this.receiveConfigurationMessage(message)) return;
switch (message.type) {
case "writeOutput":
this.runtime?.write({ data: encodeTerminalOutput(message.text) });
break;
case "restoreOutput":
this.runtime?.restoreOutput({ data: encodeTerminalOutput(message.text) });
break;
case "renderSnapshot":
this.runtime?.renderSnapshot({ state: message.state });
break;
case "paste":
this.runtime?.paste(message.text);
break;
case "clear":
this.runtime?.clear();
break;
case "focus":
this.runtime?.focus({ forceRefocus: message.forceRefocus });
break;
case "resize":
this.runtime?.resize({
shouldClaim: message.shouldClaim ?? true,
forceClaim: message.forceClaim,
});
break;
}
}
private receiveConfigurationMessage(
message: Exclude<
InboundMessage,
MountMessage | { type: "unmount" } | { type: "resolveLocalFileLinkResponse" }
>,
): boolean {
switch (message.type) {
case "setTheme":
this.applyThemeBackground(message.theme);
this.runtime?.setTheme({ theme: message.theme });
return true;
case "setScrollback":
this.runtime?.setScrollback({ lines: message.lines });
return true;
case "setFont":
this.runtime?.setFont({ fontFamily: message.fontFamily, fontSize: message.fontSize });
return true;
case "setPendingModifiers":
this.runtime?.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
return true;
case "setSwipeGesturesEnabled":
this.swipeGesturesEnabled = message.enabled;
return true;
default:
return false;
}
}
private mount(message: MountMessage): void {
this.unmount(this.streamKey);
this.streamKey = message.streamKey;
this.swipeGesturesEnabled = message.swipeGesturesEnabled;
this.applyThemeBackground(message.theme);
const runtime = new TerminalEmulatorRuntime();
this.runtime = runtime;
runtime.setCallbacks({
callbacks: {
onFindRequest: () => sendToNative({ type: "findRequest", streamKey: message.streamKey }),
onFindResult: (result) =>
sendToNative({ type: "findResult", streamKey: message.streamKey, result }),
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
onResize: ({ rows, cols, shouldClaim, forceClaim }) =>
sendToNative({
type: "resize",
streamKey: message.streamKey,
rows,
cols,
shouldClaim,
forceClaim,
}),
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 }),
onResolveLocalFileLink: (source) => this.requestLocalFileLinkResolution(source),
onOpenLocalFileLink: (target, disposition) =>
sendToNative({
type: "openLocalFileLink",
streamKey: message.streamKey,
target,
disposition,
}),
onFilePaste: (file) => this.forwardFilePaste(message.streamKey, file),
},
});
runtime.setPendingModifiers({ pendingModifiers: message.pendingModifiers });
runtime.mount({
root: this.root,
host: this.host,
initialSnapshot: message.initialSnapshot,
scrollback: message.scrollbackLines,
theme: message.theme,
fontFamily: message.fontFamily,
fontSize: message.fontSize,
});
sendToNative({ type: "rendererReady", streamKey: message.streamKey, isReady: true });
}
private applyThemeBackground(theme: ITheme): void {
const background = getTerminalBackground(theme);
document.documentElement.style.setProperty(TERMINAL_BACKGROUND_CSS_VAR, background);
document.body.style.backgroundColor = background;
this.root.style.backgroundColor = background;
this.host.style.backgroundColor = background;
}
blur = (): void => {
this.runtime?.blur();
};
private unmount(streamKey: string | null): void {
if (!this.runtime) {
return;
}
const previousStreamKey = this.streamKey;
this.runtime.unmount();
this.runtime = null;
this.streamKey = null;
this.resolveAllLocalFileLinkRequests(null);
if (previousStreamKey && (!streamKey || streamKey === previousStreamKey)) {
sendToNative({ type: "rendererReady", streamKey: previousStreamKey, isReady: false });
}
}
// Forward in gesture order: an earlier file can finish reading after a later one.
private forwardFilePaste(streamKey: string, file: Promise<TerminalFile | null>): void {
this.filePastes = this.filePastes.then(() => this.sendFilePaste(streamKey, file));
}
private async sendFilePaste(
streamKey: string,
file: Promise<TerminalFile | null>,
): Promise<void> {
const read = await file.catch(() => null);
if (read && this.matches(streamKey)) {
sendToNative({
type: "filePaste",
streamKey,
fileName: read.fileName,
mimeType: read.mimeType,
data: terminalFileToBase64(read),
});
}
}
private matches(streamKey: string): boolean {
return this.streamKey === streamKey;
}
private requestLocalFileLinkResolution(
source: TerminalLocalFileLinkSource,
): Promise<TerminalLocalFileLinkTarget | null> {
const streamKey = this.streamKey;
if (!streamKey) {
return Promise.resolve(null);
}
const requestId = this.nextLocalFileLinkRequestId++;
return new Promise((resolve) => {
this.pendingLocalFileLinkResolutions.set(requestId, resolve);
sendToNative({
type: "resolveLocalFileLink",
streamKey,
requestId,
source,
});
});
}
private resolveLocalFileLinkRequest(
requestId: number,
target: TerminalLocalFileLinkTarget | null,
): void {
const resolve = this.pendingLocalFileLinkResolutions.get(requestId);
if (!resolve) {
return;
}
this.pendingLocalFileLinkResolutions.delete(requestId);
resolve(target);
}
private resolveAllLocalFileLinkRequests(target: TerminalLocalFileLinkTarget | null): void {
const requests = Array.from(this.pendingLocalFileLinkResolutions.values());
this.pendingLocalFileLinkResolutions.clear();
for (const resolve of requests) {
resolve(target);
}
}
private handlePointerDown = (event: PointerEvent): void => {
if (!event.isPrimary) {
return;
}
if (!this.swipeGesturesEnabled) {
return;
}
this.trackingSwipe = true;
this.firedSwipe = false;
this.activePointerId = event.pointerId;
this.startX = event.clientX;
this.startY = event.clientY;
};
private handleTouchStart = (event: TouchEvent): void => {
if (event.touches.length !== 1) {
this.resetTap();
return;
}
const touch = event.touches[0];
this.tapTouchIdentifier = touch.identifier;
this.tapStartX = touch.clientX;
this.tapStartY = touch.clientY;
this.tapMoved = false;
};
private handleTouchMove = (event: TouchEvent): void => {
const touch = this.findTrackedTouch(event.touches);
if (!touch) {
return;
}
const dx = touch.clientX - this.tapStartX;
const dy = touch.clientY - this.tapStartY;
if (
Math.abs(dx) > TERMINAL_TAP_MOVE_TOLERANCE_PX ||
Math.abs(dy) > TERMINAL_TAP_MOVE_TOLERANCE_PX
) {
this.tapMoved = true;
}
};
private handleTouchEnd = (event: TouchEvent): void => {
const completedTap = Boolean(this.findTrackedTouch(event.changedTouches) && !this.tapMoved);
this.resetTap();
if (completedTap) {
this.runtime?.focus({ forceRefocus: true });
}
};
private handleTouchCancel = (): void => {
this.resetTap();
};
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;
}
private findTrackedTouch(touches: TouchList): Touch | null {
if (this.tapTouchIdentifier === null) {
return null;
}
for (let index = 0; index < touches.length; index += 1) {
const touch = touches.item(index);
if (touch?.identifier === this.tapTouchIdentifier) {
return touch;
}
}
return null;
}
private resetTap(): void {
this.tapTouchIdentifier = null;
this.tapStartX = 0;
this.tapStartY = 0;
this.tapMoved = 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;
window.__PASEO_TERMINAL_WEBVIEW_BLUR__ = bridge.blur;
sendToNative({ type: "bridgeReady" });
File diff suppressed because one or more lines are too long
@@ -1,104 +0,0 @@
import type { Terminal } from "@xterm/xterm";
import { afterEach, expect, test } from "vitest";
import { terminalEmulatorWebViewHtml } from "./terminal-emulator-webview-html";
interface Message {
type: string;
streamKey?: string;
result?: { resultCount: number; resultIndex: number };
data?: string;
fileName?: string;
mimeType?: string;
}
interface TerminalFrameWindow extends Window {
__PASEO_TERMINAL_WEBVIEW_RECEIVE__(message: unknown): void;
__paseoTerminal?: Terminal;
}
let frame: HTMLIFrameElement;
afterEach(() => frame?.remove());
async function mountWebView() {
const messages: Message[] = [];
frame = document.createElement("iframe");
frame.style.width = "500px";
frame.style.height = "300px";
document.body.append(frame);
const win = frame.contentWindow as TerminalFrameWindow;
Object.assign(win, {
ReactNativeWebView: { postMessage: (raw: string) => messages.push(JSON.parse(raw)) },
});
win.document.open();
win.document.write(terminalEmulatorWebViewHtml);
win.document.close();
await expect.poll(() => messages.some((m) => m.type === "bridgeReady")).toBe(true);
const send = (message: object) =>
win.__PASEO_TERMINAL_WEBVIEW_RECEIVE__({ streamKey: "owned", ...message });
send({
type: "mount",
initialSnapshot: null,
scrollbackLines: 1000,
theme: { background: "#ffffff", foreground: "#111111" },
pendingModifiers: { ctrl: false, shift: false, alt: false },
swipeGesturesEnabled: false,
});
await expect
.poll(() => messages.some((m) => m.type === "rendererReady" && m.streamKey === "owned"))
.toBe(true);
return { win, send, messages };
}
test("the generated WebView searches its mounted stream and keeps Find commands off the input bridge", async () => {
const { win, send, messages } = await mountWebView();
send({ type: "writeOutput", text: "first a.b\r\n" + "padding\r\n".repeat(30) + "last A.B" });
await expect
.poll(() => {
const b = win.__paseoTerminal?.buffer.active;
return b
? Array.from({ length: b.length }, (_, i) => b.getLine(i)?.translateToString(true)).join(
"\n",
)
: "";
})
.toContain("last A.B");
send({ type: "findWidgetSize", size: { width: 356, height: 58 } });
send({ type: "find", query: "a.b" });
await expect
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
.toMatchObject({ resultCount: 2, resultIndex: 1 });
send({ type: "find", query: "a.b", direction: "previous" });
await expect
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
.toMatchObject({ resultCount: 2, resultIndex: 0 });
send({ type: "find", streamKey: "stale", query: "absent" });
expect(win.__paseoTerminal!.getSelection()).toBe("a.b");
send({ type: "clearFind" });
await expect
.poll(() => messages.findLast((m) => m.type === "findResult")?.result)
.toMatchObject({ resultCount: 0 });
expect(messages.filter((m) => m.type === "input")).toEqual([]);
send({ type: "unmount" });
});
test("the generated WebView forwards file-only pastes to native as file bytes", async () => {
const { win, send, messages } = await mountWebView();
const clipboardData = new DataTransfer();
clipboardData.items.add(
new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }),
);
win.document
.querySelector(".xterm-helper-textarea")!
.dispatchEvent(new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true }));
await expect
.poll(() => messages.find((m) => m.type === "filePaste"))
.toEqual({
type: "filePaste",
streamKey: "owned",
fileName: "image.png",
mimeType: "image/png",
data: "iVBORw==",
});
expect(messages.filter((m) => m.type === "input")).toEqual([]);
send({ type: "unmount" });
});
+7 -11
View File
@@ -1589,19 +1589,15 @@ const x = 1;
it("warms shortstat cache in the background without blocking listing callers", async () => {
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
warmCheckoutShortstatInBackground(repoDir);
const warmed = new Promise<void>((resolve) => {
warmCheckoutShortstatInBackground(repoDir, undefined, resolve);
});
// The warm hands off to git and returns; listing callers see no value yet.
expect(getCachedCheckoutShortstat(repoDir)).toBeUndefined();
await warmed;
// A repo with no origin/main computes to null, but null should still be cached.
for (let attempts = 0; attempts < 20; attempts += 1) {
const cached = getCachedCheckoutShortstat(repoDir);
if (cached !== undefined) {
expect(cached).toBeNull();
return;
}
await sleep(25);
}
throw new Error("shortstat background warm did not populate cache in time");
expect(getCachedCheckoutShortstat(repoDir)).toBeNull();
});
it("commits messages with quotes safely", async () => {