Make mobile terminals load faster (#1147)

* Speed up terminal restores on mobile

* Preserve terminal output bytes in the app
This commit is contained in:
Mohamed Boudra
2026-05-23 18:41:10 +08:00
committed by GitHub
parent 84f1dfc8cb
commit 846c9b9da3
32 changed files with 1304 additions and 357 deletions
@@ -13,13 +13,15 @@ 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 type { TerminalOutputData } from "../terminal/runtime/terminal-emulator-runtime";
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;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -64,6 +66,7 @@ type BridgeInboundMessage =
}
| { type: "unmount"; streamKey: string }
| { type: "writeOutput"; streamKey: string; text: string }
| { type: "restoreOutput"; streamKey: string; text: string }
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string }
@@ -158,6 +161,7 @@ export default function TerminalEmulator({
const bridgeReadyVersionRef = useRef(0);
const rendererReadyVersionRef = useRef(0);
const pendingMessagesRef = useRef<BridgeInboundMessage[]>([]);
const outputDecoderRef = useRef(new TextDecoder());
const mountedStreamKeyRef = useRef<string | null>(null);
const bridgeReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const rendererReadyTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -272,19 +276,37 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
sendToWebView({ type: "writeOutput", streamKey, text });
writeOutput: (data: TerminalOutputData) => {
const output = outputDecoderRef.current.decode(data, { stream: true });
if (output.length === 0) {
return;
}
sendToWebView({ type: "writeOutput", streamKey, text: output });
},
restoreOutput: (data: TerminalOutputData) => {
outputDecoderRef.current.decode();
const text = outputDecoderRef.current.decode(data, { stream: false });
if (text.length === 0) {
return;
}
sendToWebView({ type: "restoreOutput", streamKey, text });
},
renderSnapshot: (state: TerminalState | null) => {
outputDecoderRef.current.decode();
sendToWebView({ type: "renderSnapshot", streamKey, state });
},
clear: () => {
outputDecoderRef.current.decode();
sendToWebView({ type: "clear", streamKey });
},
}),
[sendToWebView, streamKey],
);
useEffect(() => {
outputDecoderRef.current.decode();
}, [streamKey]);
useEffect(() => {
if (bridgeReadyVersion <= 0) return;
const mountMessage = createMountMessage(mountConfigRef.current);
@@ -19,7 +19,10 @@ import type { ITheme } from "@xterm/xterm";
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 "../terminal/runtime/terminal-emulator-runtime";
import {
TerminalEmulatorRuntime,
type TerminalOutputData,
} from "../terminal/runtime/terminal-emulator-runtime";
import type { TerminalRendererReadyChange } from "../utils/terminal-renderer-readiness";
import { openExternalUrl } from "../utils/open-external-url";
import { focusWithRetries } from "../utils/web-focus";
@@ -29,7 +32,8 @@ import {
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
writeOutput: (data: TerminalOutputData) => void;
restoreOutput: (data: TerminalOutputData) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
@@ -254,8 +258,12 @@ export default function TerminalEmulator({
domBridgeRef,
(): DOMImperativeFactory => ({
writeOutput: (...args) => {
const text = args[0];
if (typeof text === "string") runtimeRef.current?.write({ text });
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.write({ data });
},
restoreOutput: (...args) => {
const data = args[0];
if (data instanceof Uint8Array) runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (...args) => {
const state = args[0];
@@ -274,8 +282,11 @@ export default function TerminalEmulator({
useImperativeHandle(
ref,
(): TerminalEmulatorHandle => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
writeOutput: (data: TerminalOutputData) => {
runtimeRef.current?.write({ data });
},
restoreOutput: (data: TerminalOutputData) => {
runtimeRef.current?.restoreOutput({ data });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
+21 -2
View File
@@ -25,7 +25,9 @@ import {
TerminalStreamController,
type TerminalStreamControllerStatus,
} from "@/terminal/runtime/terminal-stream-controller";
import { resolveTerminalRestoreOptions } from "@/terminal/runtime/terminal-restore-options";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { useIsCompactFormFactor } from "@/constants/layout";
@@ -171,6 +173,9 @@ export function TerminalPane({
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const supportsTerminalRestoreModes = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.["terminal-restore-modes"] === true,
);
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const terminalStreamKey = useMemo(() => `${scopeKey}:${terminalId}`, [scopeKey, terminalId]);
@@ -354,11 +359,18 @@ export function TerminalPane({
const controller = new TerminalStreamController({
client,
getPreferredSize: () => measuredTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, text }) => {
onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return;
}
emulatorRef.current?.writeOutput(text);
emulatorRef.current?.writeOutput(data);
},
onRestore: ({ terminalId: restoreTerminalId, data }) => {
workspaceTerminalSession.snapshots.clear({ terminalId: restoreTerminalId });
if (!isWorkspaceFocused || terminalIdRef.current !== restoreTerminalId) {
return;
}
emulatorRef.current?.restoreOutput(data);
},
onSnapshot: ({ terminalId: snapshotTerminalId, state }) => {
workspaceTerminalSession.snapshots.set({ terminalId: snapshotTerminalId, state });
@@ -367,6 +379,12 @@ export function TerminalPane({
}
emulatorRef.current?.renderSnapshot(state);
},
getRestoreOptions: () => {
return resolveTerminalRestoreOptions({
supportsTerminalRestoreModes,
size: measuredTerminalSizeRef.current,
});
},
onStatusChange: handleStreamControllerStatus,
});
@@ -386,6 +404,7 @@ export function TerminalPane({
handleStreamControllerStatus,
isConnected,
isWorkspaceFocused,
supportsTerminalRestoreModes,
workspaceTerminalSession.snapshots,
]);