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 98a26a1d6a0..b5e215426f4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -495,6 +495,10 @@ function createPaneContainer(): HTMLElement { configurable: true, value: {} }) + Object.defineProperties(container, { + clientWidth: { configurable: true, value: 1200 }, + clientHeight: { configurable: true, value: 800 } + }) return container } @@ -17244,6 +17248,57 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('paints remote replay at its snapshot grid before returning to the live pane grid', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-grid') + const capturedReplayCallback: { + current: + | ((data: string, meta?: { snapshotCols?: number; snapshotRows?: number }) => void) + | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-grid', replay: '' } + }) + transportFactoryQueue.push(transport) + + const pane = createPane(1) + const operations: string[] = [] + pane.terminal.resize = vi.fn((cols: number, rows: number) => { + operations.push(`resize:${cols}x${rows}`) + pane.terminal.cols = cols + pane.terminal.rows = rows + }) + pane.fitAddon.fit = vi.fn(() => pane.terminal.resize(180, 50)) + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 180, rows: 50 })) + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + operations.push(`write:${data}`) + callback?.() + }) as typeof pane.terminal.write + const manager = createManager(1) + const deps = createDeps() + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.('snapshot payload', { + snapshotCols: 60, + snapshotRows: 20 + }) + await flushAsyncTicks(12) + + expect(operations).toContain('resize:60x20') + expect(operations.indexOf('resize:60x20')).toBeLessThan( + operations.indexOf('write:snapshot payload') + ) + expect(operations.lastIndexOf('resize:180x50')).toBeGreaterThan( + operations.indexOf('write:snapshot payload') + ) + expect(pane.terminal.cols).toBe(180) + expect(pane.terminal.rows).toBe(50) + disposable.dispose() + }) + it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => { const { connectPanePty } = await import('./pty-connection') enableActiveRuntimeEnvironment() @@ -25689,7 +25744,7 @@ describe('connectPanePty', () => { } }) - it('skips remote-runtime PTYs (their size lives outside the local ptySizes map)', async () => { + it('re-fits and re-reports a remote viewport on reveal even when its grid is unchanged', async () => { const getSize = vi.mocked(window.api.pty.getSize) getSize.mockClear() const { connectPanePty } = await import('./pty-connection') @@ -25702,17 +25757,68 @@ describe('connectPanePty', () => { paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } }) const pane = createPane(2) + pane.fitAddon.fit = vi.fn() const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { noteVisibilityResume: () => void } transport.resize.mockClear() + pane.fitAddon.proposeDimensions.mockClear() binding.noteVisibilityResume() await flushAsyncTicks() - // Never even queries size for a remote pane, and never re-asserts. expect(getSize).not.toHaveBeenCalled() + expect(pane.fitAddon.proposeDimensions).toHaveBeenCalled() + expect(transport.resize).toHaveBeenCalledWith(120, 40) + }) + + it('does not fit or report a remote viewport from a zero-sized reveal box', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('remote:env-1@@terminal-zero-box') + const capturedReplayCallback: { + current: + | ((data: string, meta?: { snapshotCols?: number; snapshotRows?: number }) => void) + | null + } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-zero-box', replay: '' } + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(2) + Object.defineProperties(pane.container, { + clientWidth: { configurable: true, value: 0 }, + clientHeight: { configurable: true, value: 0 } + }) + pane.fitAddon.fit = vi.fn() + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as { + noteVisibilityResume: () => void + } + transport.resize.mockClear() + pane.fitAddon.proposeDimensions.mockClear() + pane.terminal.write.mockClear() + + capturedReplayCallback.current?.('must wait for a box', { + snapshotCols: 60, + snapshotRows: 20 + }) + binding.noteVisibilityResume() + await flushAsyncTicks() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.write).not.toHaveBeenCalledWith( + 'must wait for a box', + expect.any(Function) + ) expect(transport.resize).not.toHaveBeenCalled() + expect(transport.claimViewport).not.toHaveBeenCalled() }) it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => { diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 14a514cfee5..79ea0bcfaf3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -1136,6 +1136,7 @@ export function connectPanePty( let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} + let resumePendingReplayDataDrain = (): void => {} let startupInjectTimer: ReturnType | null = null let agentTaskCompleteNotificationGraceTimer: ReturnType | null = null let agentTaskCompleteNotificationMaxTimer: ReturnType | null = null @@ -4349,6 +4350,28 @@ export function connectPanePty( } } } + const reportRemoteViewportOnReveal = (): void => { + const revealedPtyId = transport.getPtyId() + if ( + !revealedPtyId || + !isRemoteRuntimePtyId(revealedPtyId) || + pane.container.clientWidth <= 0 || + pane.container.clientHeight <= 0 || + shouldSuppressDesktopPtyResize() + ) { + return + } + safeFitAndThen(pane, 'remote-reveal-viewport', () => { + if (disposed || transport.getPtyId() !== revealedPtyId || shouldSuppressDesktopPtyResize()) { + return + } + const cols = pane.terminal.cols + const rows = pane.terminal.rows + if (Number.isFinite(cols) && Number.isFinite(rows) && cols > 0 && rows > 0) { + transport.resize(cols, rows) + } + }) + } let pendingForegroundGridDriftCheckRaf: number | null = null let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY const readProposedTerminalGrid = (): { cols: number; rows: number } | null => { @@ -4422,6 +4445,9 @@ export function connectPanePty( if (disposed) { return } + if (pane.container.clientWidth > 0 && pane.container.clientHeight > 0) { + resumePendingReplayDataDrain() + } if ( deferTerminalGeometryMutationDuringRebuild( pane.terminal, @@ -5608,6 +5634,8 @@ export function connectPanePty( type PendingReplayData = { data: string clearBeforeReplay: boolean + snapshotCols?: number + snapshotRows?: number ptyId: string | null generation: number streamGeneration: number @@ -5658,6 +5686,22 @@ export function connectPanePty( continue } } + const snapshotDimensions = resolvePositiveTerminalDimensions( + payload.snapshotCols, + payload.snapshotRows + ) + if ( + snapshotDimensions && + (pane.terminal.cols !== snapshotDimensions.cols || + pane.terminal.rows !== snapshotDimensions.rows) + ) { + suppressStructuralReplayPtyResize = true + try { + pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows) + } finally { + suppressStructuralReplayPtyResize = false + } + } if (clearBeforeReplay || data.length > 0) { // Why: an empty clearing frame is still an authoritative repaint and // must clear a stale agent signal from an earlier payload. @@ -5699,7 +5743,13 @@ export function connectPanePty( return appliedCurrentPayload } const scheduleReplayDataDrain = (): void => { - if (replayDrainQueued) { + if ( + replayDrainQueued || + pendingReplayData === null || + pane.container.clientWidth <= 0 || + pane.container.clientHeight <= 0 || + !safeFit(pane) + ) { return } const scheduledPtyId = pendingReplayData?.ptyId ?? null @@ -5724,7 +5774,12 @@ export function connectPanePty( shouldRestore: () => !disposed && transport.getPtyId() === scheduledPtyId && - transportStreamGeneration === scheduledStreamGeneration + transportStreamGeneration === scheduledStreamGeneration, + afterRestore: () => { + if (replayCompleted) { + safeFit(pane) + } + } } ) ) @@ -5741,6 +5796,7 @@ export function connectPanePty( finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) }) } + resumePendingReplayDataDrain = scheduleReplayDataDrain const replayDataCallback = ( data: string, meta: PtyReplayDataMeta = {}, @@ -5749,6 +5805,8 @@ export function connectPanePty( pendingReplayData = { data, clearBeforeReplay: meta.clearBeforeReplay !== false, + ...(meta.snapshotCols !== undefined ? { snapshotCols: meta.snapshotCols } : {}), + ...(meta.snapshotRows !== undefined ? { snapshotRows: meta.snapshotRows } : {}), ptyId: transport.getPtyId(), generation: (replayPayloadGeneration += 1), streamGeneration, @@ -9283,6 +9341,8 @@ export function connectPanePty( noteVisibilityResume() { armVisibleRemoteViewportClaim() claimPendingVisibleRemoteViewport() + reportRemoteViewportOnReveal() + resumePendingReplayDataDrain() ptySizeReassertion.request({ fit: false }) consumeHibernatedAgentWake() requestKnownWindowsShiftEnterReconfirmation() diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 57e0c9e2e4e..48266e27816 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -49,6 +49,10 @@ export type PtyBufferSnapshot = { /** Metadata for one authoritative replay payload. */ export type PtyReplayDataMeta = { clearBeforeReplay?: boolean + /** Source grid for this replay payload. Zero means the producer could not + * determine a size and must never reach xterm.resize(). */ + snapshotCols?: number + snapshotRows?: number pendingEscapeTailAnsi?: string /** Kitty flags the snapshot's owner PROVED at `snapshotSeq`. Absent means * unknown; the pane tracker must stay unproven rather than assume zero. */ diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 9eddfae0155..d8398021db3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -103,6 +103,8 @@ type ProcessPtyOutputOptions = { replayingBufferedData?: boolean suppressAttentionEvents?: boolean clearBeforeReplay?: boolean + snapshotCols?: number + snapshotRows?: number // Why: a mid-escape tail; the replay consumer writes it LAST (after the post-replay reset) so the next live chunk completes it, not renders it literally (#7329). pendingEscapeTailAnsi?: string /** Kitty flags the snapshot owner proved at `snapshotSeq`. */ @@ -498,6 +500,8 @@ export function createPtyOutputProcessor({ if (options.replayingBufferedData && callbacks.onReplayData) { const replayMeta = { ...(options.clearBeforeReplay === false ? { clearBeforeReplay: false } : {}), + ...(options.snapshotCols !== undefined ? { snapshotCols: options.snapshotCols } : {}), + ...(options.snapshotRows !== undefined ? { snapshotRows: options.snapshotRows } : {}), ...(options.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: options.pendingEscapeTailAnsi } : {}), diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts index 4087cf59a15..cddbf93c115 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts @@ -6099,6 +6099,32 @@ describe('createRemoteRuntimePtyTransport', () => { expect(onConnect).toHaveBeenCalled() }) + it('forwards the source grid with an initial replay payload', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const onReplayData = vi.fn() + const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' }) + + await transport.connect({ url: '', callbacks: { onReplayData } }) + await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) + const { streamId } = latestSubscribePayload() + emitSnapshotFrame( + streamId, + TerminalStreamOpcode.SnapshotStart, + encodeTerminalStreamJson({ kind: 'scrollback', cols: 60, rows: 20 }) + ) + emitSnapshotFrame( + streamId, + TerminalStreamOpcode.SnapshotChunk, + encodeTerminalStreamText('source-grid replay') + ) + emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array()) + + expect(onReplayData).toHaveBeenCalledWith('source-grid replay', { + snapshotCols: 60, + snapshotRows: 20 + }) + }) + it('resolves explicit binary snapshot requests without replaying into xterm', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const onReplayData = vi.fn() diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index 6eff59d84ce..aef1f06a25e 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -1768,6 +1768,8 @@ export function createRemoteRuntimePtyTransport( outputProcessor.processData(data, storedCallbacks, { replayingBufferedData: true, suppressAttentionEvents: true, + ...(meta?.cols !== undefined ? { snapshotCols: meta.cols } : {}), + ...(meta?.rows !== undefined ? { snapshotRows: meta.rows } : {}), ...(meta?.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}), diff --git a/src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts b/src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts index 1d903d5bd52..eb073540b0f 100644 --- a/src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts +++ b/src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts @@ -38,6 +38,7 @@ class ScriptedSnapshotServer { private cursorUnits = 0 requestIds: (number | undefined)[] = [] nextRequestedReply: RequestedReply = { kind: 'buffer', data: 'MANUAL' } + omitNextSize = false dropNextOutput = false holdResyncReplies = false private heldRequestId: number | null = null @@ -112,11 +113,12 @@ class ScriptedSnapshotServer { } private sendStart(meta: { requestId?: number; truncated?: boolean; unavailable?: string }): void { + const size = this.omitNextSize ? {} : { cols: 80, rows: 24 } + this.omitNextSize = false this.send( TerminalStreamOpcode.SnapshotStart, encodeTerminalStreamJson({ - cols: 80, - rows: 24, + ...size, seq: meta.truncated ? undefined : this.cursorUnits, ...meta }) @@ -197,6 +199,16 @@ describe('remote terminal snapshot outcome reasons', () => { }) }) + it('uses 0x0 as the unknown-size sentinel for an older snapshot producer', async () => { + const stream = await subscribeClient() + server.nextRequestedReply = { kind: 'buffer', data: 'LEGACY' } + server.omitNextSize = true + + await expect(stream.serializeBufferOutcome({ scrollbackRows: 100 })).resolves.toMatchObject({ + snapshot: { data: 'LEGACY', cols: 0, rows: 0 } + }) + }) + it('reports a proven-empty buffer as a snapshot, not as an unavailable reply', async () => { const stream = await subscribeClient() server.nextRequestedReply = { kind: 'buffer', data: '' } diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 183838fdc2f..c818a84ccdf 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -64,6 +64,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = { onSnapshot: ( data: string, meta?: { + cols?: number + rows?: number pendingEscapeTailAnsi?: string seq?: number kittyKeyboardFlags?: number @@ -888,8 +890,8 @@ class RemoteRuntimeTerminalMultiplexer { availability: classifySnapshotAvailability(stream.snapshotOverflowed, info), snapshot: { data: data ?? '', - cols: info?.cols ?? 80, - rows: info?.rows ?? 24, + cols: info?.cols ?? 0, + rows: info?.rows ?? 0, seq: info?.seq, source: info?.source, kittyKeyboardFlags: info?.kittyKeyboardFlags, @@ -899,6 +901,8 @@ class RemoteRuntimeTerminalMultiplexer { clearPendingSnapshotRequest(stream) } else if (target === 'initial') { stream.callbacks.onSnapshot(data ?? '', { + cols: info?.cols, + rows: info?.rows, pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, kittyKeyboardFlags: info?.kittyKeyboardFlags @@ -909,6 +913,8 @@ class RemoteRuntimeTerminalMultiplexer { // An empty snapshot is still applied so stale dropped output does // not linger on a terminal the model says is blank. stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { + cols: info?.cols, + rows: info?.rows, pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, seq: info?.seq, kittyKeyboardFlags: info?.kittyKeyboardFlags diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 39cd8ce829a..e9219aeccf2 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -572,6 +572,8 @@ describe('remote runtime terminal multiplex ACK gate', () => { injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state') expect(onSnapshot).toHaveBeenCalledWith('initial state', { + cols: 120, + rows: 40, pendingEscapeTailAnsi: undefined }) expect(onSubscribed).toHaveBeenCalledTimes(1) @@ -590,6 +592,8 @@ describe('remote runtime terminal multiplex ACK gate', () => { // clears screen and scrollback first and must not replay the subscribe // lifecycle. expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, { + cols: 120, + rows: 40, pendingEscapeTailAnsi: undefined }) expect(onSubscribed).toHaveBeenCalledTimes(1) @@ -607,6 +611,8 @@ describe('remote runtime terminal multiplex ACK gate', () => { '' ) expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', { + cols: 120, + rows: 40, pendingEscapeTailAnsi: undefined }) expect(onSubscribed).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/runtime/runtime-terminal-stream.ts b/src/renderer/src/runtime/runtime-terminal-stream.ts index 327e8d3e904..93625e8aa1f 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.ts @@ -13,7 +13,10 @@ export type RemoteRuntimePtyIdParts = { export type RuntimeTerminalDataSubscriptionOptions = { startAtLiveTail?: boolean - onSnapshot?: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void + onSnapshot?: ( + data: string, + meta?: { cols?: number; rows?: number; pendingEscapeTailAnsi?: string } + ) => void onEnd?: () => void onError?: (message: string) => void onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void