diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index add578b7e2c..4c56bc6458f 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 { diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts index abe054b80c1..16a4e0092b6 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.test.ts @@ -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, diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 3b2af6fed87..d069244cf4f 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -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 + 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) + } } } } diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts index bc728ce7dd2..205bd8ecaa9 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index c0ab35f654c..88e70096a59 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -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( diff --git a/tests/e2e/fixtures/streaming-scrollback-fixture.cjs b/tests/e2e/fixtures/streaming-scrollback-fixture.cjs new file mode 100644 index 00000000000..9db942e0232 --- /dev/null +++ b/tests/e2e/fixtures/streaming-scrollback-fixture.cjs @@ -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) +}) diff --git a/tests/e2e/terminal-scroll-intent-follow.spec.ts b/tests/e2e/terminal-scroll-intent-follow.spec.ts new file mode 100644 index 00000000000..d3873805948 --- /dev/null +++ b/tests/e2e/terminal-scroll-intent-follow.spec.ts @@ -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 { + 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 { + 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 { + 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('.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 { + 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('.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 { + 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') + }) +})