fix(terminal): retain alt frames through park reveal (#13882)

* fix(terminal): repaint an alt frame the fit never lands for

#13014 skips a too-wide alt frame on snapshot replay and relies on a post-fit
repaint. For a pane visible but permanently below the 48x24px fit floor,
readProposedTerminalCols is forever undefined, so skipIfTargetUnknown drops the
frame — and the only compensating repaint lives in a safe-fit continuation that
is failed rather than run for such a pane. The frame is lost until the user
widens the pane.

Adds a deferred repaint gated on display:none rather than on target-unknown,
preserving #13014's no-clip guarantee for a pane that later fits narrower.

Does NOT fix artificial-opencode-terminal-load.spec.ts:761. Two open concerns:
the repaint waits out the full 40-frame safe-fit retry budget (~1.3s) against
that spec's own 2s restore budget, and the cold-park reveal path at
pty-connection.ts:8222/8289 still has no deferral net.

Co-authored-by: Orca <help@stably.ai>

* fix(terminal): restore the deferred PTY input hop

Inlining forwardPtyInput into the onData handler dropped the
deps.deferPtyInput dispatch, leaving the callback and its type declared
and supplied but never called. Link-click mouse suppression silently
stopped withholding mouse-report frames, so a click in a mouse-tracking
TUI reached the PTY and the link-open fallback never fired.

Covers both branches, which had no test at all.

* fix(terminal): retain alt frames until final fit

* fix(terminal): preserve park intent across effect replay

* test(terminal): satisfy native static analysis

* fix(terminal): consume park intent after layout replay

* fix(terminal): re-read park intent instead of caching it per pane

useTerminalParkMountIntent cached park intent in a ref for the lifetime of
the component. The ref was never load-bearing: React finishes the render
phase before any effect runs, so StrictMode's passive-effect replay already
observes the same render-phase value. What makes the reveal work is reading
at render time rather than at connect time, which a plain call already does.

The ref did go stale. The pane lifecycle effect re-runs on [tabId, cwd] for
the same component instance, so a worktree path or startupCwd change
resupplied mountFollowsTerminalPark: true long after the park reveal, since
the post-replay consume clears the per-run ptyDeps copy and not the ref.
Each spurious run re-armed the pre-paint snapshot prefetch.

Also scope the post-replay consume behind a tested one-shot helper so splits
created after the restored-layout replay keep ordinary reconnect semantics,
and correct two comments that described the removed ref.

---------

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
(cherry picked from commit ef8eea51af)
This commit is contained in:
Neil
2026-08-13 22:43:24 -07:00
committed by Jinjing
parent a50d49936c
commit ae579b9ac6
11 changed files with 301 additions and 91 deletions
@@ -1562,6 +1562,7 @@ function TerminalPane(
worktreeId,
cwd,
startup: CODEX_ACCOUNT_RESTART_STARTUP,
mountFollowsTerminalPark: false,
paneTransportsRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
@@ -48,6 +48,9 @@ export type PtyConnectionDeps = {
} | null
restoredLeafId?: string | null
restoredPtyIdByLeafId?: Record<string, string>
/** Park intent sampled at render time, before the host disposes the tab's
* watchers; consumed once the restored layout has been replayed. */
mountFollowsTerminalPark: boolean
paneTransportsRef: React.RefObject<Map<number, PtyTransport>>
paneMode2031Ref: React.RefObject<Map<number, boolean>>
/** Per-pane mirror of the kitty keyboard flags the pane's application
@@ -62,18 +62,6 @@ async function drainFakeTimerWork(limit = 20): Promise<void> {
vi.clearAllTimers()
}
// Why: a reveal remount reads the still-live park watcher entry at connect time to
// tell itself apart from an in-place reattach; the host disposes it a beat later.
async function parkTabForReveal(tabId: string, ptyId: string, worktreeId = 'wt-1'): Promise<void> {
const { parkedWatchersByTabId } = await import('./terminal-parked-watcher-registry')
parkedWatchersByTabId.set(tabId, {
worktreeId,
tabPtyId: ptyId,
paneIdByPtyId: new Map([[ptyId, 1]]),
disposersByPtyId: new Map([[ptyId, () => {}]])
})
}
async function drainPendingTimeouts(pendingTimeouts: (() => void)[], limit = 100): Promise<void> {
let iterations = 0
while (pendingTimeouts.length > 0) {
@@ -624,6 +612,7 @@ function createDeps(overrides: Record<string, unknown> = {}) {
startup: null,
restoredLeafId: null,
restoredPtyIdByLeafId: {},
mountFollowsTerminalPark: false,
paneTransportsRef: { current: new Map() },
paneMode2031Ref: { current: new Map() },
paneKittyKeyboardModesRef: { current: new Map() },
@@ -3148,6 +3137,39 @@ describe('connectPanePty', () => {
expect(pane.terminal.write).toHaveBeenCalledWith(redraw, expect.any(Function))
})
it('routes terminal input through deferPtyInput so the host can withhold it', async () => {
// Regression: inlining forwardPtyInput into onData dropped this hop, silently disabling link-click mouse suppression.
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const transport = createMockTransport('pty-1')
transportFactoryQueue.push(transport)
const deferPtyInput = vi.fn()
connectPanePty(pane as never, createManager(1) as never, createDeps({ deferPtyInput }) as never)
await flushAsyncTicks()
sendTerminalInputThroughPane(pane, 'a')
expect(deferPtyInput).toHaveBeenCalledWith(1, 'a', expect.any(Function))
expect(transport.sendInput).not.toHaveBeenCalled()
const forward = deferPtyInput.mock.calls[0]?.[2] as (data: string) => void
forward('a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
})
it('forwards terminal input directly when the host supplies no deferPtyInput', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
const transport = createMockTransport('pty-1')
transportFactoryQueue.push(transport)
connectPanePty(pane as never, createManager(1) as never, createDeps() as never)
await flushAsyncTicks()
sendTerminalInputThroughPane(pane, 'a')
expect(transport.sendInput).toHaveBeenCalledWith('a')
})
it('does not let OpenTUI-style small ANSI redraw bursts monopolize foreground writes', async () => {
const { connectPanePty } = await import('./pty-connection')
const pane = createPane(1)
@@ -8758,7 +8780,7 @@ describe('connectPanePty', () => {
expect(writes.join('')).not.toContain('ALT-FRAME-BODY')
})
it('drops a live daemon alt frame until a hidden pane has a final grid', async () => {
it('keeps a daemon alt frame at its capture grid while the target grid is unknown', async () => {
const { connectPanePty } = await import('./pty-connection')
const transport = createMockTransport('tab-pty')
transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) =>
@@ -8796,7 +8818,8 @@ describe('connectPanePty', () => {
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0])
expect(writes.join('')).toContain('PREFIX-SCROLLBACK')
expect(writes.join('')).not.toContain('ALT-FRAME-BODY')
expect(writes.join('')).toContain('ALT-FRAME-BODY')
expect(writes.filter((write) => write.includes('ALT-FRAME-BODY'))).toHaveLength(1)
})
it('falls back to the merged daemon snapshot when split metadata is incomplete', async () => {
@@ -15446,9 +15469,8 @@ describe('connectPanePty', () => {
disposable.dispose()
})
it('pulses a skipped hidden alt frame when reveal keeps the snapshot grid', async () => {
it('keeps a hidden-output alt frame at the capture grid below the fit floor', async () => {
const { connectPanePty } = await import('./pty-connection')
const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops')
const transport = createMockTransport('pty-id')
let onData: ConnectCallbacks['onData']
transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
@@ -15470,41 +15492,36 @@ describe('connectPanePty', () => {
scrollbackAnsi: 'preserved history'
})
const pane = createPane(1)
let paneRect = createRect(0, 0)
pane.container.getBoundingClientRect = vi.fn(() => paneRect)
let xtermContainerDisplay = 'none'
const xtermContainer = new EventTarget() as HTMLElement
Object.defineProperty(xtermContainer, 'parentElement', { value: null })
Object.defineProperty(xtermContainer, 'ownerDocument', {
value: { defaultView: { getComputedStyle: () => ({ display: xtermContainerDisplay }) } }
pane.terminal.cols = 80
pane.terminal.rows = 24
const writes: string[] = []
pane.terminal.write = vi.fn((data: string, callback?: () => void) => {
writes.push(data)
callback?.()
})
;(pane as { xtermContainer?: HTMLElement }).xtermContainer = xtermContainer
pane.container.getBoundingClientRect = vi.fn(() => createRect(4, 3))
const deps = createDeps({ isVisibleRef: { current: false } })
const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never)
const binding = connectPanePty(pane as never, createManager(1) as never, deps as never)
await flushAsyncTicks(6)
onData?.(hidden, { seq: hidden.length, rawLength: hidden.length })
const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel')
_dispatchPtyModelRestoreNeededForTest({
id: 'pty-id',
reason: 'hidden-drop',
markerSeq: hidden.length + 1
})
;(deps.isVisibleRef as { current: boolean }).current = true
onData?.('x', { seq: hidden.length + 1, rawLength: 1 })
const { requestTerminalBacklogRecovery } =
await import('@/lib/pane-manager/pane-terminal-output-scheduler')
requestTerminalBacklogRecovery(pane.terminal as never)
await flushAsyncTicks(20)
const writes = (pane.terminal.write as ReturnType<typeof vi.fn>).mock.calls.map(
(call) => call[0]
)
expect(writes.join('')).not.toContain('TOO-WIDE-ALT-FRAME')
expect(writes.join('')).toContain('\x1b[?1004h\x1b[?25l')
expect(writes.join('')).toContain('TOO-WIDE-ALT-FRAME')
expect(writes.join('')).toContain('preserved history')
expect(writes.filter((write) => write.includes('TOO-WIDE-ALT-FRAME'))).toHaveLength(1)
expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40)
expect(transport.resize).not.toHaveBeenCalled()
xtermContainerDisplay = 'block'
paneRect = createRect(800, 600)
safeFit(pane as never)
await flushAsyncTicks(12)
const pulseStart = transport.resize.mock.calls.findIndex(
([cols, rows]) => cols === 119 && rows === 40
)
expect(pulseStart).toBeGreaterThanOrEqual(0)
expect(transport.resize.mock.calls[pulseStart + 1]).toEqual([120, 40])
disposable.dispose()
binding.dispose()
})
it('drains foreground output after a renderer-sourced hidden-backlog snapshot without seq', async () => {
@@ -21401,7 +21418,6 @@ describe('connectPanePty', () => {
alternateScreen: true,
scrollbackAnsi: 'PARKED-SSH-SCROLLBACK\r\n'
})
await parkTabForReveal('tab-1', sshPtyId)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] },
@@ -21419,6 +21435,7 @@ describe('connectPanePty', () => {
pane as never,
createManager(1) as never,
createDeps({
mountFollowsTerminalPark: true,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
}) as never
@@ -21464,7 +21481,6 @@ describe('connectPanePty', () => {
transportFactoryQueue.push(createMockTransport())
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise)
vi.mocked(window.api.ssh.connect).mockReturnValue(sshConnect.promise)
await parkTabForReveal('tab-1', sshPtyId)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId, generation: 7 }] },
@@ -21491,6 +21507,7 @@ describe('connectPanePty', () => {
pane as never,
createManager(1) as never,
createDeps({
mountFollowsTerminalPark: true,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
}) as never
@@ -21543,7 +21560,6 @@ describe('connectPanePty', () => {
seq: 125,
source: 'headless'
})
await parkTabForReveal('tab-1', foreignPtyId)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: foreignPtyId }] },
@@ -21560,6 +21576,7 @@ describe('connectPanePty', () => {
pane as never,
createManager(1) as never,
createDeps({
mountFollowsTerminalPark: true,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: foreignPtyId }
}) as never
@@ -21595,7 +21612,6 @@ describe('connectPanePty', () => {
})
transportFactoryQueue.push(transport)
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(snapshot.promise)
await parkTabForReveal('tab-1', sshPtyId)
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: sshPtyId }] },
@@ -21612,6 +21628,7 @@ describe('connectPanePty', () => {
pane as never,
createManager(1) as never,
createDeps({
mountFollowsTerminalPark: true,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
}) as never
@@ -21649,7 +21666,6 @@ describe('connectPanePty', () => {
source: 'headless'
})
transportFactoryQueue.push(transport)
await parkTabForReveal('tab-1', remotePtyId)
mockStoreState = {
...mockStoreState,
runtimeStatusByEnvironmentId: new Map([
@@ -21665,7 +21681,7 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps()
const deps = createDeps({ mountFollowsTerminalPark: true })
connectPanePty(pane as never, createManager(1) as never, deps as never)
for (let step = 0; step < 30; step += 1) {
@@ -21680,7 +21696,7 @@ describe('connectPanePty', () => {
expect(transport.getPtyId).toHaveReturnedWith(remotePtyId)
})
it('restores a local main-model snapshot after a contentless park reattach', async () => {
it('keeps a parked main-model alt frame at its capture grid while hidden', async () => {
const { connectPanePty } = await import('./pty-connection')
const localPtyId = 'global-floating-terminal@@terminal-1'
const transport = createMockTransport(localPtyId)
@@ -21691,13 +21707,15 @@ describe('connectPanePty', () => {
transportFactoryQueue.push(transport)
const getMainBufferSnapshot = vi.mocked(window.api.pty.getMainBufferSnapshot)
getMainBufferSnapshot.mockResolvedValue({
data: 'FLOATING-PARK-RESTORE-OK\r\n',
data: 'FLOATING-PARK-ALT-FRAME',
frameRestoreAnsi: '\x1b[?25l',
cols: 113,
rows: 32,
seq: 558,
source: 'headless'
source: 'headless',
alternateScreen: true,
scrollbackAnsi: 'FLOATING-PARK-HISTORY\r\n'
})
await parkTabForReveal('tab-1', localPtyId, 'global-floating-terminal')
mockStoreState = {
...mockStoreState,
tabsByWorktree: {
@@ -21715,8 +21733,10 @@ describe('connectPanePty', () => {
}
const pane = createPane(1)
pane.container.getBoundingClientRect = vi.fn(() => createRect(0, 0))
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
mountFollowsTerminalPark: true,
worktreeId: 'global-floating-terminal',
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: localPtyId }
@@ -21733,7 +21753,55 @@ describe('connectPanePty', () => {
expect.objectContaining({ sessionId: localPtyId })
)
expect(deps.syncPanePtyLayoutBinding).toHaveBeenCalledWith(1, localPtyId)
expect(writes.join('')).toContain('FLOATING-PARK-RESTORE-OK')
expect(writes.join('')).toContain('FLOATING-PARK-HISTORY')
expect(writes.join('')).toContain('FLOATING-PARK-ALT-FRAME')
expect(writes.filter((write) => write.includes('FLOATING-PARK-ALT-FRAME'))).toHaveLength(1)
expect(pane.terminal.resize).toHaveBeenCalledWith(113, 32)
})
it('restores a parked SSH alt frame after StrictMode watcher disposal', async () => {
const { connectPanePty } = await import('./pty-connection')
const sshPtyId = toAppSshPtyId('conn-1', 'relay-pty-static-alt')
const transport = createMockTransport(sshPtyId)
transport.connect.mockImplementation(async () => {
transport.getPtyId.mockReturnValue(sshPtyId)
return { id: sshPtyId, isReattach: true, replay: '' }
})
transportFactoryQueue.push(transport)
vi.mocked(window.api.pty.getMainBufferSnapshot).mockResolvedValue({
data: '\x1b[0m\x1b[?1049h\x1b[HSTATIC-SSH-ALT-FRAME',
frameRestoreAnsi: '\x1b[0m\x1b[?1049h\x1b[?6l\x1b[1;21H',
cols: 100,
rows: 30,
seq: 512,
source: 'headless',
alternateScreen: true,
scrollbackAnsi: 'PRESERVED-SSH-HISTORY\r\n'
})
mockStoreState = {
...mockStoreState,
tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] },
repos: [{ id: 'repo1', connectionId: 'conn-1' }],
deferredSshReconnectTargets: ['conn-1'],
deferredSshSessionIdsByTabId: { 'tab-1': sshPtyId }
}
const pane = createPane(1)
const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
mountFollowsTerminalPark: true,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
})
connectPanePty(pane as never, createManager(1) as never, deps as never)
for (let step = 0; step < 30; step += 1) {
parseCallbacks.shift()?.()
await flushAsyncTicks(2)
}
expect(window.api.pty.getMainBufferSnapshot).toHaveBeenCalledOnce()
expect(writes.join('')).toContain('PRESERVED-SSH-HISTORY')
expect(writes.filter((write) => write.includes('STATIC-SSH-ALT-FRAME'))).toHaveLength(1)
})
it('falls back to relay replay when the SSH model snapshot stalls', async () => {
@@ -21747,9 +21815,7 @@ describe('connectPanePty', () => {
})
transportFactoryQueue.push(transport)
vi.mocked(window.api.pty.getMainBufferSnapshot).mockReturnValue(new Promise(() => {}))
// Why parked: only a reveal remount probes main's model — the pane reads the
// still-live park watcher entry at connect time to tell the two apart.
await parkTabForReveal('tab-1', sshPtyId)
// Why parked: only a reveal remount probes main's model.
mockStoreState = {
...mockStoreState,
@@ -21762,6 +21828,7 @@ describe('connectPanePty', () => {
const pane = createPane(1)
const { writes } = captureCallbackTerminalWrites(pane)
const deps = createDeps({
mountFollowsTerminalPark: true,
restoredLeafId: LEAF_1,
restoredPtyIdByLeafId: { [LEAF_1]: sshPtyId }
})
@@ -280,7 +280,6 @@ import {
setCommandCodeDoneSettleExecutor
} from './command-code-done-settle'
import { canCommandCodeOutputOwnPane } from './command-code-output-ownership'
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
import {
getExecutionHostIdForWorktree,
getSettingsForWorktreeRuntimeOwner
@@ -1096,10 +1095,7 @@ export function connectPanePty(
// settles after remount must not remount its already-replaced successor.
const terminalRecoveryGeneration = captureTerminalPaneRecoveryGeneration(deps.tabId)
const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId)
// Why sampled here: the host disposes this tab's park watcher in the effect
// that follows this mount, so connect time is the only moment a pane can tell
// a reveal remount from an in-place reattach.
let mountFollowsTerminalPark = isTerminalTabParked(deps.tabId)
let mountFollowsTerminalPark = deps.mountFollowsTerminalPark
let authoritativeReattachGeneration = 0
exposeE2eTerminalPtyOutputDebug()
let disposed = false
@@ -7303,9 +7299,7 @@ export function connectPanePty(
skippedAltFrame =
snapshot.alternateScreen === true &&
snapshot.frameRestoreAnsi !== undefined &&
shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane), {
skipIfTargetUnknown: true
})
shouldSkipAltFrameForWidthMismatch(snapshot.cols, readProposedTerminalCols(pane))
for (const replayChunk of buildMainModelSnapshotReplayWrites(snapshot, {
skipAltFrame: skippedAltFrame
})) {
@@ -8256,8 +8250,7 @@ export function connectPanePty(
typeof snapshotFrameRestoreAnsi === 'string' &&
shouldSkipAltFrameForWidthMismatch(
connectResult.snapshotCols,
readProposedTerminalCols(pane),
{ skipIfTargetUnknown: true }
readProposedTerminalCols(pane)
)
writeReplayData(
daemonAltFrameSkippable
@@ -8326,10 +8319,7 @@ export function connectPanePty(
for (const replayChunk of buildMainModelSnapshotReplayWrites(modelSnapshot, {
skipAltFrame: shouldSkipAltFrameForWidthMismatch(
modelCols,
readProposedTerminalCols(pane),
{
skipIfTargetUnknown: true
}
readProposedTerminalCols(pane)
)
})) {
writeReplayData(replayChunk)
@@ -58,10 +58,10 @@ export function terminalWatcherLiveWorkspaceIds(workspaceIds: Iterable<string>):
}
/**
* Whether this tab is parked right now — the reveal remount's own mount effect
* runs before the host effect that disposes the watcher (child effects first),
* so a pane reading this at connect time can tell a park-reveal from an
* in-place reattach. Empty entries are pinned-close tombstones, not live parks.
* Whether this tab is parked right now — the reveal remount renders before the
* host effect that disposes the watcher, so a pane reading this at render time
* can tell a park-reveal from an in-place reattach. Empty entries are
* pinned-close tombstones, not live parks.
*/
export function isTerminalTabParked(tabId: string): boolean {
return (parkedWatchersByTabId.get(tabId)?.disposersByPtyId.size ?? 0) > 0
@@ -89,18 +89,35 @@ describe('shouldSkipAltFrameForWidthMismatch', () => {
expect(shouldSkipAltFrameForWidthMismatch(Number.NaN, 128)).toBe(false)
expect(shouldSkipAltFrameForWidthMismatch(Number.POSITIVE_INFINITY, 128)).toBe(false)
})
it('can conservatively skip a live frame until a hidden pane has a final grid', () => {
expect(shouldSkipAltFrameForWidthMismatch(135, undefined, { skipIfTargetUnknown: true })).toBe(
true
)
expect(
shouldSkipAltFrameForWidthMismatch(undefined, undefined, { skipIfTargetUnknown: true })
).toBe(false)
})
})
describe('buildMainModelSnapshotReplayWrites alt-frame skip', () => {
it('restores the exact capture-grid alt frame while the target grid is unknown', async () => {
const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 })
const snapshot = {
data: '\x1b[1;1HTOP---------\x1b[2;1HMIDDLE------\x1b[3;1HBOTTOM------',
frameRestoreAnsi: '\x1b[?25l',
alternateScreen: true,
scrollbackAnsi: 'history'
}
try {
const skipAltFrame = shouldSkipAltFrameForWidthMismatch(12, undefined)
for (const chunk of buildMainModelSnapshotReplayWrites(snapshot, { skipAltFrame })) {
await writeTerminal(terminal, chunk)
}
expect(
Array.from({ length: 3 }, (_, row) =>
terminal.buffer.active.getLine(row)?.translateToString(true)
)
).toEqual(['TOP---------', 'MIDDLE------', 'BOTTOM------'])
expect(terminal.buffer.normal.getLine(0)?.translateToString(true)).toBe('history')
} finally {
terminal.dispose()
}
})
it('keeps normal history and a clean alt grid through the real resize path', async () => {
const terminal = new Terminal({ cols: 12, rows: 5, scrollback: 20 })
const snapshot = {
@@ -35,8 +35,8 @@ export function resolvePositiveTerminalDimensions(
* The column count the post-replay fit will land on. Why not terminal.cols: a
* pane that has not been fitted yet still reads xterm's 80-column default, so
* comparing against it would drop frames whose width actually matches the
* container. Returns undefined when the pane cannot be measured; callers with
* a repaint owner can clear and defer, while offline preconnect paint cannot.
* container. Returns undefined when the pane cannot be measured, so replay can
* retain the frame at its capture grid until a final fit exists.
*/
export function readProposedTerminalCols(pane: ManagedPane): number | undefined {
return readProposedPaneFitDimensions(pane)?.cols
@@ -44,15 +44,14 @@ export function readProposedTerminalCols(pane: ManagedPane): number | undefined
export function shouldSkipAltFrameForWidthMismatch(
snapshotCols: number | undefined,
targetCols: number | undefined,
options: { skipIfTargetUnknown?: boolean } = {}
targetCols: number | undefined
): boolean {
if (typeof snapshotCols !== 'number' || !Number.isFinite(snapshotCols) || snapshotCols <= 0) {
return false
}
if (typeof targetCols !== 'number' || !Number.isFinite(targetCols) || targetCols <= 0) {
// A hidden pane has no final grid; its live app can repaint after the deferred fit.
return options.skipIfTargetUnknown === true
// Keep the frame at its capture grid until a real fit can replace that grid.
return false
}
// Fixed-grid alt rows clip at narrower columns; normal history remains reflowable.
return snapshotCols > targetCols
@@ -9,6 +9,7 @@ import {
resolvePaneLinkCwd,
resolvePaneSeedCwd,
resolveQueuedInitialCwd,
replayLayoutWithOneShotParkIntent,
resetTerminalKeyboardProtocolAfterInterrupt,
retireMountedTerminalPaneSurface,
shouldDetachPaneTransportOnUnmount,
@@ -248,6 +249,35 @@ describe('splitPaneWithOneShotStartup', () => {
})
})
describe('replayLayoutWithOneShotParkIntent', () => {
it('exposes park intent to replayed panes and clears it before later splits', () => {
const deps = { mountFollowsTerminalPark: true }
const observedByReplayedPane: boolean[] = []
const restored = replayLayoutWithOneShotParkIntent(deps, () => {
observedByReplayedPane.push(deps.mountFollowsTerminalPark)
return 'restored-panes'
})
expect(restored).toBe('restored-panes')
expect(observedByReplayedPane).toEqual([true])
// A split after replay reads the same deps object, so it must see ordinary reconnect semantics.
expect(deps.mountFollowsTerminalPark).toBe(false)
})
it('clears park intent even when layout replay throws', () => {
const deps = { mountFollowsTerminalPark: true }
expect(() =>
replayLayoutWithOneShotParkIntent(deps, () => {
throw new Error('replay failed')
})
).toThrow('replay failed')
expect(deps.mountFollowsTerminalPark).toBe(false)
})
})
describe('applyTerminalScrollbackRowsToMountedPanes', () => {
it('updates mounted pane xterm scrollback options only when needed', () => {
const firstOptions = { scrollback: 1_000 }
@@ -130,6 +130,7 @@ import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-i
import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent-settle'
import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers'
import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent'
import { e2eConfig } from '@/lib/e2e-config'
import {
PRIMARY_SELECTION_MAX_LENGTH,
@@ -483,6 +484,19 @@ export function splitPaneWithOneShotStartup<TPane>(
}
}
/** Scopes `deps.mountFollowsTerminalPark` to the restored-layout replay. */
export function replayLayoutWithOneShotParkIntent<TRestored>(
deps: { mountFollowsTerminalPark: boolean },
replayLayout: () => TRestored
): TRestored {
// Why: only panes reconstructed by this replay belong to the park reveal; later splits must use ordinary reconnect semantics.
try {
return replayLayout()
} finally {
deps.mountFollowsTerminalPark = false
}
}
export function shouldDetachPaneTransportOnUnmount(args: {
tabStillExists: boolean
tabId: string
@@ -665,6 +679,7 @@ export function useTerminalPaneLifecycle({
const systemPrefersDarkRef = useRef(systemPrefersDark)
systemPrefersDarkRef.current = systemPrefersDark
const previousVisibleForReconcileRef = useRef<TerminalPaneVisibilitySnapshot | null>(null)
const mountFollowsTerminalPark = useTerminalParkMountIntent(tabId)
const linkProviderDisposablesRef = useRef(new Map<number, IDisposable>())
const terminalHandleLinkDisposablesRef = useRef(new Map<number, IDisposable>())
const linkifierClickPrimingDisposablesRef = useRef(new Map<number, IDisposable>())
@@ -854,6 +869,7 @@ export function useTerminalPaneLifecycle({
worktreeId,
cwd: startupCwd,
startup: startupWithSetupSplitWait,
mountFollowsTerminalPark,
paneTransportsRef,
paneMode2031Ref,
paneKittyKeyboardModesRef,
@@ -1575,7 +1591,9 @@ export function useTerminalPaneLifecycle({
window.__paneManagers = window.__paneManagers ?? new Map()
window.__paneManagers.set(tabId, manager)
}
const restoredPaneByLeafId = replayTerminalLayout(manager, initialLayoutRef.current, isActive)
const restoredPaneByLeafId = replayLayoutWithOneShotParkIntent(ptyDeps, () =>
replayTerminalLayout(manager, initialLayoutRef.current, isActive)
)
const restoredBuffers = initialLayoutRef.current.buffersByLeafId
restoreScrollbackBuffers(
@@ -0,0 +1,76 @@
/** @vitest-environment happy-dom */
import { StrictMode, act, useEffect } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import { disposeParkedTabWatchers, parkedWatchersByTabId } from './terminal-parked-watcher-registry'
import { useTerminalParkMountIntent } from './use-terminal-park-mount-intent'
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const TAB_ID = 'strict-park-reveal'
const observations: boolean[] = []
function Child(): null {
const mountFollowsTerminalPark = useTerminalParkMountIntent(TAB_ID)
useEffect(() => {
observations.push(mountFollowsTerminalPark)
}, [mountFollowsTerminalPark])
return null
}
function Parent() {
useEffect(() => {
disposeParkedTabWatchers(TAB_ID)
}, [])
return <Child />
}
function parkTab(): void {
parkedWatchersByTabId.set(TAB_ID, {
worktreeId: 'wt-1',
tabPtyId: 'pty-1',
paneIdByPtyId: new Map([['pty-1', 1]]),
disposersByPtyId: new Map([['pty-1', () => {}]])
})
}
afterEach(() => {
observations.length = 0
disposeParkedTabWatchers(TAB_ID)
})
describe('useTerminalParkMountIntent', () => {
it('survives StrictMode effect replay after the parent disposes the watcher', () => {
parkTab()
const container = document.createElement('div')
const root = createRoot(container)
act(() => {
root.render(
<StrictMode>
<Parent />
</StrictMode>
)
})
expect(observations).toEqual([true, true])
act(() => root.unmount())
})
// Why: the lifecycle effect re-runs on cwd changes for the same component
// instance, so intent cached per instance would resupply a stale park reveal.
it('re-reads park intent when the same instance re-renders after disposal', () => {
parkTab()
const container = document.createElement('div')
const root = createRoot(container)
act(() => root.render(<Child />))
expect(observations).toEqual([true])
disposeParkedTabWatchers(TAB_ID)
act(() => root.render(<Child />))
expect(observations).toEqual([true, false])
act(() => root.unmount())
})
})
@@ -0,0 +1,9 @@
import { isTerminalTabParked } from './terminal-parked-watcher-registry'
// Why render time: the host effect that disposes the watcher runs after every
// child render, so reading here — not at connect time — is what lets a reveal
// remount still see the park. Caching it per instance would instead resupply a
// stale reveal on later same-instance effect re-runs (cwd change).
export function useTerminalParkMountIntent(tabId: string): boolean {
return isTerminalTabParked(tabId)
}