diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 7ef9762b5..4f0858128 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -6205,11 +6205,7 @@ describe('registerPtyHandlers', () => { paneKey })) as number await spawn() - const ready = spawnController.waitForRendererSerializer?.( - reusedPtyId, - priorGeneration, - 1_000 - ) + const ready = spawnController.waitForRendererSerializer?.(reusedPtyId, priorGeneration, 1_000) clearProviderPtyState(reusedPtyId) clearProviderPtyState(reusedPtyId) await handlers.get('pty:settlePaneSerializer')!(null, { paneKey, gen: secondGen }) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c08e72b63..3e7ba7e27 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -19311,13 +19311,10 @@ export class OrcaRuntimeService { timeoutMs?: number, signal?: AbortSignal ): Promise { - return this.ptyController?.waitForRendererSerializer?.( - ptyId, - afterGeneration, - timeoutMs, - signal - ) ?? + return ( + this.ptyController?.waitForRendererSerializer?.(ptyId, afterGeneration, timeoutMs, signal) ?? Promise.resolve(false) + ) } // Why: a leaf appears in the graph before its PTY spawns. If we issue a diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 862ead494..9d5e0b1fd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1411,7 +1411,13 @@ export type PreloadApi = { onClearBufferRequest: (callback: (data: { ptyId: string }) => void) => () => void sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ) => void declarePendingPaneSerializer: (paneKey: string) => Promise settlePaneSerializer: (paneKey: string, gen: number) => Promise diff --git a/src/preload/index.ts b/src/preload/index.ts index a77fbad0f..699853f6c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1142,7 +1142,13 @@ const api = { sendSerializedBuffer: ( requestId: string, - snapshot: { data: string; cols: number; rows: number; seq?: number; lastTitle?: string } | null + snapshot: { + data: string + cols: number + rows: number + seq?: number + lastTitle?: string + } | null ): void => { ipcRenderer.send('pty:serializeBuffer:response', { requestId, snapshot }) }, diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index f9a4e1fc4..975aad10f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -107,7 +107,9 @@ import { } from '../native-chat/native-chat-leaf-routing' import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability' import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { captureTerminalShutdownLayout } from './terminal-shutdown-layout-capture' import { getOverrideAffectedPanes, getPanesNeedingOverrideFit } from './override-affected-panes' import { @@ -517,15 +519,12 @@ export default function TerminalPane({ if (rect.width === 0 || rect.height === 0) { continue } - safeFit(pane) - const stuckAtMobile = - event.priorCols != null && - event.priorRows != null && - pane.terminal.cols === event.priorCols && - pane.terminal.rows === event.priorRows - if (stuckAtMobile && event.cols > 0 && event.rows > 0) { - pane.terminal.resize(event.cols, event.rows) - } + applyDesktopFitFallbackAfterReplay(pane, { + ...event, + // Why: the timeout/replay queue can outlive this pane binding; + // never apply old server dimensions to a replacement PTY. + shouldApply: () => getAffectedPanes().includes(pane) + }) } }) } @@ -1130,7 +1129,7 @@ export default function TerminalPane({ const clearPaneScrollback = useCallback( (pane: ManagedPane): void => { clearedScrollbackLeafIdsRef.current.add(pane.leafId) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) // Why: also clear the host buffer for remote-server panes, or the next // host snapshot replays the scrollback we just cleared locally. const ptyId = paneTransportsRef.current.get(pane.id)?.getPtyId() ?? null @@ -1884,26 +1883,27 @@ export default function TerminalPane({ return } for (const pane of manager.getPanes()) { - safeFit(pane) - const transport = paneTransportsRef.current.get(pane.id) - if (!transport?.isConnected()) { - continue - } - const ptyId = transport.getPtyId() - if (!ptyId) { - continue - } - // Why: match pty-connection resize guards so web refit retries do not - // forward SIGWINCH while mobile-lock or phone-fit overrides are active. - if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { - continue - } - // Why: skip forwarding a stale near-zero fit to the host PTY while the - // overlay is still settling after a worktree switch. - if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { - continue - } - transport.resize(pane.terminal.cols, pane.terminal.rows) + safeFitAndThen(pane, 'web-client-pty-resize', () => { + const transport = paneTransportsRef.current.get(pane.id) + if (!transport?.isConnected()) { + return + } + const ptyId = transport.getPtyId() + if (!ptyId) { + return + } + // Why: match pty-connection resize guards so web refit retries do not + // forward SIGWINCH while mobile-lock or phone-fit overrides are active. + if (getFitOverrideForPty(ptyId) || isPtyLocked(ptyId)) { + return + } + // Why: skip forwarding a stale near-zero fit to the host PTY while the + // overlay is still settling after a worktree switch. + if (pane.terminal.cols < 8 || pane.terminal.rows < 4) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) } } const scheduleFrame = (): void => { diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts new file mode 100644 index 000000000..3fef752e5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from 'vitest' +import { applyDesktopFitFallbackAfterReplay } from './desktop-fit-fallback' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +function createPane() { + const terminal = { + cols: 49, + rows: 20, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, + resize: vi.fn((cols: number, rows: number) => { + terminal.cols = cols + terminal.rows = rows + }) + } + return { + terminal, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + }, + fitAddon: { + proposeDimensions: vi.fn(() => null), + fit: vi.fn() + } + } +} + +describe('desktop fit fallback', () => { + it('waits until structural replay completes before direct resize', async () => { + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20 + }) + expect(pane.terminal.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).toHaveBeenCalledWith(120, 40) + }) + + it('drops deferred dimensions when the pane binding becomes stale', async () => { + const pane = createPane() + let isCurrent = true + beginTerminalScrollIntentBufferRebuild(pane.terminal) + applyDesktopFitFallbackAfterReplay(pane as never, { + cols: 120, + rows: 40, + priorCols: 49, + priorRows: 20, + shouldApply: () => isCurrent + }) + + isCurrent = false + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.terminal.resize).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts new file mode 100644 index 000000000..7bb5da4f4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/desktop-fit-fallback.ts @@ -0,0 +1,42 @@ +import type { ManagedPane } from '@/lib/pane-manager/pane-manager-types' +import { safeFit } from '@/lib/pane-manager/pane-fit' +import { deferTerminalGeometryMutationDuringRebuild } from '@/lib/pane-manager/terminal-scroll-intent-rebuild' + +type DesktopFitFallbackDimensions = { + cols: number + rows: number + priorCols?: number | null + priorRows?: number | null + shouldApply?: () => boolean +} + +export function applyDesktopFitFallbackAfterReplay( + pane: ManagedPane, + dimensions: DesktopFitFallbackDimensions +): void { + const applyFallback = (): void => { + if (dimensions.shouldApply?.() === false) { + return + } + safeFit(pane) + const stuckAtPriorGrid = + dimensions.priorCols != null && + dimensions.priorRows != null && + pane.terminal.cols === dimensions.priorCols && + pane.terminal.rows === dimensions.priorRows + if (stuckAtPriorGrid && dimensions.cols > 0 && dimensions.rows > 0) { + pane.terminal.resize(dimensions.cols, dimensions.rows) + } + } + // Why: the server dimensions are only a fallback; source-dimension replay + // must parse and restore its viewport before this can reflow xterm. + if ( + !deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'desktop-fit-fallback', + applyFallback + ) + ) { + applyFallback() + } +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 317742e68..2cfae6126 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -418,57 +418,65 @@ function createPane(paneId: number) { cursorY: 0, cursorX: 0 } + const terminal = { + cols: 120, + rows: 40, + element: {}, + buffer: { + active: activeBuffer + }, + modes: { + bracketedPasteMode: false, + sendFocusMode: false + }, + options: { + scrollback: 5_000, + ignoreBracketedPasteMode: false, + theme: { + foreground: '#eeeeee', + background: '#111111' + } + }, + write: vi.fn<(data: string, callback?: () => void) => void>(function write(...args): void { + const [data, callback] = args + if (data === '' || callback?.name === 'runParsedSteps') { + callback?.() + } + }), + resize: vi.fn(), + clear: vi.fn(), + scrollToBottom: vi.fn(() => { + activeBuffer.viewportY = activeBuffer.baseY + }), + scrollToLine: vi.fn((line: number) => { + activeBuffer.viewportY = line + }), + scrollLines: vi.fn((amount: number) => { + activeBuffer.viewportY = Math.max( + 0, + Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) + ) + }), + paste: vi.fn(), + onData: vi.fn(() => ({ dispose: vi.fn() })), + onResize: vi.fn(() => ({ dispose: vi.fn() })), + onRender: vi.fn((_listener: () => void) => ({ dispose: vi.fn() })), + onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), + hasSelection: vi.fn(() => false), + parser: { + registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), + registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) + } + } return { id: paneId, leafId, stablePaneId: leafId, - terminal: { - cols: 120, - rows: 40, - element: {}, - buffer: { - active: activeBuffer - }, - modes: { - bracketedPasteMode: false, - sendFocusMode: false - }, - options: { - scrollback: 5_000, - ignoreBracketedPasteMode: false, - theme: { - foreground: '#eeeeee', - background: '#111111' - } - }, - write: vi.fn(), - resize: vi.fn(), - clear: vi.fn(), - scrollToBottom: vi.fn(() => { - activeBuffer.viewportY = activeBuffer.baseY - }), - scrollToLine: vi.fn((line: number) => { - activeBuffer.viewportY = line - }), - scrollLines: vi.fn((amount: number) => { - activeBuffer.viewportY = Math.max( - 0, - Math.min(activeBuffer.baseY, activeBuffer.viewportY + amount) - ) - }), - paste: vi.fn(), - onData: vi.fn(() => ({ dispose: vi.fn() })), - onResize: vi.fn(() => ({ dispose: vi.fn() })), - onTitleChange: vi.fn(() => ({ dispose: vi.fn() })), - hasSelection: vi.fn(() => false), - parser: { - registerCsiHandler: vi.fn(() => ({ dispose: vi.fn() })), - registerOscHandler: vi.fn(() => ({ dispose: vi.fn() })) - } - }, + terminal, container: createPaneContainer(), fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: terminal.cols, rows: terminal.rows })) } } } @@ -4934,7 +4942,10 @@ describe('connectPanePty', () => { const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) @@ -4962,23 +4973,33 @@ describe('connectPanePty', () => { connectPanePty(pane as never, manager as never, deps as never) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() }) it('clears pre-hook launch config when an Orca-started command exits', async () => { + vi.useFakeTimers({ toFake: ['setTimeout'] }) const { connectPanePty } = await import('./pty-connection') + vi.mocked(window.api.pty.confirmForegroundProcess).mockResolvedValue('zsh') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } const transport = createMockTransport() + let currentPtyId: string | null = null + vi.mocked(transport.getPtyId).mockImplementation(() => currentPtyId) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + currentPtyId = 'pty-local-1' capturedDataCallback.current = callbacks.onData ?? null return 'pty-local-1' }) transportFactoryQueue.push(transport) const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] }, + ptyIdsByTabId: { 'tab-1': [] } + } as StoreState connectPanePty( createPane(1) as never, @@ -4991,7 +5012,8 @@ describe('connectPanePty', () => { agentEnv: {} }, launchAgent: 'codex' - } + }, + restoredPtyIdByLeafId: {} }) as never ) @@ -5004,7 +5026,9 @@ describe('connectPanePty', () => { expect.objectContaining({ agentType: 'codex' }) ) capturedDataCallback.current?.('\x1b]133;D;130\x07thebr ~/repo $ ') - await flushAsyncTicks(1) + await flushAsyncTicks() + await vi.advanceTimersByTimeAsync(350) + await flushAsyncTicks() expect(mockStoreState.clearAgentLaunchConfig).toHaveBeenCalledWith(paneKey) expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() @@ -6023,7 +6047,7 @@ describe('connectPanePty', () => { it('resets reattach renderer state after daemon snapshot replay without applying the full mode reset', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004hrestored snapshot' } @@ -6073,7 +6097,7 @@ describe('connectPanePty', () => { // the snapshot bytes, so a remote pane whose size drifted from the daemon's // grid still repaints the exact host layout. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6121,13 +6145,301 @@ describe('connectPanePty', () => { expect(resizeToSnapshotCall as number).toBeLessThan(snapshotWriteCall as number) }) + it('waits for a recovered destination fit before forwarding its grid or live output', async () => { + const { connectPanePty } = await import('./pty-connection') + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const transport = createMockTransport('tab-pty') + let deliverLiveData = (_data: string): void => {} + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (callbacks?.onData) { + deliverLiveData = callbacks.onData + } + return sessionId + ? { + id: sessionId, + snapshot: 'source-grid snapshot', + snapshotCols: 80, + snapshotRows: 24 + } + : null + } + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + const { parseCallbacks, writes } = captureCallbackTerminalWrites(pane) + const signalPty = window.api.pty.signal as unknown as ReturnType + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + transport.resize.mockClear() + signalPty.mockClear() + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(transport.resize).not.toHaveBeenCalled() + expect(signalPty).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + + deliverLiveData('live-after-snapshot') + await flushAsyncTicks(4) + expect(writes.join('')).not.toContain('live-after-snapshot') + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(12) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(signalPty).toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(writes.join('')).toContain('live-after-snapshot') + }) + + it('restores a pinned viewport only after a same-size reattach snapshot finishes parsing', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => + sessionId + ? { + id: sessionId, + snapshot: 'same-size authoritative snapshot', + snapshotCols: 120, + snapshotRows: 40 + } + : null + ) + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: 'tab-pty' }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.baseY = 100 + pane.terminal.buffer.active.viewportY = 80 + markTerminalPinnedViewport(pane.terminal) + const { parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + + // Model xterm's native pinned state after clear + replay: the old line + // number is no longer meaningful, but the old distance from bottom is. + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 0 + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(180) + markTerminalFollowOutput(pane.terminal) + }) + + it('does not apply a queued reattach snapshot after the PTY is replaced', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ + id: string + snapshot: string + }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ sessionId, callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + if (!sessionId) { + return null + } + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'stale authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeReplacement = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('stale authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeReplacement) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + }) + + it('does not apply a queued reattach snapshot after its PTY exits', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('tab-pty') + const reattachResult = createDeferred<{ id: string; snapshot: string }>() + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks?: ConnectCallbacks }) => { + replayCallback.current = callbacks?.onReplayData ?? null + return reattachResult.promise + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + replayCallback.current?.('blocking replay') + await flushAsyncTicks(12) + reattachResult.resolve({ id: 'tab-pty', snapshot: 'dead authoritative snapshot' }) + await flushAsyncTicks(12) + const resizeCallsBeforeExit = transport.resize.mock.calls.length + vi.mocked(transport.getPtyId).mockReturnValue(null) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('dead authoritative snapshot') + expect(transport.resize).toHaveBeenCalledTimes(resizeCallsBeforeExit) + expect(window.api.pty.signal).not.toHaveBeenCalledWith('tab-pty', 'SIGWINCH') + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + }) + + it('drops stale callbacks but delivers fresh replacement output after an old replay clear', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind, markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('tab-pty') + const oldResult = createDeferred() + const oldCallbacks: { current: ConnectCallbacks | null } = { current: null } + const replacementCallbacks: { current: ConnectCallbacks | null } = { current: null } + let connectCount = 0 + transport.connect.mockImplementation( + async ({ callbacks }: { sessionId?: string; callbacks?: ConnectCallbacks }) => { + connectCount += 1 + if (connectCount === 1) { + oldCallbacks.current = callbacks ?? null + return oldResult.promise + } + vi.mocked(transport.getPtyId).mockReturnValue('replacement-pty') + replacementCallbacks.current = callbacks ?? null + return 'replacement-pty' + } + ) + transportFactoryQueue.push(transport) + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 80 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ + restoredLeafId: LEAF_1, + restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } + }) + + connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(8) + oldCallbacks.current?.onReplayData?.('old replay') + await flushAsyncTicks(8) + parseCallbacks.shift()?.() + await flushAsyncTicks(8) + oldCallbacks.current?.onError?.('SSH_SESSION_EXPIRED: tab-pty') + oldResult.resolve(undefined) + await flushAsyncTicks(20) + expect(replacementCallbacks.current).not.toBeNull() + + replacementCallbacks.current?.onData?.('B-PROMPT') + oldCallbacks.current?.onData?.('STALE-A') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(writes).toContain('B-PROMPT') + expect(writes).not.toContain('STALE-A') + expect(pane.terminal.scrollToBottom).toHaveBeenCalled() + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + }) + + it('retries a fresh-spawn native follow reset when renderer dimensions return', async () => { + const { connectPanePty } = await import('./pty-connection') + const { getTerminalScrollIntentKind } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('fresh-pty') + transportFactoryQueue.push(transport) + mockStoreState = { + ...mockStoreState, + tabsByWorktree: { 'wt-1': [{ id: 'tab-1', ptyId: null }] } + } as StoreState + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 40 + pane.terminal.buffer.active.baseY = 100 + const renderListener: { current: (() => void) | null } = { current: null } + const renderDisposable = { dispose: vi.fn() } + pane.terminal.onRender = vi.fn((listener: () => void) => { + renderListener.current = listener + return renderDisposable + }) + vi.mocked(pane.terminal.scrollToBottom) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation(() => { + pane.terminal.buffer.active.viewportY = pane.terminal.buffer.active.baseY + }) + + connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(pane.terminal.buffer.active.viewportY).toBe(40) + expect(renderListener.current).not.toBeNull() + + renderListener.current?.() + expect(pane.terminal.buffer.active.viewportY).toBe(100) + expect(pane.terminal.scrollToBottom).toHaveBeenCalledTimes(2) + expect(getTerminalScrollIntentKind(pane.terminal)).toBe('followOutput') + expect(renderDisposable.dispose).toHaveBeenCalledTimes(1) + }) + it('writes the daemon pendingEscapeTailAnsi after the reset on local reattach (#7329)', async () => { // Why: the mid-escape tail must be re-armed LAST — after the reattach reset, // whose ESC would abort it — so the racing live continuation completes it // instead of rendering literally. Covers the local daemon reattach path, // which previously dropped the field the remote path already honored. const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6269,7 +6581,7 @@ describe('connectPanePty', () => { it('preserves live modes and injects focus-in after focused agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6311,7 +6623,7 @@ describe('connectPanePty', () => { it('keeps ?25h in the live agent reattach reset when the snapshot leaves the cursor visible', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { // A mid-frame snapshot cut after the TUI re-showed its cursor. @@ -6344,7 +6656,7 @@ describe('connectPanePty', () => { it('does not inject focus-in after reattach when the terminal does not own DOM focus', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lrestored cursor snapshot' } @@ -6379,7 +6691,7 @@ describe('connectPanePty', () => { it('resets stale focus and cursor modes for a focused non-agent shell reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6416,7 +6728,7 @@ describe('connectPanePty', () => { it('does not treat persisted tab launchAgent metadata as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6464,7 +6776,7 @@ describe('connectPanePty', () => { it('does not treat an agent-name token in a shell title as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { id: sessionId, snapshot: '\x1b[?1004h\x1b[?25lstale shell snapshot' } @@ -6503,7 +6815,7 @@ describe('connectPanePty', () => { it('does not treat ordinary shell scrollback mentioning Cursor Agent as a live agent reattach', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6596,7 +6908,7 @@ describe('connectPanePty', () => { // authoritative source and wins by precedence. it('paints only the daemon snapshot when reattach result includes both snapshot and replay', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -6631,7 +6943,7 @@ describe('connectPanePty', () => { it('paints only relay replay when reattach result has replay and coldRestore but no snapshot', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('tab-pty') transport.connect.mockImplementation(async ({ sessionId }: { sessionId?: string }) => { if (sessionId) { return { @@ -7872,7 +8184,7 @@ describe('connectPanePty', () => { ) transportFactoryQueue.push(transport) const pane = createPane(1) - const { writes } = captureCallbackTerminalWrites(pane) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) const deps = createDeps({ restoredLeafId: LEAF_1, restoredPtyIdByLeafId: { [LEAF_1]: 'tab-pty' } @@ -7882,8 +8194,14 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) const snapshotIndex = writes.indexOf('authoritative-snapshot') - const liveIndex = writes.indexOf('post-snapshot-live') expect(snapshotIndex).toBeGreaterThanOrEqual(0) + expect(writes).not.toContain('post-snapshot-live') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(2) + } + await flushAsyncTicks(8) + const liveIndex = writes.indexOf('post-snapshot-live') expect(liveIndex).toBeGreaterThan(snapshotIndex) }) @@ -8059,6 +8377,49 @@ describe('connectPanePty', () => { ) }) + it('holds post-snapshot live bytes until a hidden restore can fit the destination grid', async () => { + enableMainAuthority() + const { safeFit } = await import('@/lib/pane-manager/pane-tree-ops') + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'source-grid hidden snapshot\r\n', + cols: 80, + rows: 24, + seq: 64 + }) + pane.fitAddon.proposeDimensions = vi.fn(() => undefined) as never + transport.resize.mockClear() + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(transport.resize).not.toHaveBeenCalled() + dataCallback('live-after-hidden', { seq: 81, rawLength: 17 }) + await flushAsyncTicks(6) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).not.toContain( + 'live-after-hidden' + ) + + pane.fitAddon.proposeDimensions = vi.fn(() => ({ cols: 120, rows: 40 })) as never + safeFit(pane as never) + await flushAsyncTicks(20) + + expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(pane.terminal.write.mock.calls.map((call) => String(call[0])).join('')).toContain( + 'live-after-hidden' + ) + }) + it('kicks pane recovery when reveal finds the write pipeline certified dead', async () => { // The 2026-07-13 fossil-pane incident shape: bytes drop while hidden, // the pipeline is certified dead, and certification's own recovery @@ -8488,6 +8849,17 @@ describe('connectPanePty', () => { it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = await startInFlightRestore() + const { markTerminalPinnedViewport } = + await import('@/lib/pane-manager/terminal-scroll-intent') + const parseCallbacks: (() => void)[] = [] + pane.terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + markTerminalPinnedViewport(pane.terminal) // Flood while the snapshot is in flight: overflows the 512KB restore // queue — the live stream is outrunning snapshot fetch+replay. @@ -8501,6 +8873,15 @@ describe('connectPanePty', () => { // Cut 1: the overflow abandons the restore instead of re-fetching. expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + for (const callback of parseCallbacks.splice(0)) { + callback() + } + // The post-replay fit is part of the transaction now; let its promise + // settle before asserting that overflow abandonment owns later bytes. + await flushAsyncTicks(20) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(142) // Cut 2: drop sentinels and seq-gap chunks during the flood window // must not re-arm restores — the post-gap bytes write through. @@ -10315,7 +10696,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10401,7 +10786,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: false }, @@ -10477,7 +10866,11 @@ describe('connectPanePty', () => { const pane = createPane(1) pane.terminal.cols = 80 pane.terminal.rows = 8 - const { writes } = captureCallbackTerminalWrites(pane) + const writes: string[] = [] + pane.terminal.write = vi.fn((data: string, callback?: () => void) => { + writes.push(data) + callback?.() + }) const manager = createManager(1) const deps = createDeps({ isVisibleRef: { current: true }, @@ -11735,6 +12128,120 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('cancels a delayed snapshot scroll restore when the pane binding is disposed', async () => { + const { connectPanePty } = await import('./pty-connection') + const { isTerminalScrollIntentRebuildInFlight } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + pane.terminal.scrollToLine.mockClear() + disposable.dispose() + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(true) + + pane.terminal.buffer.active.baseY = 200 + pane.terminal.buffer.active.viewportY = 200 + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + expect(isTerminalScrollIntentRebuildInFlight(pane.terminal)).toBe(false) + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('does not apply a delayed snapshot restore after newer user intent', async () => { + const { connectPanePty } = await import('./pty-connection') + const { markTerminalFollowOutput } = await import('@/lib/pane-manager/terminal-scroll-intent') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible-after\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: 'snapshot-state\r\n', + cols: 100, + rows: 30, + seq: hidden.length + live.length + }) + + const pane = createPane(1) + pane.terminal.buffer.active.viewportY = 42 + pane.terminal.buffer.active.baseY = 100 + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const deps = createDeps({ isVisibleRef: { current: false } }) + const disposable = connectPanePty(pane as never, createManager(1) as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(live, { + seq: hidden.length + live.length, + rawLength: live.length + }) + await flushAsyncTicks(20) + + expect(writes).toContain('snapshot-state\r\n') + pane.terminal.buffer.active.viewportY = 200 + pane.terminal.buffer.active.baseY = 200 + markTerminalFollowOutput(pane.terminal) + pane.terminal.scrollToLine.mockClear() + for (const callback of parseCallbacks) { + callback() + } + await flushAsyncTicks() + + // Why: replay completion must not overwrite scroll intent recorded while + // xterm was still parsing the restored snapshot. + expect(pane.terminal.scrollToLine).not.toHaveBeenCalled() + disposable.dispose() + }) + it('does not signal SIGWINCH after hidden-backlog snapshot replay when dimensions are unchanged', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12347,7 +12854,7 @@ describe('connectPanePty', () => { capturedReplayCallback.current?.('first replay') capturedReplayCallback.current?.('second replay') - await flushAsyncTicks(2) + await flushAsyncTicks(6) expect(pane.terminal.write).toHaveBeenCalledTimes(1) expect(pane.terminal.write).toHaveBeenNthCalledWith( @@ -12368,6 +12875,116 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('holds newer live bytes until a later replay frame has fully parsed', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-live-order') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'remote:env-1@@terminal-live-order' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + expect(writes).toEqual(['\x1b[2J\x1b[3J\x1b[H']) + + callbacksRef.data?.('NEWER-LIVE\r\n') + expect(writes).not.toContain('NEWER-LIVE\r\n') + for (let index = 0; index < 12 && parseCallbacks.length > 0; index += 1) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(8) + + const replayIndex = writes.indexOf('authoritative replay') + const resetIndex = writes.indexOf(POST_REPLAY_REATTACH_RESET) + const liveIndex = writes.indexOf('NEWER-LIVE\r\n') + expect(replayIndex).toBeGreaterThan(0) + expect(resetIndex).toBeGreaterThan(replayIndex) + expect(liveIndex).toBeGreaterThan(resetIndex) + binding.dispose() + }) + + it('drops a queued relay replay instead of retagging it for a replacement PTY', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-old') + const replayCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + replayCallback.current = callbacks.onReplayData ?? null + return 'remote:env-1@@terminal-old' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + + replayCallback.current?.('blocking replay') + await flushAsyncTicks(8) + replayCallback.current?.('stale queued replay') + vi.mocked(transport.getPtyId).mockReturnValue('remote:env-1@@terminal-replacement') + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(12) + + expect(writes).not.toContain('blocking replay') + expect(writes).not.toContain('stale queued replay') + binding.dispose() + }) + + it('requests snapshot recovery for one oversized live frame deferred by replay', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-large-live') + const callbacksRef: { + replay: ((data: string) => void) | null + data: ((data: string) => void) | null + } = { replay: null, data: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + callbacksRef.replay = callbacks.onReplayData ?? null + callbacksRef.data = callbacks.onData ?? null + return 'pty-large-live' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const { writes, parseCallbacks } = captureCallbackTerminalWrites(pane) + const binding = connectPanePty(pane as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks(8) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue(null) + getMainBufferSnapshot.mockClear() + + callbacksRef.replay?.('authoritative replay') + await flushAsyncTicks(8) + const oversizedLiveFrame = 'L'.repeat(512 * 1024 + 1) + callbacksRef.data?.(oversizedLiveFrame) + while (parseCallbacks.length > 0) { + parseCallbacks.shift()?.() + await flushAsyncTicks(4) + } + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-large-live', { + scrollbackRows: 5000 + }) + expect(writes.some((write) => write.startsWith('L'))).toBe(false) + binding.dispose() + }) + it('does not switch renderers for Arabic output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() @@ -13610,12 +14227,10 @@ describe('connectPanePty', () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() let remoteCallbacks: ConnectCallbacks | undefined - transport.attach.mockImplementation( - ({ callbacks }: { callbacks?: ConnectCallbacks }) => { - transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') - remoteCallbacks = callbacks - } - ) + transport.attach.mockImplementation(({ callbacks }: { callbacks?: ConnectCallbacks }) => { + transport.getPtyId.mockReturnValue('remote:env-1@@terminal-delayed') + remoteCallbacks = callbacks + }) transportFactoryQueue.push(transport) mockStoreState = { ...mockStoreState, @@ -15876,7 +16491,7 @@ describe('connectPanePty', () => { it('replays attach buffer for deferred SSH reattach and clears stale tab session metadata', async () => { const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport() + const transport = createMockTransport('leaf-session') transport.connect.mockImplementation(async (opts: { sessionId?: string }) => { const id = opts.sessionId ?? 'pty-new' transport.getPtyId.mockReturnValue(id) @@ -18620,7 +19235,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() // xterm is 120x40 (createPane default), PTY reports 80x24 → re-assert. - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('does not fit during visibility-resume reassertion', async () => { @@ -18645,7 +19260,7 @@ describe('connectPanePty', () => { await flushAsyncTicks() expect(fit).not.toHaveBeenCalled() - expect(transport.resize).toHaveBeenCalledWith(120, 40) + expect(transport.resize).toHaveBeenCalledWith(120, 40, { claim: true }) }) it('re-asserts after observed pane geometry changes while visible', async () => { @@ -18863,6 +19478,61 @@ describe('connectPanePty', () => { } }) + it('defers a remote-desktop viewport claim until structural replay completes', async () => { + const originalDocument = globalThis.document + ;(globalThis as { document?: Document }).document = { + visibilityState: 'visible', + hasFocus: vi.fn(() => true) + } as unknown as Document + globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => { + queueMicrotask(() => callback(0)) + return 1 + }) + const { setFitOverride } = await import('@/lib/pane-manager/mobile-fit-overrides') + const { beginTerminalScrollIntentBufferRebuild, endTerminalScrollIntentBufferRebuild } = + await import('@/lib/pane-manager/terminal-scroll-intent-rebuild') + const pane = createPane(2) + const observer = installObservedPane(pane) + try { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + let proposedGrid = { cols: 120, rows: 40 } + pane.fitAddon = { + ...pane.fitAddon, + proposeDimensions: vi.fn(() => proposedGrid) + } as never + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks() + observer.trigger() + await flushAsyncTicks() + setFitOverride('pty-pane-2', 'remote-desktop-fit', 80, 24) + proposedGrid = { cols: 70, rows: 30 } + vi.mocked(pane.terminal.resize).mockClear() + transport.resize.mockClear() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + observer.trigger() + await flushAsyncTicks() + expect(pane.terminal.resize).not.toHaveBeenCalled() + expect(transport.resize).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await flushAsyncTicks() + expect(pane.terminal.resize).toHaveBeenCalledWith(70, 30) + expect(transport.resize).toHaveBeenCalledWith(70, 30, { claim: true }) + } finally { + setFitOverride('pty-pane-2', 'desktop-fit', 0, 0) + observer.restore() + globalThis.document = originalDocument + } + }) + it('skips observed desktop reassertion while mobile owns the PTY without a fit override', async () => { const { setDriverForPty } = await import('@/lib/pane-manager/mobile-driver-state') const pane = createPane(2) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 351fc5b5e..a833ea24f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -41,7 +41,12 @@ import { type HasPty } from './terminal-dead-session-reconcile' import type { PtyConnectionDeps } from './pty-connection-types' -import { safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from '@/lib/pane-manager/pane-tree-ops' import { requestStablePaneFit } from '@/lib/pane-manager/pane-fit-resize-observer' import { getFitOverrideForPty, bindPanePtyId } from '@/lib/pane-manager/mobile-fit-overrides' import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' @@ -49,7 +54,12 @@ import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty import { shouldClaimRemoteDesktopViewport } from './remote-desktop-viewport-claim' import { getAppliedSizeReadE2eDelayMs } from './pty-applied-size-read-e2e-delay' import { createPtySizeReassertion } from './pty-size-reassertion' -import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isPaneReplaying, + replayIntoTerminal, + replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed +} from './replay-guard' import { isTerminalWritePipelineCertifiedDead, registerUndeliverableWriteHandler @@ -119,10 +129,16 @@ import { } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' +import { clearTerminalScrollbackAndFollowOutput } from '@/lib/pane-manager/terminal-scrollback-clear' import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent + getTerminalScrollIntentKind, + markTerminalFollowOutput } from '@/lib/pane-manager/terminal-scroll-intent' +import { + cancelTerminalScrollIntentBufferRebuildCompletions, + deferTerminalGeometryMutationDuringRebuild +} from '@/lib/pane-manager/terminal-scroll-intent-rebuild' +import { createTerminalStructuralReplayCoordinator } from '@/lib/pane-manager/terminal-structural-replay-coordinator' import { createBrowserUuid } from '@/lib/browser-uuid' import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id' import { @@ -963,6 +979,7 @@ export function connectPanePty( const terminalRecoveryInstance = registerTerminalPaneRecoveryInstance(deps.tabId) exposeE2eTerminalPtyOutputDebug() let disposed = false + const structuralReplayCoordinator = createTerminalStructuralReplayCoordinator(pane.terminal) let connectFrame: number | null = null let connectFallbackTimer: ReturnType | null = null let startupGridSettleHandle: TerminalStartupGridSettleHandle | null = null @@ -970,6 +987,10 @@ export function connectPanePty( let connectStarted = false let unregisterBacklogRecovery: (() => void) | null = null let unregisterDocumentVisibilityRecovery: (() => void) | null = null + let cancelHiddenOutputSnapshotScrollRestore = (): void => {} + let pendingHiddenSnapshotFit: SafeFitContinuationHandle | null = null + let pendingReattachFit: SafeFitContinuationHandle | null = null + let cancelFreshSpawnFollowReset = (): void => {} let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} @@ -3166,8 +3187,13 @@ export function connectPanePty( const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY let hasReceivedPtyOutput = false - let deferredReattachLiveData: { data: string; meta?: PtyDataMeta }[] | null = null + let deferredReattachLiveData: + | { data: string; ptyId: string | null; streamGeneration: number; meta?: PtyDataMeta }[] + | null = null let deferredReattachLiveDataChars = 0 + let reattachLiveDataDeferralDepth = 0 + let deferredReattachLiveDataOwners = new Map() + let transportStreamGeneration = 0 const MAX_DEFERRED_REATTACH_LIVE_CHARS = 512 * 1024 const MAX_DEFERRED_REATTACH_LIVE_CHUNKS = 1_024 const markTerminalInputSent = (): void => { @@ -3633,7 +3659,7 @@ export function connectPanePty( getPtyId: () => transport.getPtyId(), isRemotePtyId: isRemoteRuntimePtyId, shouldSuppressDesktopResize: () => shouldSuppressDesktopPtyResize(), - fit: () => safeFit(pane), + fitAndRun: (continuation) => safeFitAndThen(pane, 'pty-size-reassertion', continuation), getTerminalDimensions: () => ({ cols: pane.terminal.cols, rows: pane.terminal.rows }), getAppliedSize: async (ptyId) => { // Why: e2e seam — delays the read past the reveal fit to reproduce the @@ -3716,6 +3742,18 @@ export function connectPanePty( let pendingPaneGeometryChanged = false const handleObservedPaneGeometry = (): void => { pendingGeometryReportRaf = null + if (disposed) { + return + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'observed-pane-geometry', + handleObservedPaneGeometry + ) + ) { + return + } const paneGeometryChanged = pendingPaneGeometryChanged pendingPaneGeometryChanged = false const currentPtyId = transport.getPtyId() @@ -3846,7 +3884,9 @@ export function connectPanePty( // the grid stabilizes. While hidden it keeps watching for a late settle. isAuthoritative: () => isRendererPtyResizeAuthoritative(), measure: () => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -3883,7 +3923,9 @@ export function connectPanePty( } } const measureStartupGrid = (): { cols: number; rows: number } | null => { - safeFit(pane) + if (!safeFit(pane)) { + return null + } const cols = pane.terminal.cols const rows = pane.terminal.rows return cols > 0 && rows > 0 ? { cols, rows } : null @@ -4036,7 +4078,7 @@ export function connectPanePty( () => { clearHiddenOutputRestoreState() discardTerminalOutput(pane.terminal) - pane.terminal.clear() + clearTerminalScrollbackAndFollowOutput(pane.terminal) } ) const unregisterTitleSource = registerPtyTitleSource(ptyId, (handler) => @@ -4464,12 +4506,60 @@ export function connectPanePty( }, 50) } + let freshSpawnFollowResetDisposables: IDisposable[] = [] + cancelFreshSpawnFollowReset = (): void => { + for (const disposable of freshSpawnFollowResetDisposables) { + disposable.dispose() + } + freshSpawnFollowResetDisposables = [] + } + const resetFreshSpawnFollowOutput = (): void => { + cancelFreshSpawnFollowReset() + markTerminalFollowOutput(pane.terminal) + let nativeFollowResetComplete = false + const tryResetNativeFollow = (): void => { + if ( + disposed || + getTerminalScrollIntentKind(pane.terminal) !== 'followOutput' || + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + 'fresh-spawn-follow-reset', + tryResetNativeFollow + ) + ) { + return + } + try { + pane.terminal.scrollToBottom() + nativeFollowResetComplete = true + cancelFreshSpawnFollowReset() + } catch (err) { + if (!(err instanceof TypeError && /dimensions/.test(err.message))) { + cancelFreshSpawnFollowReset() + throw err + } + } + } + tryResetNativeFollow() + if (!nativeFollowResetComplete) { + // Why: xterm's browser viewport can reject scrolling while its renderer + // is detached; the first render/resize is the earliest safe native retry. + freshSpawnFollowResetDisposables = [ + pane.terminal.onRender(tryResetNativeFollow), + pane.terminal.onResize(tryResetNativeFollow) + ] + } + } + const startFreshSpawn = ( startupOverride?: PendingStartupCommand | null, options: FreshSpawnOptions = {} ): Promise => { clearPaneMode2031State() clearHiddenOutputRestoreState() + // Why: a canceled old replay clear can preserve xterm's native + // isUserScrolling flag. A replacement shell must start in follow mode. + resetFreshSpawnFollowOutput() // Why: a fresh spawn is a new process with kitty keyboard flags at // zero. The exit-handler reset alone is not enough: a late exit from a // replaced PTY takes the stale-transport early return and skips it, so @@ -4497,6 +4587,7 @@ export function connectPanePty( : window.api.pty.declarePendingPaneSerializer(cacheKey).catch(() => null) transportConnectInFlightSince = Date.now() + const outputCallbacks = captureTransportOutputCallbacks(reportError) const spawnedRaw = transport.connect({ url: '', cols, @@ -4509,12 +4600,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(spawnedRaw) @@ -4524,6 +4610,13 @@ export function connectPanePty( }) const trackedPromise: Promise = Promise.resolve(spawnedRaw) .then(async (spawnedPtyId) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return null + } const resolvedPtyId = spawnedPtyId && typeof spawnedPtyId === 'object' && 'id' in spawnedPtyId ? spawnedPtyId.id @@ -4578,10 +4671,7 @@ export function connectPanePty( reconcilePtySizeAfterSpawn(resolvedPtyId, cols, rows) } const gen = await preSignalPromise - if ( - resolvedPtyId && - (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId)) - ) { + if (resolvedPtyId && (typeof gen === 'number' || isRemoteRuntimePtyId(resolvedPtyId))) { if (!isRemoteRuntimePtyId(resolvedPtyId) || !hasPtySerializer(resolvedPtyId)) { registerPaneSerializerFor(resolvedPtyId) } @@ -4790,10 +4880,18 @@ export function connectPanePty( writeFreshShellViewportBlanking() } - const sendFocusedReattachFocusInAfterReplay = (): void => { + const sendFocusedReattachFocusInAfterReplay = ( + expectedPtyId: string | null = transport.getPtyId(), + expectedStreamGeneration = transportStreamGeneration + ): void => { const scheduledGeneration = reattachReplayPayloadSignalGeneration void waitForTerminalOutputParsed(pane.terminal).then(() => { - if (disposed) { + const currentPtyId = transport.getPtyId() + if ( + disposed || + expectedStreamGeneration !== transportStreamGeneration || + currentPtyId !== expectedPtyId + ) { return } // Why: a newer replay frame owns the judgment; its own post-parse @@ -4835,20 +4933,53 @@ export function connectPanePty( type PendingReplayData = { data: string clearBeforeReplay: boolean + ptyId: string | null + generation: number + streamGeneration: number pendingEscapeTailAnsi?: string } let pendingReplayData: PendingReplayData | null = null + let replayPayloadGeneration = 0 let replayDrainQueued = false - const drainReplayDataQueue = async (): Promise => { + const drainReplayDataQueue = async ( + expectedPtyId: string | null, + expectedStreamGeneration: number + ): Promise => { + let appliedCurrentPayload = false while (pendingReplayData !== null) { - const { data, clearBeforeReplay, pendingEscapeTailAnsi } = pendingReplayData + if ( + pendingReplayData.ptyId !== expectedPtyId || + pendingReplayData.streamGeneration !== expectedStreamGeneration + ) { + return false + } + if ( + transport.getPtyId() !== expectedPtyId || + transportStreamGeneration !== expectedStreamGeneration + ) { + pendingReplayData = null + return false + } + const payload = pendingReplayData + const { data, clearBeforeReplay, pendingEscapeTailAnsi } = payload pendingReplayData = null + const isCurrentPayload = (): boolean => + !disposed && + payload.generation === replayPayloadGeneration && + payload.streamGeneration === transportStreamGeneration && + transport.getPtyId() === payload.ptyId + if (!isCurrentPayload()) { + continue + } // Relay replay buffers may overlap with content already rendered in // xterm. Local eager replay decides this earlier so metadata-only frames // can keep restored scrollback while still using the replay guard. if (clearBeforeReplay) { await writeReplayDataAsync('\x1b[2J\x1b[3J\x1b[H') + if (!isCurrentPayload()) { + continue + } } if (clearBeforeReplay || data.length > 0) { // Why: an empty clearing frame is still an authoritative repaint and @@ -4861,9 +4992,15 @@ export function connectPanePty( // apply as sets to keep the mirrored stack from accumulating frames. kittyKeyboardModes.scanReplay(data) await writeReplayDataAsync(data) + if (!isCurrentPayload()) { + continue + } if (clearBeforeReplay || data.length > 0) { await writeReplayDataAsync(reattachReplayResetSequence(data)) - sendFocusedReattachFocusInAfterReplay() + if (!isCurrentPayload()) { + continue + } + sendFocusedReattachFocusInAfterReplay(payload.ptyId, payload.streamGeneration) } // Why: the daemon could not serialize a PTY read that ended mid-escape, // so the emulator shipped the dangling partial separately. Write it LAST @@ -4873,43 +5010,113 @@ export function connectPanePty( if (pendingEscapeTailAnsi) { await writeReplayDataAsync(pendingEscapeTailAnsi) } - if (disposed) { - pendingReplayData = null - return + if (!isCurrentPayload()) { + continue } // Why: remote-runtime snapshots can arrive after WebGL attached to an // empty buffer; rebuilding after replay parses seeds the glyph atlas // from the now-populated xterm state. manager.rebuildPaneWebgl(pane.id) + appliedCurrentPayload = true } + return appliedCurrentPayload + } + const scheduleReplayDataDrain = (): void => { + if (replayDrainQueued) { + return + } + const scheduledPtyId = pendingReplayData?.ptyId ?? null + replayDrainQueued = true + // Why: live bytes are newer than the authoritative replay frame. Hold + // them until clear + replay + reset have all parsed, or replay can erase them. + const scheduledStreamGeneration = + pendingReplayData?.streamGeneration ?? transportStreamGeneration + beginReattachLiveDataDeferral(scheduledStreamGeneration) + let replayCompleted = false + replayWriteQueue = replayWriteQueue + .catch(() => undefined) + .then(() => + structuralReplayCoordinator.run( + async () => { + replayCompleted = await drainReplayDataQueue( + scheduledPtyId, + scheduledStreamGeneration + ) + }, + { + shouldRestore: () => + !disposed && + transport.getPtyId() === scheduledPtyId && + transportStreamGeneration === scheduledStreamGeneration + } + ) + ) + .then(() => { + replayCompleted &&= !disposed && transport.getPtyId() === scheduledPtyId + }) + .finally(() => { + replayDrainQueued = false + if (pendingReplayData !== null) { + // Why: preserve the PTY identity captured when the callback fired; + // re-reading it here could retag stale bytes for a replacement PTY. + scheduleReplayDataDrain() + } + finishReattachLiveDataDeferral(replayCompleted, scheduledStreamGeneration) + }) } const replayDataCallback = ( data: string, - meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {} + meta: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } = {}, + streamGeneration = transportStreamGeneration ): void => { pendingReplayData = { data, clearBeforeReplay: meta.clearBeforeReplay !== false, + ptyId: transport.getPtyId(), + generation: (replayPayloadGeneration += 1), + streamGeneration, ...(meta.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: meta.pendingEscapeTailAnsi } : {}) } - if (replayDrainQueued) { - return - } - replayDrainQueued = true - replayWriteQueue = replayWriteQueue - .catch(() => undefined) - .then(drainReplayDataQueue) - .finally(() => { - replayDrainQueued = false - if (pendingReplayData !== null) { - replayDataCallback(pendingReplayData.data, { - clearBeforeReplay: pendingReplayData.clearBeforeReplay, - ...(pendingReplayData.pendingEscapeTailAnsi - ? { pendingEscapeTailAnsi: pendingReplayData.pendingEscapeTailAnsi } - : {}) - }) + scheduleReplayDataDrain() + } + + const captureTransportOutputCallbacks = (onError: (message: string) => void) => { + // Why: a new stream generation cannot inherit an old replay's pending + // destination-grid fit or keep its live-data waiter open. + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + pendingReattachFit?.cancel() + pendingReattachFit = null + const generation = (transportStreamGeneration += 1) + const isCurrent = (): boolean => !disposed && generation === transportStreamGeneration + return { + generation, + callbacks: { + onConnect: (): void => { + if (isCurrent()) { + reportRemoteRendererSerializerReady() + } + }, + onData: (data: string, meta?: PtyDataMeta): void => { + if (isCurrent()) { + dataCallback(data, meta, generation) + } + }, + onReplayData: ( + data: string, + meta?: { clearBeforeReplay?: boolean; pendingEscapeTailAnsi?: string } + ): void => { + if (isCurrent()) { + replayDataCallback(data, meta, generation) + } + }, + onError: (message: string): void => { + if (isCurrent()) { + onError(message) + } } - }) + } + } } type PendingHiddenOutputRestoreChunk = { @@ -4929,6 +5136,12 @@ export function connectPanePty( let hiddenOutputRestoreDeferredRetryTimer: ReturnType | null = null let hiddenOutputRestoreForegroundDeadlineTimer: ReturnType | null = null let hiddenOutputRestoreDeferredRetryAttempts = 0 + let hiddenOutputSnapshotScrollRestore: { + ptyId: string | null + generation: number + valid: boolean + started: boolean + } | null = null // Why: hidden recovery state belongs to one PTY stream. Reattach/restart // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null @@ -6035,6 +6248,14 @@ export function connectPanePty( : hiddenOutputRestorePendingChunks.slice() const hadPendingOverflow = hiddenOutputRestorePendingOverflow hiddenOutputRestoreGeneration += 1 + if ( + hiddenOutputSnapshotScrollRestore?.valid && + hiddenOutputSnapshotScrollRestore.ptyId === expectedPtyId + ) { + // Why: same-PTY flood abandonment stops recovery bookkeeping, but its + // already-queued replay must keep the rebuild bracket and final pin. + hiddenOutputSnapshotScrollRestore.generation = hiddenOutputRestoreGeneration + } hiddenOutputRestoreInFlight = null hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null @@ -6099,6 +6320,7 @@ export function connectPanePty( } function clearHiddenOutputRestoreState(): void { + cancelSnapshotScrollRestore() clearPendingLiveChunksDuringRestore() hiddenStartupRendererQueryPending = '' hiddenRendererStateDirty = false @@ -6108,6 +6330,23 @@ export function connectPanePty( hiddenOutputRestoreGeneration += 1 } + function cancelSnapshotScrollRestore(): void { + pendingHiddenSnapshotFit?.cancel() + pendingHiddenSnapshotFit = null + const scrollRestore = hiddenOutputSnapshotScrollRestore + if (!scrollRestore) { + return + } + scrollRestore.valid = false + hiddenOutputSnapshotScrollRestore = null + if (scrollRestore.started) { + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + } + // Why: invalidation suppresses restoration, but queued bytes still own + // the bracket until their FIFO sentinels prove parsing has finished. + } + cancelHiddenOutputSnapshotScrollRestore = cancelSnapshotScrollRestore + function clearPaneMode2031State(): void { deps.paneMode2031Ref.current.delete(pane.id) deps.paneLastThemeModeRef.current.delete(pane.id) @@ -6174,7 +6413,7 @@ export function connectPanePty( }) } - function applyMainBufferSnapshot(snapshot: { + async function applyMainBufferSnapshot(snapshot: { data: string cols: number rows: number @@ -6182,8 +6421,19 @@ export function connectPanePty( alternateScreen?: boolean scrollbackAnsi?: string pendingEscapeTailAnsi?: string - }): void { - const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) + }): Promise { + const restorePtyId = transport.getPtyId() + const restoreGeneration = hiddenOutputRestoreGeneration + if (hiddenOutputSnapshotScrollRestore) { + cancelSnapshotScrollRestore() + } + const scrollRestore = { + ptyId: restorePtyId, + generation: restoreGeneration, + valid: true, + started: false + } + hiddenOutputSnapshotScrollRestore = scrollRestore const colsBeforeReplay = pane.terminal.cols const rowsBeforeReplay = pane.terminal.rows const hasSnapshotDimensions = @@ -6191,83 +6441,135 @@ export function connectPanePty( Number.isFinite(snapshot.rows) && snapshot.cols > 0 && snapshot.rows > 0 - discardTerminalOutput(pane.terminal) - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) - ) { - // Why: serialized terminal snapshots encode layout at their source - // dimensions. Replay at those dimensions first, then fit back below. - // This xterm-only resize must not SIGWINCH the live TUI. - suppressSnapshotReplayPtyResize = true - try { - pane.terminal.resize(snapshot.cols, snapshot.rows) - } finally { - suppressSnapshotReplayPtyResize = false - } - } - if (!snapshot.alternateScreen) { - // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen - // TUIs (Claude Code, vim) keep their scroll history in xterm, so - // clearing on restore loses scroll-up after a hidden->visible return. - // Mirrors the attach-time guard in pty-transport.ts. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - } else if (snapshot.scrollbackAnsi !== undefined) { - // Why: SerializeAddon captures normal and alternate buffers together. - // Rebuild normal while it is active, then return to a clean alt frame. - writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') - writeReplayData(snapshot.scrollbackAnsi) - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } else { - // Why: the snapshot's own ?1049h is a no-op when the pane is already on - // the alternate screen, and the serialized frame skips blank cells — so - // without clearing the alt screen the pre-hide frame bleeds through - // every cell the final frame leaves blank. \x1b[2J on the alt buffer - // does not touch the normal buffer's scrollback the TUI returns to. - writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') - } - writeReplayData(snapshot.data) - // Why: status/title-corroborated live agents own ?25l/?1004h (a forced - // ?1004l here would silence focus events until the agent restarts, since - // agents only enable focus reporting at startup). - writeReplayData( - hasLiveAgentReattachStatusOrTitleSignal() - ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET - : POST_REPLAY_LIVE_SNAPSHOT_RESET - ) - if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was taken with main's emulator mid-escape; - // re-arming the dangling sequence must be the FINAL replay write (any - // later ESC — including the reset above — aborts it) so the racing - // live tail's continuation completes it exactly as live, instead of - // rendering literally (Bug E fix / #7329). - writeReplayData(snapshot.pendingEscapeTailAnsi) - } - hiddenRendererStateDirty = false - recordRendererOrderedSeq(snapshot) - resetHiddenRendererRiskState() - recordTerminalOutput(pane.terminal) - const currentPtyId = transport.getPtyId() - if (currentPtyId && !getFitOverrideForPty(currentPtyId)) { - safeFit(pane) - const replayChangedDimensions = hasSnapshotDimensions - ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows - : pane.terminal.cols !== colsBeforeReplay || pane.terminal.rows !== rowsBeforeReplay - if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { - transport.resize(pane.terminal.cols, pane.terminal.rows) - if (!isRemoteRuntimePtyId(currentPtyId)) { - // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild - // their internal scroll viewport to the top on tab return. - window.api.pty.signal(currentPtyId, 'SIGWINCH') + try { + await structuralReplayCoordinator.run( + async () => { + if ( + !scrollRestore.valid || + disposed || + transport.getPtyId() !== scrollRestore.ptyId || + hiddenOutputRestoreGeneration !== scrollRestore.generation + ) { + return + } + scrollRestore.started = true + discardTerminalOutput(pane.terminal) + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows) + ) { + // Why: xterm parses writes later. Keep snapshot dimensions until + // the FIFO sentinel completes so serialized wraps stay exact. + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshot.cols, snapshot.rows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + if (!snapshot.alternateScreen) { + // Why: this clear (incl. \x1b[3J) wipes xterm's scrollback. Alt-screen + // TUIs (Claude Code, vim) keep their scroll history in xterm, so + // clearing on restore loses scroll-up after a hidden->visible return. + // Mirrors the attach-time guard in pty-transport.ts. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } else { + // Why: the snapshot's own ?1049h is a no-op when the pane is already on + // the alternate screen, and the serialized frame skips blank cells — so + // without clearing the alt screen the pre-hide frame bleeds through + // every cell the final frame leaves blank. \x1b[2J on the alt buffer + // does not touch the normal buffer's scrollback the TUI returns to. + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + } + writeReplayData(snapshot.data) + // Why: status/title-corroborated live agents own ?25l/?1004h (a forced + // ?1004l here would silence focus events until the agent restarts, since + // agents only enable focus reporting at startup). + writeReplayData( + hasLiveAgentReattachStatusOrTitleSignal() + ? POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET + : POST_REPLAY_LIVE_SNAPSHOT_RESET + ) + if (snapshot.pendingEscapeTailAnsi) { + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). + writeReplayData(snapshot.pendingEscapeTailAnsi) + } + hiddenRendererStateDirty = false + recordRendererOrderedSeq(snapshot) + resetHiddenRendererRiskState() + recordTerminalOutput(pane.terminal) + await waitForTerminalReplayWritesParsed(pane.terminal) + }, + { + shouldRestore: () => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation, + afterRestore: async () => { + const isCurrentRestore = (): boolean => + scrollRestore.valid && + !disposed && + transport.getPtyId() === scrollRestore.ptyId && + hiddenOutputRestoreGeneration === scrollRestore.generation + if (!isCurrentRestore()) { + return + } + const currentPtyId = transport.getPtyId() + if (!currentPtyId || getFitOverrideForPty(currentPtyId)) { + return + } + const fit = safeFitAndThen( + pane, + 'hidden-snapshot-pty-resize', + () => { + if (!isCurrentRestore() || transport.getPtyId() !== currentPtyId) { + return + } + const replayChangedDimensions = hasSnapshotDimensions + ? pane.terminal.cols !== snapshot.cols || pane.terminal.rows !== snapshot.rows + : pane.terminal.cols !== colsBeforeReplay || + pane.terminal.rows !== rowsBeforeReplay + if (replayChangedDimensions && isRendererPtyResizeAuthoritative()) { + transport.resize(pane.terminal.cols, pane.terminal.rows) + if (!isRemoteRuntimePtyId(currentPtyId)) { + // Why: redundant SIGWINCH can make alternate-screen TUIs rebuild + // their internal scroll viewport to the top on tab return. + window.api.pty.signal(currentPtyId, 'SIGWINCH') + } + } + }, + { shouldContinue: isCurrentRestore } + ) + pendingHiddenSnapshotFit = fit + try { + await fit.completion + } finally { + if (pendingHiddenSnapshotFit === fit) { + pendingHiddenSnapshotFit = null + } + } + if (isCurrentRestore()) { + scheduleReattachIdleAgentCursorReset() + } + } } + ) + } finally { + if (hiddenOutputSnapshotScrollRestore === scrollRestore) { + hiddenOutputSnapshotScrollRestore = null } - scheduleReattachIdleAgentCursorReset() } - // Why: snapshot replay clears and rebuilds xterm state; re-apply the - // user's scroll intent once so hidden catch-up cannot repin the viewport. - // Restore by bottom offset — the rebuilt buffer renumbers every row, so - // the pre-replay absolute viewport line points at arbitrary content. - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' }) } function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean { @@ -6404,7 +6706,15 @@ export function connectPanePty( } hiddenOutputRestoreDeferredRetryAttempts = 0 restoreIterations += 1 - applyMainBufferSnapshot(snapshot) + await applyMainBufferSnapshot(snapshot) + if ( + disposed || + hiddenOutputRestoreGeneration !== restoreGeneration || + hiddenOutputRestorePtyId !== currentPtyId || + transport.getPtyId() !== currentPtyId + ) { + return + } // Why: everything at or before snapshot.seq is now painted; chunks // still draining from main's ACK backlog below that point are // duplicates the dataCallback reconciliation must suppress. @@ -6509,14 +6819,41 @@ export function connectPanePty( } } - const dataCallback = (data: string, meta?: PtyDataMeta): void => { + const dataCallback = ( + data: string, + meta?: PtyDataMeta, + streamGeneration = transportStreamGeneration + ): void => { + if (streamGeneration !== transportStreamGeneration) { + return + } if (deferredReattachLiveData !== null) { - deferredReattachLiveData.push({ data, ...(meta ? { meta } : {}) }) - deferredReattachLiveDataChars += data.length - let dropped = false + // Why: a replacement stream must not inherit either bytes or a gap + // marker from the replay owner it superseded. + deferredReattachLiveData = deferredReattachLiveData.filter( + (chunk) => chunk.streamGeneration === streamGeneration + ) + deferredReattachLiveDataChars = deferredReattachLiveData.reduce( + (total, chunk) => total + chunk.data.length, + 0 + ) + const oversized = data.length > MAX_DEFERRED_REATTACH_LIVE_CHARS + const deferredData = oversized ? data.slice(-MAX_DEFERRED_REATTACH_LIVE_CHARS) : data + deferredReattachLiveData.push({ + data: deferredData, + ptyId: transport.getPtyId(), + streamGeneration, + ...(meta ? { meta } : {}) + }) + deferredReattachLiveDataChars += deferredData.length + // Why: retaining one arbitrarily large IPC frame would bypass this + // queue's memory bound. Mark it as a stream gap so snapshot recovery + // replaces it instead of feeding a partial ANSI frame to xterm. + let dropped = oversized while ( - deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || - deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS + deferredReattachLiveData.length > 1 && + (deferredReattachLiveData.length > MAX_DEFERRED_REATTACH_LIVE_CHUNKS || + deferredReattachLiveDataChars > MAX_DEFERRED_REATTACH_LIVE_CHARS) ) { const removed = deferredReattachLiveData.shift() deferredReattachLiveDataChars -= removed?.data.length ?? 0 @@ -6719,34 +7056,72 @@ export function connectPanePty( } }) - const beginReattachLiveDataDeferral = (): void => { - deferredReattachLiveData = [] - deferredReattachLiveDataChars = 0 + const beginReattachLiveDataDeferral = (ownerGeneration = transportStreamGeneration): void => { + reattachLiveDataDeferralDepth += 1 + if (reattachLiveDataDeferralDepth === 1) { + deferredReattachLiveData = [] + deferredReattachLiveDataChars = 0 + deferredReattachLiveDataOwners = new Map() + } + if (!deferredReattachLiveDataOwners.has(ownerGeneration)) { + deferredReattachLiveDataOwners.set(ownerGeneration, { failed: false }) + } } - const finishReattachLiveDataDeferral = (deliver: boolean): void => { + const finishReattachLiveDataDeferral = ( + deliver: boolean, + acceptedGeneration = transportStreamGeneration + ): void => { + if (reattachLiveDataDeferralDepth <= 0) { + return + } + if (!deliver) { + const owner = deferredReattachLiveDataOwners.get(acceptedGeneration) + if (owner) { + owner.failed = true + } + } + reattachLiveDataDeferralDepth -= 1 + if (reattachLiveDataDeferralDepth > 0) { + return + } const chunks = deferredReattachLiveData deferredReattachLiveData = null deferredReattachLiveDataChars = 0 - if (!deliver || disposed || !chunks) { + const currentPtyId = transport.getPtyId() + const currentGeneration = transportStreamGeneration + const currentOwner = deferredReattachLiveDataOwners.get(currentGeneration) + deferredReattachLiveDataOwners = new Map() + if (disposed || !chunks) { return } // Why: createOrAttach snapshots precede bytes emitted before its IPC // reply. Paint the authoritative replay first, then admit those live // chunks so the replay clear cannot erase newer output. for (const chunk of chunks) { - dataCallback(chunk.data, chunk.meta) + if ( + chunk.ptyId !== currentPtyId || + chunk.streamGeneration !== currentGeneration || + currentOwner?.failed === true + ) { + continue + } + dataCallback(chunk.data, chunk.meta, chunk.streamGeneration) } } - const handleReattachResult = ( + const handleReattachResult = async ( result: PtyConnectResult | string | void, staleSessionId?: string | null, - coldRestoreStartup?: ColdRestoreAgentResumeStartup | null - ): boolean => { + coldRestoreStartup?: ColdRestoreAgentResumeStartup | null, + attemptGeneration = transportStreamGeneration + ): Promise => { if (disposed) { return false } + if (attemptGeneration !== transportStreamGeneration) { + return false + } const connectResult = result && typeof result === 'object' && 'id' in result ? (result as PtyConnectResult) : null @@ -6807,6 +7182,15 @@ export function connectPanePty( }) return false } + const isCurrentReattachPayload = (): boolean => { + const currentPtyId = transport.getPtyId() + return ( + !disposed && attemptGeneration === transportStreamGeneration && currentPtyId === ptyId + ) + } + if (!isCurrentReattachPayload()) { + return false + } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) registerSideEffectFactConsumerForPty(ptyId) @@ -6832,125 +7216,179 @@ export function connectPanePty( // newer than disk-recorded scrollback. If we ever return all three, // the daemon and relay are by definition tracking the same session // and only the freshest source belongs on screen. - if (connectResult?.snapshot) { - rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) - // Why: the daemon serializes its grid with soft-wrapped lines as - // continuous text. Replaying that at a different column count rewraps - // rows one cell early/late (bug #7279). Replay at the snapshot's own - // dimensions first; safeFit below fits the pane back and resizes the - // remote PTY. Suppress the xterm->PTY forward so this layout-only - // resize does not SIGWINCH the live remote TUI. Mirrors - // applyMainBufferSnapshot. - const snapshotCols = connectResult.snapshotCols - const snapshotRows = connectResult.snapshotRows - const hasSnapshotDimensions = - typeof snapshotCols === 'number' && - typeof snapshotRows === 'number' && - Number.isFinite(snapshotCols) && - Number.isFinite(snapshotRows) && - snapshotCols > 0 && - snapshotRows > 0 - if ( - hasSnapshotDimensions && - (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) - ) { - suppressSnapshotReplayPtyResize = true + const hasStructuralReplay = Boolean( + connectResult?.snapshot || connectResult?.replay || connectResult?.coldRestore + ) + let reattachPayloadApplied = !hasStructuralReplay + const applyReattachPayload = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + if (connectResult?.snapshot) { + rememberReattachPayloadAgentSignal(connectResult.snapshot, { fullScreenReplay: true }) + // Why: the daemon serializes its grid with soft-wrapped lines as + // continuous text. Replaying that at a different column count rewraps + // rows one cell early/late (bug #7279). Replay at the snapshot's own + // dimensions first; safeFit below fits the pane back and resizes the + // remote PTY. Suppress the xterm->PTY forward so this layout-only + // resize does not SIGWINCH the live remote TUI. Mirrors + // applyMainBufferSnapshot. + const snapshotCols = connectResult.snapshotCols + const snapshotRows = connectResult.snapshotRows + const hasSnapshotDimensions = + typeof snapshotCols === 'number' && + typeof snapshotRows === 'number' && + Number.isFinite(snapshotCols) && + Number.isFinite(snapshotRows) && + snapshotCols > 0 && + snapshotRows > 0 + if ( + hasSnapshotDimensions && + (pane.terminal.cols !== snapshotCols || pane.terminal.rows !== snapshotRows) + ) { + suppressSnapshotReplayPtyResize = true + try { + pane.terminal.resize(snapshotCols, snapshotRows) + } finally { + suppressSnapshotReplayPtyResize = false + } + } + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: the daemon snapshot's rehydrate preamble carries the live + // session's kitty keyboard flags; re-arm the mirror from it so Option + // chords keep their kitty encoding after a window reload. + kittyKeyboardModes.scanReplay(connectResult.snapshot) + writeReplayData(connectResult.snapshot) + // Snapshot reattach keeps a live session, so avoid the broader mode + // reset. We only drop renderer-owned state that should not leak from + // replay bytes into the restored renderer terminal. + writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) + if (connectResult.pendingEscapeTailAnsi) { + // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the + // reset (whose ESC would abort it) so the racing live continuation + // completes it instead of rendering literally (#7329). + writeReplayData(connectResult.pendingEscapeTailAnsi) + } + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + // Snapshot superseded the cold-restore payload — ack it so the + // daemon does not redeliver it on the next reattach. + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.replay) { + rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) + // Relay replay holds the last 100 KB of raw output. The xterm may + // already hold pre-disconnect content; clear first to avoid + // duplication. The reattach reset clears renderer-owned state without + // tearing down the still-running TUI's live modes. + writeReplayData('\x1b[2J\x1b[3J\x1b[H') + // Why: raw relay replay contains the application's own kitty pushes + // when they fall inside the retained window; re-arm the mirror with + // replay (set) semantics so redelivery cannot grow the stack. + kittyKeyboardModes.scanReplay(connectResult.replay) + writeReplayData(connectResult.replay) + writeReplayData(reattachReplayResetSequence(connectResult.replay)) + sendFocusedReattachFocusInAfterReplay(ptyId, attemptGeneration) + if (connectResult.coldRestore) { + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + } + } else if (connectResult?.coldRestore) { + // replayIntoTerminal: the recorded scrollback is raw PTY output that + // may contain query sequences the previous agent CLI emitted; + // writing them through xterm.write would trigger auto-replies that + // land in the new shell's stdin. See replay-guard.ts. + writeReplayData(connectResult.coldRestore.scrollback) + const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() + const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) + if (didPrepareResume) { + if (preparedStartup?.hasSleepingRecord) { + showSessionRestoredBanner() + } + clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) + } + // Cold-restore means the daemon lost the session and spawned a + // fresh shell — no TUI is consuming the mode-setting bytes that a + // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so + // reset them to match the fresh shell's expectations. + writeReplayData(POST_REPLAY_MODE_RESET) + // Why: the dead run's scrollback was never scanned, and any kitty + // flags it pushed died with it — the fresh shell starts at zero. + kittyKeyboardModes.reset() + consumeRestoredViewportBlankingMarker() + writeFreshShellViewportBlanking() + if (!isRemoteRuntimePtyId(ptyId)) { + window.api.pty.ackColdRestore(ptyId) + } + if (didPrepareResume && !coldRestoreStartup) { + schedulePendingStartupCommandDelivery() + } + } + if (hasStructuralReplay) { + await waitForTerminalReplayWritesParsed(pane.terminal) + if (!isCurrentReattachPayload()) { + return + } + reattachPayloadApplied = true + } + } + + const fitAfterReattachRestore = async (): Promise => { + if (!isCurrentReattachPayload()) { + return + } + const reattachPtyId = transport.getPtyId() + if (!reattachPtyId) { + return + } + if (!getFitOverrideForPty(reattachPtyId)) { + const fit = safeFitAndThen( + pane, + 'reattach-pty-resize', + () => { + if (!isCurrentReattachPayload() || transport.getPtyId() !== reattachPtyId) { + return + } + const reattachCols = pane.terminal.cols + const reattachRows = pane.terminal.rows + if (reattachCols > 0 && reattachRows > 0) { + transport.resize(reattachCols, reattachRows) + } + // Why: POSIX only delivers SIGWINCH when terminal dimensions actually + // change. Sending it explicitly guarantees restored TUIs repaint at + // the correct cursor position after snapshot replay. + if (!isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') + } + }, + { shouldContinue: isCurrentReattachPayload } + ) + pendingReattachFit = fit try { - pane.terminal.resize(snapshotCols, snapshotRows) + await fit.completion } finally { - suppressSnapshotReplayPtyResize = false + if (pendingReattachFit === fit) { + pendingReattachFit = null + } } - } - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: the daemon snapshot's rehydrate preamble carries the live - // session's kitty keyboard flags; re-arm the mirror from it so Option - // chords keep their kitty encoding after a window reload. - kittyKeyboardModes.scanReplay(connectResult.snapshot) - writeReplayData(connectResult.snapshot) - // Snapshot reattach keeps a live session, so avoid the broader mode - // reset. We only drop renderer-owned state that should not leak from - // replay bytes into the restored renderer terminal. - writeReplayData(reattachReplayResetSequence(connectResult.snapshot)) - if (connectResult.pendingEscapeTailAnsi) { - // Why last: re-arm the daemon's dangling mid-escape sequence AFTER the - // reset (whose ESC would abort it) so the racing live continuation - // completes it instead of rendering literally (#7329). - writeReplayData(connectResult.pendingEscapeTailAnsi) - } - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - // Snapshot superseded the cold-restore payload — ack it so the - // daemon does not redeliver it on the next reattach. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.replay) { - rememberReattachPayloadAgentSignal(connectResult.replay, { fullScreenReplay: true }) - // Relay replay holds the last 100 KB of raw output. The xterm may - // already hold pre-disconnect content; clear first to avoid - // duplication. The reattach reset clears renderer-owned state without - // tearing down the still-running TUI's live modes. - writeReplayData('\x1b[2J\x1b[3J\x1b[H') - // Why: raw relay replay contains the application's own kitty pushes - // when they fall inside the retained window; re-arm the mirror with - // replay (set) semantics so redelivery cannot grow the stack. - kittyKeyboardModes.scanReplay(connectResult.replay) - writeReplayData(connectResult.replay) - writeReplayData(reattachReplayResetSequence(connectResult.replay)) - sendFocusedReattachFocusInAfterReplay() - if (connectResult.coldRestore) { - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - } - } else if (connectResult?.coldRestore) { - // replayIntoTerminal: the recorded scrollback is raw PTY output that - // may contain query sequences the previous agent CLI emitted; - // writing them through xterm.write would trigger auto-replies that - // land in the new shell's stdin. See replay-guard.ts. - writeReplayData(connectResult.coldRestore.scrollback) - const preparedStartup = coldRestoreStartup ?? buildColdRestoreAgentResumeStartup() - const didPrepareResume = applyColdRestoreAgentResumeStartup(preparedStartup) - if (didPrepareResume) { - if (preparedStartup?.hasSleepingRecord) { - showSessionRestoredBanner() - } - clearSleepingRecordAfterColdRestoreSpawn(preparedStartup) - } - // Cold-restore means the daemon lost the session and spawned a - // fresh shell — no TUI is consuming the mode-setting bytes that a - // crashed TUI (e.g. Claude's \e[?1004h) left in the scrollback, so - // reset them to match the fresh shell's expectations. - writeReplayData(POST_REPLAY_MODE_RESET) - // Why: the dead run's scrollback was never scanned, and any kitty - // flags it pushed died with it — the fresh shell starts at zero. - kittyKeyboardModes.reset() - consumeRestoredViewportBlankingMarker() - writeFreshShellViewportBlanking() - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.ackColdRestore(ptyId) - } - if (didPrepareResume && !coldRestoreStartup) { - schedulePendingStartupCommandDelivery() + } else if (isCurrentReattachPayload() && !isRemoteRuntimePtyId(reattachPtyId)) { + window.api.pty.signal(reattachPtyId, 'SIGWINCH') } } - // Why: when a mobile-fit override is active, skip sending desktop dims - // to the PTY — the PTY is already at phone dimensions and must stay there. - const reattachPtyId = transport.getPtyId() - if (!reattachPtyId || !getFitOverrideForPty(reattachPtyId)) { - safeFit(pane) - const reattachCols = pane.terminal.cols - const reattachRows = pane.terminal.rows - if (reattachCols > 0 && reattachRows > 0) { - transport.resize(reattachCols, reattachRows) - } + if (hasStructuralReplay) { + await structuralReplayCoordinator.run(applyReattachPayload, { + shouldRestore: isCurrentReattachPayload, + afterRestore: fitAfterReattachRestore + }) + } else { + await applyReattachPayload() + await fitAfterReattachRestore() } - // Why: POSIX only delivers SIGWINCH when terminal dimensions actually - // change. Sending it explicitly guarantees restored TUIs repaint at - // the correct cursor position after snapshot replay. - if (!isRemoteRuntimePtyId(ptyId)) { - window.api.pty.signal(ptyId, 'SIGWINCH') + if (!isCurrentReattachPayload() || !reattachPayloadApplied) { + return false } scheduleReattachIdleAgentCursorReset() @@ -7150,7 +7588,14 @@ export function connectPanePty( const coldRestoreStartup = buildColdRestoreAgentResumeStartup() clearPaneMode2031State() clearHiddenOutputRestoreState() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7169,18 +7614,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) .catch(() => null) @@ -7189,6 +7623,14 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } console.warn( `[pty-connection] Reattach result for tab=${deps.tabId}:`, result @@ -7199,7 +7641,7 @@ export function connectPanePty( : 'undefined' ) if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7214,8 +7656,13 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult(result, pendingSessionId, coldRestoreStartup) - finishReattachLiveDataDeferral(accepted) + const accepted = await handleReattachResult( + result, + pendingSessionId, + coldRestoreStartup, + outputCallbacks.generation + ) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(pendingSessionId)) { @@ -7234,13 +7681,13 @@ export function connectPanePty( } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } console.warn(`[pty-connection] Reattach FAILED for tab=${deps.tabId}:`, err) - if (disposed) { + if (disposed || outputCallbacks.generation !== transportStreamGeneration) { return } if (isSshSessionExpiredError(err)) { @@ -7357,7 +7804,14 @@ export function connectPanePty( let expiredReattachError = false const coldRestoreStartup = buildColdRestoreAgentResumeStartup() - beginReattachLiveDataDeferral() + const outputCallbacks = captureTransportOutputCallbacks((message) => { + if (isSshSessionExpiredError(message)) { + expiredReattachError = true + return + } + reportError(message) + }) + beginReattachLiveDataDeferral(outputCallbacks.generation) transportConnectInFlightSince = Date.now() const reattachPromise = transport.connect({ url: '', @@ -7374,18 +7828,7 @@ export function connectPanePty( ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: (message) => { - if (isSshSessionExpiredError(message)) { - expiredReattachError = true - return - } - reportError(message) - } - } + callbacks: outputCallbacks.callbacks }) void Promise.resolve(reattachPromise) @@ -7395,8 +7838,16 @@ export function connectPanePty( }) void Promise.resolve(reattachPromise) .then(async (result) => { + if (outputCallbacks.generation !== transportStreamGeneration) { + finishReattachLiveDataDeferral(false, outputCallbacks.generation) + const gen = await preSignalPromise + if (typeof gen === 'number') { + void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) + } + return + } if (!result && expiredReattachError) { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) @@ -7411,12 +7862,13 @@ export function connectPanePty( }) return } - const accepted = handleReattachResult( + const accepted = await handleReattachResult( result, deferredReattachSessionId, - coldRestoreStartup + coldRestoreStartup, + outputCallbacks.generation ) - finishReattachLiveDataDeferral(accepted) + finishReattachLiveDataDeferral(accepted, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { if (!isRemoteRuntimePtyId(deferredReattachSessionId)) { @@ -7435,12 +7887,15 @@ export function connectPanePty( } }) .catch(async (err) => { - finishReattachLiveDataDeferral(false) + finishReattachLiveDataDeferral(false, outputCallbacks.generation) const gen = await preSignalPromise if (typeof gen === 'number') { void window.api.pty.clearPendingPaneSerializer(cacheKey, gen).catch(() => {}) } const message = err instanceof Error ? err.message : String(err) + if (outputCallbacks.generation !== transportStreamGeneration) { + return + } warnTerminalLifecycleAnomaly('restored PTY reattach threw', { tabId: deps.tabId, worktreeId: deps.worktreeId, @@ -7484,16 +7939,12 @@ export function connectPanePty( try { clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: attachPtyId, cols, rows, - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? attachPtyId bindActivePanePty(attachedPtyId, { @@ -7541,16 +7992,12 @@ export function connectPanePty( } clearPaneMode2031State() clearHiddenOutputRestoreState() + const outputCallbacks = captureTransportOutputCallbacks(reportError) transport.attach({ existingPtyId: spawnedPtyId, cols, rows, - callbacks: { - onConnect: reportRemoteRendererSerializerReady, - onData: dataCallback, - onReplayData: replayDataCallback, - onError: reportError - } + callbacks: outputCallbacks.callbacks }) const attachedPtyId = transport.getPtyId() ?? spawnedPtyId // Why: this path reuses a PTY spawned by an earlier mount, so no @@ -7738,10 +8185,16 @@ export function connectPanePty( reconcileIfSessionMissing, dispose() { disposed = true + cancelPendingSafeFitContinuations(pane) + pendingHiddenSnapshotFit = null + pendingReattachFit = null // A normal park/reconnect/remount does not advance the recovery epoch; // invalidate this concrete xterm so its delayed retry cannot hit the next. terminalRecoveryInstance.unregister() unregisterUndeliverableWriteHandler() + cancelHiddenOutputSnapshotScrollRestore() + structuralReplayCoordinator.dispose() + cancelFreshSpawnFollowReset() // Why: the post-spawn reconcile polls across frames; cancel its pending // rAF so a torn-down pane cannot keep fitting/resizing after disposal. ptySizeReconcileHandle?.cancel() diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts index 3b22b2d9a..592862633 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.test.ts @@ -15,7 +15,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -34,7 +34,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), forwardResize @@ -53,8 +53,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { calls.push('fit') + continuation() }), getTerminalDimensions: vi.fn(() => { calls.push('measure') @@ -81,8 +82,9 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(() => { + fitAndRun: vi.fn((continuation) => { forwardResize(82, 30) + continuation() }), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 82, rows: 30 })), @@ -97,14 +99,14 @@ describe('createPtySizeReassertion', () => { }) it('can verify current dimensions without fitting again', async () => { - const fit = vi.fn() + const fitAndRun = vi.fn((continuation: () => void) => continuation()) const forwardResize = vi.fn() const reassertion = createPtySizeReassertion({ isDisposed: () => false, getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit, + fitAndRun, getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => ({ cols: 120, rows: 30 })), forwardResize @@ -113,7 +115,7 @@ describe('createPtySizeReassertion', () => { reassertion.request({ fit: false }) await flushAsyncTicks() - expect(fit).not.toHaveBeenCalled() + expect(fitAndRun).not.toHaveBeenCalled() expect(forwardResize).toHaveBeenCalledWith(82, 30) }) @@ -124,7 +126,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'remote:terminal-1', isRemotePtyId: () => true, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -134,7 +136,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => true, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize: vi.fn() @@ -164,7 +166,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize, forwardResize @@ -201,7 +203,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: targetCols, rows: 40 }), getAppliedSize, forwardResize @@ -240,7 +242,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -273,7 +275,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -313,7 +315,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -346,7 +348,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => dims, getAppliedSize, forwardResize @@ -369,7 +371,7 @@ describe('createPtySizeReassertion', () => { getPtyId: () => 'pty-1', isRemotePtyId: () => false, shouldSuppressDesktopResize: () => false, - fit: vi.fn(), + fitAndRun: (continuation) => continuation(), getTerminalDimensions: () => ({ cols: 82, rows: 30 }), getAppliedSize: vi.fn(async () => { throw new Error('unavailable') diff --git a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts index 7f127b670..6c5db87d0 100644 --- a/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts +++ b/src/renderer/src/components/terminal-pane/pty-size-reassertion.ts @@ -5,7 +5,7 @@ export type PtySizeReassertionOptions = { getPtyId: () => string | null isRemotePtyId: (ptyId: string) => boolean shouldSuppressDesktopResize: () => boolean - fit: () => void + fitAndRun: (continuation: () => void) => void getTerminalDimensions: () => PtySizeReassertionDimensions getAppliedSize: (ptyId: string) => Promise forwardResize: (cols: number, rows: number) => void @@ -46,7 +46,8 @@ export function createPtySizeReassertion(options: PtySizeReassertionOptions): Pt return } if (shouldFit) { - options.fit() + options.fitAndRun(() => run(false)) + return } const target = options.getTerminalDimensions() if (!dimensionsAreUsable(target)) { diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index 8f5d99ecb..beddf35e1 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -4,6 +4,7 @@ import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync, + waitForTerminalReplayWritesParsed, type ReplayingPanesRef } from './replay-guard' import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' @@ -418,6 +419,27 @@ describe('replay-guard', () => { }) describe('replay-guard stall handling (probe-certified release)', () => { + it('waits for the FIFO replay sentinel without releasing on elapsed time', async () => { + vi.useFakeTimers() + const { terminal } = makeFakePane(1) + let resolved = false + + void waitForTerminalReplayWritesParsed(terminal, { stallCheckMs: 1_000 }).then(() => { + resolved = true + }) + expect(terminal.lastData).toEqual(['']) + + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['', '']) + expect(resolved).toBe(false) + vi.advanceTimersByTime(60_000) + expect(resolved).toBe(false) + + terminal.flush() + await Promise.resolve() + expect(resolved).toBe(true) + }) + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { // Why this is the load-bearing safety test: a time-based release here // would leak xterm auto-replies into the shell (and a leaked ESC into an diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 1e1042de0..6711390d8 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -247,3 +247,49 @@ export function replayIntoTerminalAsync( }) }) } + +/** Resolves after every replay write already queued on this terminal has + * parsed. A delayed FIFO probe covers a lost sentinel callback without ever + * treating elapsed time alone as proof that parsing finished. */ +export function waitForTerminalReplayWritesParsed( + terminal: ReplayGuardWriteTarget, + options: Pick = {} +): Promise { + return new Promise((resolve) => { + let finished = false + let stallTimer: ReturnType | null = null + const finish = (): void => { + if (finished) { + return + } + finished = true + if (stallTimer !== null) { + clearTimeout(stallTimer) + stallTimer = null + } + resolve() + } + const queueProbe = (): void => { + if (finished) { + return + } + try { + // Why: an empty write is FIFO with earlier replay bytes. Its callback + // can recover a lost sentinel callback without changing parser state. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + } + stallTimer = setTimeout(queueProbe, options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS) + try { + // Why empty: pendingEscapeTailAnsi must remain the final replay bytes; + // xterm still orders this completion after every earlier write. + terminal.write('', finish) + } catch { + // A disposed terminal cannot parse any remaining replay bytes. + finish() + } + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index d1af2c9f1..cf92bca5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,7 +10,7 @@ import { } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' import { guardParserHandler } from './terminal-parser-handler-guard' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit, safeFitAndThen } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, normalizeTerminalScrollSensitivity, @@ -300,21 +300,26 @@ export function applyTerminalAppearance( // separate hook and lets live toggles (settings change, font swap) // land immediately. manager.setPaneLigaturesEnabled(pane.id, ligaturesEnabled) - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } const transport = paneTransports.get(pane.id) // Why: skip PTY resize when a mobile-fit override is active — the PTY // is already at the correct phone dimensions and must not be resized // back to desktop dimensions by an appearance change. const appearancePtyId = transport?.getPtyId() if (transport?.isConnected() && (!appearancePtyId || !getFitOverrideForPty(appearancePtyId))) { - transport.resize(pane.terminal.cols, pane.terminal.rows) maybePushMode2031Flip(pane.id, appearance.mode, transport, paneMode2031, paneLastThemeMode) + safeFitAndThen(pane, 'appearance-pty-resize', () => { + const currentTransport = paneTransports.get(pane.id) + if ( + currentTransport !== transport || + !transport.isConnected() || + transport.getPtyId() !== appearancePtyId + ) { + return + } + transport.resize(pane.terminal.cols, pane.terminal.rows) + }) + } else { + safeFit(pane) } } diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts index b42304cb8..217168518 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.test.ts @@ -13,7 +13,8 @@ vi.mock('@/lib/pane-manager/pane-terminal-output-scheduler', () => ({ requestTerminalBacklogRecovery: vi.fn() })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: vi.fn() + enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn() })) vi.mock('./pane-helpers', () => ({ fitAndFocusPanes: vi.fn(), @@ -73,6 +74,22 @@ describe('resumeTerminalVisibility reveal repaint', () => { expect(scheduleTabRevealWebglAtlasRecovery).toHaveBeenCalledTimes(1) }) + it('captures native trim movement before enforcing viewport intent', async () => { + const terminal = { name: 'trimmed-terminal' } + const manager = createManager() + manager.getPanes.mockReturnValue([{ terminal }]) + const { enforceTerminalCurrentScrollIntent, syncTerminalScrollIntentFromViewport } = vi.mocked( + await import('@/lib/pane-manager/terminal-scroll-intent') + ) + + resumeTerminalVisibility(resumeArgs(manager, true)) + + expect(syncTerminalScrollIntentFromViewport).toHaveBeenCalledWith(terminal) + expect(syncTerminalScrollIntentFromViewport.mock.invocationCallOrder[0]).toBeLessThan( + enforceTerminalCurrentScrollIntent.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY + ) + }) + it('schedules the repaint after rendering resumes on a heavy reveal', () => { const order: string[] = [] const manager = createManager(order) diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index d1a0cc9e2..5892dead9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -5,7 +5,10 @@ import { flushTerminalOutput, requestTerminalBacklogRecovery } from '@/lib/pane-manager/pane-terminal-output-scheduler' -import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent' +import { + enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport +} from '@/lib/pane-manager/terminal-scroll-intent' import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers' import { scheduleTabRevealWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' @@ -51,6 +54,7 @@ export function resumeTerminalVisibility({ captureViewportPositions, withSuppressedScrollTracking }: ResumeTerminalVisibilityArgs): void { + syncTerminalViewportIntents(manager) // Why: WebGL resume can disturb xterm's viewport bookkeeping before the // post-resume fit runs. Capture numeric viewport positions first; the // restore path avoids content matching so duplicate agent log lines do @@ -133,6 +137,7 @@ export function recoverVisibleTerminalWindowWake({ requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: WINDOW_WAKE_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) manager.resumeRendering() if (isActive) { fitAndFocusPanes(manager) @@ -170,6 +175,7 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean): requestTerminalBacklogRecovery(pane.terminal) flushTerminalOutput(pane.terminal, { maxChars: VISIBLE_RESUME_FLUSH_CHARS }) } + syncTerminalViewportIntents(manager) // Resume WebGL immediately so the terminal shows its last-known state // on the first painted frame. macOS context creation is ~5 ms; on // Windows (ANGLE -> D3D11) it can be 100-500 ms but a deferred resume @@ -190,3 +196,11 @@ function enforceTerminalViewportIntents(manager: PaneManager): void { enforceTerminalCurrentScrollIntent(pane.terminal) } } + +function syncTerminalViewportIntents(manager: PaneManager): void { + for (const pane of manager.getPanes()) { + // Why: native scrollback trimming moves a pinned viewport content-stably. + // Capture that live position before resume/fit can disturb it. + syncTerminalScrollIntentFromViewport(pane.terminal) + } +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index 4587aac93..de537af3d 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -20,6 +20,7 @@ const mocks = vi.hoisted(() => ({ getTerminalOutputEpoch: vi.fn(() => 0), handleTerminalFileDrop: vi.fn(), enforceTerminalCurrentScrollIntent: vi.fn(), + syncTerminalScrollIntentFromViewport: vi.fn(), pasteTerminalText: vi.fn(), recordTerminalUserInputForLeaf: vi.fn(), requestTerminalBacklogRecovery: vi.fn(), @@ -79,7 +80,8 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({ })) vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({ - enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent + enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent, + syncTerminalScrollIntentFromViewport: mocks.syncTerminalScrollIntentFromViewport })) vi.mock('./terminal-drop-handler', () => ({ diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index c2c355a8e..e1fe139d4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -106,10 +106,8 @@ import { getConnectionId } from '@/lib/connection-context' import { getExecutionHostIdForWorktree } from '@/lib/worktree-runtime-owner' import { isPaneReplaying, type ReplayingPanesRef } from './replay-guard' import { fitAndFocusPanes, fitPanes } from './pane-helpers' -import { - markTerminalPinnedViewport, - syncTerminalScrollIntentSoon -} from '@/lib/pane-manager/terminal-scroll-intent' +import { markTerminalPinnedViewport } from '@/lib/pane-manager/terminal-scroll-intent' +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 { e2eConfig } from '@/lib/e2e-config' @@ -1024,11 +1022,20 @@ export function useTerminalPaneLifecycle({ } if (e.type === 'keydown') { + const shouldSyncCurrentTerminal = (): boolean => + managerRef.current + ?.getPanes() + .some((candidate) => candidate.terminal === pane.terminal) === true if (e.key === 'PageUp' || e.key === 'Home') { markTerminalPinnedViewport(pane.terminal) - syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true }) + syncTerminalScrollIntentSoon(pane.terminal, { + preservePinnedAtBottom: true, + shouldSync: shouldSyncCurrentTerminal + }) } else if (e.key === 'PageDown' || e.key === 'End') { - syncTerminalScrollIntentSoon(pane.terminal) + syncTerminalScrollIntentSoon(pane.terminal, { + shouldSync: shouldSyncCurrentTerminal + }) } } diff --git a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts index da714e9bf..1d2bd6c22 100644 --- a/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts +++ b/src/renderer/src/components/terminal-pane/useTerminalFontZoom.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { dispatchZoomLevelChanged } from '@/lib/zoom-events' -import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' +import { safeFit } from '@/lib/pane-manager/pane-tree-ops' import { getPaneOwnedActiveHelperTextarea } from './regular-terminal-focus-ownership' type FontZoomDeps = { @@ -57,13 +57,7 @@ export function useTerminalFontZoom({ } pane.terminal.options.fontSize = nextSize - try { - const state = captureScrollState(pane.terminal) - safeFit(pane) - restoreScrollState(pane.terminal, state) - } catch { - /* ignore */ - } + safeFit(pane) const percent = Math.round((nextSize / globalSize) * 100) dispatchZoomLevelChanged('terminal', percent) diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts index daf769989..b4675047d 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.test.ts @@ -5,6 +5,10 @@ import { detachPaneFitResizeObserver, requestStablePaneFit } from './pane-fit-resize-observer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' type ResizeObserverCallbackLike = ConstructorParameters[0] @@ -174,6 +178,23 @@ describe('attachPaneFitResizeObserver', () => { expect(onSettled).toHaveBeenCalledTimes(1) }) + it('does not notify settled callbacks until a replay-deferred fit completes', async () => { + const onSettled = vi.fn() + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + requestStablePaneFit(pane, onSettled) + flushAnimationFrames() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(onSettled).not.toHaveBeenCalled() + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(onSettled).toHaveBeenCalledTimes(1) + }) + it('skips observer fits while the terminal has no visible geometry', () => { const pane = createPane(() => ({ cols: 80, rows: 24 }), { rect: { width: 0, height: 0 } diff --git a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts index 867a2b5dd..16d1a0a5b 100644 --- a/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts +++ b/src/renderer/src/lib/pane-manager/pane-fit-resize-observer.ts @@ -1,5 +1,5 @@ import type { ManagedPane, ManagedPaneInternal } from './pane-manager-types' -import { safeFit } from './pane-tree-ops' +import { cancelPendingSafeFitContinuations, safeFitAndThen } from './pane-tree-ops' type ProposedDimensions = { cols: number @@ -77,7 +77,8 @@ function flushStableFitCallbacks(pane: StableFitPane): void { function finishStableFit(pane: StableFitPane, shouldFit: boolean): void { setPendingObservedFitRafId(pane, null) if (shouldFit) { - safeFit(pane) + safeFitAndThen(pane, 'stable-pane-fit', () => flushStableFitCallbacks(pane)) + return } flushStableFitCallbacks(pane) } @@ -164,4 +165,5 @@ export function detachPaneFitResizeObserver(pane: ManagedPaneInternal): void { setPendingObservedFitRafId(pane, null) } stableFitCallbacks.delete(pane) + cancelPendingSafeFitContinuations(pane) } diff --git a/src/renderer/src/lib/pane-manager/pane-fit.ts b/src/renderer/src/lib/pane-manager/pane-fit.ts new file mode 100644 index 000000000..573ba6ab7 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-fit.ts @@ -0,0 +1,257 @@ +import type { ManagedPane, ManagedPaneInternal, ScrollState } from './pane-manager-types' +import { getFitOverrideForPty } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + isTerminalStructuralScrollIntentCurrent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + captureScrollState, + releaseScrollStateMarker, + restoreScrollStateAfterFit, + resumePendingFitScrollRestoreAfterFit +} from './pane-scroll' +import { + deferTerminalGeometryMutationDuringRebuild, + isTerminalScrollIntentRebuildInFlight +} from './terminal-scroll-intent-rebuild' + +const MIN_PANE_FIT_WIDTH_PX = 48 +const MIN_PANE_FIT_HEIGHT_PX = 24 +const MIN_PANE_FIT_COLS = 8 +const MIN_PANE_FIT_ROWS = 4 + +export type SafeFitContinuationHandle = { + completion: Promise + cancel: () => void +} + +type PendingSafeFitContinuation = { + continuation: () => void + shouldContinue: () => boolean + resolve: (completed: boolean) => void +} + +const pendingSafeFitContinuations = new WeakMap< + ManagedPane, + Map +>() + +function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { + try { + return pane.fitAddon.proposeDimensions() ?? null + } catch { + return null + } +} + +function canMeasurePaneForFit(pane: ManagedPane): boolean { + const measure = pane.container?.getBoundingClientRect + if (typeof measure === 'function') { + const rect = measure.call(pane.container) + if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { + return false + } + } + const dims = getProposedDimensions(pane) + if (!dims) { + return false + } + // Why: worktree switches can briefly measure a near-zero overlay before + // fallback positioning lands. Fitting there pins the PTY at ~2 cols. + return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS +} + +function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { + // Why: split reparent has its own delayed restore; restoring here can fight that timer. + return !( + 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState + ) +} + +function performSafeFit(pane: ManagedPane): boolean { + if (deferTerminalGeometryMutationDuringRebuild(pane.terminal, 'safe-fit', () => safeFit(pane))) { + return false + } + if (!canMeasurePaneForFit(pane)) { + return false + } + let scrollIntent = null as ReturnType + let pinnedScrollState: ScrollState | null = null + let shouldRestoreScroll = false + const captureScrollForFit = (): void => { + scrollIntent = captureTerminalStructuralScrollIntent(pane.terminal) + // Why: fit can reflow and renumber every buffer row; a marker tracks the + // pinned content itself, while a numeric line would point elsewhere after. + pinnedScrollState = + scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null + shouldRestoreScroll = true + } + try { + // Why: a mobile-owned PTY must stay at its phone grid on passive desktop panes. + const ptyId = pane.container?.dataset?.ptyId + const override = ptyId ? getFitOverrideForPty(ptyId) : null + if (override) { + if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.terminal.resize(override.cols, override.rows) + } else { + resumePendingFitScrollRestoreAfterFit(pane.terminal) + } + return true + } + + const dims = getProposedDimensions(pane) + if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { + // Why: divider drags often stay within one cell; avoid needless clear/refresh churn. + resumePendingFitScrollRestoreAfterFit(pane.terminal) + return true + } + if (canPreserveScrollIntentForFit(pane)) { + captureScrollForFit() + } + pane.fitAddon.fit() + return true + } catch { + // Container may not have dimensions yet. + return false + } finally { + if (shouldRestoreScroll) { + try { + if (resumePendingFitScrollRestoreAfterFit(pane.terminal)) { + } else if (pinnedScrollState) { + const state: ScrollState = pinnedScrollState + pinnedScrollState = null + restoreScrollStateAfterFit(pane.terminal, state, { + onRestored: () => { + // Why: do not replace a durable pre-replay pin with transient 0/0 geometry. + if (!state.wasAtBottom) { + markTerminalPinnedViewport(pane.terminal) + } + }, + shouldRestore: () => + !isTerminalScrollIntentRebuildInFlight(pane.terminal) && + isTerminalStructuralScrollIntentCurrent(pane.terminal, scrollIntent) + }) + } else { + restoreTerminalStructuralScrollIntent(pane.terminal, scrollIntent) + } + } catch { + // Why: SSH reattach can briefly expose xterm without renderer dimensions. + } finally { + if (pinnedScrollState) { + releaseScrollStateMarker(pinnedScrollState) + } + } + } + } +} + +function settlePendingSafeFitContinuation( + pane: ManagedPane, + operationKey: string, + pending: PendingSafeFitContinuation, + completed: boolean +): void { + const operations = pendingSafeFitContinuations.get(pane) + if (operations?.get(operationKey) !== pending) { + return + } + operations.delete(operationKey) + if (operations.size === 0) { + pendingSafeFitContinuations.delete(pane) + } + pending.resolve(completed) +} + +function flushPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + for (const [operationKey, pending] of operations) { + if (!pending.shouldContinue()) { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + continue + } + try { + pending.continuation() + settlePendingSafeFitContinuation(pane, operationKey, pending, true) + } catch { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + } +} + +export function safeFit(pane: ManagedPane): boolean { + const completed = performSafeFit(pane) + if (completed) { + // Why: replay transactions may be waiting for renderer dimensions; any + // successful ordinary fit is the event that makes their PTY grid authoritative. + flushPendingSafeFitContinuations(pane) + } + return completed +} + +export function cancelPendingSafeFitContinuations(pane: ManagedPane): void { + const operations = pendingSafeFitContinuations.get(pane) + if (!operations) { + return + } + pendingSafeFitContinuations.delete(pane) + for (const pending of operations.values()) { + pending.resolve(false) + } +} + +// Why: callers that forward xterm's grid to a PTY must wait for a measurable +// fit or explicit lifecycle cancellation instead of observing replay dimensions. +export function safeFitAndThen( + pane: ManagedPane, + operationKey: string, + continuation: () => void, + options: { shouldContinue?: () => boolean } = {} +): SafeFitContinuationHandle { + const operations = pendingSafeFitContinuations.get(pane) ?? new Map() + const replaced = operations.get(operationKey) + if (replaced) { + settlePendingSafeFitContinuation(pane, operationKey, replaced, false) + } + let resolveCompletion = (_completed: boolean): void => {} + const completion = new Promise((resolve) => { + resolveCompletion = resolve + }) + const pending: PendingSafeFitContinuation = { + continuation, + shouldContinue: options.shouldContinue ?? (() => true), + resolve: resolveCompletion + } + const currentOperations = pendingSafeFitContinuations.get(pane) ?? operations + currentOperations.set(operationKey, pending) + pendingSafeFitContinuations.set(pane, currentOperations) + const cancel = (): void => { + settlePendingSafeFitContinuation(pane, operationKey, pending, false) + } + if (!pending.shouldContinue()) { + cancel() + return { completion, cancel } + } + if ( + deferTerminalGeometryMutationDuringRebuild( + pane.terminal, + `safe-fit-and-then:${operationKey}`, + () => { + if (pendingSafeFitContinuations.get(pane)?.get(operationKey) === pending) { + safeFit(pane) + } + } + ) + ) { + return { completion, cancel } + } + safeFit(pane) + return { completion, cancel } +} diff --git a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts index 4d0801337..81baa63f5 100644 --- a/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-initial-fit-lifecycle.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' +import { restoreScrollStateAfterFit } from './pane-scroll' function createPane(pendingInitialFitRafId: number | null): ManagedPaneInternal { const leafId = '11111111-1111-4111-8111-111111111111' as never @@ -54,4 +55,31 @@ describe('pane initial fit lifecycle', () => { expect(pane.pendingInitialFitRafId).toBeNull() expect(panes.has(pane.id)).toBe(false) }) + + it('cancels pending fit scroll restoration before terminal disposal', () => { + const cancelAnimationFrame = vi.fn() + vi.stubGlobal( + 'requestAnimationFrame', + vi.fn(() => 23) + ) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const pane = createPane(null) + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + + restoreScrollStateAfterFit( + pane.terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker as never + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + disposePane(pane, new Map([[pane.id, pane]])) + + expect(cancelAnimationFrame).toHaveBeenCalledWith(23) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index 4b0fef324..a18e432d0 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -46,6 +46,7 @@ function createPane(): ManagedPaneInternal { loadAddon: vi.fn(), attachCustomWheelEventHandler: vi.fn(), refresh: vi.fn(), + cols: 80, rows: 24 } as never, container: {} as never, @@ -57,7 +58,8 @@ function createPane(): ManagedPaneInternal { webglDisabledAfterContextLoss: false, hasComplexScriptOutput: false, fitAddon: { - fit: vi.fn() + fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 80, rows: 23 })) } as never, fitResizeObserver: null, pendingObservedFitRafId: null, diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index adc43af01..ea938f9a3 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -5,9 +5,10 @@ import { detachPaneFitResizeObserver } from './pane-fit-resize-observer' import { clearPendingSplitScrollRestore } from './pane-split-scroll' +import { cancelDeferredScrollRestore } from './pane-scroll' import { activateOrcaTerminalUnicodeProvider } from '../../../../shared/terminal-unicode-provider' import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' -import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' @@ -244,6 +245,13 @@ export function disposePane( } catch { /* ignore */ } + try { + // Why: fit retries own xterm markers and frame callbacks independently of + // split restoration; both must be released before terminal disposal. + cancelDeferredScrollRestore(pane.terminal) + } catch { + /* ignore */ + } try { pane.ligaturesAddon?.dispose() } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index 8218de5e8..5f2c3d5fc 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -124,6 +124,8 @@ export type ScrollState = { viewportY: number baseY: number firstVisibleLineMarker?: IMarker + firstVisibleLogicalLineMarker?: IMarker + firstVisibleLogicalCellOffset?: number } export type ManagedPaneInternal = { diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts index 9d1317d8e..9378945e4 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal as HeadlessTerminal } from '@xterm/headless' import type { IMarker, Terminal } from '@xterm/xterm' import { captureScrollState, getTerminalOutputEpoch, recordTerminalOutput, restoreScrollState, + restoreScrollStateAfterFit, restoreScrollStateAfterLayout } from './pane-scroll' import type { ScrollState } from './pane-manager-types' @@ -59,6 +61,24 @@ function setMarkerLine(marker: IMarker, line: number): void { mutableMarker.line = line } +function writeHeadless(terminal: HeadlessTerminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function findBufferLineContaining(terminal: HeadlessTerminal, text: string): number { + for (let lineY = 0; lineY < terminal.buffer.active.length; lineY += 1) { + if (terminal.buffer.active.getLine(lineY)?.translateToString(true).includes(text)) { + return lineY + } + } + return -1 +} + +function makeHeadlessRestorable(terminal: HeadlessTerminal): Terminal { + Object.defineProperty(terminal, 'element', { configurable: true, value: {} }) + return terminal as unknown as Terminal +} + describe('scroll state', () => { afterEach(() => { vi.useRealTimers() @@ -286,6 +306,61 @@ describe('scroll state', () => { expect(terminal.buffer.active.viewportY).toBe(30) }) + it('releases fit markers when restoration throws an unexpected error', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine).mockImplementation(() => { + throw new Error('unexpected renderer failure') + }) + const state: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + } + + expect(() => + restoreScrollStateAfterFit(terminal, state, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + ).toThrow('unexpected renderer failure') + expect(marker.isDisposed).toBe(true) + }) + + it('releases fit markers when an asynchronous retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const terminal = createTerminal({ viewportY: 10, baseY: 100 }) + const marker = createMarker(42) + vi.mocked(terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected asynchronous renderer failure') + }) + + restoreScrollStateAfterFit( + terminal, + { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 42, + baseY: 100, + firstVisibleLineMarker: marker + }, + { onRestored: vi.fn(), shouldRestore: () => true } + ) + + expect(() => frameCallbacks.shift()?.(0)).toThrow('unexpected asynchronous renderer failure') + expect(marker.isDisposed).toBe(true) + }) + it('scrolls to the current bottom when the pane was previously at bottom', () => { const terminal = createTerminal({ viewportY: 10, baseY: 250 }) const state: ScrollState = { @@ -316,4 +391,142 @@ describe('scroll state', () => { expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(10) }) + + it.each([ + { + fromCols: 10, + toCols: 20, + pinnedText: 'abcdefghij', + expectedTop: 'ABCDEFGHIJabcdefghij' + }, + { fromCols: 20, toCols: 7, pinnedText: 'KLMNOPQRSTuvwxyz', expectedTop: 'efghijK' } + ])( + 'restores the same logical cells through real xterm reflow ($fromCols->$toCols)', + async ({ fromCols, toCols, pinnedText, expectedTop }) => { + const headless = new HeadlessTerminal({ + cols: fromCols, + rows: 5, + scrollback: 1000, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, pinnedText) + expect(pinnedLine).toBeGreaterThan(0) + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + headless.resize(toCols, 5) + expect(state.firstVisibleLogicalLineMarker?.isDisposed).toBe(false) + expect(restoreScrollState(terminal, state)).toBe(true) + + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe(expectedTop) + } finally { + headless.dispose() + } + } + ) + + it('uses a logical marker for backend-only ConPTY compatibility', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 5, + scrollback: 1000, + allowProposedApi: true, + windowsPty: { backend: 'conpty' } + }) + try { + await writeHeadless(headless, 'prefix\r\n') + await writeHeadless(headless, 'ABCDEFGHIJabcdefghijKLMNOPQRSTuvwxyz\r\n') + for (let index = 0; index < 10; index += 1) { + await writeHeadless(headless, `tail-${index}\r\n`) + } + const pinnedLine = findBufferLineContaining(headless, 'abcdefghij') + headless.scrollToLine(pinnedLine) + const terminal = makeHeadlessRestorable(headless) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLogicalLineMarker).toBeDefined() + headless.resize(20, 5) + expect(restoreScrollState(terminal, state)).toBe(true) + expect( + headless.buffer.active.getLine(headless.buffer.active.viewportY)?.translateToString(true) + ).toBe('ABCDEFGHIJabcdefghij') + } finally { + headless.dispose() + } + }) + + it('keeps a physical marker for the default non-reflowing cursor line', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789') + const terminal = makeHeadlessRestorable(headless) + headless.scrollLines(-1) + const state = captureScrollState(terminal) + + expect(state.firstVisibleLineMarker).toBeDefined() + expect(state.firstVisibleLogicalLineMarker).toBeUndefined() + headless.resize(20, 3) + expect(restoreScrollState(terminal, state)).toBe(true) + } finally { + headless.dispose() + } + }) + + it('uses physical markers for legacy ConPTY and logical markers for modern ConPTY', async () => { + const captureForBuild = async (buildNumber: number): Promise => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true, + windowsPty: { backend: 'conpty', buildNumber } + }) + await writeHeadless(headless, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789\r\n') + await writeHeadless(headless, 'tail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, 'KLMNOPQRST') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + headless.dispose() + return state + } + + const legacy = await captureForBuild(19045) + const modern = await captureForBuild(26100) + + expect(legacy.firstVisibleLogicalLineMarker).toBeUndefined() + expect(modern.firstVisibleLogicalLineMarker).toBeDefined() + }) + + it('counts a wide glyph wrap placeholder as zero logical cells', async () => { + const headless = new HeadlessTerminal({ + cols: 10, + rows: 3, + scrollback: 100, + allowProposedApi: true + }) + try { + await writeHeadless(headless, '123456789界abcdefghij\r\ntail-1\r\ntail-2\r\ntail-3\r\n') + const pinnedLine = findBufferLineContaining(headless, '界') + headless.scrollToLine(pinnedLine) + const state = captureScrollState(makeHeadlessRestorable(headless)) + + expect(state.firstVisibleLogicalCellOffset).toBe(9) + } finally { + headless.dispose() + } + }) }) diff --git a/src/renderer/src/lib/pane-manager/pane-scroll.ts b/src/renderer/src/lib/pane-manager/pane-scroll.ts index 083c41b4d..bb79faa45 100644 --- a/src/renderer/src/lib/pane-manager/pane-scroll.ts +++ b/src/renderer/src/lib/pane-manager/pane-scroll.ts @@ -1,9 +1,14 @@ import type { Terminal } from '@xterm/xterm' import type { ScrollState } from './pane-manager-types' +import { + captureLogicalLineAnchor, + resolveLogicalCellOffsetLine +} from './terminal-reflow-scroll-anchor' +import { forceTerminalViewportScrollbarSync } from './terminal-viewport-scrollbar-sync' const terminalOutputEpochs = new WeakMap() const deferredScrollRestores = new WeakMap< - Terminal, + object, { cancelled: boolean rafIds: number[] @@ -11,6 +16,19 @@ const deferredScrollRestores = new WeakMap< timeoutIds: ReturnType[] } >() +const pendingFitScrollRestores = new WeakMap< + object, + { + cancelled: boolean + rafId: number | null + retryAfterFit: () => boolean + shouldRestore: () => boolean + state: ScrollState + } +>() +const FIT_SCROLL_RESTORE_MAX_FRAMES = 2 + +type ScrollRestoreResult = 'restored' | 'retry' | 'skipped' export function recordTerminalOutput(terminal: Terminal): void { terminalOutputEpochs.set(terminal, getTerminalOutputEpoch(terminal) + 1) @@ -20,7 +38,8 @@ export function getTerminalOutputEpoch(terminal: Terminal): number { return terminalOutputEpochs.get(terminal) ?? 0 } -export function cancelDeferredScrollRestore(terminal: Terminal): void { +export function cancelDeferredScrollRestore(terminal: object): void { + cancelPendingFitScrollRestore(terminal) const pending = deferredScrollRestores.get(terminal) if (!pending) { return @@ -42,24 +61,138 @@ export function captureScrollState(terminal: Terminal): ScrollState { const buf = terminal.buffer.active const viewportY = buf.viewportY const wasAtBottom = viewportY >= buf.baseY + const logicalAnchor = + !wasAtBottom && buf.type === 'normal' + ? captureLogicalLineAnchor(terminal, viewportY) + : undefined + const firstVisibleLineMarker = + !wasAtBottom && buf.type === 'normal' + ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) + : undefined return { bufferType: buf.type, wasAtBottom, viewportY, baseY: buf.baseY, - // Why: xterm markers track the same buffer line through resize reflow; - // a numeric viewport line alone can point at different content afterward. - firstVisibleLineMarker: - !wasAtBottom && buf.type === 'normal' - ? terminal.registerMarker?.(viewportY - (buf.baseY + buf.cursorY)) - : undefined + // Why: continuation-row markers can be deleted or drift during reflow. + // Keep the physical marker for no-reflow ConPTY/cursor-line cases, and + // anchor reflowing content at the logical line's stable first row. + firstVisibleLineMarker, + firstVisibleLogicalLineMarker: + logicalAnchor?.lineY === viewportY + ? firstVisibleLineMarker + : logicalAnchor + ? terminal.registerMarker?.(logicalAnchor.lineY - (buf.baseY + buf.cursorY)) + : undefined, + firstVisibleLogicalCellOffset: logicalAnchor?.cellOffset } } -export function restoreScrollState(terminal: Terminal, state: ScrollState): void { +export function restoreScrollState(terminal: Terminal, state: ScrollState): boolean { cancelDeferredScrollRestore(terminal) - restoreScrollStateNow(terminal, state) - releaseScrollStateMarker(state) + try { + return restoreScrollStateNow(terminal, state) === 'restored' + } finally { + releaseScrollStateMarker(state) + } +} + +export function restoreScrollStateAfterFit( + terminal: Terminal, + state: ScrollState, + options: { onRestored: () => void; shouldRestore: () => boolean } +): void { + cancelDeferredScrollRestore(terminal) + if (!options.shouldRestore()) { + releaseScrollStateMarker(state) + return + } + let initialResult: ScrollRestoreResult + try { + initialResult = restoreScrollStateNow(terminal, state) + } catch (error) { + releaseScrollStateMarker(state) + throw error + } + if (initialResult !== 'retry' || typeof requestAnimationFrame !== 'function') { + releaseScrollStateMarker(state) + if (initialResult === 'restored') { + options.onRestored() + } + return + } + + const pending = { + cancelled: false, + rafId: null as number | null, + retryAfterFit: (): boolean => false, + shouldRestore: options.shouldRestore, + state + } + let remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + const finish = (restored: boolean): void => { + if (pending.cancelled) { + return + } + pending.cancelled = true + pendingFitScrollRestores.delete(terminal) + releaseScrollStateMarker(state) + if (restored && options.shouldRestore()) { + options.onRestored() + } + } + const retry = (): boolean => { + pending.rafId = null + if (pending.cancelled || !options.shouldRestore()) { + finish(false) + return false + } + let result: ScrollRestoreResult + try { + result = restoreScrollStateNow(terminal, state) + } catch (error) { + finish(false) + throw error + } + if (result === 'restored') { + finish(true) + return true + } + remainingFrames -= 1 + if (result !== 'retry') { + finish(false) + return false + } + if (remainingFrames <= 0) { + // Why: background/WebGL teardown can outlast a bounded frame retry. + // Keep the content marker parked for the next real fit/reveal. + return true + } + pending.rafId = requestAnimationFrame(retry) + return true + } + pending.retryAfterFit = () => { + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) + pending.rafId = null + } + remainingFrames = FIT_SCROLL_RESTORE_MAX_FRAMES + 1 + return retry() + } + pendingFitScrollRestores.set(terminal, pending) + pending.rafId = requestAnimationFrame(retry) +} + +export function resumePendingFitScrollRestoreAfterFit(terminal: Terminal): boolean { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { + return false + } + if (!pending.shouldRestore()) { + cancelPendingFitScrollRestore(terminal) + return false + } + return pending.retryAfterFit() } export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollState): void { @@ -114,13 +247,13 @@ export function restoreScrollStateAfterLayout(terminal: Terminal, state: ScrollS deferredScrollRestores.set(terminal, pending) } -function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { +function restoreScrollStateNow(terminal: Terminal, state: ScrollState): ScrollRestoreResult { if (!terminal.element) { - return + return 'retry' } const buf = terminal.buffer.active if (state.bufferType === 'alternate' || buf.type !== state.bufferType) { - return + return 'skipped' } // Why: WebGL suspend disposes xterm's render service while leaving @@ -129,24 +262,42 @@ function restoreScrollStateNow(terminal: Terminal, state: ScrollState): void { // window quietly — the next visibility flip re-fits and re-restores. if (state.wasAtBottom) { if (safeScrollCall(() => terminal.scrollToBottom())) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } - return + return 'retry' } + const logicalMarkerLine = + state.firstVisibleLogicalLineMarker && !state.firstVisibleLogicalLineMarker.isDisposed + ? state.firstVisibleLogicalLineMarker.line + : -1 const markerLine = state.firstVisibleLineMarker && !state.firstVisibleLineMarker.isDisposed ? state.firstVisibleLineMarker.line : -1 - const targetLine = Math.min(markerLine >= 0 ? markerLine : state.viewportY, buf.baseY) + const logicalTargetLine = + logicalMarkerLine >= 0 && state.firstVisibleLogicalCellOffset !== undefined + ? resolveLogicalCellOffsetLine( + terminal, + logicalMarkerLine, + state.firstVisibleLogicalCellOffset + ) + : null + const targetLine = Math.min( + logicalTargetLine ?? (markerLine >= 0 ? markerLine : state.viewportY), + buf.baseY + ) state.viewportY = targetLine // Why: deferred rAF/timeout restores re-invoke this function after xterm // reflow settles; keep the marker alive so each call consults the live // line. Callers (restoreScrollState, the timeout in // restoreScrollStateAfterLayout, cancelDeferredScrollRestore) own disposal. if (safeScrollCall(() => terminal.scrollToLine(targetLine))) { - forceViewportScrollbarSync(terminal) + forceTerminalViewportScrollbarSync(terminal) + return 'restored' } + return 'retry' } function safeScrollCall(fn: () => void): boolean { @@ -166,23 +317,21 @@ function safeScrollCall(fn: () => void): boolean { export function releaseScrollStateMarker(state: ScrollState): void { state.firstVisibleLineMarker?.dispose() - state.firstVisibleLineMarker = undefined + if (state.firstVisibleLogicalLineMarker !== state.firstVisibleLineMarker) { + state.firstVisibleLogicalLineMarker?.dispose() + } + state.firstVisibleLineMarker = state.firstVisibleLogicalLineMarker = undefined } -// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. -// A synchronous one-line jiggle updates the scrollbar without a visible paint. -function forceViewportScrollbarSync(terminal: Terminal): void { - const buf = terminal.buffer.active - if (buf.viewportY >= buf.baseY) { - // Why: jiggle-scrolling at bottom makes xterm stop following active output - // after split-pane resizes; scrollToBottom already places the thumb there. +function cancelPendingFitScrollRestore(terminal: object): void { + const pending = pendingFitScrollRestores.get(terminal) + if (!pending) { return } - if (buf.viewportY > 0) { - safeScrollCall(() => terminal.scrollLines(-1)) - safeScrollCall(() => terminal.scrollLines(1)) - } else if (buf.viewportY < buf.baseY) { - safeScrollCall(() => terminal.scrollLines(1)) - safeScrollCall(() => terminal.scrollLines(-1)) + pending.cancelled = true + if (pending.rafId !== null && typeof cancelAnimationFrame === 'function') { + cancelAnimationFrame(pending.rafId) } + releaseScrollStateMarker(pending.state) + pendingFitScrollRestores.delete(terminal) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index cd5c046b2..c57175bb4 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -7,10 +7,6 @@ import { writeForegroundTerminalChunk, type ForegroundTerminalOutputTarget } from './pane-terminal-foreground-render-settle' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent -} from './terminal-scroll-intent' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' import { @@ -833,6 +829,9 @@ function hasDrainableBacklog(): boolean { return false } +// Why no per-write scroll enforcement: xterm's BufferService.isUserScrolling +// natively owns live follow/pin semantics. App-side intent enforcement is +// limited to structural operations xterm cannot identify, such as replay. function writeBackgroundTerminalChunk( terminal: TerminalOutputTarget, data: string, @@ -848,29 +847,13 @@ function writeBackgroundTerminalChunk( const runOnWriteFailure = onWriteFailure ? (): void => runGuardedWriteCompletionStep('background-on-write-failure', onWriteFailure) : undefined - const scrollIntent = captureTerminalWriteScrollIntent(terminal) try { - if (!scrollIntent) { - if (!runOnParsed || terminal.write.length < 2) { - terminal.write(data) - runOnParsed?.() - return true - } - terminal.write(data, runOnParsed) - return true - } - const runScrollIntentThenParsed = (): void => { - runGuardedWriteCompletionStep('background-scroll-intent', () => - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - ) - runOnParsed?.() - } - if (terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - runScrollIntentThenParsed() + runOnParsed?.() return true } - terminal.write(data, runScrollIntentThenParsed) + terminal.write(data, runOnParsed) return true } catch { runOnWriteFailure?.() @@ -878,32 +861,6 @@ function writeBackgroundTerminalChunk( } } -function writeForegroundTerminalChunkWithIntent( - terminal: TerminalOutputTarget, - data: string, - options: { - forceViewportRefresh: boolean - followupViewportRefresh: boolean - shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver - onParsed?: TerminalOutputParsedCallback - onWriteFailure?: () => void - } -): boolean { - const scrollIntent = captureTerminalWriteScrollIntent(terminal) - return writeForegroundTerminalChunk(terminal, data, { - forceViewportRefresh: options.forceViewportRefresh, - followupViewportRefresh: options.followupViewportRefresh, - shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, - onParsed: () => { - // Why: recovery must repaint from the scrolled buffer state that xterm - // will keep, not from a pre-intent-restored viewport snapshot. - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - options.onParsed?.() - }, - onWriteFailure: options.onWriteFailure - }) -} - function takeNextDrainableEntry(): QueueEntry | null { let largeBacklogEntry: QueueEntry | null = null for (const entry of queuedByTerminal.values()) { @@ -1015,7 +972,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( entry.terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1052,8 +1009,8 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null return null } } catch { - // Why: beforeWrite or pre-write viewport capture can fail before xterm owns - // the bytes. Cancel the armed watch without claiming parser failure. + // Why: beforeWrite or write setup can fail before xterm owns the bytes. + // Cancel the armed watch without claiming parser failure. cancelTerminalWriteStallWatch(entry.terminal) ackCreditsParsed?.() fireQueuedAckCredits(entry) @@ -1305,7 +1262,7 @@ export function writeTerminalOutput( }) try { options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + writeForegroundTerminalChunk( terminal, options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, { @@ -1399,7 +1356,7 @@ export function flushTerminalOutput( try { queuedWrite.beforeWrite?.(queuedWrite.data) const writeAccepted = queuedWrite.foreground - ? writeForegroundTerminalChunkWithIntent( + ? writeForegroundTerminalChunk( terminal, queuedWrite.stripTransientCursorShows ? removeTransientCursorShowSequences(queuedWrite.data) @@ -1431,7 +1388,7 @@ export function flushTerminalOutput( return } } catch { - // Why: pre-write hooks/capture failed before xterm owned these bytes. + // Why: pre-write hooks/setup failed before xterm owned these bytes. // Cancel the watch; consumed + abandoned chunks still credit delivery. cancelTerminalWriteStallWatch(terminal) ackCreditsParsed?.() diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index 16a4e0092..66c3ed3a1 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -1,7 +1,23 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { equalizePaneSplitSizes, safeFit } from './pane-tree-ops' +import { + cancelPendingSafeFitContinuations, + equalizePaneSplitSizes, + safeFit, + safeFitAndThen +} from './pane-tree-ops' import type { ManagedPaneInternal, ScrollState } from './pane-manager-types' import { setFitOverride, hydrateOverrides } from './mobile-fit-overrides' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' class MockHTMLElement { classList: { contains: (cls: string) => boolean } @@ -21,6 +37,7 @@ beforeAll(() => { afterEach(() => { hydrateOverrides([]) + vi.unstubAllGlobals() }) function createPane({ @@ -178,6 +195,54 @@ describe('safeFit', () => { expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) }) + it('coalesces fits until replay parsing and scroll restoration complete', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + activeBuffer.viewportY = 80 + activeBuffer.baseY = 100 + markTerminalPinnedViewport(pane.terminal) + const intent = captureTerminalStructuralScrollIntent(pane.terminal) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + + safeFit(pane) + safeFit(pane) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + + activeBuffer.viewportY = 200 + activeBuffer.baseY = 200 + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + expect(activeBuffer.viewportY).toBe(180) + }) + endTerminalScrollIntentBufferRebuild(pane.terminal) + restoreTerminalStructuralScrollIntent(pane.terminal, intent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + }) + + it('drops a deferred fit when a replay rebuild is canceled', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + safeFit(pane) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + }) + it('restores the viewport if fit clobbers it during resize', () => { const pane = createPane({ proposedCols: 100, @@ -231,6 +296,66 @@ describe('safeFit', () => { expect(marker.dispose).toHaveBeenCalled() }) + it('records the restored post-reflow pin when widening lowers baseY', () => { + const pane = createPane({ + proposedCols: 160, + proposedRows: 32, + terminalCols: 80, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 + }) + + safeFit(pane) + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('preserves a durable pin when the remounted fit buffer is still empty', () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + markTerminalPinnedViewport(pane.terminal) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 0 + safeFit(pane) + + activeBuffer.viewportY = 0 + activeBuffer.baseY = 80 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(22) + }) + it('keeps a follow-output pane at the bottom through fit', () => { const pane = createPane({ proposedCols: 100, @@ -251,22 +376,236 @@ describe('safeFit', () => { expect(pane.terminal.scrollToBottom).toHaveBeenCalled() }) - it('does not throw when xterm rejects scroll restoration during layout', () => { + it('retries a transient dimensions failure before recording the post-fit pin', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) const pane = createPane({ proposedCols: 100, proposedRows: 32, terminalCols: 120, terminalRows: 32 }) - const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number } + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } activeBuffer.viewportY = 42 activeBuffer.baseY = 100 - vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { - throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + activeBuffer.cursorY = 0 + const marker = { line: 42, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + marker.line = 30 }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) expect(() => safeFit(pane)).not.toThrow() expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(activeBuffer.viewportY).toBe(0) + expect(marker.dispose).not.toHaveBeenCalled() + + frameCallbacks.shift()?.(0) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalled() + + activeBuffer.viewportY = 0 + vi.mocked(pane.terminal.scrollToLine).mockClear() + enforceTerminalCurrentScrollIntent(pane.terminal) + expect(pane.terminal.scrollToLine).toHaveBeenLastCalledWith(30) + }) + + it('cancels a pending fit retry when snapshot replay starts', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + markTerminalPinnedViewport(pane.terminal) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + safeFit(pane) + beginTerminalScrollIntentBufferRebuild(pane.terminal) + frameCallbacks.shift()?.(0) + + expect(pane.terminal.scrollToLine).toHaveBeenCalledTimes(1) + expect(marker.dispose).toHaveBeenCalledTimes(1) + endTerminalScrollIntentBufferRebuild(pane.terminal) + }) + + it('carries the original fit marker across another fit before retry', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + safeFit(pane) + + expect(activeBuffer.viewportY).toBe(30) + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) + expect(frameCallbacks).toHaveLength(1) + }) + + it('resumes an exhausted dimensions retry on a same-grid reveal fit', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const marker = { line: 30, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine).mockImplementation(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + frameCallbacks.shift()?.(0) + frameCallbacks.shift()?.(0) + expect(marker.dispose).not.toHaveBeenCalled() + + vi.mocked(pane.terminal.scrollToLine).mockImplementation((line: number) => { + activeBuffer.viewportY = line + }) + ;(pane.terminal as unknown as { cols: number }).cols = 100 + safeFit(pane) + expect(activeBuffer.viewportY).toBe(30) + expect(marker.dispose).toHaveBeenCalledTimes(1) + }) + + it('releases a replacement marker when a resumed retry throws', () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 120, + terminalRows: 32 + }) + const activeBuffer = pane.terminal.buffer.active as { + viewportY: number + baseY: number + cursorY?: number + } + activeBuffer.viewportY = 42 + activeBuffer.baseY = 100 + activeBuffer.cursorY = 0 + const originalMarker = { line: 30, isDisposed: false, dispose: vi.fn() } + const replacementMarker = { line: 5, isDisposed: false, dispose: vi.fn() } + ;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi + .fn() + .mockReturnValueOnce(originalMarker) + .mockReturnValueOnce(replacementMarker) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + activeBuffer.baseY = 70 + activeBuffer.viewportY = 0 + }) + vi.mocked(pane.terminal.scrollToLine) + .mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + .mockImplementationOnce(() => { + throw new Error('unexpected resumed restore failure') + }) + markTerminalPinnedViewport(pane.terminal) + + safeFit(pane) + expect(() => safeFit(pane)).not.toThrow() + + expect(originalMarker.dispose).toHaveBeenCalledTimes(1) + expect(replacementMarker.dispose).toHaveBeenCalledTimes(1) }) it('still refits when a split-scroll lock is active and the grid changed', () => { @@ -396,6 +735,73 @@ describe('safeFit', () => { expect(paneB.fitAddon.fit).toHaveBeenCalledTimes(1) expect(paneB.terminal.resize).not.toHaveBeenCalled() }) + + it('runs an authoritative continuation only after a deferred replay fit', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.fit).mockImplementation(() => { + pane.terminal.resize(100, 32) + }) + const observedDimensions: { cols: number; rows: number }[] = [] + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + safeFitAndThen(pane, 'pty-resize', () => { + observedDimensions.push({ cols: pane.terminal.cols, rows: pane.terminal.rows }) + }) + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(observedDimensions).toEqual([]) + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(observedDimensions).toEqual([{ cols: 100, rows: 32 }]) + }) + + it('retains an authoritative continuation until a later measurable fit succeeds', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue(undefined) + const continuation = vi.fn() + + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + expect(continuation).not.toHaveBeenCalled() + vi.mocked(pane.fitAddon.proposeDimensions).mockReturnValue({ cols: 100, rows: 32 }) + safeFit(pane) + + await expect(pending.completion).resolves.toBe(true) + expect(continuation).toHaveBeenCalledTimes(1) + }) + + it('cancels an authoritative fit continuation disposed before its post-replay microtask', async () => { + const pane = createPane({ + proposedCols: 100, + proposedRows: 32, + terminalCols: 80, + terminalRows: 24 + }) + const continuation = vi.fn() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + const pending = safeFitAndThen(pane, 'pty-resize', continuation) + + endTerminalScrollIntentBufferRebuild(pane.terminal) + cancelTerminalScrollIntentBufferRebuildCompletions(pane.terminal) + cancelPendingSafeFitContinuations(pane) + await Promise.resolve() + + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(continuation).not.toHaveBeenCalled() + await expect(pending.completion).resolves.toBe(false) + }) }) describe('equalizePaneSplitSizes', () => { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index d069244cf..833eb1ff8 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -6,16 +6,15 @@ import type { PaneStyleOptions } from './pane-manager-types' import { createDivider, disposeDivider } from './pane-divider' -import { getFitOverrideForPty } from './mobile-fit-overrides' import { disposeWebgl, attachWebgl } from './pane-webgl-renderer' -import { - captureTerminalWriteScrollIntent, - enforceTerminalWriteScrollIntent, - syncTerminalScrollIntentFromViewport -} from './terminal-scroll-intent' -import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll' -import type { ScrollState } from './pane-manager-types' +import { safeFit } from './pane-fit' +export { + cancelPendingSafeFitContinuations, + safeFit, + safeFitAndThen, + type SafeFitContinuationHandle +} from './pane-fit' export { captureScrollState, restoreScrollState } from './pane-scroll' // --------------------------------------------------------------------------- @@ -33,111 +32,6 @@ type TreeOpsCallbacks = { requestPaneReparentFrame?: (callback: FrameRequestCallback) => void } -const MIN_PANE_FIT_WIDTH_PX = 48 -const MIN_PANE_FIT_HEIGHT_PX = 24 -const MIN_PANE_FIT_COLS = 8 -const MIN_PANE_FIT_ROWS = 4 - -function getProposedDimensions(pane: ManagedPane): { cols: number; rows: number } | null { - try { - return pane.fitAddon.proposeDimensions() ?? null - } catch { - return null - } -} - -function canMeasurePaneForFit(pane: ManagedPane): boolean { - const measure = pane.container.getBoundingClientRect - if (typeof measure === 'function') { - const rect = measure.call(pane.container) - if (rect.width < MIN_PANE_FIT_WIDTH_PX || rect.height < MIN_PANE_FIT_HEIGHT_PX) { - return false - } - } - const dims = getProposedDimensions(pane) - if (!dims) { - return false - } - // Why: worktree switches can briefly measure a near-zero overlay before - // fallback positioning lands. Fitting there pins the PTY at ~2 cols until - // the next user-driven resize. - return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS -} - -function canPreserveScrollIntentForFit(pane: ManagedPane): boolean { - // Why: split reparent has its own delayed restore; restoring here can fight that timer. - return !( - 'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState - ) -} - -export function safeFit(pane: ManagedPane): void { - if (!canMeasurePaneForFit(pane)) { - return - } - let scrollIntent = null as ReturnType - let pinnedScrollState: ScrollState | null = null - let shouldRestoreScroll = false - const captureScrollForFit = (): void => { - scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) - // Why: fit can reflow and renumber every buffer row; a marker tracks the - // pinned content itself, while a numeric line would point elsewhere after. - pinnedScrollState = - scrollIntent?.kind === 'pinnedViewport' ? captureScrollState(pane.terminal) : null - shouldRestoreScroll = true - } - try { - // Why: when a mobile client has resized this PTY to phone dimensions, - // the desktop must keep xterm at those dimensions instead of fitting to - // the desktop pane geometry. This prevents desktop auto-fit from undoing - // the mobile resize. Uses data-pty-id (set by bindPanePtyId) to look up - // the override by ptyId directly, avoiding pane ID collisions across tabs. - const ptyId = pane.container.dataset.ptyId - const override = ptyId ? getFitOverrideForPty(ptyId) : null - if (override) { - if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) { - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.terminal.resize(override.cols, override.rows) - } - return - } - - const dims = getProposedDimensions(pane) - if (dims && dims.cols === pane.terminal.cols && dims.rows === pane.terminal.rows) { - // Why: divider drags fire refits every frame, but most frames do not - // cross a cell boundary. Skipping those avoids FitAddon.clear()+refresh() - // churn, which was causing visible terminal blinking while resizing. - return - } - if (canPreserveScrollIntentForFit(pane)) { - captureScrollForFit() - } - pane.fitAddon.fit() - } catch { - // Container may not have dimensions yet - } finally { - if (shouldRestoreScroll) { - try { - if (pinnedScrollState) { - restoreScrollState(pane.terminal, pinnedScrollState) - syncTerminalScrollIntentFromViewport(pane.terminal) - } else { - enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent) - } - } catch { - // Why: xterm can temporarily expose a terminal whose renderer has not - // initialized dimensions yet during SSH reattach/layout. Fit is best-effort. - } finally { - if (pinnedScrollState) { - releaseScrollStateMarker(pinnedScrollState) - } - } - } - } -} - export function fitAllPanesInternal(panes: Map): void { for (const pane of panes.values()) { safeFit(pane) diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index 91b95f2ca..f2a07f875 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -3,6 +3,10 @@ import type { ManagedPaneInternal } from './pane-manager-types' import { disposePane } from './pane-lifecycle' import { suspendPaneRendering } from './pane-rendering-control' import { disposeWebgl } from './pane-webgl-renderer' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createPane( overrides: Partial> = {} @@ -14,11 +18,17 @@ function createPane( stablePaneId: leafId, terminal: { element: null, + cols: 80, rows: 24, + buffer: { active: { type: 'normal', viewportY: 0, baseY: 0 } }, refresh: vi.fn(), + resize: vi.fn(), dispose: vi.fn() } as never, - container: {} as never, + container: { + dataset: {}, + getBoundingClientRect: () => ({ width: 800, height: 600 }) + } as never, xtermContainer: {} as never, linkTooltip: {} as never, terminalGpuAcceleration: 'off', @@ -28,6 +38,7 @@ function createPane( hasComplexScriptOutput: false, fitAddon: { fit: vi.fn(), + proposeDimensions: vi.fn(() => ({ cols: 100, rows: 24 })), dispose: vi.fn() } as never, fitResizeObserver: null, @@ -66,6 +77,26 @@ describe('pane WebGL refresh lifecycle', () => { expect(pane.pendingWebglRefreshRafId).toBe(29) }) + it('defers the DOM-renderer refit until structural replay completes', async () => { + const refreshFrame: { current: FrameRequestCallback | null } = { current: null } + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + refreshFrame.current = callback + return 29 + }) + const pane = createPane() + beginTerminalScrollIntentBufferRebuild(pane.terminal) + + disposeWebgl(pane, { refreshDimensions: true }) + refreshFrame.current?.(0) + expect(pane.fitAddon.fit).not.toHaveBeenCalled() + expect(pane.terminal.refresh).not.toHaveBeenCalled() + + endTerminalScrollIntentBufferRebuild(pane.terminal) + await Promise.resolve() + expect(pane.fitAddon.fit).toHaveBeenCalledTimes(1) + expect(pane.terminal.refresh).toHaveBeenCalledTimes(1) + }) + it('actively releases the xterm WebGL context before disposing the addon', () => { const loseContext = vi.fn() const canvas = { width: 120, height: 40 } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index 7ae295b4a..790ce3a74 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -6,6 +6,7 @@ import { getTerminalWebglAutoDecision, resetTerminalWebglAutoDecision } from './terminal-webgl-auto-policy' +import { safeFitAndThen } from './pane-fit' export const ENABLE_WEBGL_RENDERER = true let suggestedRendererType: 'dom' | undefined @@ -90,8 +91,11 @@ export function disposeWebgl( pane.pendingWebglRefreshRafId = requestAnimationFrame(() => { pane.pendingWebglRefreshRafId = null try { - pane.fitAddon.fit() - pane.terminal.refresh(0, pane.terminal.rows - 1) + // Why: context loss can coincide with snapshot parsing; refresh only + // after the replay-aware fit has authoritative renderer dimensions. + safeFitAndThen(pane, 'webgl-fallback-refresh', () => { + pane.terminal.refresh(0, pane.terminal.rows - 1) + }) } catch { /* ignore — pane may have been disposed in the meantime */ } diff --git a/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts new file mode 100644 index 000000000..c7ed2bcce --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-reflow-scroll-anchor.ts @@ -0,0 +1,137 @@ +import type { Terminal } from '@xterm/xterm' + +type ReflowLineReader = { + getCellMetrics: (lineY: number, column: number) => { code: number; width: number } | undefined + isWrapped: (lineY: number) => boolean +} + +type TerminalWithInternalBufferLines = Terminal & { + _core?: { + _bufferService?: { + buffer?: { + lines?: { + get: (lineY: number) => + | { + getCodePoint: (column: number) => number + getWidth: (column: number) => number + isWrapped: boolean + length: number + } + | undefined + } + } + } + } +} + +export function captureLogicalLineAnchor( + terminal: Terminal, + viewportY: number +): { cellOffset: number; lineY: number } | undefined { + const buf = terminal.buffer.active + if (typeof buf.getLine !== 'function' || shouldKeepPhysicalResizeAnchor(terminal)) { + return undefined + } + const lines = createReflowLineReader(terminal) + let lineY = viewportY + while (lineY > 0 && lines.isWrapped(lineY)) { + lineY -= 1 + } + const cursorLineY = buf.baseY + buf.cursorY + if (terminal.options?.reflowCursorLine !== true && lineContainsLine(lines, lineY, cursorLineY)) { + return undefined + } + let cellOffset = 0 + for (let currentLineY = lineY; currentLineY < viewportY; currentLineY += 1) { + cellOffset += readReflowedRowCellCount(terminal, lines, currentLineY) + } + return { cellOffset, lineY } +} + +function shouldKeepPhysicalResizeAnchor(terminal: Terminal): boolean { + const windowsPty = terminal.options?.windowsPty + if (!windowsPty?.buildNumber) { + return false + } + // Why: xterm disables reflow only when an explicit legacy build is present; + // Orca's backend-only fallback for an unknown Windows build still reflows. + return windowsPty.backend !== 'conpty' || windowsPty.buildNumber < 21376 +} + +function lineContainsLine( + lines: ReflowLineReader, + logicalStartY: number, + targetY: number +): boolean { + if (targetY < logicalStartY) { + return false + } + for (let lineY = logicalStartY + 1; lineY <= targetY; lineY += 1) { + if (!lines.isWrapped(lineY)) { + return false + } + } + return true +} + +export function resolveLogicalCellOffsetLine( + terminal: Terminal, + logicalStartY: number, + cellOffset: number +): number { + const buf = terminal.buffer.active + const lines = createReflowLineReader(terminal) + let lineY = logicalStartY + let remainingCells = cellOffset + while (lineY < buf.baseY && lines.isWrapped(lineY + 1)) { + const rowCells = readReflowedRowCellCount(terminal, lines, lineY) + if (remainingCells < rowCells) { + break + } + remainingCells -= rowCells + lineY += 1 + } + return lineY +} + +function readReflowedRowCellCount( + terminal: Terminal, + lines: ReflowLineReader, + lineY: number +): number { + const cols = Math.max(terminal.cols, 1) + const lastCell = lines.getCellMetrics(lineY, cols - 1) + const nextFirstCell = lines.getCellMetrics(lineY + 1, 0) + // Why: xterm wraps a width-2 glyph one cell early when only the last column + // remains. That placeholder is not part of the logical cell offset. + return lastCell?.code === 0 && lastCell.width === 1 && nextFirstCell?.width === 2 + ? cols - 1 + : cols +} + +function createReflowLineReader(terminal: Terminal): ReflowLineReader { + const internalLines = (terminal as TerminalWithInternalBufferLines)._core?._bufferService?.buffer + ?.lines + if (internalLines) { + // Why: public getLine/getCell allocate wrapper objects per row/cell. The + // pinned xterm core exposes the same active lines without resize-path GC. + return { + isWrapped: (lineY) => internalLines.get(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const line = internalLines.get(lineY) + if (!line || column < 0 || column >= line.length) { + return undefined + } + return { code: line.getCodePoint(column), width: line.getWidth(column) } + } + } + } + const buffer = terminal.buffer.active + return { + isWrapped: (lineY) => buffer.getLine(lineY)?.isWrapped ?? false, + getCellMetrics: (lineY, column) => { + const cell = buffer.getLine(lineY)?.getCell(column) + return cell ? { code: cell.getCode(), width: cell.getWidth() } : undefined + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts new file mode 100644 index 000000000..730adc5a9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-buffer-snapshot.ts @@ -0,0 +1,53 @@ +export type TerminalScrollBufferType = 'normal' | 'alternate' + +export type TerminalScrollBufferTarget = { + buffer?: { + active?: { + type?: string + viewportY?: number + baseY?: number + } + } +} + +export type TerminalScrollBufferSnapshot = { + bufferType: TerminalScrollBufferType + viewportY: number + baseY: number +} + +export function readTerminalScrollBufferSnapshot( + terminal: TerminalScrollBufferTarget +): TerminalScrollBufferSnapshot | null { + const buffer = terminal.buffer?.active + const viewportY = buffer?.viewportY + const baseY = buffer?.baseY + if (typeof viewportY !== 'number' || typeof baseY !== 'number') { + return null + } + return { + bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', + viewportY, + baseY + } +} + +export function isTerminalViewportAtBottom(viewportY: number, baseY: number): boolean { + return viewportY >= baseY +} + +export function clampTerminalViewportY(viewportY: number, baseY: number): number { + return Math.max(0, Math.min(viewportY, baseY)) +} + +export function safeTerminalScrollCall(scroll: () => void): boolean { + try { + scroll() + return true + } catch (err) { + if (err instanceof TypeError && /dimensions/.test(err.message)) { + return false + } + throw err + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts new file mode 100644 index 000000000..954561570 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts @@ -0,0 +1,254 @@ +import type { IDisposable } from '@xterm/xterm' +import { + bindTerminalScrollIntentKey, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + isTerminalScrollIntentKeyBindingCurrent, + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' + +const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ + 'xterm-viewport', + 'xterm-scrollbar', + 'xterm-slider' +] as const +const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( + (className) => `.${className}` +).join(',') + +function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { + if (typeof Element === 'undefined' || !(target instanceof Element)) { + return false + } + // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. + return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null +} + +type TerminalWithOnData = { + onData?: (listener: (data: string) => void) => { dispose?: unknown } | undefined + _core?: { + coreService?: { + onUserInput?: (listener: () => void) => { dispose?: unknown } | undefined + } + } +} + +// Mouse reports (SGR "\x1b[ boolean, + captureInteractionRevision: () => number, + resyncUserInput: (interactionRevision: number) => void +): { dispose: () => void } | null { + const terminalWithInput = terminal as TerminalWithOnData + const onData = terminalWithInput.onData + if (typeof onData !== 'function') { + return null + } + const onUserInput = terminalWithInput._core?.coreService?.onUserInput + let pendingUserInputRevision: number | null = null + try { + const dataSubscription = onData((data: string) => { + if (isMouseReportInput(data)) { + pendingUserInputRevision = null + if (isActive() && getTerminalScrollIntentKind(terminal) === 'pinnedViewport') { + // Why: xterm treats mouse reports as user input and scrolls bottom + // before onData. Restore the reading position before output follows. + enforceTerminalCurrentScrollIntent(terminal) + } + return + } + if (typeof onUserInput === 'function') { + const interactionRevision = pendingUserInputRevision + pendingUserInputRevision = null + if (interactionRevision !== null && isActive()) { + resyncUserInput(interactionRevision) + } + } else if (isActive()) { + // Compatibility fallback for test doubles or an unexpected xterm + // shape; pinned production xterm uses onUserInput below. + resyncUserInput(captureInteractionRevision()) + } + }) + const userInputSubscription = onUserInput?.(() => { + // Why: xterm emits onUserInput immediately before its matching onData. + // Reserve order here, then let onData classify typing versus mouse. + pendingUserInputRevision = captureInteractionRevision() + }) + return { + dispose: () => { + if (dataSubscription && typeof dataSubscription.dispose === 'function') { + dataSubscription.dispose() + } + if (userInputSubscription && typeof userInputSubscription.dispose === 'function') { + userInputSubscription.dispose() + } + } + } + } catch { + return null + } +} + +/** Wires the user-driven scroll signals (wheel, scrollbar pointer drags) that + * are allowed to change a terminal's scroll intent. Output-driven scroll + * events deliberately do not update intent (see terminal-scroll-intent.ts). */ +export function attachTerminalScrollIntentTracking( + terminal: TerminalScrollIntentTarget, + host: HTMLElement, + intentKey?: TerminalScrollIntentKey +): IDisposable { + if (!bindTerminalScrollIntentKey(terminal, intentKey)) { + syncTerminalScrollIntentFromViewport(terminal) + } + let disposed = false + const isActive = (): boolean => !disposed + let pointerScrollActive = false + let cancelPostRebuildSync: (() => void) | null = null + let nextInteractionRevision = 0 + let latestCommittedInteractionRevision = 0 + let postRebuildSync: { revision: number; mode: 'sample' | 'preservePinnedAtBottom' } | null = null + const captureInteractionRevision = (): number => (nextInteractionRevision += 1) + + const syncFromViewportOrAfterRebuild = ( + mode: 'sample' | 'preservePinnedAtBottom' = 'sample', + interactionRevision = captureInteractionRevision() + ): boolean => { + if (interactionRevision < latestCommittedInteractionRevision) { + return false + } + latestCommittedInteractionRevision = interactionRevision + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + return true + } + postRebuildSync = { revision: interactionRevision, mode } + if (!cancelPostRebuildSync) { + cancelPostRebuildSync = onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + cancelPostRebuildSync = null + const pendingSync = postRebuildSync + postRebuildSync = null + if ( + completed && + isActive() && + pendingSync && + pendingSync.revision === latestCommittedInteractionRevision + ) { + // Why: wheel/scrollbar movement during replay must be sampled from + // the completed buffer, never from its transient cleared rows. + const preservePinnedAtBottom = pendingSync.mode === 'preservePinnedAtBottom' + if ( + preservePinnedAtBottom && + getTerminalScrollIntentKind(terminal) !== 'pinnedViewport' + ) { + markTerminalPinnedViewport(terminal) + } + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom + }) + if (preservePinnedAtBottom) { + // Why: an upward wheel or scrollbar gesture against the cleared 0/0 + // buffer must not erase the durable pin. Settle after restoration so + // a real move wins and a no-op gesture can still return to follow. + syncTerminalScrollIntentSoon(terminal, { + allowBufferShrink: true, + preservePinnedAtBottom: true, + shouldSync: isActive + }) + } + } + }) + } + return false + } + const userInputResync = subscribeScrollIntentUserInputResync( + terminal, + isActive, + captureInteractionRevision, + (interactionRevision) => syncFromViewportOrAfterRebuild('sample', interactionRevision) + ) + + const onWheel = (event: WheelEvent): void => { + if (!syncFromViewportOrAfterRebuild(event.deltaY < 0 ? 'preservePinnedAtBottom' : 'sample')) { + return + } + if (event.deltaY < 0) { + markTerminalPinnedViewport(terminal) + syncTerminalScrollIntentSoon(terminal, { + preservePinnedAtBottom: true, + shouldSync: isActive + }) + return + } + syncTerminalScrollIntentSoon(terminal, { shouldSync: isActive }) + } + + const onPointerDown = (event: PointerEvent): void => { + pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) + } + + const onPointerDone = (): void => { + if (!pointerScrollActive) { + return + } + pointerScrollActive = false + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + + const onScroll = (): void => { + if (pointerScrollActive) { + syncFromViewportOrAfterRebuild('preservePinnedAtBottom') + } + } + + host.addEventListener('wheel', onWheel, { capture: true, passive: true }) + host.addEventListener('pointerdown', onPointerDown, true) + host.addEventListener('scroll', onScroll, true) + globalThis.addEventListener?.('pointerup', onPointerDone, true) + globalThis.addEventListener?.('pointercancel', onPointerDone, true) + return { + dispose: () => { + // Why: native pinned output can grow baseY without a DOM scroll event; + // persist that geometry before remount, but never let an old instance + // overwrite a successor already bound to the same leaf key. + if (isTerminalScrollIntentKeyBindingCurrent(terminal)) { + syncTerminalScrollIntentFromViewport(terminal) + } + disposed = true + cancelPostRebuildSync?.() + cancelPostRebuildSync = null + postRebuildSync = null + userInputResync?.dispose() + host.removeEventListener('wheel', onWheel, true) + host.removeEventListener('pointerdown', onPointerDown, true) + host.removeEventListener('scroll', onScroll, true) + globalThis.removeEventListener?.('pointerup', onPointerDone, true) + globalThis.removeEventListener?.('pointercancel', onPointerDone, true) + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts new file mode 100644 index 000000000..c3739b5c4 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-input-resync.test.ts @@ -0,0 +1,298 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + captureTerminalStructuralScrollIntent, + enforceTerminalCurrentScrollIntent, + getTerminalScrollIntentKind, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' + +function createTerminal(viewportY: number, baseY: number) { + const terminal = { + buffer: { active: { type: 'normal' as const, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }), + onData: undefined as + | ((listener: (data: string) => void) => { dispose: () => void }) + | undefined, + _core: undefined as + | { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + | undefined + } + return terminal +} + +class TestElement extends EventTarget { + parentElement: TestElement | null = null + readonly classList = { + contains: (className: string): boolean => this.className.split(/\s+/).includes(className) + } + + constructor(public className = '') { + super() + } + + closest(selector: string): TestElement | null { + for (const candidate of selector.split(',')) { + const trimmed = candidate.trim() + if (trimmed.startsWith('.') && this.classList.contains(trimmed.slice(1))) { + return this + } + } + return this.parentElement?.closest(selector) ?? null + } +} + +function createTerminalWithInputCapture(viewportY: number, baseY: number) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(viewportY, baseY) + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } +} + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() +}) + +describe('terminal scroll-intent input resync', () => { + it('heals a stale pin when typing scrolls the terminal to the bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('heals a stale pre-reflow pin when typing reaches a shorter buffer bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.baseY = 70 + terminal.buffer.active.viewportY = 70 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('keeps a real pin when app-consumed input does not move the viewport', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[5~') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not reclassify a pin from mouse reports even when they scroll to bottom', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<35;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + expect(terminal.buffer.active.viewportY).toBe(42) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + it('does not let a focus reply make a following mouse report reclassify the pin', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedInput.listener?.('\x1b[I') + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<0;10;5M') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('ignores a parser reply that has no matching user-input signal', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('\x1b[1;1R') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not apply input resync after tracking is disposed', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + + markTerminalPinnedViewport(terminal) + capturedUserInput.listener?.() + disposable.dispose() + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + capturedInput.listener?.('a') + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it('defers real typing intent until a snapshot rebuild completes', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('lets later typing supersede a wheel-up pin during snapshot replay', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture(80, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('a') + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + disposable.dispose() + }) + + it('ignores parser auto-replies while a snapshot rebuild is partial', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput } = createTerminalWithInputCapture(42, 100) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const intent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 5 + terminal.buffer.active.viewportY = 5 + capturedInput.listener?.('\x1b[1;1R') + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(142) + disposable.dispose() + }) + + it('supports bottom-offset restore for structural buffer rebuilds', () => { + const terminal = createTerminal(550, 600) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 80 + terminal.buffer.active.viewportY = 80 + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) + expect(terminal.buffer.active.viewportY).toBe(30) + }) + + it('retains the intended bottom-offset pin when renderer dimensions reject restore', () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) + terminal.buffer.active.baseY = 200 + terminal.buffer.active.viewportY = 200 + terminal.scrollToLine.mockImplementationOnce(() => { + throw new TypeError("Cannot read properties of undefined (reading 'dimensions')") + }) + + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + expect(terminal.buffer.active.viewportY).toBe(200) + + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts new file mode 100644 index 000000000..3de8e79a9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-rebuild.ts @@ -0,0 +1,125 @@ +// Why: buffer rebuilds (snapshot replay clear + rewrite) parse asynchronously. +// Until the rebuild's bytes have parsed, viewportY/baseY describe a transient +// half-cleared buffer; any intent capture/enforce latched from it pins the +// terminal at line 0. Callers bracket the rebuild and re-apply intent once +// after parse (see terminal-scroll-intent.ts). +const terminalScrollIntentRebuilds = new WeakMap() +const terminalScrollIntentRebuildCompletions = new WeakMap< + object, + Set<(completed: boolean) => void> +>() +const deferredTerminalGeometryMutations = new WeakMap< + object, + { + mutations: Map void> + } +>() + +function notifyRebuildCompletions( + completions: Set<(completed: boolean) => void> | undefined, + completed: boolean +): void { + for (const completion of completions ?? []) { + try { + completion(completed) + } catch (error) { + // Why: one optional observer must not strand the rebuild or prevent the + // coordinator from restoring the authoritative viewport. + console.error('[terminal] scroll-intent rebuild completion failed', error) + } + } +} + +export function beginTerminalScrollIntentBufferRebuild(terminal: object): void { + terminalScrollIntentRebuilds.set(terminal, (terminalScrollIntentRebuilds.get(terminal) ?? 0) + 1) +} + +export function endTerminalScrollIntentBufferRebuild(terminal: object): void { + const count = terminalScrollIntentRebuilds.get(terminal) ?? 0 + if (count <= 1) { + terminalScrollIntentRebuilds.delete(terminal) + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, true) + return + } + terminalScrollIntentRebuilds.set(terminal, count - 1) +} + +export function isTerminalScrollIntentRebuildInFlight(terminal: object): boolean { + return (terminalScrollIntentRebuilds.get(terminal) ?? 0) > 0 +} + +export function onTerminalScrollIntentBufferRebuildComplete( + terminal: object, + completion: (completed: boolean) => void +): () => void { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + completion(true) + return () => {} + } + let completions = terminalScrollIntentRebuildCompletions.get(terminal) + if (!completions) { + completions = new Set() + terminalScrollIntentRebuildCompletions.set(terminal, completions) + } + completions.add(completion) + return () => { + completions?.delete(completion) + if (completions?.size === 0) { + terminalScrollIntentRebuildCompletions.delete(terminal) + } + } +} + +// Why: source-dimension replay must finish and restore its viewport before +// unrelated fit/resize work is allowed to reflow the rebuilt buffer. +export function deferTerminalGeometryMutationDuringRebuild( + terminal: object, + operationKey: string, + mutation: () => void +): boolean { + if (!isTerminalScrollIntentRebuildInFlight(terminal)) { + return false + } + const existing = deferredTerminalGeometryMutations.get(terminal) + if (existing) { + existing.mutations.set(operationKey, mutation) + return true + } + const mutations = new Map([[operationKey, mutation]]) + const deferred = { mutations } + deferredTerminalGeometryMutations.set(terminal, deferred) + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + if (!completed) { + deferredTerminalGeometryMutations.delete(terminal) + return + } + // Why: rebuild completion listeners run before the coordinator restores + // intent; the microtask makes every geometry mutation post-restore. + queueMicrotask(() => { + if (deferredTerminalGeometryMutations.get(terminal) !== deferred) { + return + } + // Keep the entry cancellable until execution begins; disposal may land + // after rebuild completion but before this post-restore microtask. + deferredTerminalGeometryMutations.delete(terminal) + for (const [key, pendingMutation] of mutations) { + if (!deferTerminalGeometryMutationDuringRebuild(terminal, key, pendingMutation)) { + pendingMutation() + } + } + }) + }) + return true +} + +export function cancelTerminalScrollIntentBufferRebuildCompletions(terminal: object): void { + const completions = terminalScrollIntentRebuildCompletions.get(terminal) + terminalScrollIntentRebuildCompletions.delete(terminal) + notifyRebuildCompletions(completions, false) + deferredTerminalGeometryMutations.delete(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts new file mode 100644 index 000000000..a4962dff8 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-settle.ts @@ -0,0 +1,33 @@ +import { + syncTerminalScrollIntentFromViewport, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' + +export function syncTerminalScrollIntentSoon( + terminal: TerminalScrollIntentTarget, + options: { + allowBufferShrink?: boolean + preservePinnedAtBottom?: boolean + shouldSync?: () => boolean + } = {} +): void { + const sync = (): void => { + if (options.shouldSync?.() === false) { + return + } + syncTerminalScrollIntentFromViewport(terminal, options) + } + queueMicrotask(sync) + requestAnimationFrame(sync) + requestAnimationFrame(() => requestAnimationFrame(sync)) + // Why: preservePinnedAtBottom only bridges xterm's async scroll application. + // The settle tick must reclassify from the real viewport, otherwise a wheel + // the viewport never followed latches a phantom pin at the bottom. + setTimeout(() => { + if (options.shouldSync?.() !== false) { + syncTerminalScrollIntentFromViewport(terminal, { + allowBufferShrink: options.allowBufferShrink + }) + } + }, 80) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts new file mode 100644 index 000000000..4e6c2b666 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-structural-transitions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, + markTerminalFollowOutput, + markTerminalPinnedViewport, + restoreTerminalStructuralScrollIntent +} from './terminal-scroll-intent' + +type BufferType = 'normal' | 'alternate' + +function createTerminal(viewportY: number, baseY: number, type: BufferType = 'normal') { + const terminal = { + buffer: { active: { type, viewportY, baseY } }, + scrollToBottom: vi.fn(() => { + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + }), + scrollToLine: vi.fn((line: number) => { + terminal.buffer.active.viewportY = line + }) + } + return terminal +} + +describe('terminal structural scroll-intent transitions', () => { + it.each([ + { + name: 'live pinned growth', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 76, baseY: 120, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 120, bufferType: 'normal' } + }, + { + name: 'empty pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'shorter pinned remount', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 20, baseY: 30, type: 'normal' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'alternate buffer entered from a normal-buffer pin', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 0, baseY: 0, type: 'alternate' as const }, + expected: { kind: 'pinnedViewport', viewportY: 76, baseY: 100, bufferType: 'normal' } + }, + { + name: 'untracked return to bottom', + storedKind: 'pinnedViewport' as const, + live: { viewportY: 100, baseY: 100, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 100, baseY: 100, bufferType: 'normal' } + }, + { + name: 'empty follow-output remount', + storedKind: 'followOutput' as const, + live: { viewportY: 0, baseY: 0, type: 'normal' as const }, + expected: { kind: 'followOutput', viewportY: 0, baseY: 0, bufferType: 'normal' } + } + ])('captures the authoritative coordinates for $name', ({ storedKind, live, expected }) => { + const key = `structural-${storedKind}-${live.type}-${live.viewportY}-${live.baseY}` + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, key) + if (storedKind === 'pinnedViewport') { + markTerminalPinnedViewport(original) + } else { + original.buffer.active.viewportY = original.buffer.active.baseY + markTerminalFollowOutput(original) + } + + const current = createTerminal(live.viewportY, live.baseY, live.type) + bindTerminalScrollIntentKey(current, key) + + expect(captureTerminalStructuralScrollIntent(current)).toMatchObject(expected) + }) + + it('restores a durable remount pin by bottom offset without overwriting newer intent', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-remount-revision') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0) + bindTerminalScrollIntentKey(remounted, 'structural-remount-revision') + const staleIntent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.viewportY = 200 + remounted.buffer.active.baseY = 200 + markTerminalFollowOutput(remounted) + restoreTerminalStructuralScrollIntent(remounted, staleIntent, { restoreBy: 'bottomOffset' }) + + expect(remounted.scrollToLine).not.toHaveBeenCalled() + expect(remounted.buffer.active.viewportY).toBe(200) + }) + + it('keeps a normal-buffer pin dormant while replay restores an alternate buffer', () => { + const original = createTerminal(76, 100) + bindTerminalScrollIntentKey(original, 'structural-buffer-switch') + markTerminalPinnedViewport(original) + const remounted = createTerminal(0, 0, 'alternate') + bindTerminalScrollIntentKey(remounted, 'structural-buffer-switch') + const intent = captureTerminalStructuralScrollIntent(remounted) + + remounted.buffer.active.baseY = 40 + remounted.buffer.active.viewportY = 40 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).not.toHaveBeenCalled() + + remounted.buffer.active.type = 'normal' + remounted.buffer.active.baseY = 140 + remounted.buffer.active.viewportY = 140 + restoreTerminalStructuralScrollIntent(remounted, intent, { restoreBy: 'bottomOffset' }) + expect(remounted.scrollToLine).toHaveBeenLastCalledWith(116) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index 205bd8eca..5d3c9f26a 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -1,15 +1,22 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { - attachTerminalScrollIntentTracking, - captureTerminalWriteScrollIntent, + bindTerminalScrollIntentKey, + captureTerminalStructuralScrollIntent, enforceTerminalCurrentScrollIntent, - enforceTerminalWriteScrollIntent, getTerminalScrollIntentKind, markTerminalFollowOutput, markTerminalPinnedViewport, syncTerminalScrollIntentFromViewport, - syncTerminalScrollIntentSoon + restoreTerminalStructuralScrollIntent } from './terminal-scroll-intent' +import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' +import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent-dom-tracking' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' function createTerminal({ viewportY, @@ -99,14 +106,39 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('treats a viewport exactly one row above bottom as pinned', () => { + const terminal = createTerminal({ viewportY: 99, baseY: 100 }) + + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + syncTerminalScrollIntentFromViewport(terminal) + expect(captureTerminalStructuralScrollIntent(terminal)?.kind).toBe('pinnedViewport') + }) + + it('clears a pinned scrollback into follow-output state', () => { + const terminal = { + ...createTerminal({ viewportY: 42, baseY: 100 }), + clear: vi.fn() + } + markTerminalPinnedViewport(terminal) + + clearTerminalScrollbackAndFollowOutput(terminal) + + expect(terminal.clear).toHaveBeenCalledOnce() + expect(terminal.scrollToBottom).toHaveBeenCalledOnce() + expect(terminal.clear.mock.invocationCallOrder[0]).toBeLessThan( + terminal.scrollToBottom.mock.invocationCallOrder[0] + ) + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('preserves a pinned viewport after output moves xterm to bottom', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 125 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).toHaveBeenCalledWith(42) expect(terminal.buffer.active.viewportY).toBe(42) @@ -116,11 +148,11 @@ describe('terminal scroll intent', () => { it('follows output after output advances while following', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) markTerminalFollowOutput(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.baseY = 125 terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1) expect(terminal.buffer.active.viewportY).toBe(125) @@ -129,16 +161,30 @@ describe('terminal scroll intent', () => { it('does not preserve across buffer type changes', () => { const terminal = createTerminal({ viewportY: 42, baseY: 100 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) terminal.buffer.active.type = 'alternate' terminal.buffer.active.viewportY = 0 - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.scrollToLine).not.toHaveBeenCalled() expect(terminal.buffer.active.viewportY).toBe(0) }) + it('does not enforce a captured intent after newer user intent supersedes it', () => { + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + markTerminalPinnedViewport(terminal) + const staleSnapshot = captureTerminalStructuralScrollIntent(terminal) + + terminal.buffer.active.viewportY = terminal.buffer.active.baseY + markTerminalFollowOutput(terminal) + terminal.buffer.active.baseY = 125 + restoreTerminalStructuralScrollIntent(terminal, staleSnapshot) + + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') + }) + it('syncs intent from the current viewport after user scroll settles', () => { const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -148,6 +194,20 @@ describe('terminal scroll intent', () => { expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') }) + it('records xterm native scrollback-trim movement before structural enforcement', () => { + const terminal = createTerminal({ viewportY: 10, baseY: 20 }) + markTerminalPinnedViewport(terminal) + + // At scrollback capacity xterm keeps baseY fixed and walks viewportY up + // as old rows trim, preserving the visible content without app help. + terminal.buffer.active.viewportY = 5 + syncTerminalScrollIntentFromViewport(terminal) + terminal.buffer.active.viewportY = 0 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(5) + }) + it('tracks upward wheel immediately and records the settled viewport', async () => { const frameCallbacks: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -253,6 +313,131 @@ describe('terminal scroll intent', () => { remountedDisposable.dispose() }) + it('captures durable pinned coordinates before replaying into an empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-remount-replay' + ) + markTerminalPinnedViewport(firstTerminal) + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-remount-replay' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + + expect(intent).toMatchObject({ + kind: 'pinnedViewport', + viewportY: 76, + baseY: 100 + }) + remountedTerminal.buffer.active.viewportY = 100 + remountedTerminal.buffer.active.baseY = 100 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(76) + expect(remountedTerminal.buffer.active.viewportY).toBe(76) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('refreshes pinned base geometry before a keyed empty remount', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 10, baseY: 20 }) + const firstHost = new TestElement() as unknown as HTMLElement + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + firstHost, + 'leaf-growing-pin' + ) + markTerminalPinnedViewport(firstTerminal) + + firstTerminal.buffer.active.baseY = 30 + syncTerminalScrollIntentFromViewport(firstTerminal) + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedHost = new TestElement() as unknown as HTMLElement + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + remountedHost, + 'leaf-growing-pin' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 30 + remountedTerminal.buffer.active.baseY = 30 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 10, baseY: 30 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(10) + firstDisposable.dispose() + remountedDisposable.dispose() + }) + + it('persists native pinned growth on disposal for the next keyed replay', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + markTerminalPinnedViewport(firstTerminal) + firstTerminal.buffer.active.baseY = 120 + + firstDisposable.dispose() + + const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 }) + const remountedDisposable = attachTerminalScrollIntentTracking( + remountedTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-growth' + ) + const intent = captureTerminalStructuralScrollIntent(remountedTerminal) + remountedTerminal.buffer.active.viewportY = 200 + remountedTerminal.buffer.active.baseY = 200 + restoreTerminalStructuralScrollIntent(remountedTerminal, intent, { + restoreBy: 'bottomOffset' + }) + + expect(intent).toMatchObject({ viewportY: 76, baseY: 120 }) + expect(remountedTerminal.scrollToLine).toHaveBeenLastCalledWith(156) + remountedDisposable.dispose() + }) + + it('does not let an old terminal disposal overwrite its keyed successor', () => { + vi.stubGlobal('Element', TestElement) + const firstTerminal = createTerminal({ viewportY: 76, baseY: 100 }) + const firstDisposable = attachTerminalScrollIntentTracking( + firstTerminal, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalPinnedViewport(firstTerminal) + + const successor = createTerminal({ viewportY: 100, baseY: 100 }) + const successorDisposable = attachTerminalScrollIntentTracking( + successor, + new TestElement() as unknown as HTMLElement, + 'leaf-dispose-successor' + ) + markTerminalFollowOutput(successor) + firstTerminal.buffer.active.baseY = 150 + firstDisposable.dispose() + + expect(getTerminalScrollIntentKind(successor)).toBe('followOutput') + successorDisposable.dispose() + }) + it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => { vi.stubGlobal('Element', TestElement) const terminal = createTerminal({ viewportY: 100, baseY: 100 }) @@ -358,6 +543,33 @@ describe('terminal scroll intent', () => { expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75) }) + it('does not let a stale key-settle callback overwrite a remounted terminal', async () => { + const frameCallbacks: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + frameCallbacks.push(callback) + return frameCallbacks.length + }) + vi.useFakeTimers({ toFake: ['setTimeout'] }) + let firstTerminalIsCurrent = true + const first = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(first, 'key-settle-remount') + markTerminalPinnedViewport(first) + first.buffer.active.viewportY = 50 + syncTerminalScrollIntentSoon(first, { shouldSync: () => firstTerminalIsCurrent }) + + const replacement = createTerminal({ viewportY: 100, baseY: 100 }) + bindTerminalScrollIntentKey(replacement, 'key-settle-remount') + markTerminalFollowOutput(replacement) + firstTerminalIsCurrent = false + await Promise.resolve() + while (frameCallbacks.length > 0) { + frameCallbacks.shift()?.(16) + } + vi.advanceTimersByTime(80) + + expect(getTerminalScrollIntentKind(replacement)).toBe('followOutput') + }) + it('enforces current intent once for visibility resume', () => { const terminal = createTerminal({ viewportY: 40, baseY: 100 }) markTerminalPinnedViewport(terminal) @@ -434,11 +646,11 @@ describe('terminal scroll intent', () => { markTerminalPinnedViewport(terminal) for (let batch = 1; batch <= 2; batch += 1) { - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) // xterm follows output during the write because the viewport was at bottom. terminal.buffer.active.baseY += 25 terminal.buffer.active.viewportY = terminal.buffer.active.baseY - enforceTerminalWriteScrollIntent(terminal, snapshot) + restoreTerminalStructuralScrollIntent(terminal, snapshot) expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY) } expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput') @@ -470,16 +682,205 @@ describe('terminal scroll intent', () => { expect(terminal.buffer.active.viewportY).toBe(150) }) - it('supports bottom-offset restore for buffer-rebuild write paths', () => { - const terminal = createTerminal({ viewportY: 550, baseY: 600 }) + it('does not re-latch a pinned intent from a transiently shorter rebuilt buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) markTerminalPinnedViewport(terminal) - const snapshot = captureTerminalWriteScrollIntent(terminal) + const snapshot = captureTerminalStructuralScrollIntent(terminal) - terminal.buffer.active.baseY = 80 - terminal.buffer.active.viewportY = 80 - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' }) + // Snapshot replay cleared the buffer; enforcement races the async parse. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, snapshot) - expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30) - expect(terminal.buffer.active.viewportY).toBe(30) + // The replay finishes parsing and the scrollback regrows past the pin. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 284 + enforceTerminalCurrentScrollIntent(terminal) + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(248) + expect(terminal.buffer.active.viewportY).toBe(248) }) + + it('keeps a pinned intent when capture races a cleared unparsed buffer', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + + // A structural capture sees the rebuilt buffer while it is still empty; + // the at-bottom(0/0) reading is transient and must not convert the pin. + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + const snapshot = captureTerminalStructuralScrollIntent(terminal) + expect(snapshot?.kind).toBe('pinnedViewport') + }) + + it('suspends intent capture and enforcement while a buffer rebuild is in flight', () => { + const terminal = createTerminal({ viewportY: 248, baseY: 254 }) + markTerminalPinnedViewport(terminal) + const preReplay = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.baseY = 0 + terminal.buffer.active.viewportY = 0 + expect(captureTerminalStructuralScrollIntent(terminal)).toBeNull() + enforceTerminalCurrentScrollIntent(terminal) + + // A live streaming batch lands while the replay is partially parsed. + terminal.buffer.active.baseY = 284 + terminal.buffer.active.viewportY = 0 + restoreTerminalStructuralScrollIntent(terminal, preReplay) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + expect(terminal.scrollToBottom).not.toHaveBeenCalled() + + terminal.buffer.active.viewportY = 284 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, preReplay, { restoreBy: 'bottomOffset' }) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(278) + expect(terminal.buffer.active.viewportY).toBe(278) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + }) + + it.each([ + { deltaY: -10, finalViewportY: 150, expectedKind: 'pinnedViewport', expectedLine: 150 }, + { deltaY: 10, finalViewportY: 200, expectedKind: 'followOutput', expectedLine: null } + ])( + 'resyncs a $expectedKind wheel intent after a delayed rebuild', + async ({ deltaY, finalViewportY, expectedKind, expectedLine }) => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: deltaY }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = finalViewportY + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { + restoreBy: 'bottomOffset' + }) + + expect(getTerminalScrollIntentKind(terminal)).toBe(expectedKind) + terminal.buffer.active.viewportY = 0 + terminal.scrollToLine.mockClear() + enforceTerminalCurrentScrollIntent(terminal) + if (expectedLine === null) { + expect(terminal.scrollToBottom).toHaveBeenCalled() + } else { + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(expectedLine) + } + disposable.dispose() + } + ) + + it('preserves a durable pin when wheel-up lands on the cleared replay buffer', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 80, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('keeps rebuild wheel intent when xterm classifies the same event as mouse input', async () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const { terminal, capturedInput, capturedUserInput } = createTerminalWithInputCapture({ + viewportY: 80, + baseY: 100 + }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + const staleIntent = captureTerminalStructuralScrollIntent(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + capturedUserInput.listener?.() + capturedInput.listener?.('\x1b[<64;10;5M') + await Promise.resolve() + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + endTerminalScrollIntentBufferRebuild(terminal) + restoreTerminalStructuralScrollIntent(terminal, staleIntent, { restoreBy: 'bottomOffset' }) + await Promise.resolve() + + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(180) + expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport') + disposable.dispose() + }) + + it('does not resync deferred wheel intent from a canceled partial rebuild', () => { + vi.stubGlobal('requestAnimationFrame', () => 0) + vi.stubGlobal('Element', TestElement) + const terminal = createTerminal({ viewportY: 42, baseY: 100 }) + const host = new TestElement() as unknown as HTMLElement + const disposable = attachTerminalScrollIntentTracking(terminal, host) + markTerminalPinnedViewport(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + const wheel = new Event('wheel') as WheelEvent + Object.defineProperty(wheel, 'deltaY', { value: -10 }) + host.dispatchEvent(wheel) + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + endTerminalScrollIntentBufferRebuild(terminal) + + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 100 + enforceTerminalCurrentScrollIntent(terminal) + expect(terminal.scrollToLine).toHaveBeenLastCalledWith(42) + disposable.dispose() + }) + + function createTerminalWithInputCapture(args: { viewportY: number; baseY: number }) { + const capturedInput: { listener: ((data: string) => void) | null } = { listener: null } + const capturedUserInput: { listener: (() => void) | null } = { listener: null } + const terminal = createTerminal(args) as ReturnType & { + onData?: (listener: (data: string) => void) => { dispose: () => void } + _core?: { coreService: { onUserInput: (listener: () => void) => { dispose: () => void } } } + } + terminal.onData = (listener: (data: string) => void) => { + capturedInput.listener = listener + return { dispose: vi.fn() } + } + terminal._core = { + coreService: { + onUserInput: (listener: () => void) => { + capturedUserInput.listener = listener + return { dispose: vi.fn() } + } + } + } + return { terminal, capturedInput, capturedUserInput } + } }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index 88e70096a..8fa96d62c 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -1,35 +1,36 @@ -import type { IDisposable } from '@xterm/xterm' +import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { + clampTerminalViewportY, + isTerminalViewportAtBottom, + readTerminalScrollBufferSnapshot, + safeTerminalScrollCall, + type TerminalScrollBufferType +} from './terminal-scroll-buffer-snapshot' type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' -type BufferType = 'normal' | 'alternate' - -type TerminalScrollIntentTarget = { - buffer?: { - active?: { - type?: string - viewportY?: number - baseY?: number - } - } +export type TerminalScrollIntentTarget = { + buffer?: Parameters[0]['buffer'] scrollToBottom?: () => void scrollToLine?: (line: number) => void } -type TerminalScrollIntentKey = string +export type TerminalScrollIntentKey = string type TerminalScrollIntent = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } -type TerminalScrollIntentWriteSnapshot = { +export type TerminalStructuralScrollIntentSnapshot = { kind: TerminalScrollIntentKind - bufferType: BufferType + bufferType: TerminalScrollBufferType viewportY: number baseY: number + revision: number } type TerminalScrollIntentEnforceOptions = { @@ -47,47 +48,31 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< TerminalScrollIntentTarget, TerminalScrollIntentKey >() +const terminalScrollIntentKeyBindingByTerminal = new WeakMap() const terminalScrollIntentByKey = new Map() +const terminalScrollIntentBindingByKey = new Map() -const BOTTOM_TOLERANCE_ROWS = 1 -const XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES = [ - 'xterm-viewport', - 'xterm-scrollbar', - 'xterm-slider' -] as const -const XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR = XTERM_SCROLL_INTENT_POINTER_TARGET_CLASSES.map( - (className) => `.${className}` -).join(',') - -function readBufferSnapshot( - terminal: TerminalScrollIntentTarget -): { bufferType: BufferType; viewportY: number; baseY: number } | null { - const buffer = terminal.buffer?.active - const viewportY = buffer?.viewportY - const baseY = buffer?.baseY - if (typeof viewportY !== 'number' || typeof baseY !== 'number') { - return null - } - return { - bufferType: buffer?.type === 'alternate' ? 'alternate' : 'normal', - viewportY, - baseY - } -} - -function isAtBottom(viewportY: number, baseY: number): boolean { - return viewportY >= baseY - BOTTOM_TOLERANCE_ROWS -} +let nextTerminalScrollIntentRevision = 1 +let nextTerminalScrollIntentKeyBinding = 1 function writeIntent( terminal: TerminalScrollIntentTarget, kind: TerminalScrollIntentKind ): TerminalScrollIntent | null { - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } - const intent = { kind, ...snapshot } + return writeIntentSnapshot(terminal, kind, snapshot) +} + +function writeIntentSnapshot( + terminal: TerminalScrollIntentTarget, + kind: TerminalScrollIntentKind, + snapshot: { bufferType: TerminalScrollBufferType; viewportY: number; baseY: number } +): TerminalScrollIntent { + const intent = { kind, ...snapshot, revision: nextTerminalScrollIntentRevision } + nextTerminalScrollIntentRevision += 1 terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) if (key) { @@ -105,7 +90,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI return key ? terminalScrollIntentByKey.get(key) : undefined } -function bindTerminalScrollIntentKey( +export function bindTerminalScrollIntentKey( terminal: TerminalScrollIntentTarget, key: TerminalScrollIntentKey | undefined ): TerminalScrollIntent | undefined { @@ -113,6 +98,10 @@ function bindTerminalScrollIntentKey( return terminalScrollIntentByTerminal.get(terminal) } terminalScrollIntentKeyByTerminal.set(terminal, key) + const binding = nextTerminalScrollIntentKeyBinding + nextTerminalScrollIntentKeyBinding += 1 + terminalScrollIntentKeyBindingByTerminal.set(terminal, binding) + terminalScrollIntentBindingByKey.set(key, binding) const existing = terminalScrollIntentByKey.get(key) if (existing) { terminalScrollIntentByTerminal.set(terminal, existing) @@ -120,28 +109,17 @@ function bindTerminalScrollIntentKey( return existing } -function clampViewportY(viewportY: number, baseY: number): number { - return Math.max(0, Math.min(viewportY, baseY)) -} - -function safeScrollCall(fn: () => void): boolean { - try { - fn() +export function isTerminalScrollIntentKeyBindingCurrent( + terminal: TerminalScrollIntentTarget +): boolean { + const key = terminalScrollIntentKeyByTerminal.get(terminal) + if (!key) { return true - } catch (err) { - if (err instanceof TypeError && /dimensions/.test(err.message)) { - return false - } - throw err } -} - -function isTerminalScrollIntentPointerTarget(target: EventTarget | null): target is Element { - if (typeof Element === 'undefined' || !(target instanceof Element)) { - return false - } - // xterm's custom scrollbar uses separate thumb/track nodes from the viewport. - return target.closest(XTERM_SCROLL_INTENT_POINTER_TARGET_SELECTOR) !== null + return ( + terminalScrollIntentKeyBindingByTerminal.get(terminal) === + terminalScrollIntentBindingByKey.get(key) + ) } export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void { @@ -154,45 +132,53 @@ export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget) export function syncTerminalScrollIntentFromViewport( terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} + options: { allowBufferShrink?: boolean; preservePinnedAtBottom?: boolean } = {} ): void { - const snapshot = readBufferSnapshot(terminal) + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return } const existing = readStoredIntent(terminal) // Why: a remounted/replayed terminal can briefly report an empty or shorter // scrollback. That transient state must not erase a durable pinned viewport. - if (existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY) { + if ( + !options.allowBufferShrink && + existing?.kind === 'pinnedViewport' && + snapshot.baseY < existing.baseY + ) { terminalScrollIntentByTerminal.set(terminal, existing) return } if ( options.preservePinnedAtBottom && existing?.kind === 'pinnedViewport' && - isAtBottom(snapshot.viewportY, snapshot.baseY) + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) ) { return } - writeIntent( - terminal, - isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' - ) -} - -export function syncTerminalScrollIntentSoon( - terminal: TerminalScrollIntentTarget, - options: { preservePinnedAtBottom?: boolean } = {} -): void { - const sync = (): void => syncTerminalScrollIntentFromViewport(terminal, options) - queueMicrotask(sync) - requestAnimationFrame(sync) - requestAnimationFrame(() => requestAnimationFrame(sync)) - // Why: preservePinnedAtBottom only bridges xterm's async scroll application. - // The settle tick must reclassify from the real viewport, otherwise a wheel - // the viewport never followed (sub-row delta, TUI-consumed mouse report, - // plain PageUp/Home sent to the app) latches a phantom pin at the bottom. - setTimeout(() => syncTerminalScrollIntentFromViewport(terminal), 80) + const kind = isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' + // Why: parser auto-replies and repeated wheel settle samples often observe + // no intent change. Avoid manufacturing revisions that can cancel a valid + // structural restore or amplify terminal-output bursts. + if ( + existing?.kind === kind && + existing.bufferType === snapshot.bufferType && + (kind === 'followOutput' || existing.viewportY === snapshot.viewportY) + ) { + if (kind === 'pinnedViewport' && existing.baseY !== snapshot.baseY) { + // Why: native pinned output can grow baseY without moving viewportY. + // Refresh geometry without creating a user-intent revision so a later + // keyed remount restores the same content, not the stale bottom offset. + Object.assign(existing, snapshot) + } + return + } + writeIntent(terminal, kind) } export function getTerminalScrollIntentKind( @@ -202,52 +188,82 @@ export function getTerminalScrollIntentKind( if (existing) { return existing.kind } - const snapshot = readBufferSnapshot(terminal) + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return 'followOutput' } - return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport' + return isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport' } -export function captureTerminalWriteScrollIntent( +export function captureTerminalStructuralScrollIntent( terminal: TerminalScrollIntentTarget -): TerminalScrollIntentWriteSnapshot | null { - const snapshot = readBufferSnapshot(terminal) +): TerminalStructuralScrollIntentSnapshot | null { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return null + } + const snapshot = readTerminalScrollBufferSnapshot(terminal) if (!snapshot) { return null } const existing = readStoredIntent(terminal) let kind = existing?.kind ?? - (isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport') + (isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ? 'followOutput' + : 'pinnedViewport') // Why: a pinned intent whose live viewport still sits at the bottom is a - // phantom pin (the user's scroll never detached the viewport). Enforcing it - // would freeze the terminal at the current line on every write batch. - if (kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + // phantom pin (the user's scroll never detached the viewport). Restoring it + // after a structural operation would freeze the terminal at a stale line. + // Only trust the at-bottom reading when the scrollback is at least as long + // as the pin's — a shorter one is a cleared buffer awaiting replay. + if ( + kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) && + (!existing || snapshot.baseY >= existing.baseY) + ) { kind = 'followOutput' } + // Why: a keyed remount starts at 0/0 before replay. Preserve the durable + // pre-remount coordinates or a bottom-offset restore silently loses the pin. + const capturedCoordinates = + existing?.kind === 'pinnedViewport' && snapshot.baseY < existing.baseY ? existing : snapshot return { + ...capturedCoordinates, kind, - bufferType: snapshot.bufferType, - viewportY: snapshot.viewportY, - baseY: snapshot.baseY + revision: existing?.revision ?? 0 } } -export function enforceTerminalWriteScrollIntent( +export function isTerminalStructuralScrollIntentCurrent( terminal: TerminalScrollIntentTarget, - snapshot: TerminalScrollIntentWriteSnapshot | null, + snapshot: TerminalStructuralScrollIntentSnapshot | null +): boolean { + if (!snapshot) { + return false + } + return (readStoredIntent(terminal)?.revision ?? 0) === snapshot.revision +} + +export function restoreTerminalStructuralScrollIntent( + terminal: TerminalScrollIntentTarget, + snapshot: TerminalStructuralScrollIntentSnapshot | null, options: TerminalScrollIntentEnforceOptions = {} ): void { - if (!snapshot) { + if ( + !snapshot || + !isTerminalStructuralScrollIntentCurrent(terminal, snapshot) || + isTerminalScrollIntentRebuildInFlight(terminal) + ) { return } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) if (!current || current.bufferType !== snapshot.bufferType) { return } if (snapshot.kind === 'followOutput') { - if (safeScrollCall(() => terminal.scrollToBottom?.())) { + if (safeTerminalScrollCall(() => terminal.scrollToBottom?.())) { writeIntent(terminal, 'followOutput') } return @@ -256,89 +272,60 @@ export function enforceTerminalWriteScrollIntent( options.restoreBy === 'bottomOffset' ? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY) : snapshot.viewportY - const targetY = clampViewportY(requestedY, current.baseY) + const targetY = clampTerminalViewportY(requestedY, current.baseY) if (current.viewportY !== targetY) { - safeScrollCall(() => terminal.scrollToLine?.(targetY)) + if (!safeTerminalScrollCall(() => terminal.scrollToLine?.(targetY))) { + // Why: renderer teardown can reject the scroll before xterm changes its + // native viewport; retain the intended pin for the next fit/retry rather + // than latching the transient current bottom. + writeIntentSnapshot(terminal, 'pinnedViewport', { + bufferType: current.bufferType, + viewportY: targetY, + baseY: current.baseY + }) + return + } + } + const existing = readStoredIntent(terminal) + // Why: a scrollback shorter than the stored pin means the buffer is being + // rebuilt; re-latching from it would overwrite the durable line with the + // cleared buffer's line 0. + if (existing?.kind === 'pinnedViewport' && current.baseY < existing.baseY) { + return } writeIntent(terminal, 'pinnedViewport') } export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void { + if (isTerminalScrollIntentRebuildInFlight(terminal)) { + return + } const existing = readStoredIntent(terminal) if (!existing) { - enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal)) + restoreTerminalStructuralScrollIntent(terminal, captureTerminalStructuralScrollIntent(terminal)) return } const snapshot = { kind: existing.kind, bufferType: existing.bufferType, viewportY: existing.viewportY, - baseY: existing.baseY + baseY: existing.baseY, + revision: existing.revision } - if (snapshot.kind === 'pinnedViewport' && isAtBottom(snapshot.viewportY, snapshot.baseY)) { + if ( + snapshot.kind === 'pinnedViewport' && + isTerminalViewportAtBottom(snapshot.viewportY, snapshot.baseY) + ) { // Why: a pin recorded at the bottom means the viewport never detached; // resuming must follow live output, not freeze at that stale line. snapshot.kind = 'followOutput' } - const current = readBufferSnapshot(terminal) + const current = readTerminalScrollBufferSnapshot(terminal) // Why: a shorter live buffer than the stored intent means the buffer was // rebuilt (snapshot replay/remount); absolute lines are renumbered there. const restoreBy = snapshot.kind === 'pinnedViewport' && current && current.baseY < snapshot.baseY ? 'bottomOffset' : 'viewportLine' - enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy }) -} - -export function attachTerminalScrollIntentTracking( - terminal: TerminalScrollIntentTarget, - host: HTMLElement, - intentKey?: TerminalScrollIntentKey -): IDisposable { - if (!bindTerminalScrollIntentKey(terminal, intentKey)) { - syncTerminalScrollIntentFromViewport(terminal) - } - let pointerScrollActive = false - - const onWheel = (event: WheelEvent): void => { - if (event.deltaY < 0) { - markTerminalPinnedViewport(terminal) - syncTerminalScrollIntentSoon(terminal, { preservePinnedAtBottom: true }) - return - } - syncTerminalScrollIntentSoon(terminal) - } - - const onPointerDown = (event: PointerEvent): void => { - pointerScrollActive = isTerminalScrollIntentPointerTarget(event.target) - } - - const onPointerDone = (): void => { - if (!pointerScrollActive) { - return - } - pointerScrollActive = false - syncTerminalScrollIntentFromViewport(terminal) - } - - const onScroll = (): void => { - if (pointerScrollActive) { - syncTerminalScrollIntentFromViewport(terminal) - } - } - - host.addEventListener('wheel', onWheel, { capture: true, passive: true }) - host.addEventListener('pointerdown', onPointerDown, true) - host.addEventListener('scroll', onScroll, true) - globalThis.addEventListener?.('pointerup', onPointerDone, true) - globalThis.addEventListener?.('pointercancel', onPointerDone, true) - return { - dispose: () => { - host.removeEventListener('wheel', onWheel, true) - host.removeEventListener('pointerdown', onPointerDown, true) - host.removeEventListener('scroll', onScroll, true) - globalThis.removeEventListener?.('pointerup', onPointerDone, true) - globalThis.removeEventListener?.('pointercancel', onPointerDone, true) - } - } + restoreTerminalStructuralScrollIntent(terminal, snapshot, { restoreBy }) } diff --git a/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts new file mode 100644 index 000000000..84929536a --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scrollback-clear.ts @@ -0,0 +1,16 @@ +import { markTerminalFollowOutput, type TerminalScrollIntentTarget } from './terminal-scroll-intent' + +type TerminalScrollbackClearTarget = TerminalScrollIntentTarget & { + clear: () => void + scrollToBottom: () => void +} + +export function clearTerminalScrollbackAndFollowOutput( + terminal: TerminalScrollbackClearTarget +): void { + terminal.clear() + // Why: xterm clear() leaves BufferService.isUserScrolling latched when the + // viewport was pinned, so a public zero-distance bottom scroll must reset it. + terminal.scrollToBottom() + markTerminalFollowOutput(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts new file mode 100644 index 000000000..a4e2d86d4 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.test.ts @@ -0,0 +1,248 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createTerminalStructuralReplayCoordinator } from './terminal-structural-replay-coordinator' +import { + markTerminalPinnedViewport, + syncTerminalScrollIntentFromViewport +} from './terminal-scroll-intent' +import { + isTerminalScrollIntentRebuildInFlight, + onTerminalScrollIntentBufferRebuildComplete +} from './terminal-scroll-intent-rebuild' +import { restoreScrollStateAfterFit } from './pane-scroll' +import type { ScrollState } from './pane-manager-types' + +function createTerminal(viewportY: number, baseY: number) { + const active = { type: 'normal', viewportY, baseY } + return { + buffer: { active }, + scrollToBottom: vi.fn(() => { + active.viewportY = active.baseY + }), + scrollToLine: vi.fn((line: number) => { + active.viewportY = line + }) + } +} + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve = (): void => {} + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise + }) + return { promise, resolve } +} + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('terminal structural replay coordinator', () => { + it('serializes rebuilds and restores a pinned bottom offset after each parse', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const firstParsed = deferred() + const secondParsed = deferred() + const starts: string[] = [] + + const first = coordinator.run(async () => { + starts.push('first') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await firstParsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + const second = coordinator.run(async () => { + starts.push('second') + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await secondParsed.promise + terminal.buffer.active.viewportY = 300 + terminal.buffer.active.baseY = 300 + }) + + await Promise.resolve() + await Promise.resolve() + expect(starts).toEqual(['first']) + firstParsed.resolve() + await first + expect(terminal.buffer.active.viewportY).toBe(180) + await Promise.resolve() + expect(starts).toEqual(['first', 'second']) + secondParsed.resolve() + await second + expect(terminal.buffer.active.viewportY).toBe(280) + }) + + it('lets user viewport intent observed during replay supersede the old pin', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + if (completed) { + syncTerminalScrollIntentFromViewport(terminal, { allowBufferShrink: true }) + } + }) + await parsed.promise + terminal.buffer.active.viewportY = 190 + terminal.buffer.active.baseY = 200 + }) + + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(190) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('releases a rebuild on disposal without restoring a half-parsed buffer', async () => { + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const neverParsed = new Promise(() => {}) + let postRebuildCompleted: boolean | null = null + + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + onTerminalScrollIntentBufferRebuildComplete(terminal, (completed) => { + postRebuildCompleted = completed + }) + await neverParsed + }) + await Promise.resolve() + await Promise.resolve() + coordinator.dispose() + await completion + + expect(postRebuildCompleted).toBe(false) + expect(terminal.buffer.active.viewportY).toBe(0) + expect(terminal.scrollToLine).not.toHaveBeenCalled() + }) + + it('cancels a pre-existing fit restore before replay can make it stale', async () => { + const rafCallbacks: FrameRequestCallback[] = [] + const cancelAnimationFrame = vi.fn() + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + rafCallbacks.push(callback) + return rafCallbacks.length + }) + vi.stubGlobal('cancelAnimationFrame', cancelAnimationFrame) + const terminal = createTerminal(80, 100) as ReturnType & { + element: object | null + } + terminal.element = null + const staleState: ScrollState = { + bufferType: 'normal', + wasAtBottom: false, + viewportY: 20, + baseY: 100 + } + restoreScrollStateAfterFit(terminal as never, staleState, { + onRestored: vi.fn(), + shouldRestore: () => true + }) + expect(rafCallbacks).toHaveLength(1) + + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const parsed = deferred() + const completion = coordinator.run(async () => { + terminal.buffer.active.viewportY = 0 + terminal.buffer.active.baseY = 0 + await parsed.promise + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + }) + await Promise.resolve() + await Promise.resolve() + expect(cancelAnimationFrame).toHaveBeenCalledWith(1) + + terminal.element = {} + parsed.resolve() + await completion + expect(terminal.buffer.active.viewportY).toBe(180) + rafCallbacks[0]?.(0) + expect(terminal.buffer.active.viewportY).toBe(180) + }) + + it('restores and releases replay when an optional completion listener throws', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}) + const terminal = createTerminal(80, 100) + markTerminalPinnedViewport(terminal) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const laterCompletion = vi.fn() + + await coordinator.run(() => { + terminal.buffer.active.viewportY = 200 + terminal.buffer.active.baseY = 200 + onTerminalScrollIntentBufferRebuildComplete(terminal, () => { + throw new Error('optional listener failed') + }) + onTerminalScrollIntentBufferRebuildComplete(terminal, laterCompletion) + }) + + expect(laterCompletion).toHaveBeenCalledWith(true) + expect(terminal.buffer.active.viewportY).toBe(180) + expect(isTerminalScrollIntentRebuildInFlight(terminal)).toBe(false) + expect(consoleError).toHaveBeenCalledWith( + '[terminal] scroll-intent rebuild completion failed', + expect.any(Error) + ) + }) + + it('keeps later replay work serialized behind an asynchronous post-restore fit', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitCompleted = deferred() + const fitStarted = deferred() + const events: string[] = [] + + const first = coordinator.run( + () => { + events.push('first-replay') + }, + { + afterRestore: async () => { + events.push('first-fit') + fitStarted.resolve() + await fitCompleted.promise + } + } + ) + const second = coordinator.run(() => { + events.push('second-replay') + }) + + await fitStarted.promise + expect(events).toEqual(['first-replay', 'first-fit']) + fitCompleted.resolve() + await first + await second + expect(events).toEqual(['first-replay', 'first-fit', 'second-replay']) + }) + + it('releases an asynchronous post-restore wait when the coordinator is disposed', async () => { + const terminal = createTerminal(80, 100) + const coordinator = createTerminalStructuralReplayCoordinator(terminal) + const fitNeverCompletes = new Promise(() => {}) + const fitStarted = deferred() + const completion = coordinator.run(() => undefined, { + afterRestore: async () => { + fitStarted.resolve() + await fitNeverCompletes + } + }) + + await fitStarted.promise + coordinator.dispose() + + await expect(completion).resolves.toBeUndefined() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts new file mode 100644 index 000000000..35aee56a5 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-structural-replay-coordinator.ts @@ -0,0 +1,91 @@ +import { + captureTerminalStructuralScrollIntent, + restoreTerminalStructuralScrollIntent, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' +import { + beginTerminalScrollIntentBufferRebuild, + cancelTerminalScrollIntentBufferRebuildCompletions, + endTerminalScrollIntentBufferRebuild +} from './terminal-scroll-intent-rebuild' +import { cancelDeferredScrollRestore } from './pane-scroll' + +type StructuralReplayTask = () => void | Promise + +type StructuralReplayOptions = { + shouldRestore?: () => boolean + afterRestore?: () => void | Promise +} + +export type TerminalStructuralReplayCoordinator = { + run: (task: StructuralReplayTask, options?: StructuralReplayOptions) => Promise + dispose: () => void +} + +// Why: clear-and-replay bytes parse later and can overlap. One pane-scoped +// queue prevents dimension changes and stale viewport restores from interleaving. +export function createTerminalStructuralReplayCoordinator( + terminal: TerminalScrollIntentTarget +): TerminalStructuralReplayCoordinator { + let disposed = false + let activeCancellation: (() => void) | null = null + let tail = Promise.resolve() + + const run = ( + task: StructuralReplayTask, + options: StructuralReplayOptions = {} + ): Promise => { + const completion = tail + .catch(() => undefined) + .then(async () => { + if (disposed) { + return + } + const intent = captureTerminalStructuralScrollIntent(terminal) + // Why: a pre-replay fit retry can otherwise run after this transaction + // and restore a stale marker over the authoritative replay viewport. + cancelDeferredScrollRestore(terminal) + beginTerminalScrollIntentBufferRebuild(terminal) + let cancelTask = (): void => {} + const cancellation = new Promise((resolve) => { + cancelTask = resolve + }) + activeCancellation = () => { + cancelTask() + } + try { + const taskCompletion = Promise.resolve(task()) + await Promise.race([taskCompletion, cancellation]) + } finally { + endTerminalScrollIntentBufferRebuild(terminal) + try { + const shouldRestore = !disposed && options.shouldRestore?.() !== false + if (shouldRestore) { + restoreTerminalStructuralScrollIntent(terminal, intent, { restoreBy: 'bottomOffset' }) + // Why: live bytes must remain serialized behind replay until any + // post-restore fit has produced the authoritative destination grid. + await Promise.race([Promise.resolve(options.afterRestore?.()), cancellation]) + } + } finally { + activeCancellation = null + } + } + }) + tail = completion + return completion + } + + return { + run, + dispose: () => { + if (disposed) { + return + } + disposed = true + // Why: a torn-down terminal may silently drop write callbacks. Release + // the rebuild without sampling its half-parsed buffer into the keyed pin. + cancelTerminalScrollIntentBufferRebuildCompletions(terminal) + activeCancellation?.() + } + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts new file mode 100644 index 000000000..b5d970e08 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-viewport-scrollbar-sync.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +// Why: xterm 6 can leave its scrollbar thumb stale when ydisp is unchanged. +// A synchronous one-line jiggle updates the scrollbar without a visible paint. +export function forceTerminalViewportScrollbarSync(terminal: Terminal): void { + const buf = terminal.buffer.active + if (buf.viewportY >= buf.baseY) { + // Why: jiggle-scrolling at bottom makes xterm stop following active output + // after split-pane resizes; scrollToBottom already places the thumb there. + return + } + if (buf.viewportY > 0) { + safeScrollCall(() => terminal.scrollLines(-1)) + safeScrollCall(() => terminal.scrollLines(1)) + } else if (buf.viewportY < buf.baseY) { + safeScrollCall(() => terminal.scrollLines(1)) + safeScrollCall(() => terminal.scrollLines(-1)) + } +} + +function safeScrollCall(fn: () => void): void { + try { + fn() + } catch (error) { + if (!(error instanceof TypeError) || !/dimensions/.test(error.message)) { + throw error + } + } +} diff --git a/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts new file mode 100644 index 000000000..2141e2bf1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-user-scrolling-contract.test.ts @@ -0,0 +1,182 @@ +/** + * Contract test for xterm's native user-scrolling ownership (vendored + * 6.1.0-beta.287; @xterm/headless shares BufferService with @xterm/xterm). + * + * Orca's live PTY write path performs NO scroll-intent enforcement — it + * relies on xterm core keeping a scrolled-up viewport stable and following + * output at the bottom (BufferService.isUserScrolling, consumed atomically + * inside scroll()). App-side enforcement is scoped to structural operations + * (snapshot replay, remount, fit reflow) in terminal-scroll-intent.ts. + * + * If an xterm upgrade breaks any assertion here, the live write path loses + * its follow/pin semantics silently — fix the write path before bumping. + */ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import packageJson from '../../../../../package.json' +import { clearTerminalScrollbackAndFollowOutput } from './terminal-scrollback-clear' + +type TerminalWithBufferService = Terminal & { + _core?: { + _bufferService?: { isUserScrolling?: boolean } + coreService?: { onUserInput?: (listener: () => void) => { dispose: () => void } } + } +} + +function write(term: Terminal, data: string): Promise { + return new Promise((resolve) => term.write(data, resolve)) +} + +async function writeLines(term: Terminal, count: number, label: string): Promise { + for (let i = 0; i < count; i += 1) { + await write(term, `${label}${i}\r\n`) + } +} + +describe('xterm native user-scrolling contract (vendored 6.1.0-beta.287)', () => { + it('pins headless and renderer xterm to the same version', () => { + expect(packageJson.dependencies['@xterm/headless']).toBe( + packageJson.devDependencies['@xterm/xterm'] + ) + }) + + it('keeps a scrolled-up viewport stable while output is written', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 5) + + await writeLines(term, 10, 'more') + expect(buffer.viewportY).toBe(pinnedY) + expect(buffer.baseY).toBe(pinnedY + 15) + }) + + it('treats a viewport one row above bottom as user-scrolling through output', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + term.scrollLines(-1) + const pinnedY = buffer.viewportY + expect(pinnedY).toBe(buffer.baseY - 1) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + await writeLines(term, 5, 'more') + expect(buffer.viewportY).toBe(pinnedY) + }) + + it('follows output at the bottom and re-follows after scrolling back down', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + + await writeLines(term, 5, 'tail') + expect(buffer.viewportY).toBe(buffer.baseY) + + term.scrollLines(-5) + term.scrollToBottom() + await writeLines(term, 5, 'after') + expect(buffer.viewportY).toBe(buffer.baseY) + }) + + it('applies scrollOnUserInput before notifying onData listeners', async () => { + const term = new Terminal({ rows: 10, cols: 40, scrollback: 1000, allowProposedApi: true }) + await writeLines(term, 30, 'line') + const buffer = term.buffer.active + term.scrollLines(-5) + let viewportSeenByOnData = -1 + const subscription = term.onData(() => { + viewportSeenByOnData = buffer.viewportY + }) + + term.input('a', true) + + // Why: Orca resyncs typing intent synchronously from onData, so this + // xterm ordering is part of the pinned-version contract. + expect(viewportSeenByOnData).toBe(buffer.baseY) + subscription.dispose() + }) + + it('distinguishes real user input from parser auto-replies', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + allowProposedApi: true + }) as TerminalWithBufferService + expect(term._core?.coreService?.onUserInput).toBeTypeOf('function') + let userInputCount = 0 + const subscription = term._core?.coreService?.onUserInput?.(() => { + userInputCount += 1 + }) + + term.input('a', true) + await write(term, '\x1b[6n') + + expect(userInputCount).toBe(1) + subscription?.dispose() + }) + + it('walks a pinned viewport down content-stably when scrollback trims', async () => { + const term = new Terminal({ rows: 5, cols: 20, scrollback: 20, allowProposedApi: true }) + await writeLines(term, 30, 'x') + const buffer = term.buffer.active + term.scrollLines(-10) + const pinnedY = buffer.viewportY + const fullBaseY = buffer.baseY + + await writeLines(term, 10, 'trim') + // Buffer is at capacity: baseY stays put while each trimmed line shifts + // the pinned viewport up by one so the visible content does not move. + expect(buffer.baseY).toBe(fullBaseY) + expect(buffer.viewportY).toBe(Math.max(0, pinnedY - 10)) + }) + + it('exposes the isUserScrolling flag the structural restore paths depend on', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + const bufferService = term._core?._bufferService + expect(typeof bufferService?.isUserScrolling).toBe('boolean') + + // scrollLines/scrollToBottom self-manage the flag, so Orca's programmatic + // scroll restores inherit xterm's native live-output ownership. + expect(bufferService?.isUserScrolling).toBe(false) + term.scrollLines(-5) + expect(bufferService?.isUserScrolling).toBe(true) + term.scrollToBottom() + expect(bufferService?.isUserScrolling).toBe(false) + }) + + it('resets native user-scrolling when a pinned scrollback is cleared', async () => { + const term = new Terminal({ + rows: 10, + cols: 40, + scrollback: 1000, + allowProposedApi: true + }) as TerminalWithBufferService + await writeLines(term, 30, 'line') + term.scrollLines(-5) + expect(term._core?._bufferService?.isUserScrolling).toBe(true) + + clearTerminalScrollbackAndFollowOutput(term) + expect(term.buffer.active.viewportY).toBe(0) + expect(term.buffer.active.baseY).toBe(0) + expect(term._core?._bufferService?.isUserScrolling).toBe(false) + + await writeLines(term, 15, 'after-clear') + expect(term.buffer.active.viewportY).toBe(term.buffer.active.baseY) + }) +}) diff --git a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts index 1365e54fa..a0ced0549 100644 --- a/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts +++ b/tests/e2e/terminal-codex-skill-preview-artifact-repro.spec.ts @@ -32,7 +32,7 @@ const CODEX_TRUST_PROMPT_RE = const CODEX_UPDATE_PROMPT_RE = /update available|install update|Skip for now|Skip until next/i const CODEX_SKILL_PREVIEW_RE = /Press enter to insert|esc to close|electron|orca-cli|orca-emulator/i const SETUP_PANE_ACTIVITY_RE = /install-orca-skills|pnpm|Progress:|Packages:|Lockfile/i -const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 -]{1,32}\s+\[Skill\]\s/ +const CLEAN_SKILL_ROW_RE = /^ [A-Za-z][A-Za-z0-9 .-]{1,32}\s+\[Skill\]\s/ const CODEX_READY_SETTLE_MS = 3_500 const SETUP_CHANGES_AFTER_PREVIEW = 3 @@ -44,6 +44,12 @@ type PaneDescriptor = { rect: { x: number; y: number; width: number; height: number } cols: number rows: number + proposed: { cols: number; rows: number } | null + appliedPtySize: { cols: number; rows: number } | null + viewportY: number + baseY: number + isUserScrolling: boolean | null + screenToPaneGap: number | null hasWebgl: boolean } @@ -259,7 +265,7 @@ async function forceTerminalWebgl(page: Page): Promise { } async function describeActiveTerminalPanes(page: Page): Promise { - return page.evaluate(() => { + return page.evaluate(async () => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = @@ -274,29 +280,54 @@ async function describeActiveTerminalPanes(page: Page): Promise { - const aRect = a.container.getBoundingClientRect() - const bRect = b.container.getBoundingClientRect() - return aRect.x - bRect.x || aRect.y - bRect.y - }) - .map((pane) => { - if (!pane.container.dataset.ptyId) { - throw new Error(`Terminal pane ${pane.id} has no PTY binding`) - } - const rect = pane.container.getBoundingClientRect() - const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id) - return { - tabId, - paneId: pane.id, - leafId: pane.leafId, - ptyId: pane.container.dataset.ptyId, - rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, - cols: pane.terminal.cols, - rows: pane.terminal.rows, - hasWebgl: rendering?.hasWebgl ?? false - } - }) + return Promise.all( + [...panes] + .sort((a, b) => { + const aRect = a.container.getBoundingClientRect() + const bRect = b.container.getBoundingClientRect() + return aRect.x - bRect.x || aRect.y - bRect.y + }) + .map(async (pane) => { + const ptyId = pane.container.dataset.ptyId + if (!ptyId) { + throw new Error(`Terminal pane ${pane.id} has no PTY binding`) + } + const rect = pane.container.getBoundingClientRect() + const screenRect = pane.container + .querySelector('.xterm-screen') + ?.getBoundingClientRect() + const rendering = diagnostics.find((diagnostic) => diagnostic.paneId === pane.id) + let proposed: { cols: number; rows: number } | null = null + try { + proposed = pane.fitAddon.proposeDimensions() ?? null + } catch { + proposed = null + } + const appliedPtySize = await window.api.pty.getSize(ptyId).catch(() => null) + const terminalCore = pane.terminal as typeof pane.terminal & { + _core?: { _bufferService?: { isUserScrolling?: boolean } } + } + return { + tabId, + paneId: pane.id, + leafId: pane.leafId, + ptyId, + rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, + cols: pane.terminal.cols, + rows: pane.terminal.rows, + proposed, + appliedPtySize, + viewportY: pane.terminal.buffer.active.viewportY, + baseY: pane.terminal.buffer.active.baseY, + isUserScrolling: + typeof terminalCore._core?._bufferService?.isUserScrolling === 'boolean' + ? terminalCore._core._bufferService.isUserScrolling + : null, + screenToPaneGap: screenRect ? rect.right - screenRect.right : null, + hasWebgl: rendering?.hasWebgl ?? false + } + }) + ) }) } @@ -490,6 +521,10 @@ async function captureClickEvidence( const beforeWindowPath = persistEvidenceFile('full-window-before-click.png', beforeFullPage) const afterPanePath = persistEvidenceFile('left-pane-after-click.png', afterPane) const bufferPath = persistEvidenceFile('left-pane-buffer.txt', beforeContent) + const metricsPath = persistEvidenceFile( + 'left-pane-metrics.json', + `${JSON.stringify(pane, null, 2)}\n` + ) await testInfo.attach('codex-skill-preview-left-pane-before-click', { body: beforePane, @@ -509,7 +544,13 @@ async function captureClickEvidence( }) testInfo.annotations.push({ type: 'codex-skill-preview-evidence-files', - description: JSON.stringify({ beforePanePath, beforeWindowPath, afterPanePath, bufferPath }) + description: JSON.stringify({ + beforePanePath, + beforeWindowPath, + afterPanePath, + bufferPath, + metricsPath + }) }) return { @@ -563,6 +604,9 @@ test.describe('Codex skill preview terminal artifact repro @headful', () => { const rightPane = await getRightTerminalPane(orcaPage) expect(leftPane.hasWebgl).toBe(true) expect(rightPane.hasWebgl).toBe(true) + expect(leftPane.proposed).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) + expect(leftPane.appliedPtySize).toEqual({ cols: leftPane.cols, rows: leftPane.rows }) + expect(leftPane.isUserScrolling).toBe(false) await waitForPaneContent( orcaPage, rightPane.tabId, diff --git a/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts b/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts new file mode 100644 index 000000000..23672bf7d --- /dev/null +++ b/tests/e2e/terminal-pinned-viewport-streaming-switch.spec.ts @@ -0,0 +1,210 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { expect, test } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { nodeTerminalCommand } from './terminal-node-command' +import { waitForPtyShellEcho } from './terminal-pty-readiness' + +// A Codex-like agent: pre-fills scrollback, then keeps streaming — commits a +// row and redraws a synchronized-output "Working…" status frame every tick. +// The stream continues while the pane is hidden, which is what routes the +// return through the hidden-output snapshot restore. +function streamingAgentFixtureScript(runId: string): string { + return ` +async function writeStdout(chunk) { + await new Promise((resolve) => process.stdout.write(chunk, resolve)) +} +let row = 0 +let pre = '' +for (; row < 300; row += 1) { + pre += 'STREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n' +} +await writeStdout(pre + 'STREAMING_SWITCH_${runId}_PRESTREAM_DONE\\n') +const spinner = ['|', '/', '-', '\\\\'] +for (let tick = 0; tick < 800; tick += 1) { + let frame = '\\x1b[?2026h' + if (tick % 3 === 0) { + frame += '\\r\\x1b[2KSTREAMING_SWITCH_${runId}_ROW_' + String(row).padStart(4, '0') + '\\n' + row += 1 + } + frame += '\\r\\x1b[2KWorking… ' + spinner[tick % 4] + ' tick=' + tick + '\\x1b[?2026l' + await writeStdout(frame) + await new Promise((resolve) => setTimeout(resolve, 50)) +} +` +} + +async function closeFeatureTips(page: Page): Promise { + await page.evaluate(() => { + const store = window.__store + store?.getState().markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) + if (store?.getState().activeModal === 'feature-tips') { + store.getState().closeModal() + } + }) +} + +async function pinActiveTerminalNearBottom(page: Page): Promise<{ + tabId: string + targetViewportY: number + baseY: number +}> { + return page.evaluate(() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!tabId || !pane) { + throw new Error('Active terminal pane unavailable') + } + const target = pane.container.querySelector('.xterm') ?? pane.container + target.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -240 + }) + ) + const buffer = pane.terminal.buffer.active + const targetViewportY = Math.max(0, buffer.baseY - 6) + pane.terminal.scrollToLine(targetViewportY) + pane.container + .querySelector('.xterm-viewport') + ?.dispatchEvent(new Event('scroll', { bubbles: true })) + return { tabId, targetViewportY, baseY: buffer.baseY } + }) +} + +async function readSettledViewport( + page: Page, + tabId: string +): Promise<{ viewportY: number; baseY: number }> { + // Wait until the replay has actually parsed (scrollback regrew) and the + // viewport stopped moving, then report where it settled. + let last: { viewportY: number; baseY: number } | null = null + let stableCount = 0 + await expect + .poll( + async () => { + const current = await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const buffer = pane?.terminal?.buffer?.active + return buffer ? { viewportY: buffer.viewportY, baseY: buffer.baseY } : null + }, tabId) + if (!current || current.baseY < 100) { + stableCount = 0 + last = current + return false + } + if (last && current.viewportY === last.viewportY) { + stableCount += 1 + } else { + stableCount = 0 + } + last = current + return stableCount >= 3 + }, + { + timeout: 20_000, + intervals: [250], + message: 'terminal viewport did not settle after returning to the streaming worktree' + } + ) + .toBe(true) + if (!last) { + throw new Error('viewport settle poll finished without a sample') + } + return last +} + +test.describe('Terminal pinned viewport with streaming agent across worktree switch', () => { + test('returning to a pinned pane with an active stream does not land at the top', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + await closeFeatureTips(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'streaming pinned repro needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + await waitForPtyShellEcho(orcaPage, ptyId, 15_000) + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-streaming-switch-${runId}.mjs`) + writeFileSync(scriptPath, streamingAgentFixtureScript(runId)) + + try { + await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 30_000), { + timeout: 15_000, + message: 'streaming fixture did not reach terminal scrollback' + }) + .toContain(`STREAMING_SWITCH_${runId}_PRESTREAM_DONE`) + + const pinned = await pinActiveTerminalNearBottom(orcaPage) + expect(pinned.baseY).toBeGreaterThan(100) + await orcaPage.waitForTimeout(150) + + // Stream continues while hidden; hidden byte drops mark the pane for a + // snapshot restore on return. + await switchToWorktree(orcaPage, secondWorktreeId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await orcaPage.waitForTimeout(3_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const settled = await readSettledViewport(orcaPage, pinned.tabId) + const bottomDistance = settled.baseY - settled.viewportY + // The user pinned six rows above the bottom. A faithful restore keeps + // them near the pin; the bug clamps to the very top of the scrollback. + expect( + settled.viewportY, + `settled at viewportY=${settled.viewportY} baseY=${settled.baseY} (pinned ${JSON.stringify(pinned)})` + ).toBeGreaterThan(20) + expect( + bottomDistance, + `settled ${bottomDistance} rows above the bottom (pinned 6 rows above)` + ).toBeGreaterThan(1) + expect( + bottomDistance, + `settled ${bottomDistance} rows above the bottom (pinned 6 rows above)` + ).toBeLessThan(80) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-scroll-intent-follow.spec.ts b/tests/e2e/terminal-scroll-intent-follow.spec.ts index d38738059..c4d8b171f 100644 --- a/tests/e2e/terminal-scroll-intent-follow.spec.ts +++ b/tests/e2e/terminal-scroll-intent-follow.spec.ts @@ -5,9 +5,11 @@ import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } fro import { execInTerminal, sendToTerminal, + waitForActivePaneHookDescriptor, waitForActivePanePtyId, waitForActiveTerminalManager } from './helpers/terminal' +import { waitForTerminalPtyDataInjector } from './helpers/terminal-pty-injection' const STREAMING_FIXTURE_PATH = path.join( process.cwd(), @@ -72,6 +74,32 @@ async function waitForMarkerAtBottom(page: Page, marker: string): Promise async function dispatchSubRowWheelUp(page: Page): Promise { await page.evaluate(() => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane?.terminal.element) { + throw new Error('Active terminal pane unavailable') + } + pane.terminal.element.dispatchEvent( + new WheelEvent('wheel', { + bubbles: true, + cancelable: true, + deltaMode: WheelEvent.DOM_DELTA_PIXEL, + deltaY: -2 + }) + ) + }) +} + +async function dispatchRealWheel(page: Page, deltaY: number): Promise { + const point = await page.evaluate(() => { const state = window.__store?.getState() const worktreeId = state?.activeWorktreeId const tabId = @@ -90,19 +118,13 @@ async function dispatchSubRowWheelUp(page: Page): Promise { throw new Error('Active terminal screen unavailable') } const rect = screen.getBoundingClientRect() - // A -2px delta is far below one cell height: xterm scrolls zero rows, the - // viewport stays at the bottom, but the wheel listener still observes an - // upward wheel — the phantom-pin shape from trackpad jitter. - const event = new WheelEvent('wheel', { - bubbles: true, - cancelable: true, - clientX: rect.left + rect.width / 2, - clientY: rect.top + Math.min(rect.height - 1, 40), - deltaMode: WheelEvent.DOM_DELTA_PIXEL, - deltaY: -2 - }) - pane.terminal.element.dispatchEvent(event) + return { + x: rect.left + rect.width / 2, + y: rect.top + Math.min(rect.height - 1, 40) + } }) + await page.mouse.move(point.x, point.y) + await page.mouse.wheel(0, deltaY) } async function dispatchPlainHomeKeydown(page: Page): Promise { @@ -142,6 +164,60 @@ async function dispatchPlainHomeKeydown(page: Page): Promise { }) } +async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise { + await page.evaluate((targetPaneKey) => { + const injectionTarget = window as Window & { + __terminalPtyDataInjection?: { inject: (paneKey: string, data: string) => boolean } + } + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + if (!pane) { + throw new Error('Active terminal pane unavailable') + } + const terminal = pane.terminal + const originalWrite = terminal.write + const holder: { write: { data: string; callback?: () => void } | null } = { write: null } + terminal.write = ((data: string, callback?: () => void) => { + holder.write = { data, callback } + }) as typeof terminal.write + try { + const payload = '\x1b[?2026h\r\x1b[2KWorking in-flight\x1b[?2026l' + if (!injectionTarget.__terminalPtyDataInjection?.inject(targetPaneKey, payload)) { + throw new Error('PTY injector unavailable') + } + const textarea = pane.container.querySelector('.xterm-helper-textarea') + if (!textarea) { + throw new Error('xterm helper textarea unavailable') + } + textarea.focus() + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'x', + code: 'KeyX' + }) + Object.defineProperty(event, 'keyCode', { configurable: true, value: 88 }) + Object.defineProperty(event, 'which', { configurable: true, value: 88 }) + textarea.dispatchEvent(event) + } finally { + terminal.write = originalWrite + } + const heldWrite = holder.write + if (!heldWrite) { + throw new Error('Foreground terminal write was not captured') + } + originalWrite.call(terminal, heldWrite.data, heldWrite.callback) + }, paneKey) +} + async function startStreamingFixturePhase1(page: Page): Promise { await waitForSessionReady(page) await waitForActiveWorktree(page) @@ -159,6 +235,8 @@ test.describe('terminal scroll intent keeps following output', () => { }) => { const ptyId = await startStreamingFixturePhase1(orcaPage) + // A -2px delta is far below one cell height: xterm scrolls zero rows, but + // the intent listener still observes the trackpad-jitter-shaped wheel. await dispatchSubRowWheelUp(orcaPage) await orcaPage.waitForTimeout(INTENT_SETTLE_WAIT_MS) @@ -177,4 +255,58 @@ test.describe('terminal scroll intent keeps following output', () => { await dispatchPlainHomeKeydown(orcaPage) await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE') }) + + test('a real wheel pin stays fixed while visible output streams', async ({ orcaPage }) => { + const ptyId = await startStreamingFixturePhase1(orcaPage) + + await dispatchRealWheel(orcaPage, -240) + await expect + .poll(async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : 0 + }) + .toBeGreaterThan(1) + const pinned = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + if (!pinned) { + throw new Error('terminal viewport unavailable after wheel pin') + } + + await sendToTerminal(orcaPage, ptyId, 'g') + await expect + .poll( + async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE2_DONE') + return Boolean(probe && probe.containsMarker && probe.viewportY === pinned.viewportY) + }, + { timeout: 30_000, message: 'visible streaming output moved the wheel-pinned viewport' } + ) + .toBe(true) + }) + + test('typing after a pinned write is queued resumes follow-output', async ({ orcaPage }) => { + await startStreamingFixturePhase1(orcaPage) + const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) + await waitForTerminalPtyDataInjector(orcaPage, paneKey) + + await dispatchRealWheel(orcaPage, -320) + await expect + .poll(async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : 0 + }) + .toBeGreaterThan(2) + + // Hold the xterm write call so typing deterministically lands between the + // old per-write intent capture and its completion-time enforcement from #8625. + await injectQueuedWriteThenType(orcaPage, paneKey) + await expect + .poll( + async () => { + const probe = await probeActiveViewport(orcaPage, 'STREAM_PHASE1_DONE') + return probe ? probe.baseY - probe.viewportY : Number.NaN + }, + { timeout: 5_000, intervals: [25] } + ) + .toBe(0) + }) })