fix(terminal): keep a brand-new remote pane's typing for the terminal it creates (#33859)

* fix(terminal): keep a brand-new remote pane's typing for the terminal it creates

* fix(terminal): new-pane typing goes only to a genuinely fresh create, never an adopted or restored terminal

* fix(terminal): decide a new pane looked blank at connect; at bind re-check only what the create can't cause

* test(terminal): replayed history from the real restore path drops a new remote pane's typing

* fix(terminal): type the replayed-history marks by xterm Terminal
This commit is contained in:
Neil
2026-10-26 15:46:50 -07:00
committed by GitHub
parent 3f256ad4d5
commit 73ede0fbed
9 changed files with 522 additions and 13 deletions
@@ -3,6 +3,7 @@ import type {
TerminalPaneLayoutNode,
TerminalPaneSplitDirection
} from '../../../../shared/terminal-tab-types'
import { markTerminalShowsReplayedHistory } from './replayed-history-terminals'
import { isTerminalLeafId } from '../../../../shared/stable-pane-id'
import {
POST_REPLAY_MODE_RESET,
@@ -126,6 +127,7 @@ export function restoreScrollbackBuffers(
})
continue
}
markTerminalShowsReplayedHistory(pane.terminal)
try {
const renderOptions = {
shouldRefreshViewportSynchronously: () => !manager.hasWebglRenderer(pane.id)
@@ -247,6 +247,44 @@ describe('connectPanePty pre-connect input', () => {
binding.dispose()
})
it('keeps a brand-new pane’s typing for the terminal it creates, but not a restored pane’s', async () => {
transportFactoryQueue.push(createMockTransport(), createMockTransport())
mockStoreState.terminalLayoutsByTabId = {}
const fresh = connectPlainPane()
const restored = connectPlainPane({
restoredLeafId: leafIdForPane(1),
restoredPtyIdByLeafId: { [leafIdForPane(1)]: 'remote:env-1@@terminal-1' }
})
const keepFresh = createdTransportOptions[0]?.keepTypeAheadForNewTerminal
const keepRestored = createdTransportOptions[1]?.keepTypeAheadForNewTerminal
expect(typeof keepFresh === 'function' && keepFresh()).toBe(true)
expect(typeof keepRestored === 'function' && keepRestored()).toBe(false)
fresh.dispose()
restored.dispose()
})
// Why: a cleared binding can still replay saved history into the pane.
it('stops keeping a new pane’s typing once replayed history shows in it', async () => {
transportFactoryQueue.push(createMockTransport())
mockStoreState.terminalLayoutsByTabId = {}
const pane = createPane(1)
const connectArgs = [pane, createManager(1), buildPaneConnectionDeps(() => mockStoreState)]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures implement the members connectPanePty touches.
const binding = connectPanePty(...(connectArgs as unknown as Parameters<typeof connectPanePty>))
const keep = createdTransportOptions[0]?.keepTypeAheadForNewTerminal
expect(typeof keep === 'function' && keep()).toBe(true)
// The fresh create's own prompt and leaf binding count only before it starts, never at bind.
pane.terminal.buffer.active.baseY = 12
expect(typeof keep === 'function' && keep('connect')).toBe(false)
expect(typeof keep === 'function' && keep('bind')).toBe(true)
// Replayed history (checked at bind too) is covered through the real restore path in
// pty-connection-remote-new-terminal-typeahead.test.ts.
binding.dispose()
})
it('drops keys typed into a deliberately slept pane instead of replaying them on wake', async () => {
markWorktreeSleepIntent('wt-1')
const transport = Object.assign(createMockTransport(), { abandonPreconnectInput: vi.fn() })
@@ -0,0 +1,238 @@
import type * as React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { connectPanePty } from './pty-connection'
import { restoreScrollbackBuffers } from './layout-serialization'
import { createDeferred } from './pty-connection-test-async'
import { createManager, createPane, leafIdForPane } from './pty-connection-test-pane-fixtures'
import { buildPaneConnectionDeps } from './pty-connection-test-deps'
import {
installTerminalTestGlobals,
restoreTerminalTestGlobals
} from './pty-connection-test-environment'
import {
buildActiveRuntimeEnvironmentState,
createInitialStoreState
} from './pty-connection-test-store-fixtures'
import type { StoreState } from './pty-connection-test-store-state'
import {
createRemoteRuntimeTransportMocks,
type MultiplexSubscriptionCallbacks
} from './remote-runtime-pty-transport-test-harness'
// Real options builder and real remote transport: the fresh create's own layout binding must not
// read as an old terminal when the held typing settles.
const {
notifyCodexPaneBoundForStaleSweep,
scheduleRuntimeGraphSync,
shouldSeedCacheTimerOnInitialTitle,
toastInfo
} = vi.hoisted(() => ({
notifyCodexPaneBoundForStaleSweep: vi.fn(),
scheduleRuntimeGraphSync: vi.fn(),
shouldSeedCacheTimerOnInitialTitle: vi.fn(() => false),
toastInfo: vi.fn()
}))
let mockStoreState: StoreState
let storeSubscribers: ((state: StoreState) => void)[] = []
let callbacks: MultiplexSubscriptionCallbacks = null
let resolvedHandle = 'terminal-1'
const layoutOwner = vi.hoisted(() => ({ isMain: false }))
vi.mock('../../../../shared/layout-owner-switch', () => ({
get ORCA_LAYOUT_OWNER_IS_MAIN() {
return layoutOwner.isMain
}
}))
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('@/lib/codex-stale-pane-sweep', () => ({ notifyCodexPaneBoundForStaleSweep }))
vi.mock('react', async (importOriginal) => {
const actual = await importOriginal<typeof React>()
return {
...actual,
useCallback: <T extends (...args: unknown[]) => unknown>(fn: T): T => fn
}
})
const {
runtimeCall,
runtimeSubscribe,
latestSubscribePayload,
emitSnapshot,
inputFrameTexts,
resetRemoteRuntimeTransport
} = createRemoteRuntimeTransportMocks({
getCallbacks: () => callbacks,
setCallbacks: (value) => {
callbacks = value
},
getResolvedPaneHandle: () => resolvedHandle,
setResolvedPaneHandle: (value) => {
resolvedHandle = value
}
})
type CreatedTerminal = {
handle: string
isReattach?: true
coldRestore?: { scrollback: string; cwd: string }
}
async function connectNewRemotePane() {
const created = createDeferred<CreatedTerminal | Error>()
const call = runtimeCall.getMockImplementation()
runtimeCall.mockImplementation(async (request: { method: string }) => {
if (request.method !== 'terminal.create') {
return call?.(request)
}
const outcome = await created.promise
if (outcome instanceof Error) {
throw outcome
}
return { ok: true, result: { terminal: outcome } }
})
const pane = createPane(1)
const userInputListeners: (() => void)[] = []
Object.assign(pane.terminal, {
_core: {
coreService: {
onUserInput: (listener: () => void) => {
userInputListeners.push(listener)
return { dispose: vi.fn() }
}
}
}
})
const connectArgs = [pane, createManager(1), buildPaneConnectionDeps(() => mockStoreState)]
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the shared pane fixtures implement the members connectPanePty touches.
const binding = connectPanePty(...(connectArgs as unknown as Parameters<typeof connectPanePty>))
await vi.waitFor(() =>
expect(runtimeCall).toHaveBeenCalledWith(expect.objectContaining({ method: 'terminal.create' }))
)
return {
pane,
created,
binding,
type: (data: string) => {
for (const listener of userInputListeners) {
listener()
}
for (const [listener] of pane.terminal.onData.mock.calls) {
listener(data)
}
}
}
}
async function completeAttach(): Promise<void> {
await vi.waitFor(() => expect(latestSubscribePayload()).toBeDefined())
const { streamId } = latestSubscribePayload()
callbacks?.onResponse({ ok: true, result: { type: 'subscribed', streamId } })
emitSnapshot(streamId, 'prompt$ ')
}
describe('a brand-new remote pane through connectPanePty', () => {
beforeEach(async () => {
resetRemoteRuntimeTransport()
storeSubscribers = []
mockStoreState = buildActiveRuntimeEnvironmentState(
createInitialStoreState(() => mockStoreState),
'env-1'
)
mockStoreState.tabsByWorktree = { 'wt-1': [{ id: 'tab-1', ptyId: null }] }
mockStoreState.ptyIdsByTabId = {}
const layout = mockStoreState.terminalLayoutsByTabId?.['tab-1']
if (layout) {
layout.ptyIdsByLeafId = {}
}
await installTerminalTestGlobals()
Object.assign(window.api, {
runtimeEnvironments: { call: runtimeCall, subscribe: runtimeSubscribe }
})
})
afterEach(async () => {
await restoreTerminalTestGlobals()
})
it('sends typing from before the create to the terminal the create makes', async () => {
const pane = await connectNewRemotePane()
pane.type('echo hi')
pane.type('\r')
pane.created.resolve({ handle: 'terminal-new' })
await completeAttach()
await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo hi\r'))
// The create bound the leaf itself; that binding is not an old terminal.
expect(
mockStoreState.terminalLayoutsByTabId?.['tab-1']?.ptyIdsByLeafId?.[leafIdForPane(1)]
).toBe('remote:env-1@@terminal-new')
pane.binding.dispose()
})
it.each([
['an adopted terminal', { handle: 'terminal-live', isReattach: true as const }],
[
'a cold-restored terminal',
{ handle: 'terminal-restored', coldRestore: { scrollback: 'old$ ', cwd: '/w' } }
]
])('drops the typing when the create returns %s', async (_label, terminal) => {
const pane = await connectNewRemotePane()
pane.type('rm -rf build\r')
pane.created.resolve(terminal)
await completeAttach()
pane.type('live')
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
expect(inputFrameTexts().join('')).toBe('live')
pane.binding.dispose()
})
it('drops the typing when saved history is replayed into the pane before it binds', async () => {
const pane = await connectNewRemotePane()
pane.type('typed on old history\r')
// The real restore path: saved scrollback for this leaf lands in the new pane.
const manager = { getPanes: () => [pane.pane], hasWebglRenderer: () => false }
restoreScrollbackBuffers(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: restoreScrollbackBuffers reads only getPanes and hasWebglRenderer.
manager as unknown as Parameters<typeof restoreScrollbackBuffers>[0],
{ [leafIdForPane(1)]: 'old$ make\r\n' },
new Map([[leafIdForPane(1), pane.pane.id]]),
{ current: new Map<number, number>() }
)
pane.created.resolve({ handle: 'terminal-new' })
await completeAttach()
// A key typed after the bind proves anything held ahead of it has settled.
pane.type('live')
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
expect(inputFrameTexts().join('')).toBe('live')
pane.binding.dispose()
})
})
@@ -1,4 +1,6 @@
import { useAppStore } from '@/store'
import { terminalShowsReplayedHistory } from '../replayed-history-terminals'
import { isWebTerminalSurfaceTabId } from '../../../../../shared/terminal-surface-id'
import { shouldRetainDisposedPaneSpawn } from './disposed-spawn-retention'
import { findColdRestoreResumeSource } from './cold-restore-resume-startup'
import { mainStartsPane } from './main-starts-pane'
@@ -35,6 +37,45 @@ function holdsInputUntilConnect(session: ConnectPanePtySession): boolean {
return !paneHasStartupWork(session)
}
/**
* Whether the pane may show an old terminal: it mirrors a host's tab, or saved history was replayed
* into it (a cleared binding can still replay). Before its first connect, also: its leaf names a
* terminal or it shows output. Those are not re-read at bind, where the fresh create causes both.
*/
function paneMayShowOldTerminal(session: ConnectPanePtySession, atBind: boolean): boolean {
if (
isWebTerminalSurfaceTabId(session.deps.tabId) ||
terminalShowsReplayedHistory(session.pane.terminal)
) {
return true
}
if (atBind) {
return false
}
const state = useAppStore.getState()
return Boolean(
session.restoredPtyIdForTransport ||
state.terminalLayoutsByTabId[session.deps.tabId]?.ptyIdsByLeafId?.[session.pane.leafId] ||
terminalShowsOutput(session.pane.terminal)
)
}
function terminalShowsOutput(terminal: ConnectPanePtySession['pane']['terminal']): boolean {
const buffer = terminal.buffer?.active
if (!buffer) {
return false
}
if (buffer.baseY > 0) {
return true
}
for (let row = 0; row < buffer.length; row += 1) {
if (buffer.getLine(row)?.translateToString(true)) {
return true
}
}
return false
}
export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPtyTransportOptions {
const holdsInput = holdsInputUntilConnect(session)
return {
@@ -48,6 +89,13 @@ export function buildPaneTransportOptions(session: ConnectPanePtySession): IpcPt
releasePreconnectInput: () => holdsInputUntilConnect(session)
}
: {}),
// Why: typing on old pixels must never reach a fresh shell (#10065).
...(holdsInput
? {
keepTypeAheadForNewTerminal: (phase: 'connect' | 'bind') =>
!paneMayShowOldTerminal(session, phase === 'bind')
}
: {}),
// Why gated: a seed alone arms the transport hold, which would flush ahead of startup work.
...(holdsInput && session.deps.preconnectInput?.length
? { preconnectInput: session.deps.preconnectInput }
@@ -269,6 +269,9 @@ export type IpcPtyTransportOptions = {
cwd?: string
/** Retain bounded user input until the pane's first PTY connection binds. */
bufferInputUntilConnect?: boolean
/** A new pane with no old screen: typing before its first remote terminal exists goes to it. */
/** 'connect' decides once that the pane looked blank; 'bind' re-checks only what a create can't cause. */
keepTypeAheadForNewTerminal?: (phase: 'connect' | 'bind') => boolean
/** Re-checked when held input would flush; false drops it (startup work appeared after the hold began). */
releasePreconnectInput?: () => boolean
/** Seed a fresh transport with input handed off from a remounted deferred split. */
@@ -55,8 +55,14 @@ async function completeAttach(): Promise<void> {
}
/** Holds `terminal.create` open until the spec settles it with the handle the host returns. */
async function createGatedTransport() {
const created = createDeferred<string | Error>()
type CreatedTerminal = {
handle: string
isReattach?: true
coldRestore?: { scrollback: string; cwd: string }
}
async function createGatedTransport(options: Record<string, unknown> = {}) {
const created = createDeferred<string | Error | CreatedTerminal>()
// Acknowledged writes to a host without input acks go out as `terminal.send` calls.
const acceptedSends: string[] = []
const call = runtimeCall.getMockImplementation()
@@ -73,11 +79,13 @@ async function createGatedTransport() {
if (outcome instanceof Error) {
throw outcome
}
return { ok: true, result: { terminal: { handle: outcome, isReattach: true } } }
// A bare handle stands for the host adopting this pane's existing terminal.
const terminal = typeof outcome === 'string' ? { handle: outcome, isReattach: true } : outcome
return { ok: true, result: { terminal } }
}
)
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1' })
const transport = createRemoteRuntimePtyTransport('env-1', { worktreeId: 'wt-1', ...options })
const waitForCreate = () =>
vi.waitFor(() => {
expect(runtimeCall).toHaveBeenCalledWith(
@@ -235,4 +243,119 @@ describe('remote reattach type-ahead', () => {
transport.destroy?.()
}
)
describe('a brand-new pane', () => {
it('delivers typing from before and during its first create to the terminal it makes', async () => {
const { transport, created, waitForCreate } = await createGatedTransport({
keepTypeAheadForNewTerminal: () => true
})
expect(transport.sendInput('ec', 'driving')).toBe(true)
const connecting = transport.connect({ url: '', callbacks: {} })
await waitForCreate()
expect(transport.sendInput('ho hi\r', 'driving')).toBe(true)
expect(inputFrameTexts()).toEqual([])
created.resolve({ handle: 'terminal-new' })
await connecting
await completeAttach()
await vi.waitFor(() => expect(inputFrameTexts().join('')).toBe('echo hi\r'))
transport.destroy?.()
})
it('drops its typing when startup work appeared before the terminal bound', async () => {
const { transport, created, waitForCreate } = await createGatedTransport({
keepTypeAheadForNewTerminal: () => true,
releasePreconnectInput: () => false
})
expect(transport.sendInput('make the tests pass\r', 'driving')).toBe(true)
const connecting = transport.connect({ url: '', callbacks: {} })
await waitForCreate()
created.resolve({ handle: 'terminal-new' })
await connecting
await completeAttach()
expect(transport.sendInput('live', 'driving')).toBe(true)
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
expect(inputFrameTexts().join('')).toBe('live')
transport.destroy?.()
})
it('drops its typing when the first create binds no terminal', async () => {
const { transport, created, waitForCreate } = await createGatedTransport({
keepTypeAheadForNewTerminal: () => true
})
const accepted = transport.sendInputAccepted?.('lost\r', 'driving')
const connecting = transport.connect({ url: '', callbacks: {} })
await waitForCreate()
created.resolve(new Error('offline'))
await connecting
await expect(accepted).resolves.toBe(false)
await flushAsyncTicks()
expect(inputFrameTexts()).toEqual([])
transport.destroy?.()
})
// Why: the host adopts a live terminal for this tab and leaf, or restores history into a new
// one; typing made for a blank pane must not run in either (#10065).
it.each([
['an adopted terminal', { handle: 'terminal-live', isReattach: true as const }],
[
'a cold-restored terminal',
{ handle: 'terminal-restored', coldRestore: { scrollback: 'old$ ', cwd: '/w' } }
]
])('drops its typing when the first create returns %s', async (_label, terminal) => {
const { transport, created, waitForCreate } = await createGatedTransport({
keepTypeAheadForNewTerminal: () => true
})
expect(transport.sendInput('rm -rf build\r', 'driving')).toBe(true)
const connecting = transport.connect({ url: '', callbacks: {} })
await waitForCreate()
created.resolve(terminal)
await connecting
await completeAttach()
expect(transport.sendInput('live', 'driving')).toBe(true)
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
expect(inputFrameTexts().join('')).toBe('live')
transport.destroy?.()
})
it('drops typing held across a failed first create when a later connect creates', async () => {
const { transport, created, waitForCreate } = await createGatedTransport({
keepTypeAheadForNewTerminal: () => true
})
expect(transport.sendInput('first try\r', 'driving')).toBe(true)
const first = transport.connect({ url: '', callbacks: {} })
await waitForCreate()
created.resolve(new Error('offline'))
await first
const call = runtimeCall.getMockImplementation()
runtimeCall.mockImplementation(async (request: { method: string }) =>
request.method === 'terminal.create'
? { ok: true, result: { terminal: { handle: 'terminal-second' } } }
: call?.(request)
)
transport.sendInput('between\r', 'driving')
await transport.connect({ url: '', callbacks: {} })
await completeAttach()
expect(transport.sendInput('live', 'driving')).toBe(true)
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
expect(inputFrameTexts().join('')).toBe('live')
transport.destroy?.()
})
it('drops its typing when replayed history appeared before the terminal bound', async () => {
let blank = true
const { transport, created, waitForCreate } = await createGatedTransport({
keepTypeAheadForNewTerminal: () => blank
})
const connecting = transport.connect({ url: '', callbacks: {} })
await waitForCreate()
expect(transport.sendInput('typed on old pixels\r', 'driving')).toBe(true)
blank = false
created.resolve({ handle: 'terminal-new' })
await connecting
await completeAttach()
expect(transport.sendInput('live', 'driving')).toBe(true)
await vi.waitFor(() => expect(inputFrameTexts().join('')).toMatch(/live$/))
expect(inputFrameTexts().join('')).toBe('live')
transport.destroy?.()
})
})
})
@@ -335,6 +335,7 @@ export function createRemoteRuntimePtyTransport(
}
const recoveryInputHold = createRemoteRuntimeRecoveryInputHold({ holdBeforeFirstConnect: true })
let isFirstConnect = true
let heldInputRelease: {
stream: RemoteRuntimeMultiplexedTerminal
waitingForValidation: boolean
@@ -3368,12 +3369,31 @@ export function createRemoteRuntimePtyTransport(
if (!replay) {
settleSignInConnectWait()
}
const expected = recoveryInputHold.expect(restoredInputEndpoint(options.sessionId))
// Why: a brand-new pane shows no old screen, so its typing belongs to the terminal it creates.
const newTerminal =
!options.sessionId && isFirstConnect && opts.keepTypeAheadForNewTerminal?.('connect') === true
isFirstConnect = false
const expected = newTerminal
? recoveryInputHold.expectNewTerminal()
: recoveryInputHold.expect(restoredInputEndpoint(options.sessionId))
let result: PtyConnectResult | undefined
try {
result = await connectPane(options)
} finally {
recoveryInputHold.settleExpectation(expected, handle !== null && !destroyed)
const boundHandle = handle !== null && !destroyed ? handle : null
// Why a genuine create only: an adopted or cold-restored terminal is not the one typed into.
const createdFresh = Boolean(result && !result.isReattach && !result.coldRestore)
recoveryInputHold.settleExpectation(
expected,
boundHandle !== null,
// Startup work, or replayed history, that appeared since mount must not run behind typing.
boundHandle &&
createdFresh &&
opts.releasePreconnectInput?.() !== false &&
opts.keepTypeAheadForNewTerminal?.('bind') === true
? heldInputEndpoint(boundHandle)
: null
)
if (handle && recoveryInputHold.isHolding()) {
releaseHeldInput()
}
@@ -27,7 +27,11 @@ type HeldInput = {
}
/** One connect's claim on pre-bind typing; identity decides which connect may settle it. */
export type RestoredInputExpectation = { readonly endpoint: HeldEndpoint }
export type RestoredInputExpectation = {
readonly endpoint: HeldEndpoint
/** A brand-new pane's first create: its pre-bind typing belongs to whatever terminal it makes. */
readonly newTerminal?: true
}
export type RemoteRuntimeRecoveryInputHold = {
isHolding: () => boolean
@@ -42,8 +46,17 @@ export type RemoteRuntimeRecoveryInputHold = {
) => Promise<boolean>
/** Binds pre-bind typing to the terminal a connect restores; null (a fresh create) drops it. */
expect: (endpoint: HeldEndpoint) => RestoredInputExpectation
/** Ends the latest connect's expectation: without a bound terminal its typing is dropped. */
settleExpectation: (expectation: RestoredInputExpectation, bound: boolean) => void
/** Keeps a brand-new pane's pre-bind typing for the terminal its first create makes. */
expectNewTerminal: () => RestoredInputExpectation
/**
* Ends the latest connect's expectation: without a bound terminal its typing is dropped. A
* new-terminal expectation keeps it only when `createdEndpoint` names the terminal it made.
*/
settleExpectation: (
expectation: RestoredInputExpectation,
bound: boolean,
createdEndpoint?: RemoteRuntimeInputEndpoint | null
) => void
/** Delivers held input to the endpoint it was typed into, or drops it if the pane rebound elsewhere. */
release: (
bound: RemoteRuntimeInputEndpoint,
@@ -128,7 +141,16 @@ export function createRemoteRuntimeRecoveryInputHold(
expected.beforeBind = true
return expectation
},
settleExpectation(expectation, bound) {
expectNewTerminal() {
const expectation: RestoredInputExpectation = { endpoint: null, newTerminal: true }
latestExpectation = expectation
if (!held?.buffer.isBuffering() || held.endpoint) {
discard()
held = newHold(null, true)
}
return expectation
},
settleExpectation(expectation, bound, createdEndpoint) {
if (expectation !== latestExpectation) {
return
}
@@ -136,11 +158,14 @@ export function createRemoteRuntimeRecoveryInputHold(
if (!held?.beforeBind) {
return
}
if (bound) {
held.beforeBind = false
} else {
if (!bound || (expectation.newTerminal && !createdEndpoint)) {
discard()
return
}
if (expectation.newTerminal) {
held.endpoint = createdEndpoint ?? null
}
held.beforeBind = false
},
release(bound, writer) {
const current = held
@@ -0,0 +1,12 @@
import type { Terminal } from '@xterm/xterm'
// Terminals saved history was replayed into: what the user sees there is an old session, not a blank pane.
const terminalsWithReplayedHistory = new WeakSet<Terminal>()
export function markTerminalShowsReplayedHistory(terminal: Terminal): void {
terminalsWithReplayedHistory.add(terminal)
}
export function terminalShowsReplayedHistory(terminal: Terminal): boolean {
return terminalsWithReplayedHistory.has(terminal)
}