Fix terminal resize after splitting panes (#1249)

This commit is contained in:
Mohamed Boudra
2026-05-31 15:42:39 +08:00
committed by GitHub
parent d1f37b5d72
commit 67133e3ebc
10 changed files with 250 additions and 103 deletions
+2
View File
@@ -183,6 +183,8 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream
- 1-byte slot: terminal slot id - 1-byte slot: terminal slot id
- variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot - variable payload: bytes for output/input, JSON-encoded `{ rows, cols }` for resize, terminal snapshot for snapshot
Terminal PTY size is last-interacting-client-wins. A client claims the PTY size only when its terminal viewport genuinely changes size or the user focuses/taps the terminal. Passive rendering work — attaching, restoring visibility, font settling, renderer refits, or just looking at a visible terminal — must not send a resize frame. The server does not broadcast resize ownership; the resized PTY redraws through normal output, and every attached client renders that output in its own local viewport.
There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams. There is also a separate file-transfer binary frame format in the same directory, used for download/upload streams.
### Compatibility rules ### Compatibility rules
@@ -0,0 +1,119 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl";
import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf";
interface TerminalSize {
rows: number | null;
cols: number | null;
}
// The xterm/client view resizes immediately on split, so it is not enough to
// prove the bug. It is the misleading symptom.
async function readXtermSize(page: Page): Promise<TerminalSize> {
return page.evaluate(() => {
const term = (window as Window & { __paseoTerminal?: { rows?: number; cols?: number } })
.__paseoTerminal;
return {
rows: typeof term?.rows === "number" ? term.rows : null,
cols: typeof term?.cols === "number" ? term.cols : null,
};
});
}
// The PTY's own view of its size, reported by an `stty size` loop running in the
// shell. This needs no focus or click, so it observes whether the daemon-side PTY
// actually received the resize frame after the split.
async function readLatestPtySize(page: Page): Promise<TerminalSize | null> {
const text = await getTerminalBufferText(page);
const matches = [...text.matchAll(/PTYSIZE (\d+) (\d+)/g)];
const last = matches.at(-1);
if (!last) {
return null;
}
return { rows: Number(last[1]), cols: Number(last[2]) };
}
function hasPtySizeReport(text: string): boolean {
return /PTYSIZE \d+ \d+/.test(text);
}
async function readXtermRows(page: Page): Promise<number | null> {
return (await readXtermSize(page)).rows;
}
async function ptyRowsMatchXtermRows(page: Page): Promise<boolean> {
const xterm = await readXtermSize(page);
const pty = await readLatestPtySize(page);
return pty?.rows === xterm.rows;
}
async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness): Promise<void> {
await page.setViewportSize({ width: 1280, height: 900 });
await withTerminalInApp(page, harness, { name: "split-resize" }, async () => {
await harness.setupPrompt(page);
const terminal = harness.terminalSurface(page);
// Continuously echo the PTY's own size. `stty size` prints "rows cols" and
// reads the controlling tty, so this keeps reporting the real PTY size
// without the test ever clicking the terminal back into focus.
await terminal.pressSequentially(
'while true; do echo "PTYSIZE $(stty size)"; sleep 0.3; done\n',
{ delay: 0 },
);
await waitForTerminalContent(page, hasPtySizeReport, 10_000);
const beforeXterm = await readXtermSize(page);
const beforePty = await readLatestPtySize(page);
expect(beforePty, "the PTY should report its size before splitting").not.toBeNull();
expect(beforeXterm.rows, "xterm should report its row count before splitting").not.toBeNull();
expect(beforePty?.rows, "while focused, the PTY size should already match the xterm size").toBe(
beforeXterm.rows,
);
// Split the pane downward. This focuses the new empty pane, so the terminal
// pane is unfocused at the exact moment its container shrinks.
await page.getByRole("button", { name: "Split pane down" }).first().click();
// The local xterm renderer shrinks immediately on split - the part of the
// behaviour that already works and that makes the bug look like nothing changed.
await expect
.poll(() => readXtermRows(page), {
message: "xterm should shrink after splitting the pane down",
timeout: 8_000,
})
.toBeLessThan(beforeXterm.rows ?? Number.POSITIVE_INFINITY);
// The PTY must follow the shrunken terminal, even though focus moved to the
// new pane. Today it stays stuck at the pre-split size until the terminal is
// clicked back into focus. This poll fails without the fix.
await expect
.poll(() => ptyRowsMatchXtermRows(page), {
message: "the PTY rows should match the resized terminal after split-down",
timeout: 8_000,
})
.toBe(true);
});
}
test.describe("Terminal split resize", () => {
test.describe.configure({ timeout: 120_000 });
let harness: TerminalE2EHarness;
test.beforeAll(async () => {
harness = await TerminalE2EHarness.create({ tempPrefix: "terminal-split-resize-" });
});
test.afterAll(async () => {
await harness?.cleanup();
});
test("splitting the pane down resizes the PTY even though focus moves to the new pane", async ({
page,
}) => {
test.setTimeout(90_000);
await verifySplitDownResizesPty(page, harness);
});
});
@@ -50,7 +50,8 @@ interface TerminalEmulatorProps {
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null; initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void; onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onTerminalKey?: (input: { onTerminalKey?: (input: {
key: string; key: string;
ctrl: boolean; ctrl: boolean;
@@ -89,7 +90,7 @@ type BridgeInboundMessage =
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string } | { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean } | { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string } | { type: "resize"; streamKey: string; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number } | { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
@@ -105,7 +106,7 @@ type BridgeOutboundMessage =
| { type: "bridgeReady" } | { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string } | { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number } | { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
| { | {
type: "terminalKey"; type: "terminalKey";
streamKey: string; streamKey: string;
@@ -188,6 +189,7 @@ export default function TerminalEmulator({
onSwipeRight, onSwipeRight,
initialSnapshot = null, initialSnapshot = null,
onInput, onInput,
onFocus,
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
@@ -229,6 +231,7 @@ export default function TerminalEmulator({
}; };
const callbacksRef = useRef({ const callbacksRef = useRef({
onInput, onInput,
onFocus,
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
@@ -241,6 +244,7 @@ export default function TerminalEmulator({
}); });
callbacksRef.current = { callbacksRef.current = {
onInput, onInput,
onFocus,
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
@@ -401,14 +405,14 @@ export default function TerminalEmulator({
useEffect(() => { useEffect(() => {
if (focusRequestToken <= 0) return; if (focusRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey }); sendToWebView({ type: "resize", streamKey, shouldClaim: true });
sendToWebView({ type: "focus", streamKey }); sendToWebView({ type: "focus", streamKey });
webViewRef.current?.requestFocus(); webViewRef.current?.requestFocus();
}, [focusRequestToken, sendToWebView, streamKey]); }, [focusRequestToken, sendToWebView, streamKey]);
useEffect(() => { useEffect(() => {
if (resizeRequestToken <= 0) return; if (resizeRequestToken <= 0) return;
sendToWebView({ type: "resize", streamKey }); sendToWebView({ type: "resize", streamKey, shouldClaim: true });
}, [resizeRequestToken, sendToWebView, streamKey]); }, [resizeRequestToken, sendToWebView, streamKey]);
useEffect(() => { useEffect(() => {
@@ -495,7 +499,11 @@ export default function TerminalEmulator({
callbacksRef.current.onInput?.(message.data); callbacksRef.current.onInput?.(message.data);
break; break;
case "resize": case "resize":
callbacksRef.current.onResize?.({ rows: message.rows, cols: message.cols }); callbacksRef.current.onResize?.({
rows: message.rows,
cols: message.cols,
shouldClaim: message.shouldClaim !== false,
});
break; break;
case "terminalKey": case "terminalKey":
callbacksRef.current.onTerminalKey?.({ callbacksRef.current.onTerminalKey?.({
@@ -592,6 +600,7 @@ export default function TerminalEmulator({
return; return;
} }
webViewRef.current?.requestFocus(); webViewRef.current?.requestFocus();
callbacksRef.current.onFocus?.();
sendToWebView({ type: "focus", streamKey, forceRefocus: true }); sendToWebView({ type: "focus", streamKey, forceRefocus: true });
}, [sendToWebView, streamKey]); }, [sendToWebView, streamKey]);
@@ -139,7 +139,8 @@ interface TerminalEmulatorProps {
onSwipeRight?: () => void; onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null; initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void; onFocus?: () => Promise<void> | void;
onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onTerminalKey?: (input: { onTerminalKey?: (input: {
key: string; key: string;
ctrl: boolean; ctrl: boolean;
@@ -219,6 +220,7 @@ export default function TerminalEmulator({
onSwipeRight, onSwipeRight,
initialSnapshot = null, initialSnapshot = null,
onInput, onInput,
onFocus,
onResize, onResize,
onTerminalKey, onTerminalKey,
onPendingModifiersConsumed, onPendingModifiersConsumed,
@@ -527,7 +529,7 @@ export default function TerminalEmulator({
if (focusRequestToken <= 0) { if (focusRequestToken <= 0) {
return () => {}; return () => {};
} }
runtimeRef.current?.resize({ force: true }); runtimeRef.current?.resize({ force: true, shouldClaim: true });
return focusWithRetries({ return focusWithRetries({
focus: () => { focus: () => {
runtimeRef.current?.focus(); runtimeRef.current?.focus();
@@ -547,7 +549,7 @@ export default function TerminalEmulator({
if (resizeRequestToken <= 0) { if (resizeRequestToken <= 0) {
return; return;
} }
runtimeRef.current?.resize({ force: true }); runtimeRef.current?.resize({ force: true, shouldClaim: true });
}, [resizeRequestToken]); }, [resizeRequestToken]);
useEffect(() => { useEffect(() => {
@@ -742,8 +744,9 @@ export default function TerminalEmulator({
}, []); }, []);
const handleRootPointerDown = useCallback(() => { const handleRootPointerDown = useCallback(() => {
onFocus?.();
runtimeRef.current?.focus(); runtimeRef.current?.focus();
}, []); }, [onFocus]);
const handleRootContextMenu = useCallback( const handleRootContextMenu = useCallback(
(event: ReactMouseEvent) => { (event: ReactMouseEvent) => {
+58 -52
View File
@@ -214,6 +214,7 @@ export function TerminalPane({
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]); const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]); const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const lastAutoFocusKeyRef = useRef<string | null>(null); const lastAutoFocusKeyRef = useRef<string | null>(null);
const lastPaneFocusResizeKeyRef = useRef<string | null>(null);
const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId });
useEffect(() => { useEffect(() => {
@@ -252,7 +253,7 @@ export function TerminalPane({
); );
useEffect(() => { useEffect(() => {
if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { if (isMobile || !isPaneFocused || !terminalId) {
lastAutoFocusKeyRef.current = null; lastAutoFocusKeyRef.current = null;
return; return;
} }
@@ -263,15 +264,36 @@ export function TerminalPane({
} }
lastAutoFocusKeyRef.current = nextFocusKey; lastAutoFocusKeyRef.current = nextFocusKey;
if (!isWorkspaceFocused) {
return;
}
requestTerminalFocus(); requestTerminalFocus();
}, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]); }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]);
useEffect(() => { useEffect(() => {
if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) { if (!isPaneFocused || !terminalId) {
lastSentTerminalSizeRef.current = null; lastPaneFocusResizeKeyRef.current = null;
requestTerminalReflow(); return;
} }
}, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]);
const focusResizeKey = `${scopeKey}:${terminalId}`;
if (lastPaneFocusResizeKeyRef.current === focusResizeKey) {
return;
}
lastPaneFocusResizeKeyRef.current = focusResizeKey;
if (!isWorkspaceFocused) {
return;
}
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [isPaneFocused, isWorkspaceFocused, requestTerminalReflow, scopeKey, terminalId]);
const handleTerminalFocus = useCallback(() => {
lastSentTerminalSizeRef.current = null;
requestTerminalReflow();
}, [requestTerminalReflow]);
const clearKeyboardRefitTimeouts = useCallback(() => { const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) { if (keyboardRefitTimeoutsRef.current.length === 0) {
@@ -308,25 +330,6 @@ export function TerminalPane({
[pulseKeyboardRefits], [pulseKeyboardRefits],
); );
useEffect(() => {
if (!isWorkspaceFocused || !terminalId) {
return;
}
// Focus transitions can temporarily report stale dimensions.
// Pulse forced refits so xterm fills the pane when returning to a terminal.
const timeoutHandles = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs),
);
return () => {
for (const handle of timeoutHandles) {
clearTimeout(handle);
}
};
}, [isWorkspaceFocused, requestTerminalReflow, terminalId]);
useEffect(() => { useEffect(() => {
if (!client || !isConnected || !isWorkspaceFocused) { if (!client || !isConnected || !isWorkspaceFocused) {
return; return;
@@ -375,7 +378,7 @@ export function TerminalPane({
const controller = new TerminalStreamController({ const controller = new TerminalStreamController({
client, client,
getPreferredSize: () => measuredTerminalSizeRef.current, getPreferredSize: () => lastSentTerminalSizeRef.current,
onOutput: ({ terminalId: outputTerminalId, data }) => { onOutput: ({ terminalId: outputTerminalId, data }) => {
if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) { if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) {
return; return;
@@ -605,33 +608,35 @@ export function TerminalPane({
], ],
); );
const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => { const handleTerminalResize = useStableEvent(
const { rows, cols } = input; (input: { rows: number; cols: number; shouldClaim: boolean }) => {
if (rows <= 0 || cols <= 0) { const { rows, cols } = input;
return; if (rows <= 0 || cols <= 0) {
} return;
const normalizedRows = Math.floor(rows); }
const normalizedCols = Math.floor(cols); const normalizedRows = Math.floor(rows);
const nextSize = { rows: normalizedRows, cols: normalizedCols }; const normalizedCols = Math.floor(cols);
measuredTerminalSizeRef.current = nextSize; const nextSize = { rows: normalizedRows, cols: normalizedCols };
if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) { measuredTerminalSizeRef.current = nextSize;
return; if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) {
} return;
const previousSent = lastSentTerminalSizeRef.current; }
if ( const previousSent = lastSentTerminalSizeRef.current;
previousSent && if (
previousSent.rows === normalizedRows && previousSent &&
previousSent.cols === normalizedCols previousSent.rows === normalizedRows &&
) { previousSent.cols === normalizedCols
return; ) {
} return;
lastSentTerminalSizeRef.current = nextSize; }
client.sendTerminalInput(terminalId, { lastSentTerminalSizeRef.current = nextSize;
type: "resize", client.sendTerminalInput(terminalId, {
rows: normalizedRows, type: "resize",
cols: normalizedCols, rows: normalizedRows,
}); cols: normalizedCols,
}); });
},
);
const handleTerminalKey = useCallback( const handleTerminalKey = useCallback(
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => { async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
@@ -764,6 +769,7 @@ export function TerminalPane({
onSwipeRight={handleSwipeRight} onSwipeRight={handleSwipeRight}
onSwipeLeft={handleSwipeLeft} onSwipeLeft={handleSwipeLeft}
onInput={handleTerminalData} onInput={handleTerminalData}
onFocus={handleTerminalFocus}
onResize={handleTerminalResize} onResize={handleTerminalResize}
onTerminalKey={handleTerminalKey} onTerminalKey={handleTerminalKey}
onInputModeChange={handleInputModeChange} onInputModeChange={handleInputModeChange}
@@ -14,6 +14,7 @@ vi.mock("@xterm/addon-webgl", () => ({
interface TerminalSize { interface TerminalSize {
rows: number; rows: number;
cols: number; cols: number;
shouldClaim: boolean;
} }
interface TerminalKeyRecord { interface TerminalKeyRecord {
@@ -198,6 +199,15 @@ describe("terminal emulator runtime in a real browser", () => {
expect(window.__paseoTerminal?.options.scrollback).toBe(42_000); expect(window.__paseoTerminal?.options.scrollback).toBe(42_000);
}); });
it("does not claim PTY ownership from passive mount refits", async () => {
await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 720, height: 360 });
await waitFor({ predicate: () => mounted.sizes.length > 0 });
expect(mounted.sizes[0]?.shouldClaim).toBe(false);
});
it("reports a larger PTY size when the terminal container grows", async () => { it("reports a larger PTY size when the terminal container grows", async () => {
await page.viewport(900, 600); await page.viewport(900, 600);
const mounted = createTerminalHost({ width: 360, height: 180 }); const mounted = createTerminalHost({ width: 360, height: 180 });
@@ -220,6 +230,7 @@ describe("terminal emulator runtime in a real browser", () => {
const grownSize = latestSize(mounted.sizes); const grownSize = latestSize(mounted.sizes);
expect(grownSize.cols).toBeGreaterThan(initialSize.cols); expect(grownSize.cols).toBeGreaterThan(initialSize.cols);
expect(grownSize.rows).toBeGreaterThan(initialSize.rows); expect(grownSize.rows).toBeGreaterThan(initialSize.rows);
expect(grownSize.shouldClaim).toBe(true);
}); });
it("refreshes visible rows on a forced same-size resize", async () => { it("refreshes visible rows on a forced same-size resize", async () => {
@@ -91,6 +91,10 @@ interface StubTerminal {
cols?: number; cols?: number;
} }
interface RuntimeFitProbe {
fitAndEmitResize: (input?: { force?: boolean; shouldClaim?: boolean }) => void;
}
function createRuntimeWithTerminal(): { function createRuntimeWithTerminal(): {
runtime: TerminalEmulatorRuntime; runtime: TerminalEmulatorRuntime;
terminal: StubTerminal & { terminal: StubTerminal & {
@@ -328,14 +332,13 @@ describe("terminal-emulator-runtime", () => {
const runtime = new TerminalEmulatorRuntime(); const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn(); const fitAndEmitResize = vi.fn();
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
fitAndEmitResize;
runtime.resize(); runtime.resize();
runtime.resize({ force: true }); runtime.resize({ force: true });
expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false); expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, undefined);
expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true); expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true });
}); });
it("updates terminal theme without remounting", () => { it("updates terminal theme without remounting", () => {
@@ -381,12 +384,11 @@ describe("terminal-emulator-runtime", () => {
expect(refresh).toHaveBeenCalledWith(0, 11); expect(refresh).toHaveBeenCalledWith(0, 11);
}); });
it("forces a refit when the page becomes visible again", () => { it("passively refits when the page becomes visible again", () => {
const runtime = new TerminalEmulatorRuntime(); const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn(); const fitAndEmitResize = vi.fn();
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
fitAndEmitResize;
(globalThis as { document?: { visibilityState?: string } }).document = { (globalThis as { document?: { visibilityState?: string } }).document = {
visibilityState: "visible", visibilityState: "visible",
}; };
@@ -397,15 +399,14 @@ describe("terminal-emulator-runtime", () => {
} }
).handleVisibilityRestore(); ).handleVisibilityRestore();
expect(fitAndEmitResize).toHaveBeenCalledWith(true); expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true, shouldClaim: false });
}); });
it("does not refit while the page is still hidden", () => { it("does not refit while the page is still hidden", () => {
const runtime = new TerminalEmulatorRuntime(); const runtime = new TerminalEmulatorRuntime();
const fitAndEmitResize = vi.fn(); const fitAndEmitResize = vi.fn();
(runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize;
fitAndEmitResize;
(globalThis as { document?: { visibilityState?: string } }).document = { (globalThis as { document?: { visibilityState?: string } }).document = {
visibilityState: "hidden", visibilityState: "hidden",
}; };
@@ -41,7 +41,7 @@ export interface TerminalEmulatorRuntimeMountInput {
export interface TerminalEmulatorRuntimeCallbacks { export interface TerminalEmulatorRuntimeCallbacks {
onInput?: (data: string) => Promise<void> | void; onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void; onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise<void> | void;
onTerminalKey?: (input: { onTerminalKey?: (input: {
key: string; key: string;
ctrl: boolean; ctrl: boolean;
@@ -68,7 +68,6 @@ interface TerminalEmulatorRuntimeDisposables {
removeWindowFocus: () => void; removeWindowFocus: () => void;
removeDocumentVisibilityChange: () => void; removeDocumentVisibilityChange: () => void;
removeVisualViewportResize: () => void; removeVisualViewportResize: () => void;
clearFitInterval: () => void;
clearFitTimeouts: () => void; clearFitTimeouts: () => void;
removeFontListeners: () => void; removeFontListeners: () => void;
removeTouchListeners: () => void; removeTouchListeners: () => void;
@@ -164,7 +163,8 @@ export class TerminalEmulatorRuntime {
}; };
private terminal: Terminal | null = null; private terminal: Terminal | null = null;
private fitAddon: FitAddon | null = null; private fitAddon: FitAddon | null = null;
private fitAndEmitResize: ((force: boolean) => void) | null = null; private fitAndEmitResize: ((input?: { force?: boolean; shouldClaim?: boolean }) => void) | null =
null;
private lastSize: { rows: number; cols: number } | null = null; private lastSize: { rows: number; cols: number } | null = null;
private cleanup: (() => void) | null = null; private cleanup: (() => void) | null = null;
private outputOperations: TerminalOutputOperation[] = []; private outputOperations: TerminalOutputOperation[] = [];
@@ -181,10 +181,10 @@ export class TerminalEmulatorRuntime {
return; return;
} }
this.fitAndEmitResize?.(true); this.fitAndEmitResize?.({ force: true, shouldClaim: false });
if (typeof window.requestAnimationFrame === "function") { if (typeof window.requestAnimationFrame === "function") {
window.requestAnimationFrame(() => { window.requestAnimationFrame(() => {
this.fitAndEmitResize?.(true); this.fitAndEmitResize?.({ force: true, shouldClaim: false });
}); });
} }
}; };
@@ -281,7 +281,7 @@ export class TerminalEmulatorRuntime {
webglAddon = null; webglAddon = null;
disposeImageAddon(); disposeImageAddon();
// WebGL and DOM renderers can have different cell dimensions. // WebGL and DOM renderers can have different cell dimensions.
this.fitAndEmitResize?.(true); this.fitAndEmitResize?.({ force: true, shouldClaim: false });
}; };
// Browser xterm is a renderer only; it never replies to terminal protocol queries. // Browser xterm is a renderer only; it never replies to terminal protocol queries.
@@ -319,7 +319,7 @@ export class TerminalEmulatorRuntime {
imageAddon = new ImageAddon(); imageAddon = new ImageAddon();
terminal.loadAddon(imageAddon); terminal.loadAddon(imageAddon);
registerProtocolQuerySuppression(); registerProtocolQuerySuppression();
this.fitAndEmitResize?.(true); this.fitAndEmitResize?.({ force: true, shouldClaim: false });
} catch { } catch {
disposeWebglRenderer(); disposeWebglRenderer();
} }
@@ -336,7 +336,9 @@ export class TerminalEmulatorRuntime {
this.fitAddon = fitAddon; this.fitAddon = fitAddon;
window.__paseoTerminal = terminal; window.__paseoTerminal = terminal;
const fitAndEmitResize = (force: boolean): void => { const fitAndEmitResize = (resizeInput?: { force?: boolean; shouldClaim?: boolean }): void => {
const force = resizeInput?.force ?? false;
const shouldClaim = resizeInput?.shouldClaim ?? true;
const currentTerminal = this.terminal; const currentTerminal = this.terminal;
const currentFitAddon = this.fitAddon; const currentFitAddon = this.fitAddon;
if (!currentTerminal || !currentFitAddon) { if (!currentTerminal || !currentFitAddon) {
@@ -365,11 +367,12 @@ export class TerminalEmulatorRuntime {
this.callbacks.onResize?.({ this.callbacks.onResize?.({
rows: nextRows, rows: nextRows,
cols: nextCols, cols: nextCols,
shouldClaim,
}); });
}; };
this.fitAndEmitResize = fitAndEmitResize; this.fitAndEmitResize = fitAndEmitResize;
fitAndEmitResize(true); fitAndEmitResize({ force: true, shouldClaim: false });
const inputDisposable = terminal.onData((data) => { const inputDisposable = terminal.onData((data) => {
if (this.suppressInput) { if (this.suppressInput) {
@@ -454,12 +457,12 @@ export class TerminalEmulatorRuntime {
terminal, terminal,
}); });
const resizeObserver = new ResizeObserver(() => { const resizeObserver = new ResizeObserver(() => {
fitAndEmitResize(false); fitAndEmitResize({ shouldClaim: true });
}); });
resizeObserver.observe(input.root); resizeObserver.observe(input.root);
resizeObserver.observe(input.host); resizeObserver.observe(input.host);
const windowResizeHandler = () => fitAndEmitResize(false); const windowResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
window.addEventListener("resize", windowResizeHandler); window.addEventListener("resize", windowResizeHandler);
const windowFocusHandler = () => { const windowFocusHandler = () => {
this.handleVisibilityRestore(); this.handleVisibilityRestore();
@@ -472,26 +475,23 @@ export class TerminalEmulatorRuntime {
document.addEventListener("visibilitychange", documentVisibilityChangeHandler); document.addEventListener("visibilitychange", documentVisibilityChangeHandler);
const visualViewport = window.visualViewport; const visualViewport = window.visualViewport;
const visualViewportResizeHandler = () => fitAndEmitResize(false); const visualViewportResizeHandler = () => fitAndEmitResize({ shouldClaim: true });
visualViewport?.addEventListener("resize", visualViewportResizeHandler); visualViewport?.addEventListener("resize", visualViewportResizeHandler);
const fitInterval = window.setInterval(() => {
fitAndEmitResize(false);
}, 250);
const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) => const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) =>
window.setTimeout(() => { window.setTimeout(() => {
fitAndEmitResize(true); fitAndEmitResize({ force: true, shouldClaim: false });
}, delayMs), }, delayMs),
); );
const fontSet = document.fonts; const fontSet = document.fonts;
const fontReadyHandler = () => { const fontReadyHandler = () => {
fitAndEmitResize(true); fitAndEmitResize({ force: true, shouldClaim: false });
}; };
fontSet?.addEventListener?.("loadingdone", fontReadyHandler); fontSet?.addEventListener?.("loadingdone", fontReadyHandler);
void fontSet?.ready void fontSet?.ready
.then(() => { .then(() => {
fitAndEmitResize(true); fitAndEmitResize({ force: true, shouldClaim: false });
return; return;
}) })
.catch(() => { .catch(() => {
@@ -499,7 +499,7 @@ export class TerminalEmulatorRuntime {
}); });
window.setTimeout(() => { window.setTimeout(() => {
fitAndEmitResize(true); fitAndEmitResize({ force: true, shouldClaim: false });
}, 0); }, 0);
if (input.initialSnapshot) { if (input.initialSnapshot) {
@@ -527,9 +527,6 @@ export class TerminalEmulatorRuntime {
removeVisualViewportResize: () => { removeVisualViewportResize: () => {
visualViewport?.removeEventListener("resize", visualViewportResizeHandler); visualViewport?.removeEventListener("resize", visualViewportResizeHandler);
}, },
clearFitInterval: () => {
window.clearInterval(fitInterval);
},
clearFitTimeouts: () => { clearFitTimeouts: () => {
for (const handle of fitTimeouts) { for (const handle of fitTimeouts) {
window.clearTimeout(handle); window.clearTimeout(handle);
@@ -565,7 +562,6 @@ export class TerminalEmulatorRuntime {
disposables.removeWindowFocus(); disposables.removeWindowFocus();
disposables.removeDocumentVisibilityChange(); disposables.removeDocumentVisibilityChange();
disposables.removeVisualViewportResize(); disposables.removeVisualViewportResize();
disposables.clearFitInterval();
disposables.clearFitTimeouts(); disposables.clearFitTimeouts();
disposables.removeFontListeners(); disposables.removeFontListeners();
disposables.removeTouchListeners(); disposables.removeTouchListeners();
@@ -635,8 +631,8 @@ export class TerminalEmulatorRuntime {
this.processOutputQueue(); this.processOutputQueue();
} }
resize(input?: { force?: boolean }): void { resize(input?: { force?: boolean; shouldClaim?: boolean }): void {
this.fitAndEmitResize?.(input?.force ?? false); this.fitAndEmitResize?.(input);
} }
setTheme(input: { theme: ITheme }): void { setTheme(input: { theme: ITheme }): void {
@@ -30,7 +30,7 @@ type InboundMessage =
| { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null }
| { type: "clear"; streamKey: string } | { type: "clear"; streamKey: string }
| { type: "focus"; streamKey: string; forceRefocus?: boolean } | { type: "focus"; streamKey: string; forceRefocus?: boolean }
| { type: "resize"; streamKey: string } | { type: "resize"; streamKey: string; shouldClaim?: boolean }
| { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setTheme"; streamKey: string; theme: ITheme }
| { type: "setScrollback"; streamKey: string; lines: number } | { type: "setScrollback"; streamKey: string; lines: number }
| { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers }
@@ -46,7 +46,7 @@ type OutboundMessage =
| { type: "bridgeReady" } | { type: "bridgeReady" }
| { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "rendererReady"; streamKey: string; isReady: boolean }
| { type: "input"; streamKey: string; data: string } | { type: "input"; streamKey: string; data: string }
| { type: "resize"; streamKey: string; rows: number; cols: number } | { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean }
| { | {
type: "terminalKey"; type: "terminalKey";
streamKey: string; streamKey: string;
@@ -244,7 +244,7 @@ class TerminalWebViewBridge {
this.runtime?.focus({ forceRefocus: message.forceRefocus }); this.runtime?.focus({ forceRefocus: message.forceRefocus });
break; break;
case "resize": case "resize":
this.runtime?.resize({ force: true }); this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false });
break; break;
case "setTheme": case "setTheme":
this.applyThemeBackground(message.theme); this.applyThemeBackground(message.theme);
@@ -273,8 +273,8 @@ class TerminalWebViewBridge {
runtime.setCallbacks({ runtime.setCallbacks({
callbacks: { callbacks: {
onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }),
onResize: ({ rows, cols }) => onResize: ({ rows, cols, shouldClaim }) =>
sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }), sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols, shouldClaim }),
onTerminalKey: (input) => onTerminalKey: (input) =>
sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }), sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }),
onPendingModifiersConsumed: () => onPendingModifiersConsumed: () =>
File diff suppressed because one or more lines are too long