fix(terminal): stop phantom pinned-viewport pins from freezing follow-output

A pinnedViewport intent could latch while the viewport was still at the
bottom (sub-row trackpad wheel, wheel consumed by a mouse-reporting TUI,
plain PageUp/Home delivered to the app). Per-write-batch enforcement then
froze the viewport at that absolute line: during agent generation the live
TUI region visibly scrolled "underneath" static committed rows, and after a
long generation the scroll position sat near the top. Rebuild paths
(snapshot replay, fit reflow) additionally restored pinned positions by raw
pre-rebuild row numbers into renumbered buffers.

- The wheel/keyboard settle tick reclassifies intent from the real viewport
  instead of preserving a pin the viewport never followed.
- Write capture treats a pinned intent whose live viewport is at the bottom
  as followOutput, so a latched phantom pin can never freeze streaming.
- Visibility-resume enforcement follows output for pins recorded at the
  bottom and restores by bottom offset when the buffer was rebuilt shorter.
- Snapshot replay restores by bottom offset; safeFit restores pinned panes
  through an xterm marker so reflow renumbering cannot misplace them.

Deterministic repro harness: unit tests red on the old code for each latch/
freeze/restore shape, plus a stdin-phase-gated streaming e2e fixture — both
e2e tests fail without this fix and pass with it.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-07-13 19:21:32 -04:00
co-authored by Orca
parent 2ebe207bf7
commit e45331fd51
7 changed files with 471 additions and 20 deletions
@@ -6064,7 +6064,9 @@ export function connectPanePty(
}
// Why: snapshot replay clears and rebuilds xterm state; re-apply the
// user's scroll intent once so hidden catch-up cannot repin the viewport.
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
// Restore by bottom offset — the rebuilt buffer renumbers every row, so
// the pre-replay absolute viewport line points at arbitrary content.
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent, { restoreBy: 'bottomOffset' })
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {
@@ -199,6 +199,58 @@ describe('safeFit', () => {
expect(activeBuffer.viewportY).toBe(42)
})
it('restores pinned content via marker when fit reflow renumbers buffer lines', () => {
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: 42, isDisposed: false, dispose: vi.fn() }
;(pane.terminal as unknown as { registerMarker: unknown }).registerMarker = vi.fn(() => marker)
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
// Reflow at narrower cols rewraps lines; the tracked content now lives
// at a different absolute line than the pre-fit viewport number.
activeBuffer.baseY = 130
activeBuffer.viewportY = 0
marker.line = 57
})
safeFit(pane)
expect(pane.terminal.scrollToLine).toHaveBeenCalledWith(57)
expect(activeBuffer.viewportY).toBe(57)
expect(marker.dispose).toHaveBeenCalled()
})
it('keeps a follow-output pane at the bottom through fit', () => {
const pane = createPane({
proposedCols: 100,
proposedRows: 32,
terminalCols: 120,
terminalRows: 32
})
const activeBuffer = pane.terminal.buffer.active as { viewportY: number; baseY: number }
activeBuffer.viewportY = 100
activeBuffer.baseY = 100
vi.mocked(pane.fitAddon.fit).mockImplementation(() => {
activeBuffer.baseY = 130
activeBuffer.viewportY = 0
})
safeFit(pane)
expect(pane.terminal.scrollToBottom).toHaveBeenCalled()
})
it('does not throw when xterm rejects scroll restoration during layout', () => {
const pane = createPane({
proposedCols: 100,
@@ -10,8 +10,11 @@ import { getFitOverrideForPty } from './mobile-fit-overrides'
import { disposeWebgl, attachWebgl } from './pane-webgl-renderer'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
enforceTerminalWriteScrollIntent,
syncTerminalScrollIntentFromViewport
} from './terminal-scroll-intent'
import { captureScrollState, releaseScrollStateMarker, restoreScrollState } from './pane-scroll'
import type { ScrollState } from './pane-manager-types'
export { captureScrollState, restoreScrollState } from './pane-scroll'
@@ -73,7 +76,16 @@ export function safeFit(pane: ManagedPane): void {
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
@@ -85,8 +97,7 @@ export function safeFit(pane: ManagedPane): void {
if (override) {
if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) {
if (canPreserveScrollIntentForFit(pane)) {
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
shouldRestoreScroll = true
captureScrollForFit()
}
pane.terminal.resize(override.cols, override.rows)
}
@@ -101,8 +112,7 @@ export function safeFit(pane: ManagedPane): void {
return
}
if (canPreserveScrollIntentForFit(pane)) {
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
shouldRestoreScroll = true
captureScrollForFit()
}
pane.fitAddon.fit()
} catch {
@@ -110,10 +120,19 @@ export function safeFit(pane: ManagedPane): void {
} finally {
if (shouldRestoreScroll) {
try {
enforceTerminalWriteScrollIntent(pane.terminal, scrollIntent)
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)
}
}
}
}
@@ -367,4 +367,119 @@ describe('terminal scroll intent', () => {
expect(terminal.scrollToLine).toHaveBeenCalledWith(40)
})
it('reverts a wheel pin to followOutput when the viewport never leaves the bottom', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.useFakeTimers({ toFake: ['setTimeout'] })
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const host = new TestElement() as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
// A sub-row trackpad delta or a wheel consumed by a mouse-reporting TUI:
// the wheel event fires but xterm's viewport never moves.
const wheelUp = new Event('wheel') as WheelEvent
Object.defineProperty(wheelUp, 'deltaY', { value: -2 })
host.dispatchEvent(wheelUp)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
await Promise.resolve()
while (frameCallbacks.length) {
frameCallbacks.shift()?.(16)
}
vi.advanceTimersByTime(80)
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
disposable.dispose()
})
it('keeps a wheel pin when the viewport leaves the bottom before settle', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.useFakeTimers({ toFake: ['setTimeout'] })
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const host = new TestElement() as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
const wheelUp = new Event('wheel') as WheelEvent
Object.defineProperty(wheelUp, 'deltaY', { value: -10 })
host.dispatchEvent(wheelUp)
terminal.buffer.active.viewportY = 60
await Promise.resolve()
while (frameCallbacks.length) {
frameCallbacks.shift()?.(16)
}
vi.advanceTimersByTime(80)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(60)
disposable.dispose()
})
it('does not freeze the viewport when a pinned intent is latched at the bottom', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
// Phantom pin: a wheel/PageUp the viewport never followed latched
// pinnedViewport while the terminal was still at the bottom.
markTerminalPinnedViewport(terminal)
for (let batch = 1; batch <= 2; batch += 1) {
const snapshot = captureTerminalWriteScrollIntent(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)
expect(terminal.buffer.active.viewportY).toBe(terminal.buffer.active.baseY)
}
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
})
it('resumes following on visibility enforce when the stored pin was recorded at the bottom', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
markTerminalPinnedViewport(terminal)
terminal.buffer.active.baseY = 400
terminal.buffer.active.viewportY = 100
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToBottom).toHaveBeenCalled()
expect(terminal.buffer.active.viewportY).toBe(400)
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
})
it('restores a pinned viewport by bottom offset after a rebuild shrinks the scrollback', () => {
const terminal = createTerminal({ viewportY: 550, baseY: 600 })
markTerminalPinnedViewport(terminal)
// Snapshot replay rebuilds a shorter, renumbered buffer.
terminal.buffer.active.baseY = 200
terminal.buffer.active.viewportY = 200
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(150)
expect(terminal.buffer.active.viewportY).toBe(150)
})
it('supports bottom-offset restore for buffer-rebuild write paths', () => {
const terminal = createTerminal({ viewportY: 550, baseY: 600 })
markTerminalPinnedViewport(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.baseY = 80
terminal.buffer.active.viewportY = 80
enforceTerminalWriteScrollIntent(terminal, snapshot, { restoreBy: 'bottomOffset' })
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(30)
expect(terminal.buffer.active.viewportY).toBe(30)
})
})
@@ -29,6 +29,14 @@ type TerminalScrollIntentWriteSnapshot = {
kind: TerminalScrollIntentKind
bufferType: BufferType
viewportY: number
baseY: number
}
type TerminalScrollIntentEnforceOptions = {
// 'viewportLine' restores the absolute buffer line (correct while content
// only grows). 'bottomOffset' restores the distance from the bottom —
// required after a buffer rebuild (snapshot replay, reflow) renumbers rows.
restoreBy?: 'viewportLine' | 'bottomOffset'
}
const terminalScrollIntentByTerminal = new WeakMap<
@@ -180,7 +188,11 @@ export function syncTerminalScrollIntentSoon(
queueMicrotask(sync)
requestAnimationFrame(sync)
requestAnimationFrame(() => requestAnimationFrame(sync))
setTimeout(sync, 80)
// 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)
}
export function getTerminalScrollIntentKind(
@@ -205,19 +217,27 @@ export function captureTerminalWriteScrollIntent(
return null
}
const existing = readStoredIntent(terminal)
const kind =
let kind =
existing?.kind ??
(isAtBottom(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)) {
kind = 'followOutput'
}
return {
kind,
bufferType: snapshot.bufferType,
viewportY: snapshot.viewportY
viewportY: snapshot.viewportY,
baseY: snapshot.baseY
}
}
export function enforceTerminalWriteScrollIntent(
terminal: TerminalScrollIntentTarget,
snapshot: TerminalScrollIntentWriteSnapshot | null
snapshot: TerminalScrollIntentWriteSnapshot | null,
options: TerminalScrollIntentEnforceOptions = {}
): void {
if (!snapshot) {
return
@@ -232,7 +252,11 @@ export function enforceTerminalWriteScrollIntent(
}
return
}
const targetY = clampViewportY(snapshot.viewportY, current.baseY)
const requestedY =
options.restoreBy === 'bottomOffset'
? current.baseY - Math.max(0, snapshot.baseY - snapshot.viewportY)
: snapshot.viewportY
const targetY = clampViewportY(requestedY, current.baseY)
if (current.viewportY !== targetY) {
safeScrollCall(() => terminal.scrollToLine?.(targetY))
}
@@ -241,14 +265,29 @@ export function enforceTerminalWriteScrollIntent(
export function enforceTerminalCurrentScrollIntent(terminal: TerminalScrollIntentTarget): void {
const existing = readStoredIntent(terminal)
const snapshot = existing
? {
kind: existing.kind,
bufferType: existing.bufferType,
viewportY: existing.viewportY
}
: captureTerminalWriteScrollIntent(terminal)
enforceTerminalWriteScrollIntent(terminal, snapshot)
if (!existing) {
enforceTerminalWriteScrollIntent(terminal, captureTerminalWriteScrollIntent(terminal))
return
}
const snapshot = {
kind: existing.kind,
bufferType: existing.bufferType,
viewportY: existing.viewportY,
baseY: existing.baseY
}
if (snapshot.kind === 'pinnedViewport' && isAtBottom(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)
// 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(
@@ -0,0 +1,44 @@
// Emits numbered scrollback in two stdin-gated phases so tests can interact
// with the terminal between deterministic write batches:
// phase 1: LINES numbered rows, then STREAM_PHASE1_DONE
// (waits for any stdin byte — a keypress escape sequence also qualifies)
// phase 2: LINES more rows in spaced chunks, then STREAM_PHASE2_DONE, exit
const LINES = 300
const PHASE2_CHUNKS = 10
const PHASE2_CHUNK_INTERVAL_MS = 30
let phase = 1
function numberedRows(from, count) {
let out = ''
for (let i = from; i < from + count; i += 1) {
out += `STREAM_LINE_${String(i).padStart(5, '0')}\n`
}
return out
}
process.stdout.write(`${numberedRows(0, LINES)}STREAM_PHASE1_DONE\n`)
process.stdin.setEncoding('utf8')
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
process.stdin.on('data', () => {
if (phase !== 1) {
return
}
phase = 2
// Spaced chunks make the renderer process several distinct write batches,
// which is what re-triggers per-batch scroll-intent enforcement.
const perChunk = Math.ceil(LINES / PHASE2_CHUNKS)
let chunk = 0
const timer = setInterval(() => {
process.stdout.write(numberedRows(LINES + chunk * perChunk, perChunk))
chunk += 1
if (chunk >= PHASE2_CHUNKS) {
clearInterval(timer)
process.stdout.write('STREAM_PHASE2_DONE\n')
process.exit(0)
}
}, PHASE2_CHUNK_INTERVAL_MS)
})
@@ -0,0 +1,180 @@
import type { Page } from '@stablyai/playwright-test'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import {
execInTerminal,
sendToTerminal,
waitForActivePanePtyId,
waitForActiveTerminalManager
} from './helpers/terminal'
const STREAMING_FIXTURE_PATH = path.join(
process.cwd(),
'tests/e2e/fixtures/streaming-scrollback-fixture.cjs'
)
// Past the scroll-intent settle window (80ms) so a phantom pin has had every
// chance to latch before phase-2 output arrives.
const INTENT_SETTLE_WAIT_MS = 250
type ViewportProbe = {
baseY: number
viewportY: number
containsMarker: boolean
}
async function probeActiveViewport(page: Page, marker: string): Promise<ViewportProbe | null> {
return page.evaluate((markerText) => {
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) {
return null
}
const buffer = pane.terminal.buffer.active
let containsMarker = false
for (let line = buffer.baseY + pane.terminal.rows - 1; line >= 0; line -= 1) {
const text = buffer.getLine(line)?.translateToString(true) ?? ''
if (text.includes(markerText)) {
containsMarker = true
break
}
}
return {
baseY: buffer.baseY,
viewportY: buffer.viewportY,
containsMarker
}
}, marker)
}
async function waitForMarkerAtBottom(page: Page, marker: string): Promise<void> {
await expect
.poll(
async () => {
const probe = await probeActiveViewport(page, marker)
return Boolean(probe && probe.containsMarker && probe.viewportY === probe.baseY)
},
{
timeout: 30_000,
message: `terminal did not reach "${marker}" with the viewport following the bottom`
}
)
.toBe(true)
}
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')
}
const screen = pane.terminal.element.querySelector<HTMLElement>('.xterm-screen')
if (!screen) {
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)
})
}
async function dispatchPlainHomeKeydown(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')
}
const textarea =
pane.terminal.element.querySelector<HTMLTextAreaElement>('.xterm-helper-textarea')
if (!textarea) {
throw new Error('xterm helper textarea unavailable')
}
textarea.focus()
// Plain Home is delivered to the PTY app (readline start-of-line); it
// never scrolls the xterm viewport.
const event = new KeyboardEvent('keydown', {
bubbles: true,
cancelable: true,
key: 'Home',
code: 'Home'
})
// Why: xterm's key evaluator reads the legacy keyCode, which KeyboardEvent
// constructors do not populate; without it no escape bytes reach the PTY.
Object.defineProperty(event, 'keyCode', { configurable: true, value: 36 })
Object.defineProperty(event, 'which', { configurable: true, value: 36 })
textarea.dispatchEvent(event)
})
}
async function startStreamingFixturePhase1(page: Page): Promise<string> {
await waitForSessionReady(page)
await waitForActiveWorktree(page)
await ensureTerminalVisible(page)
await waitForActiveTerminalManager(page, 30_000)
const ptyId = await waitForActivePanePtyId(page)
await execInTerminal(page, ptyId, `node "${STREAMING_FIXTURE_PATH}"`)
await waitForMarkerAtBottom(page, 'STREAM_PHASE1_DONE')
return ptyId
}
test.describe('terminal scroll intent keeps following output', () => {
test('a sub-row wheel-up that never moves the viewport must not stop follow-output', async ({
orcaPage
}) => {
const ptyId = await startStreamingFixturePhase1(orcaPage)
await dispatchSubRowWheelUp(orcaPage)
await orcaPage.waitForTimeout(INTENT_SETTLE_WAIT_MS)
// Any byte releases the fixture's phase-2 stream.
await sendToTerminal(orcaPage, ptyId, 'g')
await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE')
})
test('a plain Home keypress delivered to the app must not stop follow-output', async ({
orcaPage
}) => {
await startStreamingFixturePhase1(orcaPage)
// The Home escape sequence reaching the fixture's stdin doubles as the
// phase-2 release, exactly like a user pressing Home mid-generation.
await dispatchPlainHomeKeydown(orcaPage)
await waitForMarkerAtBottom(orcaPage, 'STREAM_PHASE2_DONE')
})
})