diff --git a/packages/app/e2e/terminal-alternate-screen.spec.ts b/packages/app/e2e/terminal-alternate-screen.spec.ts index d6048ce33..84364934b 100644 --- a/packages/app/e2e/terminal-alternate-screen.spec.ts +++ b/packages/app/e2e/terminal-alternate-screen.spec.ts @@ -1,6 +1,7 @@ import type { Page } from "@playwright/test"; import { test, expect } from "./fixtures"; import { TerminalE2EHarness, withTerminalInApp } from "./helpers/terminal-dsl"; +import { getTabTestIds } from "./helpers/launcher"; import { installTerminalRenderProbe, readTerminalRenderProbe, @@ -10,6 +11,54 @@ import { } from "./helpers/terminal-probes"; import { getTerminalBufferText, waitForTerminalContent } from "./helpers/terminal-perf"; +interface TerminalLayoutMetrics { + visibleSurfaceCount: number; + surfaceHeight: number; + rowCount: number; + rows: number | null; + cols: number | null; + renderedRowsHeight: number; + tabIds: string[]; +} + +async function readTerminalLayoutMetrics(page: Page): Promise { + const tabIds = await getTabTestIds(page); + return page.evaluate((currentTabIds) => { + const visibleSurfaces = Array.from( + document.querySelectorAll('[data-testid="terminal-surface"]'), + ).filter((candidate) => { + const rect = candidate.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; + }); + const surface = visibleSurfaces[0] ?? null; + const renderedRows = Array.from(document.querySelectorAll(".xterm-rows > div")); + const firstRow = renderedRows[0] ?? null; + const lastRow = renderedRows.at(-1) ?? null; + const surfaceRect = surface?.getBoundingClientRect() ?? null; + const firstRowRect = firstRow?.getBoundingClientRect() ?? null; + const lastRowRect = lastRow?.getBoundingClientRect() ?? null; + const term = ( + window as Window & { + __paseoTerminal?: { + rows?: number; + cols?: number; + }; + } + ).__paseoTerminal; + + return { + visibleSurfaceCount: visibleSurfaces.length, + surfaceHeight: surfaceRect?.height ?? 0, + rowCount: renderedRows.length, + rows: typeof term?.rows === "number" ? term.rows : null, + cols: typeof term?.cols === "number" ? term.cols : null, + renderedRowsHeight: + firstRowRect && lastRowRect ? Math.max(0, lastRowRect.bottom - firstRowRect.top) : 0, + tabIds: currentTabIds, + }; + }, tabIds); +} + async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: number) { let lastBufferText = ""; let lastProbe = await readTerminalRenderProbe(page); @@ -53,6 +102,8 @@ async function waitForAlternateScreenExit(page: Page, afterAlt: string, timeout: } test.describe("Terminal alternate-screen transitions", () => { + test.describe.configure({ timeout: 120_000 }); + let harness: TerminalE2EHarness; test.beforeAll(async () => { @@ -138,4 +189,62 @@ test.describe("Terminal alternate-screen transitions", () => { ).toEqual([]); }); }); + + test("opening vim in a new terminal fills the terminal surface", async ({ page }, testInfo) => { + test.setTimeout(60_000); + + await page.setViewportSize({ width: 1280, height: 900 }); + await installTerminalRenderProbe(page); + + await withTerminalInApp(page, harness, { name: "vim-layout" }, async () => { + await harness.setupPrompt(page); + const terminalSurface = harness.terminalSurface(page).first(); + await expect(terminalSurface).toBeVisible({ timeout: 15_000 }); + await terminalSurface.click(); + + const beforeVim = await readTerminalLayoutMetrics(page); + + await startTerminalFrameSampling(page, 2_000); + await terminalSurface.pressSequentially("vim -Nu NONE -n\n", { delay: 0 }); + await page.waitForTimeout(1_500); + + const probe = await readTerminalRenderProbe(page); + const samples: TerminalLayoutMetrics[] = []; + for (let index = 0; index < 12; index += 1) { + samples.push(await readTerminalLayoutMetrics(page)); + await page.waitForTimeout(50); + } + + await testInfo.attach("reused-terminal-layout-metrics", { + body: JSON.stringify( + { + beforeVim, + probe: summarizeTerminalRenderProbe(probe), + samples, + }, + null, + 2, + ), + contentType: "application/json", + }); + + const firstSample = samples[0]; + const finalSample = samples.at(-1); + expect(firstSample, "expected an initial layout sample").toBeTruthy(); + expect(finalSample, "expected a final layout sample").toBeTruthy(); + + expect( + finalSample?.visibleSurfaceCount ?? 0, + "opening vim should leave exactly one visible terminal surface", + ).toBe(1); + expect( + finalSample?.renderedRowsHeight ?? 0, + "vim should render rows that fill most of the terminal surface", + ).toBeGreaterThan((finalSample?.surfaceHeight ?? 0) * 0.75); + expect( + finalSample?.rowCount ?? 0, + "vim should leave a substantial number of rendered rows", + ).toBeGreaterThan(Math.max(10, Math.floor((beforeVim.rowCount || 0) * 0.75))); + }); + }); }); diff --git a/packages/app/src/components/terminal-pane.tsx b/packages/app/src/components/terminal-pane.tsx index fe6920a28..afcec86ff 100644 --- a/packages/app/src/components/terminal-pane.tsx +++ b/packages/app/src/components/terminal-pane.tsx @@ -164,7 +164,10 @@ export function TerminalPane({ const isConnected = useHostRuntimeIsConnected(serverId); const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]); - const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null); + // Keep the latest measured size for whichever client currently owns the pane, + // but only dedupe resizes that this specific client has already pushed. + const measuredTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); + const lastSentTerminalSizeRef = useRef<{ rows: number; cols: number } | null>(null); const streamControllerRef = useRef(null); const workspaceTerminalSession = useMemo( () => getWorkspaceTerminalSession({ scopeKey }), @@ -210,7 +213,7 @@ export function TerminalPane({ useEffect(() => { if (isPaneFocused && isWorkspaceFocused && isAppVisible && terminalId) { - lastReportedSizeRef.current = null; + lastSentTerminalSizeRef.current = null; requestTerminalReflow(); } }, [isAppVisible, isPaneFocused, isWorkspaceFocused, requestTerminalReflow, terminalId]); @@ -296,7 +299,8 @@ export function TerminalPane({ }, [client, isConnected, isWorkspaceFocused, workspaceTerminalSession.snapshots]); useEffect(() => { - lastReportedSizeRef.current = null; + measuredTerminalSizeRef.current = null; + lastSentTerminalSizeRef.current = null; }, [scopeKey]); const handleStreamControllerStatus = useCallback((status: TerminalStreamControllerStatus) => { @@ -316,7 +320,7 @@ export function TerminalPane({ const controller = new TerminalStreamController({ client, - getPreferredSize: () => lastReportedSizeRef.current, + getPreferredSize: () => measuredTerminalSizeRef.current, onOutput: ({ terminalId: outputTerminalId, text }) => { if (!isWorkspaceFocused || terminalIdRef.current !== outputTerminalId) { return; @@ -532,24 +536,25 @@ export function TerminalPane({ const handleTerminalResize = useStableEvent((input: { rows: number; cols: number }) => { const { rows, cols } = input; - if ( - !client || - !terminalId || - !isPaneFocused || - !isWorkspaceFocused || - !isAppVisible || - rows <= 0 || - cols <= 0 - ) { + if (rows <= 0 || cols <= 0) { return; } const normalizedRows = Math.floor(rows); const normalizedCols = Math.floor(cols); - const previous = lastReportedSizeRef.current; - if (previous && previous.rows === normalizedRows && previous.cols === normalizedCols) { + const nextSize = { rows: normalizedRows, cols: normalizedCols }; + measuredTerminalSizeRef.current = nextSize; + if (!client || !terminalId || !isPaneFocused || !isWorkspaceFocused || !isAppVisible) { return; } - lastReportedSizeRef.current = { rows: normalizedRows, cols: normalizedCols }; + const previousSent = lastSentTerminalSizeRef.current; + if ( + previousSent && + previousSent.rows === normalizedRows && + previousSent.cols === normalizedCols + ) { + return; + } + lastSentTerminalSizeRef.current = nextSize; client.sendTerminalInput(terminalId, { type: "resize", rows: normalizedRows, diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 171ca97f7..5393252c5 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -43,7 +43,11 @@ app.setName("Paseo"); // In dev mode, detect git worktrees and isolate each instance so multiple // Electron windows can run side-by-side (separate userData = separate lock). let devWorktreeName: string | null = null; -if (!app.isPackaged) { +const forcedUserDataDir = process.env.PASEO_ELECTRON_USER_DATA_DIR?.trim(); +if (forcedUserDataDir) { + app.setPath("userData", forcedUserDataDir); + log.info("[dev-user-data] forced userData dir:", forcedUserDataDir); +} else if (!app.isPackaged) { try { const topLevel = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf-8",