From 64f76be6b3b2dcfddba1f32cfdfeb84b86deb62f Mon Sep 17 00:00:00 2001 From: brennanb2025 Date: Thu, 2 Jul 2026 11:49:15 -0700 Subject: [PATCH] Clear the alt screen before restoring a hidden-output snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live capture of a reproduced case showed the frozen/overlapping tab is a cell-level merge INSIDE the renderer's buffer: the pre-hide frame (banner, prompt echo) bleeding through every blank cell of the restored final frame. The rendering stack was innocent — it faithfully painted a merged buffer. The hidden-output restore deliberately skips the destructive clear for alternate-screen snapshots to protect normal-buffer scrollback. But the snapshot's own ?1049h is a no-op on a pane that is already on the alternate screen, and the serialized frame skips blank cells entirely (cursor-forward jumps), so nothing ever erases the pre-hide frame. Fresh terminals enter the alt screen via ?1049h — which clears it — which is why the bug never reproduced in fresh windows. Fix: for alternate-screen snapshots, write \x1b[?1049h\x1b[2J\x1b[H before the snapshot data. \x1b[2J on the alt buffer clears only the alt screen; the normal buffer's scrollback (what the skip was protecting) is untouched. Verified live on the reproduced specimen: writing the same snapshot with this preamble repaired the pane pixel-perfectly. Co-authored-by: Orca --- .../terminal-pane/pty-connection.test.ts | 15 ++++++++++++++- .../components/terminal-pane/pty-connection.ts | 7 +++++++ 2 files changed, 21 insertions(+), 1 deletion(-) 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 cd806774737..211e6c1c819 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -7316,7 +7316,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('preserves scrollback by skipping the destructive clear when restoring an alternate-screen snapshot', async () => { + it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -7365,6 +7365,19 @@ describe('connectPanePty', () => { '\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) + // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized + // frames skip blank cells, so the pre-hide frame bleeds through unless the + // alt screen is cleared (without \x1b[3J) before the snapshot paints. + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049h\x1b[2J\x1b[H', + expect.any(Function) + ) + const writes = (pane.terminal.write as ReturnType).mock.calls.map( + (call) => call[0] + ) + expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + writes.indexOf('altscreen-snapshot\r\n') + ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) disposable.dispose() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 85c50f6fe8a..01f0132c3fc 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -4393,6 +4393,13 @@ export function connectPanePty( // clearing on restore loses scroll-up after a hidden->visible return. // Mirrors the attach-time guard in pty-transport.ts. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) writeReplayData(POST_REPLAY_LIVE_SNAPSHOT_RESET)