fix(terminal): make viewport ownership transactional across output, replay, and reflow (#8674)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-07-15 20:28:39 -07:00 committed by GitHub
parent 1ba71d2cee
commit 69c14fadce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
50 changed files with 5575 additions and 899 deletions

View File

@ -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 })

View File

@ -19311,13 +19311,10 @@ export class OrcaRuntimeService {
timeoutMs?: number,
signal?: AbortSignal
): Promise<boolean> {
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

View File

@ -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<number>
settlePaneSerializer: (paneKey: string, gen: number) => Promise<void>

View File

@ -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 })
},

View File

@ -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 => {

View File

@ -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()
})
})

View File

@ -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()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -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')

View File

@ -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<PtySizeReassertionDimensions | null>
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)) {

View File

@ -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

View File

@ -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<ReplayTerminalOptions, 'stallCheckMs'> = {}
): Promise<void> {
return new Promise((resolve) => {
let finished = false
let stallTimer: ReturnType<typeof setTimeout> | 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()
}
})
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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', () => ({

View File

@ -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
})
}
}

View File

@ -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)

View File

@ -5,6 +5,10 @@ import {
detachPaneFitResizeObserver,
requestStablePaneFit
} from './pane-fit-resize-observer'
import {
beginTerminalScrollIntentBufferRebuild,
endTerminalScrollIntentBufferRebuild
} from './terminal-scroll-intent-rebuild'
type ResizeObserverCallbackLike = ConstructorParameters<typeof ResizeObserver>[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 }

View File

@ -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)
}

View File

@ -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<boolean>
cancel: () => void
}
type PendingSafeFitContinuation = {
continuation: () => void
shouldContinue: () => boolean
resolve: (completed: boolean) => void
}
const pendingSafeFitContinuations = new WeakMap<
ManagedPane,
Map<string, PendingSafeFitContinuation>
>()
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<typeof captureTerminalStructuralScrollIntent>
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<boolean>((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 }
}

View File

@ -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)
})
})

View File

@ -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,

View File

@ -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 {

View File

@ -124,6 +124,8 @@ export type ScrollState = {
viewportY: number
baseY: number
firstVisibleLineMarker?: IMarker
firstVisibleLogicalLineMarker?: IMarker
firstVisibleLogicalCellOffset?: number
}
export type ManagedPaneInternal = {

View File

@ -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<void> {
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<ScrollState> => {
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()
}
})
})

View File

@ -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<Terminal, number>()
const deferredScrollRestores = new WeakMap<
Terminal,
object,
{
cancelled: boolean
rafIds: number[]
@ -11,6 +16,19 @@ const deferredScrollRestores = new WeakMap<
timeoutIds: ReturnType<typeof setTimeout>[]
}
>()
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)
}

View File

@ -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?.()

View File

@ -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', () => {

View File

@ -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<typeof captureTerminalWriteScrollIntent>
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<number, ManagedPaneInternal>): void {
for (const pane of panes.values()) {
safeFit(pane)

View File

@ -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<Pick<ManagedPaneInternal, 'pendingWebglRefreshRafId' | 'webglAddon'>> = {}
@ -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 }

View File

@ -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 */
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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[<b;x;yM" and X10 "\x1b[M...") stream at pointer
// frequency and are the one input kind whose native scroll-to-bottom must NOT
// reclassify a pin: converting it would permanently drop a reading position
// on a mere mouse-move over a mouse-tracking app.
function isMouseReportInput(data: string): boolean {
return (
data.charCodeAt(0) === 0x1b &&
data.charAt(1) === '[' &&
(data.charAt(2) === '<' || data.charAt(2) === 'M')
)
}
// Why: typing/pasting scrolls the terminal to the bottom (xterm
// scrollOnUserInput) without going through any wheel/pointer path this module
// tracks. Without a resync, a stored pin goes stale and a later
// workspace-switch restore yanks the user back to the old reading position.
// onData also carries parser auto-replies (DSR/CPR, focus reports), so pinned
// xterm's core onUserInput signal identifies which emissions were truly user-driven.
function subscribeScrollIntentUserInputResync(
terminal: TerminalScrollIntentTarget,
isActive: () => 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)
}
}
}

View File

@ -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)
})
})

View File

@ -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<object, number>()
const terminalScrollIntentRebuildCompletions = new WeakMap<
object,
Set<(completed: boolean) => void>
>()
const deferredTerminalGeometryMutations = new WeakMap<
object,
{
mutations: Map<string, () => 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)
}

View File

@ -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)
}

View File

@ -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)
})
})

View File

@ -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<typeof createTerminal> & {
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 }
}
})

View File

@ -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<typeof readTerminalScrollBufferSnapshot>[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<TerminalScrollIntentTarget, number>()
const terminalScrollIntentByKey = new Map<TerminalScrollIntentKey, TerminalScrollIntent>()
const terminalScrollIntentBindingByKey = new Map<TerminalScrollIntentKey, number>()
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 })
}

View File

@ -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)
}

View File

@ -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<void>; resolve: () => void } {
let resolve = (): void => {}
const promise = new Promise<void>((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<void>(() => {})
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<typeof createTerminal> & {
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<void>(() => {})
const fitStarted = deferred()
const completion = coordinator.run(() => undefined, {
afterRestore: async () => {
fitStarted.resolve()
await fitNeverCompletes
}
})
await fitStarted.promise
coordinator.dispose()
await expect(completion).resolves.toBeUndefined()
})
})

View File

@ -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<void>
type StructuralReplayOptions = {
shouldRestore?: () => boolean
afterRestore?: () => void | Promise<void>
}
export type TerminalStructuralReplayCoordinator = {
run: (task: StructuralReplayTask, options?: StructuralReplayOptions) => Promise<void>
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<void> => {
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<void>((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?.()
}
}
}

View File

@ -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
}
}
}

View File

@ -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<void> {
return new Promise((resolve) => term.write(data, resolve))
}
async function writeLines(term: Terminal, count: number, label: string): Promise<void> {
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)
})
})

View File

@ -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<boolean> {
}
async function describeActiveTerminalPanes(page: Page): Promise<PaneDescriptor[]> {
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<PaneDescriptor[]
throw new Error('Expected a split terminal tab with at least two panes')
}
const diagnostics = manager.getRenderingDiagnostics?.() ?? []
return [...panes]
.sort((a, b) => {
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<HTMLElement>('.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,

View File

@ -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<void> {
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<HTMLElement>('.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<HTMLElement>('.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 })
}
})
})

View File

@ -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<void>
async function dispatchSubRowWheelUp(page: Page): Promise<void> {
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<void> {
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<void> {
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<void> {
@ -142,6 +164,60 @@ async function dispatchPlainHomeKeydown(page: Page): Promise<void> {
})
}
async function injectQueuedWriteThenType(page: Page, paneKey: string): Promise<void> {
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<HTMLTextAreaElement>('.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<string> {
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)
})
})