fix(terminal): restore remote replay grid on reveal

This commit is contained in:
Brennan Benson
2026-08-14 16:12:38 -07:00
parent 500b72d8ef
commit 3b03789893
10 changed files with 238 additions and 9 deletions
@@ -495,6 +495,10 @@ function createPaneContainer(): HTMLElement {
configurable: true,
value: {}
})
Object.defineProperties(container, {
clientWidth: { configurable: true, value: 1200 },
clientHeight: { configurable: true, value: 800 }
})
return container
}
@@ -17244,6 +17248,57 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('paints remote replay at its snapshot grid before returning to the live pane grid', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
const transport = createMockTransport('remote:env-1@@terminal-grid')
const capturedReplayCallback: {
current:
| ((data: string, meta?: { snapshotCols?: number; snapshotRows?: number }) => void)
| null
} = { current: null }
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedReplayCallback.current = callbacks.onReplayData ?? null
return { id: 'remote:env-1@@terminal-grid', replay: '' }
})
transportFactoryQueue.push(transport)
const pane = createPane(1)
const operations: string[] = []
pane.terminal.resize = vi.fn((cols: number, rows: number) => {
operations.push(`resize:${cols}x${rows}`)
pane.terminal.cols = cols
pane.terminal.rows = rows
})
pane.fitAddon.fit = vi.fn(() => pane.terminal.resize(180, 50))
pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 180, rows: 50 }))
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
operations.push(`write:${data}`)
callback?.()
}) as typeof pane.terminal.write
const manager = createManager(1)
const deps = createDeps()
const disposable = connectPanePty(pane as never, manager as never, deps as never)
await flushAsyncTicks(6)
capturedReplayCallback.current?.('snapshot payload', {
snapshotCols: 60,
snapshotRows: 20
})
await flushAsyncTicks(12)
expect(operations).toContain('resize:60x20')
expect(operations.indexOf('resize:60x20')).toBeLessThan(
operations.indexOf('write:snapshot payload')
)
expect(operations.lastIndexOf('resize:180x50')).toBeGreaterThan(
operations.indexOf('write:snapshot payload')
)
expect(pane.terminal.cols).toBe(180)
expect(pane.terminal.rows).toBe(50)
disposable.dispose()
})
it('preserves live agent modes when queued replay data carries the Cursor Agent screen', async () => {
const { connectPanePty } = await import('./pty-connection')
enableActiveRuntimeEnvironment()
@@ -25689,7 +25744,7 @@ describe('connectPanePty', () => {
}
})
it('skips remote-runtime PTYs (their size lives outside the local ptySizes map)', async () => {
it('re-fits and re-reports a remote viewport on reveal even when its grid is unchanged', async () => {
const getSize = vi.mocked(window.api.pty.getSize)
getSize.mockClear()
const { connectPanePty } = await import('./pty-connection')
@@ -25702,17 +25757,68 @@ describe('connectPanePty', () => {
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const pane = createPane(2)
pane.fitAddon.fit = vi.fn()
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
noteVisibilityResume: () => void
}
transport.resize.mockClear()
pane.fitAddon.proposeDimensions.mockClear()
binding.noteVisibilityResume()
await flushAsyncTicks()
// Never even queries size for a remote pane, and never re-asserts.
expect(getSize).not.toHaveBeenCalled()
expect(pane.fitAddon.proposeDimensions).toHaveBeenCalled()
expect(transport.resize).toHaveBeenCalledWith(120, 40)
})
it('does not fit or report a remote viewport from a zero-sized reveal box', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('remote:env-1@@terminal-zero-box')
const capturedReplayCallback: {
current:
| ((data: string, meta?: { snapshotCols?: number; snapshotRows?: number }) => void)
| null
} = { current: null }
transport.connect.mockImplementation(
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
capturedReplayCallback.current = callbacks.onReplayData ?? null
return { id: 'remote:env-1@@terminal-zero-box', replay: '' }
}
)
transportFactoryQueue.push(transport)
const pane = createPane(2)
Object.defineProperties(pane.container, {
clientWidth: { configurable: true, value: 0 },
clientHeight: { configurable: true, value: 0 }
})
pane.fitAddon.fit = vi.fn()
const manager = createManager(2)
const deps = createDeps({
restoredLeafId: LEAF_2,
paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) }
})
const binding = connectPanePty(pane as never, manager as never, deps as never) as unknown as {
noteVisibilityResume: () => void
}
transport.resize.mockClear()
pane.fitAddon.proposeDimensions.mockClear()
pane.terminal.write.mockClear()
capturedReplayCallback.current?.('must wait for a box', {
snapshotCols: 60,
snapshotRows: 20
})
binding.noteVisibilityResume()
await flushAsyncTicks()
expect(pane.fitAddon.fit).not.toHaveBeenCalled()
expect(pane.terminal.write).not.toHaveBeenCalledWith(
'must wait for a box',
expect.any(Function)
)
expect(transport.resize).not.toHaveBeenCalled()
expect(transport.claimViewport).not.toHaveBeenCalled()
})
it('claims a focused visible remote mirror once when its passive fit hold arrives', async () => {
@@ -1136,6 +1136,7 @@ export function connectPanePty(
let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {}
let cleanupStartupDraftPasteTimers = (): void => {}
let unregisterE2ePtyDataInjection = (): void => {}
let resumePendingReplayDataDrain = (): void => {}
let startupInjectTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteNotificationGraceTimer: ReturnType<typeof setTimeout> | null = null
let agentTaskCompleteNotificationMaxTimer: ReturnType<typeof setTimeout> | null = null
@@ -4349,6 +4350,28 @@ export function connectPanePty(
}
}
}
const reportRemoteViewportOnReveal = (): void => {
const revealedPtyId = transport.getPtyId()
if (
!revealedPtyId ||
!isRemoteRuntimePtyId(revealedPtyId) ||
pane.container.clientWidth <= 0 ||
pane.container.clientHeight <= 0 ||
shouldSuppressDesktopPtyResize()
) {
return
}
safeFitAndThen(pane, 'remote-reveal-viewport', () => {
if (disposed || transport.getPtyId() !== revealedPtyId || shouldSuppressDesktopPtyResize()) {
return
}
const cols = pane.terminal.cols
const rows = pane.terminal.rows
if (Number.isFinite(cols) && Number.isFinite(rows) && cols > 0 && rows > 0) {
transport.resize(cols, rows)
}
})
}
let pendingForegroundGridDriftCheckRaf: number | null = null
let lastForegroundGridDriftCheckAt = Number.NEGATIVE_INFINITY
const readProposedTerminalGrid = (): { cols: number; rows: number } | null => {
@@ -4422,6 +4445,9 @@ export function connectPanePty(
if (disposed) {
return
}
if (pane.container.clientWidth > 0 && pane.container.clientHeight > 0) {
resumePendingReplayDataDrain()
}
if (
deferTerminalGeometryMutationDuringRebuild(
pane.terminal,
@@ -5608,6 +5634,8 @@ export function connectPanePty(
type PendingReplayData = {
data: string
clearBeforeReplay: boolean
snapshotCols?: number
snapshotRows?: number
ptyId: string | null
generation: number
streamGeneration: number
@@ -5658,6 +5686,22 @@ export function connectPanePty(
continue
}
}
const snapshotDimensions = resolvePositiveTerminalDimensions(
payload.snapshotCols,
payload.snapshotRows
)
if (
snapshotDimensions &&
(pane.terminal.cols !== snapshotDimensions.cols ||
pane.terminal.rows !== snapshotDimensions.rows)
) {
suppressStructuralReplayPtyResize = true
try {
pane.terminal.resize(snapshotDimensions.cols, snapshotDimensions.rows)
} finally {
suppressStructuralReplayPtyResize = false
}
}
if (clearBeforeReplay || data.length > 0) {
// Why: an empty clearing frame is still an authoritative repaint and
// must clear a stale agent signal from an earlier payload.
@@ -5699,7 +5743,13 @@ export function connectPanePty(
return appliedCurrentPayload
}
const scheduleReplayDataDrain = (): void => {
if (replayDrainQueued) {
if (
replayDrainQueued ||
pendingReplayData === null ||
pane.container.clientWidth <= 0 ||
pane.container.clientHeight <= 0 ||
!safeFit(pane)
) {
return
}
const scheduledPtyId = pendingReplayData?.ptyId ?? null
@@ -5724,7 +5774,12 @@ export function connectPanePty(
shouldRestore: () =>
!disposed &&
transport.getPtyId() === scheduledPtyId &&
transportStreamGeneration === scheduledStreamGeneration
transportStreamGeneration === scheduledStreamGeneration,
afterRestore: () => {
if (replayCompleted) {
safeFit(pane)
}
}
}
)
)
@@ -5741,6 +5796,7 @@ export function connectPanePty(
finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration)
})
}
resumePendingReplayDataDrain = scheduleReplayDataDrain
const replayDataCallback = (
data: string,
meta: PtyReplayDataMeta = {},
@@ -5749,6 +5805,8 @@ export function connectPanePty(
pendingReplayData = {
data,
clearBeforeReplay: meta.clearBeforeReplay !== false,
...(meta.snapshotCols !== undefined ? { snapshotCols: meta.snapshotCols } : {}),
...(meta.snapshotRows !== undefined ? { snapshotRows: meta.snapshotRows } : {}),
ptyId: transport.getPtyId(),
generation: (replayPayloadGeneration += 1),
streamGeneration,
@@ -9283,6 +9341,8 @@ export function connectPanePty(
noteVisibilityResume() {
armVisibleRemoteViewportClaim()
claimPendingVisibleRemoteViewport()
reportRemoteViewportOnReveal()
resumePendingReplayDataDrain()
ptySizeReassertion.request({ fit: false })
consumeHibernatedAgentWake()
requestKnownWindowsShiftEnterReconfirmation()
@@ -49,6 +49,10 @@ export type PtyBufferSnapshot = {
/** Metadata for one authoritative replay payload. */
export type PtyReplayDataMeta = {
clearBeforeReplay?: boolean
/** Source grid for this replay payload. Zero means the producer could not
* determine a size and must never reach xterm.resize(). */
snapshotCols?: number
snapshotRows?: number
pendingEscapeTailAnsi?: string
/** Kitty flags the snapshot's owner PROVED at `snapshotSeq`. Absent means
* unknown; the pane tracker must stay unproven rather than assume zero. */
@@ -103,6 +103,8 @@ type ProcessPtyOutputOptions = {
replayingBufferedData?: boolean
suppressAttentionEvents?: boolean
clearBeforeReplay?: boolean
snapshotCols?: number
snapshotRows?: number
// Why: a mid-escape tail; the replay consumer writes it LAST (after the post-replay reset) so the next live chunk completes it, not renders it literally (#7329).
pendingEscapeTailAnsi?: string
/** Kitty flags the snapshot owner proved at `snapshotSeq`. */
@@ -498,6 +500,8 @@ export function createPtyOutputProcessor({
if (options.replayingBufferedData && callbacks.onReplayData) {
const replayMeta = {
...(options.clearBeforeReplay === false ? { clearBeforeReplay: false } : {}),
...(options.snapshotCols !== undefined ? { snapshotCols: options.snapshotCols } : {}),
...(options.snapshotRows !== undefined ? { snapshotRows: options.snapshotRows } : {}),
...(options.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: options.pendingEscapeTailAnsi }
: {}),
@@ -6099,6 +6099,32 @@ describe('createRemoteRuntimePtyTransport', () => {
expect(onConnect).toHaveBeenCalled()
})
it('forwards the source grid with an initial replay payload', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
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()
emitSnapshotFrame(
streamId,
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({ kind: 'scrollback', cols: 60, rows: 20 })
)
emitSnapshotFrame(
streamId,
TerminalStreamOpcode.SnapshotChunk,
encodeTerminalStreamText('source-grid replay')
)
emitSnapshotFrame(streamId, TerminalStreamOpcode.SnapshotEnd, new Uint8Array())
expect(onReplayData).toHaveBeenCalledWith('source-grid replay', {
snapshotCols: 60,
snapshotRows: 20
})
})
it('resolves explicit binary snapshot requests without replaying into xterm', async () => {
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const onReplayData = vi.fn()
@@ -1768,6 +1768,8 @@ export function createRemoteRuntimePtyTransport(
outputProcessor.processData(data, storedCallbacks, {
replayingBufferedData: true,
suppressAttentionEvents: true,
...(meta?.cols !== undefined ? { snapshotCols: meta.cols } : {}),
...(meta?.rows !== undefined ? { snapshotRows: meta.rows } : {}),
...(meta?.pendingEscapeTailAnsi
? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi }
: {}),
@@ -38,6 +38,7 @@ class ScriptedSnapshotServer {
private cursorUnits = 0
requestIds: (number | undefined)[] = []
nextRequestedReply: RequestedReply = { kind: 'buffer', data: 'MANUAL' }
omitNextSize = false
dropNextOutput = false
holdResyncReplies = false
private heldRequestId: number | null = null
@@ -112,11 +113,12 @@ class ScriptedSnapshotServer {
}
private sendStart(meta: { requestId?: number; truncated?: boolean; unavailable?: string }): void {
const size = this.omitNextSize ? {} : { cols: 80, rows: 24 }
this.omitNextSize = false
this.send(
TerminalStreamOpcode.SnapshotStart,
encodeTerminalStreamJson({
cols: 80,
rows: 24,
...size,
seq: meta.truncated ? undefined : this.cursorUnits,
...meta
})
@@ -197,6 +199,16 @@ describe('remote terminal snapshot outcome reasons', () => {
})
})
it('uses 0x0 as the unknown-size sentinel for an older snapshot producer', async () => {
const stream = await subscribeClient()
server.nextRequestedReply = { kind: 'buffer', data: 'LEGACY' }
server.omitNextSize = true
await expect(stream.serializeBufferOutcome({ scrollbackRows: 100 })).resolves.toMatchObject({
snapshot: { data: 'LEGACY', cols: 0, rows: 0 }
})
})
it('reports a proven-empty buffer as a snapshot, not as an unavailable reply', async () => {
const stream = await subscribeClient()
server.nextRequestedReply = { kind: 'buffer', data: '' }
@@ -64,6 +64,8 @@ export type RemoteRuntimeMultiplexedTerminalCallbacks = {
onSnapshot: (
data: string,
meta?: {
cols?: number
rows?: number
pendingEscapeTailAnsi?: string
seq?: number
kittyKeyboardFlags?: number
@@ -888,8 +890,8 @@ class RemoteRuntimeTerminalMultiplexer {
availability: classifySnapshotAvailability(stream.snapshotOverflowed, info),
snapshot: {
data: data ?? '',
cols: info?.cols ?? 80,
rows: info?.rows ?? 24,
cols: info?.cols ?? 0,
rows: info?.rows ?? 0,
seq: info?.seq,
source: info?.source,
kittyKeyboardFlags: info?.kittyKeyboardFlags,
@@ -899,6 +901,8 @@ class RemoteRuntimeTerminalMultiplexer {
clearPendingSnapshotRequest(stream)
} else if (target === 'initial') {
stream.callbacks.onSnapshot(data ?? '', {
cols: info?.cols,
rows: info?.rows,
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
seq: info?.seq,
kittyKeyboardFlags: info?.kittyKeyboardFlags
@@ -909,6 +913,8 @@ class RemoteRuntimeTerminalMultiplexer {
// An empty snapshot is still applied so stale dropped output does
// not linger on a terminal the model says is blank.
stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, {
cols: info?.cols,
rows: info?.rows,
pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi,
seq: info?.seq,
kittyKeyboardFlags: info?.kittyKeyboardFlags
@@ -572,6 +572,8 @@ describe('remote runtime terminal multiplex ACK gate', () => {
injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state')
expect(onSnapshot).toHaveBeenCalledWith('initial state', {
cols: 120,
rows: 40,
pendingEscapeTailAnsi: undefined
})
expect(onSubscribed).toHaveBeenCalledTimes(1)
@@ -590,6 +592,8 @@ describe('remote runtime terminal multiplex ACK gate', () => {
// clears screen and scrollback first and must not replay the subscribe
// lifecycle.
expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, {
cols: 120,
rows: 40,
pendingEscapeTailAnsi: undefined
})
expect(onSubscribed).toHaveBeenCalledTimes(1)
@@ -607,6 +611,8 @@ describe('remote runtime terminal multiplex ACK gate', () => {
''
)
expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', {
cols: 120,
rows: 40,
pendingEscapeTailAnsi: undefined
})
expect(onSubscribed).toHaveBeenCalledTimes(1)
@@ -13,7 +13,10 @@ export type RemoteRuntimePtyIdParts = {
export type RuntimeTerminalDataSubscriptionOptions = {
startAtLiveTail?: boolean
onSnapshot?: (data: string, meta?: { pendingEscapeTailAnsi?: string }) => void
onSnapshot?: (
data: string,
meta?: { cols?: number; rows?: number; pendingEscapeTailAnsi?: string }
) => void
onEnd?: () => void
onError?: (message: string) => void
onTransportClose?: (event: { recoverable: boolean; retryWithBackoff?: boolean }) => void