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 37a000f16a2..6181a5857a3 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -326,6 +326,7 @@ type MockTransport = { getPtyId: ReturnType getConnectionId: ReturnType serializeBuffer?: ReturnType + serializeBufferOutcome?: ReturnType } const scheduleRuntimeGraphSync = vi.fn() @@ -15169,6 +15170,63 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('preserves the painted normal buffer when a successful hidden restore has no image', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' + transport.serializeBuffer = vi.fn() + transport.serializeBufferOutcome = vi.fn().mockResolvedValue({ + availability: { kind: 'snapshot' }, + snapshot: { + data: '', + scrollbackAnsi: '', + cols: 120, + rows: 40, + seq: hidden.length + live.length, + source: 'headless' + } + }) + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'remote:env-1@@terminal-1' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + const writeStart = pane.terminal.write.mock.calls.length + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + const restoreWrites = pane.terminal.write.mock.calls + .slice(writeStart) + .map(([data]) => String(data)) + const paintedFrame = Array.from({ length: 8 }, (_, index) => `painted-frame-${index + 1}`).join( + '\r\n' + ) + const actual = await renderHeadlessTerminalState([paintedFrame, ...restoreWrites], 120, 4) + const expected = await renderHeadlessTerminalState( + [paintedFrame, ...restoreWrites.filter((data) => data === live)], + 120, + 4 + ) + expect(actual).toEqual(expected) + expect(transport.serializeBufferOutcome).toHaveBeenCalledTimes(1) + expect(transport.serializeBuffer).not.toHaveBeenCalled() + disposable.dispose() + }) + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b9a94ad2dfa..fb77c526d8e 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -7300,10 +7300,15 @@ export function connectPanePty( snapshot.alternateScreen === true && snapshot.frameRestoreAnsi !== undefined && shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane)) - for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { - skipAltFrame: skippedAltFrame - })) { - writeReplayData(replayChunk) + // Why: an imageless success is not proof the pane is empty; normal replay clears screen and scrollback. + const snapshotCarriesNoImage = + snapshot.alternateScreen !== true && snapshot.data === '' && !snapshot.scrollbackAnsi + if (!snapshotCarriesNoImage) { + for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, { + skipAltFrame: skippedAltFrame + })) { + writeReplayData(replayChunk) + } } // Why: live agents own ?25l/?1004h; a forced ?1004l here would silence focus events until restart (agents enable focus reporting only at startup). writeReplayData(