Clear the alt screen before restoring a hidden-output snapshot

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 <help@stably.ai>
This commit is contained in:
brennanb2025
2026-07-02 11:49:15 -07:00
co-authored by Orca
parent d318e89231
commit 64f76be6b3
2 changed files with 21 additions and 1 deletions
@@ -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<typeof vi.fn>).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()
})
@@ -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)