From 67133e3ebcf996a195f43084e93b0b63cba4bfc5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 31 May 2026 15:42:39 +0800 Subject: [PATCH] Fix terminal resize after splitting panes (#1249) --- docs/architecture.md | 2 + .../app/e2e/terminal-split-resize.spec.ts | 119 ++++++++++++++++++ .../components/terminal-emulator.native.tsx | 21 +++- .../app/src/components/terminal-emulator.tsx | 11 +- packages/app/src/components/terminal-pane.tsx | 110 ++++++++-------- .../terminal-emulator-runtime.browser.test.ts | 11 ++ .../runtime/terminal-emulator-runtime.test.ts | 21 ++-- .../runtime/terminal-emulator-runtime.ts | 46 ++++--- .../terminal-emulator-webview-entry.ts | 10 +- .../webview/terminal-emulator-webview-html.ts | 2 +- 10 files changed, 250 insertions(+), 103 deletions(-) create mode 100644 packages/app/e2e/terminal-split-resize.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index f039d396c..e628dbbaa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -183,6 +183,8 @@ Terminal I/O is sent as binary WebSocket frames decoded by `decodeTerminalStream - 1-byte slot: terminal slot id - 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. ### Compatibility rules diff --git a/packages/app/e2e/terminal-split-resize.spec.ts b/packages/app/e2e/terminal-split-resize.spec.ts new file mode 100644 index 000000000..819a3ed5f --- /dev/null +++ b/packages/app/e2e/terminal-split-resize.spec.ts @@ -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 { + 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 { + 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 { + return (await readXtermSize(page)).rows; +} + +async function ptyRowsMatchXtermRows(page: Page): Promise { + const xterm = await readXtermSize(page); + const pty = await readLatestPtySize(page); + return pty?.rows === xterm.rows; +} + +async function verifySplitDownResizesPty(page: Page, harness: TerminalE2EHarness): Promise { + 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); + }); +}); diff --git a/packages/app/src/components/terminal-emulator.native.tsx b/packages/app/src/components/terminal-emulator.native.tsx index b090d4ad2..3383017f9 100644 --- a/packages/app/src/components/terminal-emulator.native.tsx +++ b/packages/app/src/components/terminal-emulator.native.tsx @@ -50,7 +50,8 @@ interface TerminalEmulatorProps { onSwipeRight?: () => void; initialSnapshot?: TerminalState | null; onInput?: (data: string) => Promise | void; - onResize?: (input: { rows: number; cols: number }) => Promise | void; + onFocus?: () => Promise | void; + onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; @@ -89,7 +90,7 @@ type BridgeInboundMessage = | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "clear"; streamKey: string } | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string } + | { type: "resize"; streamKey: string; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setScrollback"; streamKey: string; lines: number } | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } @@ -105,7 +106,7 @@ type BridgeOutboundMessage = | { type: "bridgeReady" } | { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "input"; streamKey: string; data: string } - | { type: "resize"; streamKey: string; rows: number; cols: number } + | { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean } | { type: "terminalKey"; streamKey: string; @@ -188,6 +189,7 @@ export default function TerminalEmulator({ onSwipeRight, initialSnapshot = null, onInput, + onFocus, onResize, onTerminalKey, onPendingModifiersConsumed, @@ -229,6 +231,7 @@ export default function TerminalEmulator({ }; const callbacksRef = useRef({ onInput, + onFocus, onResize, onTerminalKey, onPendingModifiersConsumed, @@ -241,6 +244,7 @@ export default function TerminalEmulator({ }); callbacksRef.current = { onInput, + onFocus, onResize, onTerminalKey, onPendingModifiersConsumed, @@ -401,14 +405,14 @@ export default function TerminalEmulator({ useEffect(() => { if (focusRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey }); + sendToWebView({ type: "resize", streamKey, shouldClaim: true }); sendToWebView({ type: "focus", streamKey }); webViewRef.current?.requestFocus(); }, [focusRequestToken, sendToWebView, streamKey]); useEffect(() => { if (resizeRequestToken <= 0) return; - sendToWebView({ type: "resize", streamKey }); + sendToWebView({ type: "resize", streamKey, shouldClaim: true }); }, [resizeRequestToken, sendToWebView, streamKey]); useEffect(() => { @@ -495,7 +499,11 @@ export default function TerminalEmulator({ callbacksRef.current.onInput?.(message.data); break; 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; case "terminalKey": callbacksRef.current.onTerminalKey?.({ @@ -592,6 +600,7 @@ export default function TerminalEmulator({ return; } webViewRef.current?.requestFocus(); + callbacksRef.current.onFocus?.(); sendToWebView({ type: "focus", streamKey, forceRefocus: true }); }, [sendToWebView, streamKey]); diff --git a/packages/app/src/components/terminal-emulator.tsx b/packages/app/src/components/terminal-emulator.tsx index 952619945..73e011cd4 100644 --- a/packages/app/src/components/terminal-emulator.tsx +++ b/packages/app/src/components/terminal-emulator.tsx @@ -139,7 +139,8 @@ interface TerminalEmulatorProps { onSwipeRight?: () => void; initialSnapshot?: TerminalState | null; onInput?: (data: string) => Promise | void; - onResize?: (input: { rows: number; cols: number }) => Promise | void; + onFocus?: () => Promise | void; + onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; @@ -219,6 +220,7 @@ export default function TerminalEmulator({ onSwipeRight, initialSnapshot = null, onInput, + onFocus, onResize, onTerminalKey, onPendingModifiersConsumed, @@ -527,7 +529,7 @@ export default function TerminalEmulator({ if (focusRequestToken <= 0) { return () => {}; } - runtimeRef.current?.resize({ force: true }); + runtimeRef.current?.resize({ force: true, shouldClaim: true }); return focusWithRetries({ focus: () => { runtimeRef.current?.focus(); @@ -547,7 +549,7 @@ export default function TerminalEmulator({ if (resizeRequestToken <= 0) { return; } - runtimeRef.current?.resize({ force: true }); + runtimeRef.current?.resize({ force: true, shouldClaim: true }); }, [resizeRequestToken]); useEffect(() => { @@ -742,8 +744,9 @@ export default function TerminalEmulator({ }, []); const handleRootPointerDown = useCallback(() => { + onFocus?.(); runtimeRef.current?.focus(); - }, []); + }, [onFocus]); const handleRootContextMenu = useCallback( (event: ReactMouseEvent) => { diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index 7522c33fe..628433d6c 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -214,6 +214,7 @@ export function TerminalPane({ const pendingTerminalInputRef = useRef([]); const keyboardRefitTimeoutsRef = useRef>>([]); const lastAutoFocusKeyRef = useRef(null); + const lastPaneFocusResizeKeyRef = useRef(null); const initialSnapshot = workspaceTerminalSession.snapshots.get({ terminalId }); useEffect(() => { @@ -252,7 +253,7 @@ export function TerminalPane({ ); useEffect(() => { - if (isMobile || !isWorkspaceFocused || !isPaneFocused || !terminalId) { + if (isMobile || !isPaneFocused || !terminalId) { lastAutoFocusKeyRef.current = null; return; } @@ -263,15 +264,36 @@ export function TerminalPane({ } lastAutoFocusKeyRef.current = nextFocusKey; + if (!isWorkspaceFocused) { + return; + } + requestTerminalFocus(); }, [isMobile, isPaneFocused, isWorkspaceFocused, requestTerminalFocus, scopeKey, terminalId]); useEffect(() => { - if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) { - lastSentTerminalSizeRef.current = null; - requestTerminalReflow(); + if (!isPaneFocused || !terminalId) { + lastPaneFocusResizeKeyRef.current = null; + 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(() => { if (keyboardRefitTimeoutsRef.current.length === 0) { @@ -308,25 +330,6 @@ export function TerminalPane({ [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(() => { if (!client || !isConnected || !isWorkspaceFocused) { return; @@ -375,7 +378,7 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, - getPreferredSize: () => measuredTerminalSizeRef.current, + getPreferredSize: () => lastSentTerminalSizeRef.current, onOutput: ({ terminalId: outputTerminalId, data }) => { if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) { return; @@ -605,33 +608,35 @@ export function TerminalPane({ ], ); - const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => { - const { rows, cols } = input; - if (rows <= 0 || cols <= 0) { - return; - } - const normalizedRows = Math.floor(rows); - const normalizedCols = Math.floor(cols); - const nextSize = { rows: normalizedRows, cols: normalizedCols }; - measuredTerminalSizeRef.current = nextSize; - if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) { - return; - } - const previousSent = lastSentTerminalSizeRef.current; - if ( - previousSent && - previousSent.rows === normalizedRows && - previousSent.cols === normalizedCols - ) { - return; - } - lastSentTerminalSizeRef.current = nextSize; - client.sendTerminalInput(terminalId, { - type: "resize", - rows: normalizedRows, - cols: normalizedCols, - }); - }); + const handleTerminalResize = useStableEvent( + (input: { rows: number; cols: number; shouldClaim: boolean }) => { + const { rows, cols } = input; + if (rows <= 0 || cols <= 0) { + return; + } + const normalizedRows = Math.floor(rows); + const normalizedCols = Math.floor(cols); + const nextSize = { rows: normalizedRows, cols: normalizedCols }; + measuredTerminalSizeRef.current = nextSize; + if (!input.shouldClaim || !client || !terminalId || !isWorkspaceFocused || !isAppVisible) { + return; + } + const previousSent = lastSentTerminalSizeRef.current; + if ( + previousSent && + previousSent.rows === normalizedRows && + previousSent.cols === normalizedCols + ) { + return; + } + lastSentTerminalSizeRef.current = nextSize; + client.sendTerminalInput(terminalId, { + type: "resize", + rows: normalizedRows, + cols: normalizedCols, + }); + }, + ); const handleTerminalKey = useCallback( async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => { @@ -764,6 +769,7 @@ export function TerminalPane({ onSwipeRight={handleSwipeRight} onSwipeLeft={handleSwipeLeft} onInput={handleTerminalData} + onFocus={handleTerminalFocus} onResize={handleTerminalResize} onTerminalKey={handleTerminalKey} onInputModeChange={handleInputModeChange} diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 604c39c94..baa6ae350 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -14,6 +14,7 @@ vi.mock("@xterm/addon-webgl", () => ({ interface TerminalSize { rows: number; cols: number; + shouldClaim: boolean; } interface TerminalKeyRecord { @@ -198,6 +199,15 @@ describe("terminal emulator runtime in a real browser", () => { 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 () => { await page.viewport(900, 600); const mounted = createTerminalHost({ width: 360, height: 180 }); @@ -220,6 +230,7 @@ describe("terminal emulator runtime in a real browser", () => { const grownSize = latestSize(mounted.sizes); expect(grownSize.cols).toBeGreaterThan(initialSize.cols); expect(grownSize.rows).toBeGreaterThan(initialSize.rows); + expect(grownSize.shouldClaim).toBe(true); }); it("refreshes visible rows on a forced same-size resize", async () => { diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts index 6a6748b71..cab26c8bc 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.test.ts @@ -91,6 +91,10 @@ interface StubTerminal { cols?: number; } +interface RuntimeFitProbe { + fitAndEmitResize: (input?: { force?: boolean; shouldClaim?: boolean }) => void; +} + function createRuntimeWithTerminal(): { runtime: TerminalEmulatorRuntime; terminal: StubTerminal & { @@ -328,14 +332,13 @@ describe("terminal-emulator-runtime", () => { const runtime = new TerminalEmulatorRuntime(); const fitAndEmitResize = vi.fn(); - (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = - fitAndEmitResize; + (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize; runtime.resize(); runtime.resize({ force: true }); - expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, false); - expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, true); + expect(fitAndEmitResize).toHaveBeenNthCalledWith(1, undefined); + expect(fitAndEmitResize).toHaveBeenNthCalledWith(2, { force: true }); }); it("updates terminal theme without remounting", () => { @@ -381,12 +384,11 @@ describe("terminal-emulator-runtime", () => { 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 fitAndEmitResize = vi.fn(); - (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = - fitAndEmitResize; + (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize; (globalThis as { document?: { visibilityState?: string } }).document = { visibilityState: "visible", }; @@ -397,15 +399,14 @@ describe("terminal-emulator-runtime", () => { } ).handleVisibilityRestore(); - expect(fitAndEmitResize).toHaveBeenCalledWith(true); + expect(fitAndEmitResize).toHaveBeenCalledWith({ force: true, shouldClaim: false }); }); it("does not refit while the page is still hidden", () => { const runtime = new TerminalEmulatorRuntime(); const fitAndEmitResize = vi.fn(); - (runtime as unknown as { fitAndEmitResize: (force: boolean) => void }).fitAndEmitResize = - fitAndEmitResize; + (runtime as unknown as RuntimeFitProbe).fitAndEmitResize = fitAndEmitResize; (globalThis as { document?: { visibilityState?: string } }).document = { visibilityState: "hidden", }; diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index c23e801d5..8773f586f 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -41,7 +41,7 @@ export interface TerminalEmulatorRuntimeMountInput { export interface TerminalEmulatorRuntimeCallbacks { onInput?: (data: string) => Promise | void; - onResize?: (input: { rows: number; cols: number }) => Promise | void; + onResize?: (input: { rows: number; cols: number; shouldClaim: boolean }) => Promise | void; onTerminalKey?: (input: { key: string; ctrl: boolean; @@ -68,7 +68,6 @@ interface TerminalEmulatorRuntimeDisposables { removeWindowFocus: () => void; removeDocumentVisibilityChange: () => void; removeVisualViewportResize: () => void; - clearFitInterval: () => void; clearFitTimeouts: () => void; removeFontListeners: () => void; removeTouchListeners: () => void; @@ -164,7 +163,8 @@ export class TerminalEmulatorRuntime { }; private terminal: Terminal | 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 cleanup: (() => void) | null = null; private outputOperations: TerminalOutputOperation[] = []; @@ -181,10 +181,10 @@ export class TerminalEmulatorRuntime { return; } - this.fitAndEmitResize?.(true); + this.fitAndEmitResize?.({ force: true, shouldClaim: false }); if (typeof window.requestAnimationFrame === "function") { window.requestAnimationFrame(() => { - this.fitAndEmitResize?.(true); + this.fitAndEmitResize?.({ force: true, shouldClaim: false }); }); } }; @@ -281,7 +281,7 @@ export class TerminalEmulatorRuntime { webglAddon = null; disposeImageAddon(); // 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. @@ -319,7 +319,7 @@ export class TerminalEmulatorRuntime { imageAddon = new ImageAddon(); terminal.loadAddon(imageAddon); registerProtocolQuerySuppression(); - this.fitAndEmitResize?.(true); + this.fitAndEmitResize?.({ force: true, shouldClaim: false }); } catch { disposeWebglRenderer(); } @@ -336,7 +336,9 @@ export class TerminalEmulatorRuntime { this.fitAddon = fitAddon; 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 currentFitAddon = this.fitAddon; if (!currentTerminal || !currentFitAddon) { @@ -365,11 +367,12 @@ export class TerminalEmulatorRuntime { this.callbacks.onResize?.({ rows: nextRows, cols: nextCols, + shouldClaim, }); }; this.fitAndEmitResize = fitAndEmitResize; - fitAndEmitResize(true); + fitAndEmitResize({ force: true, shouldClaim: false }); const inputDisposable = terminal.onData((data) => { if (this.suppressInput) { @@ -454,12 +457,12 @@ export class TerminalEmulatorRuntime { terminal, }); const resizeObserver = new ResizeObserver(() => { - fitAndEmitResize(false); + fitAndEmitResize({ shouldClaim: true }); }); resizeObserver.observe(input.root); resizeObserver.observe(input.host); - const windowResizeHandler = () => fitAndEmitResize(false); + const windowResizeHandler = () => fitAndEmitResize({ shouldClaim: true }); window.addEventListener("resize", windowResizeHandler); const windowFocusHandler = () => { this.handleVisibilityRestore(); @@ -472,26 +475,23 @@ export class TerminalEmulatorRuntime { document.addEventListener("visibilitychange", documentVisibilityChangeHandler); const visualViewport = window.visualViewport; - const visualViewportResizeHandler = () => fitAndEmitResize(false); + const visualViewportResizeHandler = () => fitAndEmitResize({ shouldClaim: true }); visualViewport?.addEventListener("resize", visualViewportResizeHandler); - const fitInterval = window.setInterval(() => { - fitAndEmitResize(false); - }, 250); const fitTimeouts = FIT_TIMEOUT_DELAYS_MS.map((delayMs) => window.setTimeout(() => { - fitAndEmitResize(true); + fitAndEmitResize({ force: true, shouldClaim: false }); }, delayMs), ); const fontSet = document.fonts; const fontReadyHandler = () => { - fitAndEmitResize(true); + fitAndEmitResize({ force: true, shouldClaim: false }); }; fontSet?.addEventListener?.("loadingdone", fontReadyHandler); void fontSet?.ready .then(() => { - fitAndEmitResize(true); + fitAndEmitResize({ force: true, shouldClaim: false }); return; }) .catch(() => { @@ -499,7 +499,7 @@ export class TerminalEmulatorRuntime { }); window.setTimeout(() => { - fitAndEmitResize(true); + fitAndEmitResize({ force: true, shouldClaim: false }); }, 0); if (input.initialSnapshot) { @@ -527,9 +527,6 @@ export class TerminalEmulatorRuntime { removeVisualViewportResize: () => { visualViewport?.removeEventListener("resize", visualViewportResizeHandler); }, - clearFitInterval: () => { - window.clearInterval(fitInterval); - }, clearFitTimeouts: () => { for (const handle of fitTimeouts) { window.clearTimeout(handle); @@ -565,7 +562,6 @@ export class TerminalEmulatorRuntime { disposables.removeWindowFocus(); disposables.removeDocumentVisibilityChange(); disposables.removeVisualViewportResize(); - disposables.clearFitInterval(); disposables.clearFitTimeouts(); disposables.removeFontListeners(); disposables.removeTouchListeners(); @@ -635,8 +631,8 @@ export class TerminalEmulatorRuntime { this.processOutputQueue(); } - resize(input?: { force?: boolean }): void { - this.fitAndEmitResize?.(input?.force ?? false); + resize(input?: { force?: boolean; shouldClaim?: boolean }): void { + this.fitAndEmitResize?.(input); } setTheme(input: { theme: ITheme }): void { diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts index 8cd13c6b6..f69a550e2 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-entry.ts @@ -30,7 +30,7 @@ type InboundMessage = | { type: "renderSnapshot"; streamKey: string; state: TerminalState | null } | { type: "clear"; streamKey: string } | { type: "focus"; streamKey: string; forceRefocus?: boolean } - | { type: "resize"; streamKey: string } + | { type: "resize"; streamKey: string; shouldClaim?: boolean } | { type: "setTheme"; streamKey: string; theme: ITheme } | { type: "setScrollback"; streamKey: string; lines: number } | { type: "setPendingModifiers"; streamKey: string; pendingModifiers: PendingTerminalModifiers } @@ -46,7 +46,7 @@ type OutboundMessage = | { type: "bridgeReady" } | { type: "rendererReady"; streamKey: string; isReady: boolean } | { type: "input"; streamKey: string; data: string } - | { type: "resize"; streamKey: string; rows: number; cols: number } + | { type: "resize"; streamKey: string; rows: number; cols: number; shouldClaim?: boolean } | { type: "terminalKey"; streamKey: string; @@ -244,7 +244,7 @@ class TerminalWebViewBridge { this.runtime?.focus({ forceRefocus: message.forceRefocus }); break; case "resize": - this.runtime?.resize({ force: true }); + this.runtime?.resize({ force: true, shouldClaim: message.shouldClaim !== false }); break; case "setTheme": this.applyThemeBackground(message.theme); @@ -273,8 +273,8 @@ class TerminalWebViewBridge { runtime.setCallbacks({ callbacks: { onInput: (data) => sendToNative({ type: "input", streamKey: message.streamKey, data }), - onResize: ({ rows, cols }) => - sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols }), + onResize: ({ rows, cols, shouldClaim }) => + sendToNative({ type: "resize", streamKey: message.streamKey, rows, cols, shouldClaim }), onTerminalKey: (input) => sendToNative({ type: "terminalKey", streamKey: message.streamKey, ...input }), onPendingModifiersConsumed: () => diff --git a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts index f771d7e31..124b06083 100644 --- a/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts +++ b/packages/app/src/terminal/webview/terminal-emulator-webview-html.ts @@ -2,4 +2,4 @@ // Do not edit by hand. export const terminalEmulatorWebViewHtml = - '\n\n \n \n \n \n \n \n \n'; + '\n\n \n \n \n \n \n \n \n';