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.
This commit is contained in:
Jinwoo Hong
2026-10-04 19:00:09 -04:00
committed by GitHub
parent baa56fd10d
commit 3655bd9fc2
19 changed files with 956 additions and 73 deletions
+25 -2
View File
@@ -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": [
@@ -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 }
}
@@ -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
@@ -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()
}
@@ -61,6 +61,7 @@ export function bindHiddenOutputRestoreSnapshot(session: ConnectPanePtySession):
pendingDeliveryStartSeq?: number
alternateScreen?: boolean
scrollbackAnsi?: string
carriesNormalBuffer?: boolean
pendingEscapeTailAnsi?: string
kittyKeyboardFlags?: number
terminalOwner?: 'shell'
@@ -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
}
@@ -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)
@@ -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 = {
@@ -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 }))
})
@@ -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)
})
@@ -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
})
}
},
@@ -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<Record<string, unknown>>())
})
vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle }))
vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof React>()
return {
...actual,
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
}
})
vi.mock('./pty-transport', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
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<null>
getSize: () => { cols: number; rows: number }
confirmShellForeground: () => Promise<boolean>
}): void
registerPty(ptyId: string, worktreeId: string): void
onPtyData(ptyId: string, data: string, at: number): number
}
type HostSendFrame = (opcode: number, payload?: Uint8Array<ArrayBufferLike>) => 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<number, HostStream>
closed: boolean
sendFrame: (streamId: number, opcode: number, payload?: Uint8Array<ArrayBufferLike>) => boolean
sendStreamError: (streamId: number, message: string) => void
sendAckRecoverySnapshot?: (stream: HostStream) => Promise<void>
}
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<Terminal> {
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<string, unknown>) => unknown
serializeBudgetedRequestedSnapshot: (
runtime: HostRuntime,
ptyId: string,
scrollbackRows: number
) => Promise<HostSnapshot | null>
}>('../../../../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<ArrayBufferLike>) => void) | undefined
runtimeSubscribe.mockImplementation(
async (
_args: unknown,
callbacks: {
onResponse: (response: unknown) => void
onBinary?: (bytes: Uint8Array<ArrayBufferLike>) => 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<ArrayBufferLike>
): 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<PaneEvent[]> {
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()
}
})
})
@@ -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<Record<string, unknown>>())
})
vi.mock('./cache-timer-seeding', () => ({ shouldSeedCacheTimerOnInitialTitle }))
vi.mock('sonner', () => ({ toast: { info: toastInfo } }))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof React>()
return {
...actual,
useCallback: <T extends (...args: unknown[]) => 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<Terminal> {
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<Terminal> {
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<PaneEvent[]> {
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()
}
})
})
@@ -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<typeof buildSnapshotReplayPrologue>[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
]
}
@@ -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({
@@ -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)
@@ -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<RemoteRuntimeSnapshotImage | null>
// 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
@@ -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<RemoteRuntimeSnapshotImage | null> {
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 (
@@ -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)
}
}