From c02cd1adacee92fb27784d757867a55c9b6453fa Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:30:00 -0700 Subject: [PATCH] fix(terminal): never wipe a painted pane for a snapshot that carries no image A hidden-output restore that comes back as a success frame with an empty image was still applied. The normal-buffer replay opens with \x1b[2J\x1b[3J\x1b[H, so it cleared screen and scrollback and painted nothing in their place -- turning a host that had nothing to say into a blank pane. An empty image is the absence of an answer, not proof the pane is empty, so keep the frame the user can still see. Retry and banner behavior are unchanged: an empty image still counts as a successful, non-bannered recovery. --- .../components/terminal-pane/pty-connection.ts | 16 ++++++++++++---- ...emote-hidden-output-restore-outcomes.test.ts | 17 +++++++++++++++++ 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index b9a94ad2dfa..331f288ded4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -7300,10 +7300,18 @@ 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: a success frame with no image is the host having nothing to say, not + // proof the pane is empty. The normal-buffer branch opens with + // \x1b[2J\x1b[3J\x1b[H, so applying it would wipe screen AND scrollback and + // leave exactly the blank pane this restore exists to repair. + 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( diff --git a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts index 0c9b286703c..eca5cd6bf0d 100644 --- a/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-hidden-output-restore-outcomes.test.ts @@ -638,6 +638,23 @@ describe('remote hidden-output restore outcomes', () => { drive.disposable.dispose() }) + it('[modern] leaves the painted frame alone when the empty snapshot carries no image', async () => { + const serializeBuffer = vi.fn() + const serializeBufferOutcome = vi.fn().mockResolvedValue({ + availability: { kind: 'snapshot' }, + snapshot: { ...HOST_SNAPSHOT, data: '' } + }) + const drive = await connectHiddenRemoteAgentPane(serializeBuffer, serializeBufferOutcome) + vi.useFakeTimers() + driveHiddenBacklogThenReveal(drive) + await flushAsyncTicks(20) + + // \x1b[3J wipes xterm scrollback; a host answer with nothing in it must never + // cost the user the frame they can still see. + expect(drive.writtenChunks().join('')).not.toContain('\x1b[3J') + drive.disposable.dispose() + }) + it('[modern] waits for a reported outcome instead of applying the old elapsed-time deadline', async () => { const pendingOutcome = createDeferred<{ availability: { kind: 'snapshot' }