mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user