diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7ad97e6b692..cfb4c57b797 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -8527,6 +8527,37 @@ describe('connectPanePty', () => { ) }) + it('resets cached parked terminal modes before authoritative snapshot replay', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const remotePtyId = 'remote:env-1@@terminal-1' + const transport = createMockTransport(remotePtyId) + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return { id: remotePtyId, replay: '' } + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + pane.container.dataset.parkedViewportFrame = 'true' + pane.terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(6) + replayCallback.current?.('authoritative snapshot') + await flushAsyncTicks(12) + + const writes = pane.terminal.write.mock.calls.map(([data]) => data) + expect(writes.indexOf('\x1bc')).toBeGreaterThanOrEqual(0) + expect(writes.indexOf('\x1bc')).toBeLessThan(writes.indexOf('authoritative snapshot')) + expect(writes).not.toContain('\x1b[2J\x1b[3J\x1b[H') + expect(pane.container.dataset.parkedViewportFrame).toBeUndefined() + binding.dispose() + }) + it('resizes the pane to the snapshot grid before replaying daemon snapshot bytes (bug #7279)', async () => { // Why: the daemon serializes soft-wrapped lines flat, so reattach must resize xterm to the snapshot grid before writing, or rows rewrap wrong. const { connectPanePty } = await import('./pty-connection') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ec335002286..baeef05c946 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -272,6 +272,7 @@ import { } from './command-code-done-settle' import { canCommandCodeOutputOwnPane } from './command-code-output-ownership' import { isTerminalTabParked } from './terminal-parked-watcher-registry' +import { consumeParkedTerminalViewportFrameMarker } from './terminal-parked-viewport-frame' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner @@ -5692,7 +5693,9 @@ export function connectPanePty( // xterm. Local eager replay decides this earlier so metadata-only frames // can keep restored scrollback while still using the replay guard. if (clearBeforeReplay) { - await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + await writeReplayDataAsync( + consumeParkedTerminalViewportFrameMarker(pane) ? '\x1bc' : '\x1b[2J\x1b[3J\x1b[H' + ) if (!isCurrentPayload()) { continue } diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index 42de06cac37..8c0d5bd082d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -24,15 +24,20 @@ import { import { collapseParkedExitedLeaf, startParkedPtyWatcher } from './terminal-parked-pty-watcher' import { capturedPanesByTabId, + consumeParkedTerminalViewportFrames, disposeParkedTabWatchers, parkedWatchersByTabId, type ParkedTabWatcherEntry, type ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' +import type { ReplayingPanesRef } from './replay-guard' +import { replayParkedTerminalViewportFrames } from './terminal-parked-viewport-frame' // Why: re-export so callers keep one import surface; the registry split only breaks the store-slice import cycle. export { captureParkedTerminalPaneCandidates, + consumeParkedTerminalViewportFrames, disposeAllParkedTerminalWatchers, disposeRemovedWorktreeParkedTerminalWatchers, disposeParkedTerminalWatchersForPtyIds, @@ -49,6 +54,29 @@ export { export type { ParkableTerminalTabModel } from './terminal-parked-watcher-reconciliation' export type ParkedTerminalPtyEligibility = (ptyId: string) => boolean +export function replayParkedTerminalViewportForTab(args: { + manager: PaneManager + paneByLeafId: ReadonlyMap + replayingPanesRef: ReplayingPanesRef + tabId: string + worktreeId: string +}): number { + const state = useAppStore.getState() + const tab = state.tabsByWorktree[args.worktreeId]?.find( + (candidate) => candidate.id === args.tabId + ) + return replayParkedTerminalViewportFrames({ + manager: args.manager, + frames: consumeParkedTerminalViewportFrames( + args.tabId, + state.terminalLayoutsByTabId[args.tabId]?.ptyIdsByLeafId ?? {}, + tab?.ptyId ?? null + ), + paneByLeafId: args.paneByLeafId, + replayingPanesRef: args.replayingPanesRef + }) +} + const allowOrdinaryParkRestore = (ptyId: string): boolean => isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId) !== null || diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts new file mode 100644 index 00000000000..0dd2678b00f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import { + captureParkedTerminalPanes, + captureParkedTerminalViewportFrame, + consumeParkedTerminalViewportFrameMarker, + replayParkedTerminalViewportFrames +} from './terminal-parked-viewport-frame' + +const mocks = vi.hoisted(() => ({ replayIntoTerminal: vi.fn() })) + +vi.mock('./replay-guard', () => ({ replayIntoTerminal: mocks.replayIntoTerminal })) + +function makePane( + id = 1, + leafId = 'leaf-1' +): { + pane: ManagedPane + serialize: ReturnType +} { + const serialize = vi.fn(() => 'cached viewport') + return { + pane: { + id, + leafId, + terminal: { + cols: 120, + rows: 40, + buffer: { active: { cursorX: 4, cursorY: 7 } } + }, + container: { dataset: {} }, + serializeAddon: { serialize } + } as unknown as ManagedPane, + serialize + } +} + +beforeEach(() => { + mocks.replayIntoTerminal.mockClear() +}) + +describe('parked terminal viewport frames', () => { + it('captures only the active viewport with an absolute cursor restore', () => { + const { pane, serialize } = makePane() + + const frame = captureParkedTerminalViewportFrame(pane) + + expect(serialize).toHaveBeenCalledWith({ scrollback: 0 }) + expect(frame).toEqual({ data: 'cached viewport\x1b[8;5H', cols: 120, rows: 40 }) + }) + + it('captures viewport frames only for paired runtime panes', () => { + const remote = makePane(1, 'remote-leaf') + const local = makePane(2, 'local-leaf') + const manager = { + getActivePane: () => remote.pane, + getPanes: () => [remote.pane, local.pane] + } as unknown as PaneManager + const transports = new Map([ + [1, { getPtyId: () => 'remote:windows-low-spec@@terminal-1' } as PtyTransport], + [2, { getPtyId: () => 'local-pty' } as PtyTransport] + ]) + + const captures = captureParkedTerminalPanes(manager, transports) + + expect(captures[0].viewportFrame?.data).toContain('cached viewport') + expect(captures[1]).not.toHaveProperty('viewportFrame') + expect(local.serialize).not.toHaveBeenCalled() + }) + + it('replays a matching-grid frame and skips a stale geometry', () => { + const matching = makePane(1, 'matching') + const stale = makePane(2, 'stale') + const manager = { + getPanes: () => [matching.pane, stale.pane], + hasWebglRenderer: () => false + } as unknown as PaneManager + const replayingPanesRef = { current: new Map() } + + const replayed = replayParkedTerminalViewportFrames({ + manager, + frames: [ + { leafId: 'matching', frame: { data: 'matching frame', cols: 120, rows: 40 } }, + { leafId: 'stale', frame: { data: 'stale frame', cols: 80, rows: 24 } } + ], + paneByLeafId: new Map([ + ['matching', 1], + ['stale', 2] + ]), + replayingPanesRef + }) + + expect(replayed).toBe(1) + expect(mocks.replayIntoTerminal).toHaveBeenCalledOnce() + expect(mocks.replayIntoTerminal).toHaveBeenCalledWith( + matching.pane, + replayingPanesRef, + 'matching frame', + expect.any(Object) + ) + expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(true) + expect(consumeParkedTerminalViewportFrameMarker(matching.pane)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts new file mode 100644 index 00000000000..3ccd6ce13a2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-viewport-frame.ts @@ -0,0 +1,95 @@ +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import type { PtyTransport } from './pty-transport' +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' +import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' + +const MAX_PARKED_VIEWPORT_FRAME_CHARS = 256 * 1024 +const PARKED_VIEWPORT_FRAME_DATASET_KEY = 'parkedViewportFrame' + +export type ParkedTerminalViewportFrame = { + data: string + cols: number + rows: number +} + +export type ParkedTerminalViewportFrameByLeaf = { + leafId: string + frame: ParkedTerminalViewportFrame +} + +export function captureParkedTerminalPanes( + manager: PaneManager, + paneTransports: ReadonlyMap +): { + ptyId: string | null + paneId: number + leafId: string + drivesTabTitle: boolean + viewportFrame?: ParkedTerminalViewportFrame +}[] { + const activePaneId = manager.getActivePane()?.id + return manager.getPanes().map((pane) => { + const ptyId = paneTransports.get(pane.id)?.getPtyId() ?? null + const viewportFrame = + ptyId && isRemoteRuntimePtyId(ptyId) ? captureParkedTerminalViewportFrame(pane) : null + return { + ptyId, + paneId: pane.id, + leafId: pane.leafId, + drivesTabTitle: activePaneId === pane.id, + ...(viewportFrame ? { viewportFrame } : {}) + } + }) +} + +export function captureParkedTerminalViewportFrame( + pane: ManagedPane +): ParkedTerminalViewportFrame | null { + try { + if (pane.terminal.cols < 1 || pane.terminal.rows < 1) { + return null + } + const data = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: 0 + }) + if (!data || data.length > MAX_PARKED_VIEWPORT_FRAME_CHARS) { + return null + } + return { data, cols: pane.terminal.cols, rows: pane.terminal.rows } + } catch { + return null + } +} + +export function replayParkedTerminalViewportFrames(args: { + manager: PaneManager + frames: readonly ParkedTerminalViewportFrameByLeaf[] + paneByLeafId: ReadonlyMap + replayingPanesRef: ReplayingPanesRef +}): number { + let replayed = 0 + for (const { leafId, frame } of args.frames) { + const paneId = args.paneByLeafId.get(leafId) + const pane = args.manager.getPanes().find((candidate) => candidate.id === paneId) + if (!pane || pane.terminal.cols !== frame.cols || pane.terminal.rows !== frame.rows) { + continue + } + replayIntoTerminal(pane, args.replayingPanesRef, frame.data, { + shouldRefreshViewportSynchronously: () => !args.manager.hasWebglRenderer(pane.id) + }) + pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] = 'true' + replayed += 1 + } + return replayed +} + +export function consumeParkedTerminalViewportFrameMarker( + pane: Pick +): boolean { + if (pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] !== 'true') { + return false + } + delete pane.container.dataset[PARKED_VIEWPORT_FRAME_DATASET_KEY] + return true +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts index eb436c8e127..27fe5299683 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.test.ts @@ -9,6 +9,7 @@ import { } from './pty-pre-handler-buffer' import { capturedPanesByTabId, + consumeParkedTerminalViewportFrames, parkedWatchersByTabId, pruneParkedTerminalWatchers, terminalWatcherLiveWorkspaceIds @@ -90,4 +91,58 @@ describe('terminal parked watcher registry removal', () => { expect(capturedPanesByTabId.has(FLOATING_TAB_ID)).toBe(true) expect(floatingDispose).not.toHaveBeenCalled() }) + + it('consumes a matching parked viewport frame exactly once', () => { + const frame = { data: 'cached viewport', cols: 120, rows: 40 } + capturedPanesByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + panes: [ + { + ptyId: PTY_ID, + paneId: 1, + leafId: 'leaf-1', + drivesTabTitle: true, + viewportFrame: frame + } + ] + }) + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + tabPtyId: PTY_ID, + paneIdByPtyId: new Map([[PTY_ID, 1]]), + disposersByPtyId: new Map([[PTY_ID, vi.fn()]]) + }) + + expect(consumeParkedTerminalViewportFrames(TAB_ID, { 'leaf-1': PTY_ID }, null)).toEqual([ + { leafId: 'leaf-1', frame } + ]) + expect(consumeParkedTerminalViewportFrames(TAB_ID, { 'leaf-1': PTY_ID }, null)).toEqual([]) + expect(capturedPanesByTabId.get(TAB_ID)?.panes[0]).not.toHaveProperty('viewportFrame') + }) + + it('drops a cached viewport when the parked PTY identity changed', () => { + capturedPanesByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + panes: [ + { + ptyId: PTY_ID, + paneId: 1, + leafId: 'leaf-1', + drivesTabTitle: true, + viewportFrame: { data: 'stale viewport', cols: 80, rows: 24 } + } + ] + }) + parkedWatchersByTabId.set(TAB_ID, { + worktreeId: 'removed-worktree', + tabPtyId: 'replacement-pty', + paneIdByPtyId: new Map([['replacement-pty', 1]]), + disposersByPtyId: new Map([['replacement-pty', vi.fn()]]) + }) + + expect( + consumeParkedTerminalViewportFrames(TAB_ID, { 'leaf-1': 'replacement-pty' }, null) + ).toEqual([]) + expect(capturedPanesByTabId.get(TAB_ID)?.panes[0]).not.toHaveProperty('viewportFrame') + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts index 1f4b522040a..b418a8454c9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -9,6 +9,10 @@ */ import { discardPreHandlerPtyState } from './pty-pre-handler-buffer' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import type { + ParkedTerminalViewportFrame, + ParkedTerminalViewportFrameByLeaf +} from './terminal-parked-viewport-frame' export type ParkedTerminalPaneCapture = { ptyId: string | null @@ -17,6 +21,7 @@ export type ParkedTerminalPaneCapture = { /** Stable terminal-layout leaf UUID (paneKey attribution). */ leafId: string drivesTabTitle: boolean + viewportFrame?: ParkedTerminalViewportFrame } export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] } @@ -67,6 +72,32 @@ export function isTerminalTabParked(tabId: string): boolean { return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0 } +export function consumeParkedTerminalViewportFrames( + tabId: string, + expectedPtyIdsByLeafId: Readonly>, + fallbackPtyId: string | null +): ParkedTerminalViewportFrameByLeaf[] { + const capture = capturedPanesByTabId.get(tabId) + if (!capture) { + return [] + } + const useFallback = capture.panes.length === 1 + const frames = isTerminalTabParked(tabId) + ? capture.panes.flatMap((pane) => { + const expectedPtyId = + expectedPtyIdsByLeafId[pane.leafId] ?? (useFallback ? fallbackPtyId : null) + return pane.viewportFrame && expectedPtyId && pane.ptyId === expectedPtyId + ? [{ leafId: pane.leafId, frame: pane.viewportFrame }] + : [] + }) + : [] + capturedPanesByTabId.set(tabId, { + ...capture, + panes: capture.panes.map(({ viewportFrame: _viewportFrame, ...pane }) => pane) + }) + return frames +} + export function disposeParkedTabWatchers(tabId: string): void { const entry = parkedWatchersByTabId.get(tabId) if (!entry) { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index bc230ecd419..a461c6b0153 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -122,7 +122,11 @@ import { fitAndFocusPanes, fitPanes } from './pane-helpers' import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' -import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' +import { + captureParkedTerminalPaneCandidates, + replayParkedTerminalViewportForTab +} from './terminal-parked-tab-watchers' +import { captureParkedTerminalPanes } from './terminal-parked-viewport-frame' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -1500,6 +1504,13 @@ export function useTerminalPaneLifecycle({ replayingPanesRef, restoredViewportBlankingPanesRef ) + replayParkedTerminalViewportForTab({ + manager, + paneByLeafId: restoredPaneByLeafId, + replayingPanesRef, + tabId, + worktreeId + }) const hasScrollbackRefs = Boolean(initialLayoutRef.current.scrollbackRefsByLeafId) if ( restoredBuffers && @@ -1745,12 +1756,7 @@ export function useTerminalPaneLifecycle({ captureParkedTerminalPaneCandidates( tabId, worktreeId, - manager.getPanes().map((capturedPane) => ({ - ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, - paneId: capturedPane.id, - leafId: capturedPane.leafId, - drivesTabTitle: manager.getActivePane()?.id === capturedPane.id - })) + captureParkedTerminalPanes(manager, paneTransports) ) for (const transport of paneTransports.values()) { const ptyId = transport.getPtyId() diff --git a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts index acabed0650e..7235d1f949c 100644 --- a/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts +++ b/tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.ts @@ -482,3 +482,76 @@ test('paired client keeps revealed remote terminals interactive', async ({ await client.dispose() } }) + +test('paired client fast-paints a parked terminal before the runtime responds', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(180_000) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const previousParkDelay = process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = String(PARK_DELAY_MS) + const client = await launchPairedElectronClient(offer, testInfo, 'parked-fast-paint') + const createdTerminals: string[] = [] + try { + const worktreeId = await orcaPage.evaluate(() => { + const id = window.__store?.getState().activeWorktreeId + if (!id) { + throw new Error('headed host has no active worktree') + } + return id + }) + await expect + .poll( + () => + client.page.evaluate( + (id) => + window.__store + ?.getState() + .allWorktrees() + .some((worktree) => worktree.id === id) ?? false, + worktreeId + ), + { timeout: 60_000, message: 'paired client never saw the host worktree' } + ) + .toBe(true) + await client.page.evaluate((id) => { + const state = window.__store?.getState() + state?.setActiveView('terminal') + state?.setActiveWorktree(id) + }, worktreeId) + + const { target, decoys } = await seedScenario(client, worktreeId) + createdTerminals.push(target.terminal, ...decoys.map((decoy) => decoy.terminal)) + await openClientTab(client.page, worktreeId, decoys[0].webTabId) + await openClientTab(client.page, worktreeId, decoys[1].webTabId) + await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS }) + await client.page.evaluate(async (selector) => { + await window.api.runtimeEnvironments.disconnect({ selector }) + }, client.environmentId) + + const startedAt = Date.now() + await openClientTab(client.page, worktreeId, target.webTabId) + expect( + await waitForPaneMarker(client.page, target.webTabId, 'READY:', 2_000), + 'cold-parked viewport stayed blank while the paired runtime was disconnected' + ).toBe(true) + console.log(`[paired-reveal] cached viewport painted in ${Date.now() - startedAt}ms`) + } finally { + await client.page + .evaluate(async (selector) => { + await window.api.runtimeEnvironments.connect({ selector }) + }, client.environmentId) + .catch(() => undefined) + for (const terminal of createdTerminals) { + await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', { + terminal + }).catch(() => undefined) + } + if (previousParkDelay === undefined) { + delete process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS + } else { + process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS = previousParkDelay + } + await client.dispose() + } +})