From 73ede0fbed6fa3550c8dc7eec5f4923c505a0e8e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 15:46:50 -0700 Subject: [PATCH] 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 --- .../terminal-pane/layout-serialization.ts | 2 + .../pty-connection-preconnect-input.test.ts | 38 +++ ...tion-remote-new-terminal-typeahead.test.ts | 238 ++++++++++++++++++ .../pty-connection/pane-transport-options.ts | 48 ++++ .../terminal-pane/pty-transport-types.ts | 3 + ...-pty-transport-reattach-type-ahead.test.ts | 131 +++++++++- .../remote-runtime-pty-transport.ts | 24 +- .../remote-runtime-recovery-input-hold.ts | 39 ++- .../replayed-history-terminals.ts | 12 + 9 files changed, 522 insertions(+), 13 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/pty-connection-remote-new-terminal-typeahead.test.ts create mode 100644 src/renderer/src/components/terminal-pane/replayed-history-terminals.ts diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index 866e20eb8c1..a47538d14c1 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-preconnect-input.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-preconnect-input.test.ts index a99979fc6fd..ada38917950 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-preconnect-input.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-preconnect-input.test.ts @@ -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)) + 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() }) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-remote-new-terminal-typeahead.test.ts b/src/renderer/src/components/terminal-pane/pty-connection-remote-new-terminal-typeahead.test.ts new file mode 100644 index 00000000000..0bbab80426b --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-connection-remote-new-terminal-typeahead.test.ts @@ -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>()) +}) + +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() + return { + ...actual, + useCallback: 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() + 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)) + 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 { + 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[0], + { [leafIdForPane(1)]: 'old$ make\r\n' }, + new Map([[leafIdForPane(1), pane.pane.id]]), + { current: new Map() } + ) + 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() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts index e32d966d1d6..1639341dc32 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection/pane-transport-options.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 3142f589247..d2185b4e393 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -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. */ diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts index 296c19c53a3..711929a42c5 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport-reattach-type-ahead.test.ts @@ -55,8 +55,14 @@ async function completeAttach(): Promise { } /** Holds `terminal.create` open until the spec settles it with the handle the host returns. */ -async function createGatedTransport() { - const created = createDeferred() +type CreatedTerminal = { + handle: string + isReattach?: true + coldRestore?: { scrollback: string; cwd: string } +} + +async function createGatedTransport(options: Record = {}) { + const created = createDeferred() // 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?.() + }) + }) }) diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts index da18e1a2edb..fad9ac982ef 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.ts @@ -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() } diff --git a/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts index a2c7e0b2e0b..2cf10378737 100644 --- a/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts +++ b/src/renderer/src/components/terminal-pane/remote-runtime-recovery-input-hold.ts @@ -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 /** 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 diff --git a/src/renderer/src/components/terminal-pane/replayed-history-terminals.ts b/src/renderer/src/components/terminal-pane/replayed-history-terminals.ts new file mode 100644 index 00000000000..8c3b7acf73d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/replayed-history-terminals.ts @@ -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() + +export function markTerminalShowsReplayedHistory(terminal: Terminal): void { + terminalsWithReplayedHistory.add(terminal) +} + +export function terminalShowsReplayedHistory(terminal: Terminal): boolean { + return terminalsWithReplayedHistory.has(terminal) +}