From 3655bd9fc2d0d5a8bc2d8fa8ca69262dd1416cde Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:00:09 -0400 Subject: [PATCH] fix(terminal): stop old output bleeding into Claude's screen when revisiting a remote tab (#24926) * fix(terminal): leave the alt screen before replaying a pushed host snapshot A remote terminal running a full-screen agent (Claude Code) could show old output (e.g. a setup script's pnpm log) interleaved with the agent's screen after switching back to the tab. The host's pushed snapshot is a serialized image that starts on the normal buffer and enters the alternate screen itself, but the replay drain cleared with ESC[2J without leaving alt. The image's history painted into the agent's screen, its own ?1049h was a no-op, and the agent's diff paints landed on top of the stale cells. The remote-runtime transport now marks snapshots as serialized images, and the drain grounds them with the shared snapshot prologue (switching to the normal buffer first). Raw byte replays (SSH relay ring buffers) keep the in-place clear. * fix(terminal): paint folded remote snapshots from the normal buffer everywhere Review follow-ups: - Rename the flag to carriesNormalBuffer: what the painters rely on is that the image starts on the normal buffer and enters alt itself. - Hidden-output restore had the same bug for remote requested snapshots (history folded into data, alternateScreen set): the painter entered alt first and painted the normal buffer into the TUI's screen. Requested remote snapshots now carry the flag and take the normal-buffer start. - Flag pushed snapshots replayed after a cancelled shutdown too. - Pushed snapshots carry only the screen, so over a live TUI the drain keeps the normal-buffer history it covers instead of wiping it. - Read the pane's buffer after queued output parses. - Tests compare whole buffers against a fresh terminal, use production image shapes, and pin the raw-replay path with the same oracle. * fix(terminal): let the replay drain own the recovery snapshot clear The recovery prefix's own \x1b[3J ran after the drain's prologue and wiped the history the drain keeps under a live TUI. Keep only the latch release, which still makes an empty recovery snapshot non-empty so it is applied. * fix(terminal): keep the recovery snapshot's own screen clear Consumers that write recovery snapshots straight into xterm (no replay drain) rely on the prefix clearing the stale screen. Restore \x1b[2J\x1b[H and drop only \x1b[3J, which wiped history the image does not carry. * test(terminal): drive the drain with the real recovery payload The multiplexer prepends its own screen clear; replaying that exact payload pins that the prefix cannot wipe the history a TUI covers. * fix(terminal): keep TUI-covered history only when the grids match Second review follow-ups: - A pushed image carries only the host's screen; the pane's frozen history continues it exactly only on the same grid. On another grid keeping it duplicated or dropped lines, so the image replaces it there. - Tests replay the pane's writes and grid changes into a real terminal and compare whole buffers with the host, including a mismatched-grid case. - The split branch shares the normal-buffer preamble; drop the now single-use abort helper. - Type serializeBuffer as RemoteRuntimeSnapshotImage so the flag is carried by type, not by object pass-through. - Register the grid and raw-replay cases in the reliability gate. * fix(terminal): keep TUI-covered history only while the host is still on alt The drain kept the pane's history whenever the pane was on the alt screen. If the host's TUI exited while the tab was hidden, the shell wrote past that history and the kept lines no longer continued the host's screen. Require the host's own alternateScreen too; an absent flag proves nothing, so the image replaces history as on main. Also: one buildSnapshotReplayPreamble for every first replay write, the drain's image and raw clears as separate branches (raw byte-identical to main, comment restored), and a single paneAtSourceGrid check. * fix(terminal): gate kept history on the host's shell-owner proof The host sends alternateScreen only with terminalOwner 'shell', i.e. once it has proven the TUI exited, so `alternateScreen === true` never held for a live TUI and the drain wiped the history it should keep. Replace the history only once the host proves the TUI exited; tests now use production snapshot shapes. Move the relay-overlap comment into the raw-replay branch it describes. * fix(terminal): keep TUI-covered history only on a proven shared grid An image without its grid cannot prove the pane's history continues its screen, so it now replaces history. Also update a stale recovery-prefix test comment. * test(terminal): replay host-serialized snapshots through the real wire and drain Hand-written replay meta hid a gate that production never satisfies. Drive the host's own emulator, ownership mirror, serializer and recovery publisher through the real wire, client parser, remote transport and pane drain, for a live TUI and after the host proves it exited. * fix(terminal): repaint only the alt frame over a live TUI, leaving history alone Keeping the pane's history by clearing only the normal screen assumed a pushed image carries no history, which the host does not guarantee: a 0-row push can reuse a concurrent requested capture, and its history then landed twice in scrollback. It also wiped history on a grid mismatch, where main kept it. When pane and image are both on alt, the image's normal part adds nothing (the normal buffers froze together), so paint only its alt payload after an alt-side clear, split at the host's own boundary (splitAtAlternateScreenEntry, now shared with the daemon). Any other image paints from the normal buffer. This drops keepScrollback, the owner and grid gates, and the recovery-prefix change, so history behaves exactly as on main. * test(terminal): pin the parse wait and the cancelled-shutdown flag Final-review follow-ups: a drain test where the TUI's ?1049h is still queued when the image arrives, and a transport test replaying a push buffered during a cancelled shutdown; each fails when its guard is removed. The requested-image test now names the exited-TUI case it covers, requestSnapshot shares the snapshot image type, and the clear comment no longer implies every clear drops scrollback. --- config/reliability-gates.jsonc | 27 +- .../daemon/terminal-snapshot-ansi-buffers.ts | 14 +- .../pty-connection-test-pane-fixtures.ts | 3 +- .../deferred-cold-restore-and-snapshot.ts | 3 +- .../hidden-output-restore-snapshot.ts | 1 + .../pty-connection/replay-data-drain.ts | 37 +- .../terminal-pane/pty-output-processor.ts | 4 +- .../terminal-pane/pty-transport-types.ts | 6 + ...-pty-transport-host-session-launch.test.ts | 2 +- ...time-pty-transport-snapshot-replay.test.ts | 40 +- .../remote-runtime-pty-transport.ts | 7 +- ...ote-snapshot-alt-screen-host-chain.test.ts | 398 ++++++++++++++++++ .../remote-snapshot-alt-screen-replay.test.ts | 370 ++++++++++++++++ .../terminal-snapshot-replay-paint.ts | 74 +++- .../remote-runtime-snapshot-outcome.test.ts | 3 +- ...emote-runtime-terminal-binary-snapshots.ts | 4 +- ...mote-runtime-terminal-multiplexer-types.ts | 12 +- ...te-runtime-terminal-snapshot-controller.ts | 9 +- src/shared/terminal-alternate-screen-split.ts | 15 + 19 files changed, 956 insertions(+), 73 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts create mode 100644 src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts create mode 100644 src/shared/terminal-alternate-screen-split.ts diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index faf844b5137..e5d8b5b2c57 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -16958,15 +16958,38 @@ "invariant": "Hidden-output restore, snapshot replay, metadata-only replay, and clear-before-replay must preserve order and never overlay stale bytes on newer live terminal output; restoring a snapshot onto an already-dirty pane must yield a buffer exactly equal to the snapshot frame.", "oracle": "Apply snapshots onto adversarially dirty pane states (already on the alternate screen, stale content occupying cells the new frame leaves blank, scrollback present, wide glyphs, revived sessions with restarted PTY seq counters) and assert exact buffer equality with the snapshot frame; fault injection interleaves hidden chunks, live output, metadata-only replay, and clear-before-replay, then asserts ordered terminal buffer content, clear decisions, and replay diagnostics. Marker-presence checks are not acceptable evidence on restore paths.", "commands": [ - "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts" + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "testFiles": [ "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-hidden-output-restore.test.ts", "src/renderer/src/components/terminal-pane/pty-connection-stalled-hidden-restore.test.ts", - "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts" + "src/renderer/src/components/terminal-pane/pty-connection-replay-payload-handling.test.ts", + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", + "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts" ], "assertionRefs": [ + { + "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts", + "assertions": [ + "repaints a pushed image exactly and keeps the history the TUI covers", + "keeps the pane's history when the host screen has another grid", + "keeps history unduplicated when a pushed image also carries history", + "paints the whole image when the TUI started while hidden", + "reads the pane buffer after a queued alt-screen entry parses", + "repaints from the normal buffer once the host TUI has exited", + "clears a raw byte replay in place on the alt screen", + "paints a requested image from an exited TUI exactly over an alt screen" + ] + }, + { + "file": "src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts", + "assertions": [ + "keeps the covered history for a live TUI exactly as the host holds it", + "repaints from the normal buffer once the host TUI has exited" + ] + }, { "file": "src/renderer/src/components/terminal-pane/pty-connection-hidden-backlog-snapshot.test.ts", "assertions": [ diff --git a/src/main/daemon/terminal-snapshot-ansi-buffers.ts b/src/main/daemon/terminal-snapshot-ansi-buffers.ts index e0b16d56d8f..65936f3c82b 100644 --- a/src/main/daemon/terminal-snapshot-ansi-buffers.ts +++ b/src/main/daemon/terminal-snapshot-ansi-buffers.ts @@ -1,21 +1,15 @@ import type { TerminalModes } from './types' +import { splitAtAlternateScreenEntry } from '../../shared/terminal-alternate-screen-split' export function splitTerminalSnapshotAnsi( snapshotAnsi: string, modes: TerminalModes ): { snapshotAnsi: string; scrollbackAnsi: string } { - if (!modes.alternateScreen) { - return { snapshotAnsi, scrollbackAnsi: '' } - } - const alternateScreenMarker = '\x1b[?1049h' - const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) - if (start === -1) { + const split = modes.alternateScreen ? splitAtAlternateScreenEntry(snapshotAnsi) : null + if (!split) { return { snapshotAnsi, scrollbackAnsi: '' } } // Why: rehydrateSequences owns the alt-screen transition. Keeping the // normal buffer separate lets an already-alt renderer rebuild it safely. - return { - scrollbackAnsi: snapshotAnsi.slice(0, start), - snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length) - } + return { scrollbackAnsi: split.normalAnsi, snapshotAnsi: split.alternateAnsi } } diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts index 302efe743bb..0959c996bfd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-pane-fixtures.ts @@ -1,5 +1,6 @@ import { vi } from 'vitest' import type { Mock } from 'vitest' +import type { PtyReplayDataMeta } from './pty-transport-types' export const LEAF_1 = '11111111-1111-4111-8111-111111111111' as const export const LEAF_2 = '22222222-2222-4222-8222-222222222222' as const @@ -16,7 +17,7 @@ export type ConnectCallbacks = { data: string, meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } ) => void - onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void + onReplayData?: (data: string, meta?: PtyReplayDataMeta) => void onError?: (msg: string) => void onErrorCleared?: (msg: string) => void onWriteUnavailable?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts index f2a91feb98c..24c89af5181 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/deferred-cold-restore-and-snapshot.ts @@ -159,7 +159,8 @@ export function bindDeferredColdRestoreAndSnapshot(session: ConnectPanePtySessio ...(meta.alternateScreen !== undefined ? { alternateScreen: meta.alternateScreen } : {}), ...(meta.snapshotCols !== undefined && meta.snapshotRows !== undefined ? { snapshotCols: meta.snapshotCols, snapshotRows: meta.snapshotRows } - : {}) + : {}), + ...(meta.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}) } session.scheduleReplayDataDrain() } diff --git a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts index e895128b280..a64209a71cd 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/hidden-output-restore-snapshot.ts @@ -61,6 +61,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession): pendingDeliveryStartSeq?: number alternateScreen?: boolean scrollbackAnsi?: string + carriesNormalBuffer?: boolean pendingEscapeTailAnsi?: string kittyKeyboardFlags?: number terminalOwner?: 'shell' diff --git a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts index 693ee4be67a..f748de0ea73 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/replay-data-drain.ts @@ -3,7 +3,10 @@ import { waitForTerminalOutputParsed } from '@/lib/pane-manager/pane-terminal-ou import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' -import { resolvePositiveTerminalDimensions } from '../terminal-snapshot-replay-paint' +import { + buildFoldedImageReplayWrites, + resolvePositiveTerminalDimensions +} from '../terminal-snapshot-replay-paint' import { CURSOR_SHOW_SEQUENCE, @@ -101,7 +104,8 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { alternateScreen, terminalOwner, snapshotCols, - snapshotRows + snapshotRows, + carriesNormalBuffer } = payload session.pendingReplayData = null const isCurrentPayload = (): boolean => @@ -115,15 +119,28 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { // Relay replay buffers may overlap with content already rendered in // xterm. Local eager replay decides this earlier so metadata-only frames // can keep restored scrollback while still using the replay guard. - // Why ahead of the source-grid resize: the clear is grid-independent, so - // dropping the scrollback first spares a reflow of history the very next + // Why ahead of the source-grid resize: the clear is grid-independent, so a + // clear that drops the scrollback spares a reflow of history the very next // sequence discards (see use-terminal-container-fit-sync.ts on its cost). + let replayPayload = data if (clearBeforeReplay) { - // RELEASE_SYNCHRONIZED_OUTPUT: a reconnect is exactly the event that severs a - // frame mid-flight, so this xterm may hold an open 2026 latch — and \x1b[2J does - // not clear it, so the pane would stay frozen on its last painted frame and the - // whole replay would go unseen until xterm's 1s timeout. - await session.writeReplayDataAsync(`${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H`) + if (carriesNormalBuffer) { + // Why after queued output parses: a TUI's own ?1049h may still be + // queued, and a stale read would paint the image into the wrong buffer. + await waitForTerminalOutputParsed(session.pane.terminal) + if (!isCurrentPayload()) { + continue + } + const image = buildFoldedImageReplayWrites(data, session.isPaneOnAlternateScreen()) + replayPayload = image.payload + await session.writeReplayDataAsync(image.preamble) + } else { + // RELEASE_SYNCHRONIZED_OUTPUT: a reconnect is exactly the event that severs a + // frame mid-flight, so this xterm may hold an open 2026 latch — and \x1b[2J does + // not clear it, so the pane would stay frozen on its last painted frame and the + // whole replay would go unseen until xterm's 1s timeout. + await session.writeReplayDataAsync(`${RELEASE_SYNCHRONIZED_OUTPUT}\x1b[2J\x1b[3J\x1b[H`) + } if (!isCurrentPayload()) { continue } @@ -158,7 +175,7 @@ export function bindReplayDataDrain(session: ConnectPanePtySession): void { // semantics: relay reconnects redeliver the same window, so pushes // apply as sets to keep the mirrored stack from accumulating frames. session.applySnapshotKittyKeyboardModes(data, payload) - await session.writeReplayDataAsync(data) + await session.writeReplayDataAsync(replayPayload) if (!isCurrentPayload()) { continue } diff --git a/src/renderer/src/components/terminal-pane/pty-output-processor.ts b/src/renderer/src/components/terminal-pane/pty-output-processor.ts index d80f8e1cb7f..8903348ee16 100644 --- a/src/renderer/src/components/terminal-pane/pty-output-processor.ts +++ b/src/renderer/src/components/terminal-pane/pty-output-processor.ts @@ -40,6 +40,7 @@ export type ProcessPtyOutputOptions = { terminalOwner?: 'shell' snapshotCols?: number snapshotRows?: number + carriesNormalBuffer?: boolean } function removeSuppressedCursorNativeTitles( @@ -228,7 +229,8 @@ export function createPtyOutputProcessor({ ...(options.terminalOwner ? { terminalOwner: options.terminalOwner } : {}), ...(options.snapshotCols !== undefined && options.snapshotRows !== undefined ? { snapshotCols: options.snapshotCols, snapshotRows: options.snapshotRows } - : {}) + : {}), + ...(options.carriesNormalBuffer ? { carriesNormalBuffer: true } : {}) } if (Object.keys(replayMeta).length > 0) { callbacks.onReplayData(data, replayMeta) 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 dce25fd547c..39f1c7962ef 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -38,6 +38,9 @@ export type PtyBufferSnapshot = { alternateScreen?: boolean /** Authoritative normal buffer paired with an alternate-screen frame. */ scrollbackAnsi?: string + /** `data` starts on the normal buffer and enters alt itself (remote images fold + * their normal buffer in rather than splitting it into `scrollbackAnsi`). */ + carriesNormalBuffer?: boolean /** Trailing incomplete escape sequence main's emulator ingested (a PTY read * ended mid-escape). Must be written LAST — after post-replay resets, right * before post-snapshot live chunks — so the continuation completes it @@ -65,6 +68,9 @@ export type PtyReplayDataMeta = { * it; the drain replays there and fits back to the pane afterwards. */ snapshotCols?: number snapshotRows?: number + /** An image that starts on the normal buffer and enters alt itself; absent for + * raw byte replays such as an SSH relay's ring buffer. */ + carriesNormalBuffer?: boolean } export type LocalPtySessionMetadata = { diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts index f5861cbc810..4ad4e950c81 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-host-session-launch.test.ts @@ -191,7 +191,7 @@ describe('createRemoteRuntimePtyTransport', () => { }) ) - expect(onReplayData).toHaveBeenCalledWith('hello') + expect(onReplayData).toHaveBeenCalledWith('hello', { carriesNormalBuffer: true }) expect(onConnect).toHaveBeenCalled() expect(onData).toHaveBeenCalledWith(' world', expect.objectContaining({ seq: 4 })) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts index b6fc15f6413..2ee83ace72b 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-snapshot-replay.test.ts @@ -59,7 +59,9 @@ describe('createRemoteRuntimePtyTransport', () => { 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after\x1b]0;Remote title\x07\x07' ) - expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07') + expect(onReplayData).toHaveBeenCalledWith('beforeafter\x1b]0;Remote title\x07\x07', { + carriesNormalBuffer: true + }) await vi.waitFor(() => expect(onTitleChange).toHaveBeenCalledWith('Remote title', 'Remote title') ) @@ -89,12 +91,36 @@ describe('createRemoteRuntimePtyTransport', () => { 'before\x1b]9999;{"state":"working","prompt":"old","agentType":"codex"}\x07after' ) - expect(onReplayData).toHaveBeenCalledWith('beforeafter') + expect(onReplayData).toHaveBeenCalledWith('beforeafter', { carriesNormalBuffer: true }) expect(onAgentStatus).not.toHaveBeenCalled() expect(onBell).not.toHaveBeenCalled() expect(onConnect).toHaveBeenCalled() }) + // Why: a push buffered during a cancelled shutdown is the same folded image and must + // keep its flag when the rollback replays it. + it('flags a pushed snapshot replayed after a cancelled shutdown', async () => { + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const { unregisterPtyDataHandlers, restorePtyDataHandlersAfterFailedShutdown } = + await import('./pty-shutdown-data-suspension') + 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() + const ptyId = transport.getPtyId() + expect(ptyId).toBeTruthy() + const shutdown = unregisterPtyDataHandlers([ptyId ?? '']) + emitSnapshot(streamId, 'buffered image') + expect(onReplayData).not.toHaveBeenCalled() + restorePtyDataHandlersAfterFailedShutdown(shutdown) + + await vi.waitFor(() => + expect(onReplayData).toHaveBeenCalledWith('buffered image', { carriesNormalBuffer: true }) + ) + }) + it('paints a nonempty lossy initial snapshot once before resuming live output', async () => { const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') const terminal = new Terminal({ cols: 80, rows: 24 }) @@ -157,7 +183,7 @@ describe('createRemoteRuntimePtyTransport', () => { await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled()) const { streamId } = latestSubscribePayload() emitSnapshot(streamId, 'initial') - expect(onReplayData).toHaveBeenCalledWith('initial') + expect(onReplayData).toHaveBeenCalledWith('initial', { carriesNormalBuffer: true }) expect(onConnect).toHaveBeenCalled() const snapshotPromise = transport.serializeBuffer?.({ scrollbackRows: 5000 }) @@ -197,7 +223,8 @@ describe('createRemoteRuntimePtyTransport', () => { cols: 132, rows: 43, seq: 17, - source: 'headless' + source: 'headless', + carriesNormalBuffer: true }) expect(onReplayData).toHaveBeenCalledTimes(1) expect(onData).not.toHaveBeenCalledWith('requested snapshot', expect.anything()) @@ -254,7 +281,7 @@ describe('createRemoteRuntimePtyTransport', () => { expect(latestFrameForOpcode(TerminalStreamOpcode.SnapshotRequest)).toBeUndefined() emitSnapshot(streamId, 'initial replay') - expect(onReplayData).toHaveBeenCalledWith('initial replay') + expect(onReplayData).toHaveBeenCalledWith('initial replay', { carriesNormalBuffer: true }) expect(onConnect).toHaveBeenCalled() await vi.waitFor(() => @@ -288,7 +315,8 @@ describe('createRemoteRuntimePtyTransport', () => { cols: 100, rows: 20, seq: undefined, - source: undefined + source: undefined, + carriesNormalBuffer: true }) expect(onReplayData).toHaveBeenCalledTimes(1) }) 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 cb78db75e9e..1b6cca61767 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 @@ -423,10 +423,12 @@ export function createRemoteRuntimePtyTransport( ): void => { outputProcessor.processData(data, storedCallbacks, undefined, meta) } + // Why flagged: only pushed snapshots are buffered for this pty during a shutdown. const shutdownReplayHandler = (data: string): void => { outputProcessor.processData(data, storedCallbacks, { replayingBufferedData: true, - suppressAttentionEvents: true + suppressAttentionEvents: true, + carriesNormalBuffer: true }) } const shutdownLifecycle = { @@ -2019,7 +2021,8 @@ export function createRemoteRuntimePtyTransport( // host dimensions. Absent/zero degrades to the pane's own grid. ...(meta?.cols !== undefined && meta.rows !== undefined ? { snapshotCols: meta.cols, snapshotRows: meta.rows } - : {}) + : {}), + carriesNormalBuffer: true }) } }, diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts new file mode 100644 index 00000000000..d5ed2ff5f49 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-host-chain.test.ts @@ -0,0 +1,398 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamJson, + encodeTerminalStreamFrame +} from '../../../../shared/terminal-stream-protocol' +import { flushAsyncTicks, writeHeadlessTerminal } from './pty-connection-test-async' +import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures' +import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' +import type { PtyReplayDataMeta } from './pty-transport-types' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +// Why this suite: remote-snapshot-alt-screen-replay.test.ts hand-writes the replay meta, and +// once fed a shape the host never sends. Here every input is produced by the real code: +// the host runtime's headless model and shell-ownership proof, its ack-recovery publisher, +// the wire codec, the client multiplexer, and the remote transport. Only the hand-off from +// the transport's onReplayData into the pane's callback is wired by the test. + +const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle, toastInfo } = vi.hoisted( + () => ({ + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn() + }) +) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle })) + +vi.mock('sonner', () => ({ toast: { info: toastInfo } })) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', async (importOriginal) => ({ + ...(await importOriginal>()), + createIpcPtyTransport: vi.fn(() => { + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +// Host-only surfaces, typed here so the renderer typecheck never walks the main graph. +type HostSnapshot = { + data: string + cols: number + rows: number + seq?: number + source?: string + alternateScreen?: boolean + terminalOwner?: 'shell' +} +type HostRuntime = { + setPtyController(controller: { + write: () => boolean + kill: () => boolean + getForegroundProcess: () => Promise + getSize: () => { cols: number; rows: number } + confirmShellForeground: () => Promise + }): void + registerPty(ptyId: string, worktreeId: string): void + onPtyData(ptyId: string, data: string, at: number): number +} +type HostSendFrame = (opcode: number, payload?: Uint8Array) => boolean +type HostStream = { + streamId: number + ptyId: string + outputPaused: boolean + ackRecoverySnapshotInFlight: boolean + ackOutputSourceRanges: boolean + ackPendingOutput: unknown[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean +} +type HostConnection = { + runtime: HostRuntime + streams: Map + closed: boolean + sendFrame: (streamId: number, opcode: number, payload?: Uint8Array) => boolean + sendStreamError: (streamId: number, message: string) => void + sendAckRecoverySnapshot?: (stream: HostStream) => Promise +} + +const COLS = 40 +const ROWS = 6 +const PTY_ID = 'host-pty' +const COMMAND_START = '\x1b]133;C\x07' +// A setup script's output, then an agent TUI entering the alt screen over it. +const LIVE_PANE = `${Array.from({ length: 10 }, (_, i) => `SETUP-OUTPUT-${i}`).join('\r\n')}\r\n$ claude${COMMAND_START}\x1b[?1049h\x1b[2J\x1b[HOLD-AGENT-FRAME` +// Output the client missed before the host's recovery push. +const AGENT_FRAME = '\x1b[H\x1b[2;1HNo\x1b[1Cnotice\x1b[1Ctoday' +const AGENT_EXIT = `${AGENT_FRAME}\x1b[?1049lResume with claude --resume\r\n\x1b]133;D;0\x07\x1b]133;A\x07$ ` + +type PaneEvent = string | { cols: number; rows: number } + +function bufferLines(term: Terminal, which: 'normal' | 'alternate'): string[] { + const buffer = which === 'normal' ? term.buffer.normal : term.buffer.alternate + return Array.from( + { length: buffer.length }, + (_, row) => buffer.getLine(row)?.translateToString(true) ?? '' + ) +} + +function viewport(term: Terminal, which: 'normal' | 'alternate'): string[] { + return bufferLines(term, which).slice(-term.rows) +} + +async function render(events: PaneEvent[]): Promise { + const term = new Terminal({ cols: COLS, rows: ROWS, scrollback: 100, allowProposedApi: true }) + for (const event of events) { + if (typeof event === 'string') { + await writeHeadlessTerminal(term, event) + } else { + term.resize(event.cols, event.rows) + } + } + return term +} + +/** Real host + client chain: returns what the remote transport hands the pane for the push. */ +async function publishHostRecovery( + missed: string +): Promise<{ data: string; meta: PtyReplayDataMeta; published: HostSnapshot }> { + const { OrcaRuntimeService } = await vi.importActual<{ + OrcaRuntimeService: new (store: null) => HostRuntime + }>('../../../../main/runtime/orca-runtime') + const { sendSnapshotFrames, serializeBudgetedRequestedSnapshot } = await vi.importActual<{ + sendSnapshotFrames: (send: HostSendFrame, options: Record) => unknown + serializeBudgetedRequestedSnapshot: ( + runtime: HostRuntime, + ptyId: string, + scrollbackRows: number + ) => Promise + }>('../../../../main/runtime/rpc/methods/terminal/terminal-snapshot-publication') + const { installMultiplexFlowControl } = await vi.importActual<{ + installMultiplexFlowControl: (build: HostConnection) => void + }>('../../../../main/runtime/rpc/methods/terminal/terminal-multiplex-flow-control') + + const host = new OrcaRuntimeService(null) + host.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: COLS, rows: ROWS }), + // The host has verified the shell is back in the foreground at the exit marker. + confirmShellForeground: async () => true + }) + host.registerPty(PTY_ID, 'wt-1') + host.onPtyData(PTY_ID, LIVE_PANE, Date.now()) + + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + let toClient: ((bytes: Uint8Array) => void) | undefined + runtimeSubscribe.mockImplementation( + async ( + _args: unknown, + callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + } + ) => { + toClient = callbacks.onBinary + queueMicrotask(() => callbacks.onResponse({ ok: true, result: { type: 'ready' } })) + return { unsubscribe: vi.fn(), sendBinary } + } + ) + const runtimeCall = vi.fn().mockResolvedValue({ + ok: true, + result: { + terminal: { handle: 'terminal-1', tabId: 'tab-1', leafId: 'pane:1', worktreeId: 'wt-1' } + } + }) + Object.assign(window.api, { + runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe } + }) + + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createRemoteRuntimePtyTransport('env-1', { + worktreeId: 'wt-1', + tabId: 'tab-1', + leafId: 'pane:1' + }) + const onReplayData = vi.fn<(data: string, meta?: PtyReplayDataMeta) => void>() + transport.attach({ + existingPtyId: 'remote:env-1@@terminal-1', + cols: COLS, + rows: ROWS, + callbacks: { onReplayData } + }) + await expect + .poll(() => + sendBinary.mock.calls.some( + ([bytes]) => decodeTerminalStreamFrame(bytes)?.opcode === TerminalStreamOpcode.Subscribe + ) + ) + .toBe(true) + const subscribe = sendBinary.mock.calls + .map(([bytes]) => decodeTerminalStreamFrame(bytes)) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Subscribe) + const streamId = decodeTerminalStreamJson<{ streamId: number }>(subscribe!.payload)!.streamId + const sendFrame = ( + id: number, + opcode: number, + payload?: Uint8Array + ): boolean => { + toClient?.( + encodeTerminalStreamFrame({ + opcode, + streamId: id, + seq: 0, + payload: payload ?? new Uint8Array() + }) + ) + return true + } + + // The initial push only opens the stream; the pane below already parsed these bytes live. + const initial = await serializeBudgetedRequestedSnapshot(host, PTY_ID, 0) + sendSnapshotFrames((opcode, payload) => sendFrame(streamId, opcode, payload), { + kind: 'scrollback', + cols: initial!.cols, + rows: initial!.rows, + seq: initial!.seq, + source: initial!.source, + data: initial!.data + }) + await expect.poll(() => onReplayData.mock.calls.length).toBe(1) + + host.onPtyData(PTY_ID, missed, Date.now()) + const stream: HostStream = { + streamId, + ptyId: PTY_ID, + outputPaused: false, + ackRecoverySnapshotInFlight: false, + ackOutputSourceRanges: false, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false + } + const connection: HostConnection = { + runtime: host, + streams: new Map([[streamId, stream]]), + closed: false, + sendFrame, + sendStreamError: (_id, message) => { + throw new Error(message) + } + } + const published = await serializeBudgetedRequestedSnapshot(host, PTY_ID, 0) + installMultiplexFlowControl(connection) + await connection.sendAckRecoverySnapshot?.(stream) + await expect.poll(() => onReplayData.mock.calls.length).toBe(2) + transport.destroy?.() + const [data, meta] = onReplayData.mock.calls[1]! + return { data, meta: meta ?? {}, published: published! } +} + +describe('host-published recovery snapshot onto a live alt screen', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + /** The pane's writes and grid changes, in order, ending back at the pane's own grid. */ + async function drainOntoLiveAltScreen( + data: string, + meta: PtyReplayDataMeta + ): Promise { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('agent-pty') + const replay: { current: ConnectCallbacks['onReplayData'] | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replay.current = callbacks.onReplayData ?? null + return 'agent-pty' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const events: PaneEvent[] = [] + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((chunk: string, callback?: () => void) => { + if (chunk.length > 0) { + events.push(chunk) + } + if (callback) { + parseCallbacks.push(callback) + } + }) + pane.terminal.resize.mockImplementation((cols: number, rows: number) => { + pane.terminal.cols = cols + pane.terminal.rows = rows + events.push({ cols, rows }) + }) + const deps = buildPaneConnectionDeps(() => mockStoreState) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + pane.terminal.cols = COLS + pane.terminal.rows = ROWS + pane.terminal.buffer.active.type = 'alternate' + replay.current?.(data, meta) + for (let index = 0; index < 12; index += 1) { + await flushAsyncTicks(4) + parseCallbacks.shift()?.() + } + await flushAsyncTicks(8) + binding.dispose() + expect( + events.some((event) => typeof event === 'string' && event.length > 0 && data.endsWith(event)) + ).toBe(true) + return [...events, { cols: COLS, rows: ROWS }] + } + + // Why: while the TUI lives, the host publishes no owner, so the alt flag never reaches + // the client; the pane must keep the setup history the TUI's alt screen covers. + it('keeps the covered history for a live TUI exactly as the host holds it', async () => { + const { data, meta, published } = await publishHostRecovery(AGENT_FRAME) + expect(published.alternateScreen).toBe(true) + expect(meta.terminalOwner).toBeUndefined() + expect(meta.alternateScreen).toBeUndefined() + const client = await render([LIVE_PANE, ...(await drainOntoLiveAltScreen(data, meta))]) + const host = await render([LIVE_PANE, AGENT_FRAME]) + try { + expect(client.buffer.active.type).toBe('alternate') + expect(viewport(client, 'alternate')).toEqual(viewport(host, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(host, 'normal')) + } finally { + client.dispose() + host.dispose() + } + }) + + // Why: once the host's TUI exits, its image no longer enters alt; the pane must leave + // alt and show the host's normal screen. + it('repaints from the normal buffer once the host TUI has exited', async () => { + const { data, meta } = await publishHostRecovery(AGENT_EXIT) + expect(meta.terminalOwner).toBe('shell') + expect(meta.alternateScreen).toBe(false) + const client = await render([LIVE_PANE, ...(await drainOntoLiveAltScreen(data, meta))]) + const host = await render([LIVE_PANE, AGENT_EXIT]) + const fresh = await render([data]) + try { + expect(client.buffer.active.type).toBe('normal') + expect(viewport(client, 'normal')).toEqual(viewport(host, 'normal')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(fresh, 'normal')) + } finally { + client.dispose() + host.dispose() + fresh.dispose() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts new file mode 100644 index 00000000000..016abf75a0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/remote-snapshot-alt-screen-replay.test.ts @@ -0,0 +1,370 @@ +import type * as React from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { flushAsyncTicks, writeHeadlessTerminal } from './pty-connection-test-async' +import { buildMainModelSnapshotReplayWrites } from './terminal-snapshot-replay-paint' +import { createMockTransport, createPane, createManager } from './pty-connection-test-pane-fixtures' +import type { ConnectCallbacks, MockTransport } from './pty-connection-test-pane-fixtures' +import type { PtyReplayDataMeta } from './pty-transport-types' +import { buildPaneConnectionDeps } from './pty-connection-test-deps' +import { createInitialStoreState } from './pty-connection-test-store-fixtures' +import type { StoreState } from './pty-connection-test-store-state' +import { + installTerminalTestGlobals, + restoreTerminalTestGlobals +} from './pty-connection-test-environment' + +const { scheduleRuntimeGraphSync, shouldSeedCacheTimerOnInitialTitle, toastInfo } = vi.hoisted( + () => ({ + scheduleRuntimeGraphSync: vi.fn(), + shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false), + toastInfo: vi.fn() + }) +) + +let mockStoreState: StoreState +let transportFactoryQueue: MockTransport[] = [] +let storeSubscribers: ((state: StoreState) => void)[] = [] + +vi.mock('@/runtime/sync-runtime-graph', () => ({ scheduleRuntimeGraphSync })) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState, + subscribe: (listener: (state: StoreState) => void) => { + storeSubscribers.push(listener) + return () => { + storeSubscribers = storeSubscribers.filter((candidate) => candidate !== listener) + } + } + } +})) + +vi.mock('@/lib/agent-status', async (importOriginal) => { + const { buildAgentStatusModuleMock } = await import('./pty-connection-test-environment') + return buildAgentStatusModuleMock(await importOriginal>()) +}) + +vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle })) + +vi.mock('sonner', () => ({ toast: { info: toastInfo } })) + +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useCallback: unknown>(fn: T): T => fn + } +}) + +vi.mock('./pty-transport', () => ({ + createIpcPtyTransport: vi.fn(() => { + const nextTransport = transportFactoryQueue.shift() + if (!nextTransport) { + throw new Error('No mock transport queued') + } + return nextTransport + }) +})) + +const COLS = 40 +const ROWS = 6 +// A setup script's output, then an agent TUI that entered the alt screen over it. Under a +// live TUI the normal buffer is frozen, so host and pane hold the same one. +const NORMAL_LINES = [...Array.from({ length: 10 }, (_, i) => `SETUP-OUTPUT-${i}`), '$ claude'] +const LIVE_PANE = `${NORMAL_LINES.join('\r\n')}\x1b[?1049h\x1b[2J\x1b[HOLD-AGENT-FRAME` +// Agent frames jump the cursor over cells they expect blank instead of writing spaces. +const AGENT_FRAME = '\x1b[H\x1b[2;1HNo\x1b[1Cnotice\x1b[1Ctoday' +const ENTER_AGENT_FRAME = `\x1b[0m\x1b[?1049h${AGENT_FRAME}` + +// Remote image shapes: the normal buffer folded in, then the image enters alt itself. +// Pushes carry only the host's screen; requested snapshots also carry history. +function pushedImage(hostRows: number): string { + return `${NORMAL_LINES.slice(-hostRows).join('\r\n')}${ENTER_AGENT_FRAME}` +} +// What the multiplexer hands the pane for a recovery push (its own screen clear first). +function recoveryPayload(hostRows: number): string { + return `\x1b[?2026l\x1b[2J\x1b[3J\x1b[H${pushedImage(hostRows)}` +} +const REQUESTED_IMAGE = `${NORMAL_LINES.join('\r\n')}${ENTER_AGENT_FRAME}` + +type PaneEvent = string | { cols: number; rows: number } + +function bufferLines(term: Terminal, which: 'normal' | 'alternate'): string[] { + const buffer = which === 'normal' ? term.buffer.normal : term.buffer.alternate + return Array.from( + { length: buffer.length }, + (_, row) => buffer.getLine(row)?.translateToString(true) ?? '' + ) +} + +function viewport(term: Terminal, which: 'normal' | 'alternate'): string[] { + return bufferLines(term, which).slice(-term.rows) +} + +async function render(events: PaneEvent[], rows = ROWS): Promise { + const term = new Terminal({ cols: COLS, rows, scrollback: 100, allowProposedApi: true }) + for (const event of events) { + if (typeof event === 'string') { + await writeHeadlessTerminal(term, event) + } else { + term.resize(event.cols, event.rows) + } + } + return term +} + +/** The live pane's own buffers carried through the drain's grid changes and nothing else. */ +function untouchedPane(events: PaneEvent[]): Promise { + return render([LIVE_PANE, ...events.filter((event) => typeof event !== 'string')]) +} + +describe('remote snapshot replay onto a live alt screen', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + transportFactoryQueue = [] + storeSubscribers = [] + mockStoreState = createInitialStoreState(() => mockStoreState) + installTerminalTestGlobals() + }) + + afterEach(async () => { + await restoreTerminalTestGlobals() + }) + + /** The pane's writes and grid changes, in order, ending back at the pane's own grid. */ + async function drainOntoLiveAltScreen( + data: string, + meta: PtyReplayDataMeta, + // 'alternate-queued': the TUI's ?1049h is still queued and parses with the first write. + paneBuffer: 'normal' | 'alternate' | 'alternate-queued' = 'alternate' + ): Promise { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('agent-pty') + const replay: { current: ConnectCallbacks['onReplayData'] | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replay.current = callbacks.onReplayData ?? null + return 'agent-pty' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const events: PaneEvent[] = [] + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((chunk: string, callback?: () => void) => { + if (chunk.length > 0) { + events.push(chunk) + } + if (callback) { + parseCallbacks.push(() => { + pane.terminal.buffer.active.type = paneBuffer === 'normal' ? 'normal' : 'alternate' + callback() + }) + } + }) + pane.terminal.resize.mockImplementation((cols: number, rows: number) => { + pane.terminal.cols = cols + pane.terminal.rows = rows + events.push({ cols, rows }) + }) + const deps = buildPaneConnectionDeps(() => mockStoreState) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixtures implement the pane, manager and deps members connectPanePty reads. + const binding = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + pane.terminal.cols = COLS + pane.terminal.rows = ROWS + pane.terminal.buffer.active.type = paneBuffer === 'alternate' ? 'alternate' : 'normal' + replay.current?.(data, meta) + for (let index = 0; index < 12; index += 1) { + await flushAsyncTicks(4) + parseCallbacks.shift()?.() + } + await flushAsyncTicks(8) + binding.dispose() + expect( + events.some((event) => typeof event === 'string' && event.length > 0 && data.endsWith(event)) + ).toBe(true) + return [...events, { cols: COLS, rows: ROWS }] + } + + // Why: a revisited remote tab running an agent TUI gets a pushed snapshot while xterm + // is on the agent's alt screen. Cleared in place, the image's normal screen (old setup + // output) painted into the agent's screen, under its next paints. + it('repaints a pushed image exactly and keeps the history the TUI covers', async () => { + const payload = recoveryPayload(ROWS) + const client = await render([ + LIVE_PANE, + ...(await drainOntoLiveAltScreen(payload, { + carriesNormalBuffer: true, + snapshotCols: COLS, + snapshotRows: ROWS + })) + ]) + const host = await render([LIVE_PANE]) + const fresh = await render([pushedImage(ROWS)]) + try { + expect(client.buffer.active.type).toBe('alternate') + expect(viewport(client, 'alternate')).toEqual(viewport(fresh, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(host, 'normal')) + } finally { + client.dispose() + host.dispose() + fresh.dispose() + } + }) + + // Why: the pane's normal buffer froze with the host's when the TUI entered alt, so a + // pushed image repaints only the alt frame, whatever grid the host serialized it at. + it("keeps the pane's history when the host screen has another grid", async () => { + const hostRows = 10 + const events = await drainOntoLiveAltScreen(recoveryPayload(hostRows), { + carriesNormalBuffer: true, + snapshotCols: COLS, + snapshotRows: hostRows + }) + const client = await render([LIVE_PANE, ...events]) + const untouched = await untouchedPane(events) + const fresh = await render([pushedImage(hostRows), { cols: COLS, rows: ROWS }], hostRows) + try { + expect(viewport(client, 'alternate')).toEqual(viewport(fresh, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(untouched, 'normal')) + } finally { + client.dispose() + untouched.dispose() + fresh.dispose() + } + }) + + // Why: a push can reuse a concurrent requested capture that folds history in too; + // painting that history over the kept one would duplicate it in scrollback. + it('keeps history unduplicated when a pushed image also carries history', async () => { + const client = await render([ + LIVE_PANE, + ...(await drainOntoLiveAltScreen(`\x1b[?2026l\x1b[2J\x1b[3J\x1b[H${REQUESTED_IMAGE}`, { + carriesNormalBuffer: true, + snapshotCols: COLS, + snapshotRows: ROWS + })) + ]) + const host = await render([LIVE_PANE]) + const fresh = await render([REQUESTED_IMAGE]) + try { + expect(viewport(client, 'alternate')).toEqual(viewport(fresh, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(host, 'normal')) + } finally { + client.dispose() + host.dispose() + fresh.dispose() + } + }) + + // Why: the pane's buffer is read only after queued output parses; read early, a + // still-queued ?1049h makes the image paint its normal part into the TUI. + it('reads the pane buffer after a queued alt-screen entry parses', async () => { + const client = await render([ + LIVE_PANE, + ...(await drainOntoLiveAltScreen( + recoveryPayload(ROWS), + { carriesNormalBuffer: true, snapshotCols: COLS, snapshotRows: ROWS }, + 'alternate-queued' + )) + ]) + const host = await render([LIVE_PANE]) + const fresh = await render([pushedImage(ROWS)]) + try { + expect(viewport(client, 'alternate')).toEqual(viewport(fresh, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(host, 'normal')) + } finally { + client.dispose() + host.dispose() + fresh.dispose() + } + }) + + // Why: a TUI that started while the tab was hidden left the pane on the normal + // buffer; the image must rebuild that buffer and enter alt itself. + it('paints the whole image when the TUI started while hidden', async () => { + const client = await render([ + NORMAL_LINES.join('\r\n'), + ...(await drainOntoLiveAltScreen( + recoveryPayload(ROWS), + { carriesNormalBuffer: true, snapshotCols: COLS, snapshotRows: ROWS }, + 'normal' + )) + ]) + const fresh = await render([pushedImage(ROWS)]) + try { + expect(client.buffer.active.type).toBe('alternate') + expect(viewport(client, 'alternate')).toEqual(viewport(fresh, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(fresh, 'normal')) + } finally { + client.dispose() + fresh.dispose() + } + }) + + // Why: once the host's TUI exits, its image no longer enters alt; the pane must leave + // alt and show the host's normal screen. + it('repaints from the normal buffer once the host TUI has exited', async () => { + const hostScreen = [...NORMAL_LINES, 'Resume with claude --resume', '$ '] + .slice(-ROWS) + .join('\r\n') + const client = await render([ + LIVE_PANE, + ...(await drainOntoLiveAltScreen(`\x1b[?2026l\x1b[2J\x1b[3J\x1b[H${hostScreen}`, { + carriesNormalBuffer: true, + terminalOwner: 'shell', + alternateScreen: false, + snapshotCols: COLS, + snapshotRows: ROWS + })) + ]) + const fresh = await render([hostScreen]) + try { + expect(client.buffer.active.type).toBe('normal') + expect(bufferLines(client, 'normal')).toEqual(bufferLines(fresh, 'normal')) + } finally { + client.dispose() + fresh.dispose() + } + }) + + // Why: an SSH relay replays a raw byte window, not an image; it continues the TUI on + // the alt screen and must leave the normal buffer alone. + it('clears a raw byte replay in place on the alt screen', async () => { + const client = await render([LIVE_PANE, ...(await drainOntoLiveAltScreen(AGENT_FRAME, {}))]) + const expected = await render([LIVE_PANE, `\x1b[2J${AGENT_FRAME}`]) + try { + expect(client.buffer.active.type).toBe('alternate') + expect(viewport(client, 'alternate')).toEqual(viewport(expected, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(expected, 'normal')) + } finally { + client.dispose() + expected.dispose() + } + }) + + // Why: a requested image flags alt only once the host's TUI exited (shell owner); one + // that exited without leaving alt must still paint from the normal buffer. + it('paints a requested image from an exited TUI exactly over an alt screen', async () => { + const client = await render([ + LIVE_PANE, + ...buildMainModelSnapshotReplayWrites( + { + data: REQUESTED_IMAGE, + alternateScreen: true, + carriesNormalBuffer: true + }, + { paneOnAlternateScreen: true } + ) + ]) + const fresh = await render([REQUESTED_IMAGE]) + try { + expect(viewport(client, 'alternate')).toEqual(viewport(fresh, 'alternate')) + expect(bufferLines(client, 'normal')).toEqual(bufferLines(fresh, 'normal')) + } finally { + client.dispose() + fresh.dispose() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts index fd5da6ae20b..ef534946ef1 100644 --- a/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts +++ b/src/renderer/src/components/terminal-pane/terminal-snapshot-replay-paint.ts @@ -4,12 +4,7 @@ import { ABORT_TRUNCATED_CONTROL_STRING, buildSnapshotReplayPrologue } from '../../../../shared/terminal-mode-reset-profiles' - -// Once only: CAN must precede the first ESC of the replay, but the split branch -// builds two prologues and later writes follow well-formed payloads. -function abortGapBeforeFirstWrite(writes: string[]): string[] { - return [`${ABORT_TRUNCATED_CONTROL_STRING}${writes[0]}`, ...writes.slice(1)] -} +import { splitAtAlternateScreenEntry } from '../../../../shared/terminal-alternate-screen-split' /** * Shared guards and write choreography for painting a main-model snapshot into @@ -67,6 +62,39 @@ export function shouldSkipAltFrameForWidthMismatch( return snapshotCols > targetCols } +/** First write of a snapshot replay; CAN aborts a control string the gap truncated. */ +export function buildSnapshotReplayPreamble( + args: Parameters[0] +): string { + return `${ABORT_TRUNCATED_CONTROL_STRING}${buildSnapshotReplayPrologue(args)}` +} + +/** + * Writes for a remote image, which folds its normal buffer in ahead of its alt + * entry. Over a pane already on alt, an image that ends on alt repaints only its + * alt payload, so the pane's normal buffer and history stay as they are; any + * other image paints from the normal buffer. + */ +export function buildFoldedImageReplayWrites( + data: string, + paneOnAlternateScreen: boolean +): { preamble: string; payload: string } { + const split = paneOnAlternateScreen ? splitAtAlternateScreenEntry(data) : null + if (split) { + return { + preamble: buildSnapshotReplayPreamble({ + targetAlternateScreen: true, + paneOnAlternateScreen: true + }), + payload: split.alternateAnsi + } + } + return { + preamble: buildSnapshotReplayPreamble({ targetAlternateScreen: false, paneOnAlternateScreen }), + payload: data + } +} + /** * Ordered replay writes for a main-model snapshot, including the alt-screen * choreography: main strips the `?1049h` marker when splitting scrollbackAnsi @@ -85,14 +113,11 @@ export function buildMainModelSnapshotReplayWrites( frameRestoreAnsi?: string alternateScreen?: boolean scrollbackAnsi?: string + carriesNormalBuffer?: boolean }, options: { skipAltFrame?: boolean; paneOnAlternateScreen: boolean } ): string[] { const { paneOnAlternateScreen } = options - const normalPrologue = buildSnapshotReplayPrologue({ - targetAlternateScreen: false, - paneOnAlternateScreen - }) // The alt payload always follows a hop through the normal buffer in the split // branch, so its own switch is judged from there, not from where we started. const altPrologue = (fromAlternateScreen: boolean): string => @@ -100,12 +125,17 @@ export function buildMainModelSnapshotReplayWrites( targetAlternateScreen: true, paneOnAlternateScreen: fromAlternateScreen }) - if (!snapshot.alternateScreen) { - // Why the switch can be needed here: the gap can eat the TUI's own exit - // sequence, leaving the renderer on alt while the model moved to normal — - // the restored history would paint into the alt buffer, looking right while - // scrollback stays empty (STA-4042). - return abortGapBeforeFirstWrite([normalPrologue, snapshot.data]) + // Why the switch can be needed here: the gap can eat the TUI's own exit + // sequence, leaving the renderer on alt while the model moved to normal — + // the restored history would paint into the alt buffer, looking right while + // scrollback stays empty (STA-4042). An image carrying its normal buffer + // enters alt itself, so it needs the same start. + const normalPreamble = buildSnapshotReplayPreamble({ + targetAlternateScreen: false, + paneOnAlternateScreen + }) + if (!snapshot.alternateScreen || snapshot.carriesNormalBuffer) { + return [normalPreamble, snapshot.data] } // Older snapshot producers do not expose the mode/frame boundary. Keep their // composed data rather than dropping terminal modes together with the frame. @@ -117,14 +147,12 @@ export function buildMainModelSnapshotReplayWrites( // Why a prologue per payload: main serializes the buffers separately and // each is diffed against the baseline. scrollbackAnsi used to replay before // any reset at all, so the history inherited the stale state (STA-4042). - return abortGapBeforeFirstWrite([ - normalPrologue, - snapshot.scrollbackAnsi, - altPrologue(false), - ...altFrame - ]) + return [normalPreamble, snapshot.scrollbackAnsi, altPrologue(false), ...altFrame] } // Why the prologue clears: `?1049h` does not clear the alt buffer, so the // pre-hide frame would bleed through the snapshot's blank cells. - return abortGapBeforeFirstWrite([altPrologue(paneOnAlternateScreen), ...altFrame]) + return [ + buildSnapshotReplayPreamble({ targetAlternateScreen: true, paneOnAlternateScreen }), + ...altFrame + ] } 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..a233c2d7dfe 100644 --- a/src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts +++ b/src/renderer/src/runtime/remote-runtime-snapshot-outcome.test.ts @@ -189,7 +189,8 @@ describe('remote terminal snapshot outcome reasons', () => { rows: 24, seq: 0, source: undefined, - pendingEscapeTailAnsi: undefined + pendingEscapeTailAnsi: undefined, + carriesNormalBuffer: true } }) await expect(stream.serializeBuffer({ scrollbackRows: 100 })).resolves.toMatchObject({ diff --git a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts index 6928267a12a..2a3a24af8b5 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-binary-snapshots.ts @@ -84,7 +84,9 @@ export abstract class RemoteRuntimeTerminalBinarySnapshots extends RemoteRuntime kittyKeyboardFlags: info?.kittyKeyboardFlags, alternateScreen: info?.alternateScreen, terminalOwner: info?.terminalOwner, - pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi, + // The host folds the normal buffer into `data` (see terminal-snapshot-publication.ts). + carriesNormalBuffer: true } }) clearPendingSnapshotRequest(stream) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts index 4b3a94aaf97..a8632c132a8 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer-types.ts @@ -76,6 +76,8 @@ export type RemoteRuntimeSnapshotImage = { kittyKeyboardFlags?: number alternateScreen?: boolean terminalOwner?: 'shell' + /** `data` starts on the normal buffer and enters alt itself. */ + carriesNormalBuffer?: boolean } /** Transient causes the host itself reported: a request reached it and it declined to serialize now. */ @@ -133,13 +135,9 @@ export type RemoteRuntimeMultiplexedTerminal = { resize: (cols: number, rows: number) => boolean claimViewport: (cols: number, rows: number) => boolean setOutputPaused: (paused: boolean) => boolean - serializeBuffer: (opts?: { scrollbackRows?: number }) => Promise<{ - data: string - cols: number - rows: number - seq?: number - source?: 'headless' | 'renderer' - } | null> + serializeBuffer: (opts?: { + scrollbackRows?: number + }) => Promise // Why: same request as serializeBuffer, but keeps the host's reason for an absent buffer instead of collapsing it to null. serializeBufferOutcome: (opts?: { scrollbackRows?: number diff --git a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts index e7f3eda6218..10fb18dff91 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-snapshot-controller.ts @@ -18,6 +18,7 @@ import { } from './remote-runtime-terminal-snapshot-state' import type { RemoteRuntimeMultiplexedTerminalState, + RemoteRuntimeSnapshotImage, RemoteRuntimeSnapshotOutcome } from './remote-runtime-terminal-multiplexer-types' @@ -135,13 +136,7 @@ export abstract class RemoteRuntimeTerminalSnapshotController extends RemoteRunt protected async requestSnapshot( stream: RemoteRuntimeMultiplexedTerminalState, opts?: { scrollbackRows?: number } - ): Promise<{ - data: string - cols: number - rows: number - seq?: number - source?: 'headless' | 'renderer' - } | null> { + ): Promise { const outcome = await this.requestSnapshotOutcome(stream, opts) // Why: the concurrent-request guard used to reject before the outcome existed; keep that contract for legacy callers. if ( diff --git a/src/shared/terminal-alternate-screen-split.ts b/src/shared/terminal-alternate-screen-split.ts new file mode 100644 index 00000000000..1c42ed331af --- /dev/null +++ b/src/shared/terminal-alternate-screen-split.ts @@ -0,0 +1,15 @@ +const ALTERNATE_SCREEN_ENTRY = '\x1b[?1049h' + +/** Splits a serialized image at its last alt-screen entry; null when it never enters alt. */ +export function splitAtAlternateScreenEntry( + ansi: string +): { normalAnsi: string; alternateAnsi: string } | null { + const start = ansi.lastIndexOf(ALTERNATE_SCREEN_ENTRY) + if (start === -1) { + return null + } + return { + normalAnsi: ansi.slice(0, start), + alternateAnsi: ansi.slice(start + ALTERNATE_SCREEN_ENTRY.length) + } +}