Fix terminal scroll intent across workspace switches (#6319)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-06-24 20:16:14 -07:00 committed by GitHub
parent 9f8a27de33
commit cd1d4dfff3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1696 additions and 511 deletions

View File

@ -0,0 +1,329 @@
# Terminal Scroll Intent Architecture
## Problem
Terminal panes can jump or jitter when a user switches workspaces while a TUI
pane is scrolled near, but not exactly at, the bottom. The most visible case is a
Codex-style alternate-screen TUI in a split pane:
1. The user scrolls slightly up from the bottom.
2. The user switches to another workspace.
3. The user switches back.
4. The terminal sometimes jumps to the top, jumps to the bottom, flashes at a
wrong position, or restores an older viewport.
The current code tries to repair the viewport after layout, fit, split
reparenting, hidden-output replay, and visibility resume. Those repairs compete
with each other because none of them owns the user's scroll intent. A delayed
restore can replay an old state after the user has already scrolled, while a
follow-output path can force the bottom even when the user is reading scrollback.
## Reference Investigation Summary
The useful reference pattern is not a timer-based restore loop. It is an
explicit pinned-to-bottom model owned by the terminal frontend:
- The frontend stores bottom-following state separately from xterm's transient
`viewportY`.
- `xterm.onScroll` is not used as user intent. In xterm, that event can be
content-driven and can briefly report bottom during fast output or layout work.
- User wheel and keyboard scroll commands update the intent.
- `scrollToBottom()` is an explicit command that sets the intent back to
follow-output.
- Every write snapshots the intent before writing. If the terminal was following
output, it scrolls to bottom after the write. If the user was pinned to a
viewport, it preserves the prior viewport line.
- Fit/resize uses the same rule: follow bottom if explicitly pinned, otherwise
preserve the current viewport.
- Alternate-screen state is tracked from `buffer.active.type`, but it is not a
reason to infer user scroll intent from `onScroll`.
The reference implementation also patches private xterm internals to disable
xterm's implicit scroll-to-bottom behavior and call the original bottom-scroll
only from explicit owner paths. That gives a clean ownership boundary, but it is
an escalation point for Orca because private internals increase xterm upgrade
risk.
## Current Orca Risk Points
The current Orca branch already has several independent scroll actors:
- `src/renderer/src/lib/pane-manager/pane-scroll.ts` captures `viewportY`,
`baseY`, bottom state, and sometimes a marker, then restores using immediate,
rAF, and timeout paths.
- `src/renderer/src/lib/pane-manager/pane-tree-ops.ts` captures and restores
scroll around `safeFit()`.
- `src/renderer/src/lib/pane-manager/pane-split-scroll.ts` schedules split
reparent restores across rAF and timeout phases.
- `src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts`
listens to `terminal.onScroll` and stores snapshots while visible.
- `src/renderer/src/components/terminal-pane/pty-connection.ts` writes PTY output
through foreground, background, hidden-output skip, snapshot replay, and
restore paths.
- `src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts` can
write immediately, enqueue foreground writes, coalesce synchronized output, or
drain hidden/background chunks later.
The biggest architectural mismatch is the visibility memory hook's use of
`terminal.onScroll` as a snapshot trigger. That event is not a reliable user
scroll signal. During workspace switching, output parsing, hidden replay, or fit
can make it persist transient positions that later appear as "older position"
restores.
## Design Goal
Make terminal viewport movement a result of explicit scroll intent, not a side
effect of visibility, output, or layout timing.
The terminal should have one active scroll intent per pane:
- `followOutput`: the terminal is logically pinned to the live output bottom.
New output, explicit focus-follow requests, and fits may keep it at bottom.
- `pinnedViewport`: the user is reading a specific viewport. New output,
workspace switches, hidden-output replay, and fits must not move the viewport
except when scrollback pruning or buffer replacement makes the exact line
impossible.
## State Model
Add a focused module, for example:
`src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts`
Suggested state:
```ts
export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
export type TerminalScrollIntent = {
kind: TerminalScrollIntentKind
bufferType: 'normal' | 'alternate'
viewportY: number
baseY: number
capturedAt: number
}
```
The state should be keyed by the live `Terminal` or by pane leaf identity where
it needs to survive pane replacement. For normal workspace switches that keep
the same xterm instance, the live terminal-keyed state should be authoritative.
## Intent Transitions
Only these events should change scroll intent:
- User wheel scrolls upward: set `pinnedViewport` immediately before xterm write
callbacks or rAF can pull the viewport back down.
- User wheel scrolls downward: after xterm applies the scroll, recompute whether
the viewport reached bottom. If yes, set `followOutput`; otherwise keep
`pinnedViewport`.
- User keyboard scroll commands: apply the same rules as wheel.
- Explicit `scrollToBottom()`, "follow output", or focus command with
follow-output semantics: set `followOutput`.
- Programmatic `scrollToTop()` or page/line scroll commands: set
`pinnedViewport`, unless the resulting viewport is bottom.
- Buffer change to alternate screen: record `bufferType`, but do not infer
follow-output from `onScroll`.
- Buffer return to normal screen: recompute from current viewport only if no
stronger user intent exists for the normal buffer.
- Scrollback prune: clamp a pinned viewport to the nearest valid line without
changing intent.
- Terminal remount/replay: restore from durable fallback only if the live xterm
instance was actually replaced.
Do not use xterm `onScroll` to set intent. It may still be useful as a passive
diagnostic signal, but it should not persist authoritative scroll state.
## Write Contract
All PTY output that reaches xterm should pass through one wrapper that enforces
intent around the actual `terminal.write` call.
Suggested owner:
`src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts`
Contract:
1. Capture intent and current `viewportY` before the write is scheduled.
2. Execute the write through the existing foreground/background/coalescing path.
3. After the write has parsed enough for xterm buffer state to be meaningful:
- If intent is `followOutput`, scroll to bottom.
- If intent is `pinnedViewport`, clamp and restore the saved viewport line.
4. Do not schedule multi-frame retries for normal output. A single post-write
enforcement point should be enough for standard writes.
For foreground synchronized-output holds, the enforcement point should run after
the held/coalesced frame is released, not for each partial cursor-hide chunk.
For background writes, preserve `pinnedViewport` if the pane is hidden or
inactive. Hidden output should not silently repin the terminal.
## Fit And Resize Contract
`safeFit()` should stop using generic delayed scroll restoration as its normal
behavior.
New rule:
1. If dimensions do not change, do nothing.
2. Before fit, capture current intent and viewport.
3. Fit.
4. If intent is `followOutput`, scroll to bottom.
5. If intent is `pinnedViewport`, restore the saved viewport line clamped to the
new `baseY`.
Avoid fitting hidden or zero-geometry panes. Existing geometry guards should
remain because fitting to transient workspace-switch geometry can send bad sizes
to the PTY and trigger TUI redraw churn.
## Visibility And Workspace Switch Contract
Workspace switching should not be a scroll operation.
On hide:
- Capture the current intent once.
- Do not run scroll restore.
- Do not update intent from `onScroll`.
- Continue hidden-output throttling/snapshot behavior as today.
On show:
- Reattach or refresh renderer resources as needed.
- Flush only the bounded amount of hidden output required for the active pane.
- Apply intent once after the visible output catch-up:
- `followOutput` goes to bottom.
- `pinnedViewport` stays pinned.
- Do not run repeated rAF/timeout scroll restores.
If the underlying terminal instance was replaced, use durable fallback state.
If the instance stayed alive, its live scroll intent is authoritative.
## Hidden Output And Snapshot Replay
Hidden-output snapshot replay is a true fallback path because it clears and
reconstructs xterm content. It needs scroll handling, but it should still obey
intent:
- Before snapshot replay, capture intent and viewport.
- Replay serialized content.
- Fit only if needed and only with valid visible geometry.
- Reapply the captured intent once:
- `followOutput`: bottom.
- `pinnedViewport`: previous viewport line, clamped.
- Do not let replay set follow-output just because the replayed buffer ends at
bottom.
If hidden output arrives while the user is pinned, background catch-up should not
move the visible viewport on activation.
## Split Reparenting
DOM reparenting can reset browser scroll state and WebGL resources. This is a
legitimate place for a fallback restore, but it should be scoped:
- Capture intent before reparent.
- Reparent.
- Reattach renderer if required.
- Apply intent once after DOM settles.
- Avoid restoring alternate-screen scrollback, because alternate screen has no
normal scrollback and a TUI owns its cursor.
The current split-specific rAF/timer code should become a local fallback for DOM
reparenting only, not a general model copied into workspace switching.
## Performance Constraints
The design must preserve Orca's terminal performance priorities:
- Do not keep all hidden terminals hot-rendering.
- Keep PTY/session/xterm state warm when feasible, but suspend hidden rendering
work and throttle hidden output as today.
- Avoid per-output layout reads. Intent enforcement should read xterm buffer
fields, not DOM geometry.
- Avoid multi-frame restore loops. They cause visible jitter and keep the
renderer busy after activation.
- Keep active-pane hidden-output catch-up bounded. Inactive visible split panes
can catch up over later frames.
- Do not send resize/SIGWINCH unless dimensions actually changed and the
renderer is the authoritative size owner.
The intended steady-state cost is one small buffer-state capture per xterm write
batch, not per byte and not per animation frame.
## Implementation Plan
1. Add `terminal-scroll-intent.ts`.
- Track `followOutput` vs `pinnedViewport`.
- Provide helpers for user scroll, explicit bottom, write capture, fit
capture, and intent enforcement.
2. Replace visibility-memory `onScroll` ownership.
- Remove authoritative snapshot updates from `terminal.onScroll`.
- Add capture-phase wheel listeners and keyboard scroll command hooks.
- Keep `onScroll` only for diagnostics if needed.
3. Route explicit scroll commands through intent helpers.
- `scrollToBottom()` sets `followOutput`.
- `scrollToTop()`, page up, and line up set `pinnedViewport`.
- Downward commands recompute after the command.
4. Wrap output writes.
- Add an intent-aware writer boundary in the output scheduler or in a narrow
wrapper used by the scheduler.
- Ensure foreground coalescing applies intent after the coalesced frame.
- Ensure background drains preserve pinned viewports.
5. Convert `safeFit()` to intent-aware fit.
- Capture intent before fit.
- Apply once after fit.
- Remove generic deferred scroll restore from normal fit.
6. Limit restore fallbacks.
- Keep fallback restore only for true remount/replay/reparent cases.
- Remove workspace-switch rAF/timeout restore loops once intent enforcement is
in place.
7. Add focused tests.
- User scroll up sets `pinnedViewport`.
- Output while pinned preserves viewport.
- Output while following scrolls to bottom.
- Fit while pinned preserves viewport.
- Fit while following stays at bottom.
- Hidden-output replay while pinned does not repin.
- Workspace hide/show does not change intent.
- `onScroll` does not mutate intent.
8. Add an E2E reproduction.
- Use the existing `scroll-primary` and `scroll-secondary` worktrees.
- Top pane in `scroll-primary` contains the TUI with enough scrollback.
- Scroll slightly above bottom, switch to `scroll-secondary`, then switch back.
- Assert the terminal remains within a small viewport tolerance and does not
visit top/bottom during the transition.
## Acceptance Criteria
- Switching away and back does not move a pinned TUI viewport.
- No visible flash to top or bottom during activation.
- Scrolling all the way to bottom re-enables follow-output and does not later
restore an older pinned position.
- Hidden output in inactive workspaces does not change visible scroll position
until the user explicitly follows output or reaches bottom.
- Alternate-screen TUIs do not receive extra restore/fill behavior that shifts
their cursor or scroll region.
- The active pane remains responsive during heavy hidden-output catch-up.
## Open Decisions
- Whether Orca should patch private xterm bottom-scroll internals. The reference
pattern does this for strict ownership, but Orca should first try public API
enforcement at write/fit boundaries and only escalate if xterm continues to
auto-follow independently.
- Whether scroll intent should be stored only by live `Terminal` instance or also
mirrored by leaf ID for remount fallback. The initial implementation should
use live instance state plus a leaf-keyed fallback for true replacement.
- Whether alternate screen needs a separate intent record from normal screen.
The first version can use one record with `bufferType`; a separate normal vs
alternate record is justified only if testing shows mode switches overwrite
user intent.

View File

@ -21,6 +21,11 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion
import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd'
import { useAppStore } from '@/store'
import { recordTerminalUserInputForLeaf } from './terminal-input-activity'
import {
markTerminalFollowOutput,
markTerminalPinnedViewport,
syncTerminalScrollIntentFromViewport
} from '@/lib/pane-manager/terminal-scroll-intent'
export function recordKeyboardCreatedTerminalPaneSplit(
createdPane: unknown,
@ -314,9 +319,13 @@ export function useTerminalKeyboardShortcuts({
return
}
if (action.position === 'top') {
markTerminalPinnedViewport(pane.terminal)
pane.terminal.scrollToLine(0)
syncTerminalScrollIntentFromViewport(pane.terminal)
} else {
markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
syncTerminalScrollIntentFromViewport(pane.terminal)
}
return
}

View File

@ -6255,6 +6255,12 @@ describe('connectPanePty', () => {
const pane = createPane(1)
pane.terminal.buffer.active.viewportY = 42
pane.terminal.buffer.active.baseY = 100
pane.terminal.write.mockImplementation((data: string, callback?: () => void) => {
if (data.includes('snapshot-state')) {
pane.terminal.buffer.active.viewportY = 0
}
callback?.()
})
const manager = createManager(1)
const deps = createDeps({
isVisibleRef: { current: false }

View File

@ -58,8 +58,11 @@ import {
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity'
import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility'
import { recordTerminalOutput, restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from '@/lib/pane-manager/terminal-scroll-intent'
import { createBrowserUuid } from '@/lib/browser-uuid'
import { makePaneKey, parseLegacyNumericPaneKey } from '../../../../shared/stable-pane-id'
import { createTerminalCommandLifecycle } from './terminal-command-lifecycle'
@ -3114,40 +3117,13 @@ export function connectPanePty(
})
}
function captureScrollStateForSnapshotReplay(): ScrollState | null {
const buf = pane.terminal.buffer?.active
if (!buf) {
return null
}
const viewportY = buf.viewportY
const baseY = buf.baseY
if (!Number.isFinite(viewportY) || !Number.isFinite(baseY)) {
return null
}
return {
bufferType: buf.type,
wasAtBottom: viewportY >= baseY,
viewportY,
baseY
}
}
function restoreScrollStateAfterSnapshotReplay(state: ScrollState | null): void {
if (!state || state.wasAtBottom) {
return
}
// Why: hidden-backlog replay clears xterm after visibility scroll restore;
// re-apply a scrolled-up viewport so recovery does not jump to bottom.
restoreScrollStateAfterLayout(pane.terminal, state)
}
function applyMainBufferSnapshot(snapshot: {
data: string
cols: number
rows: number
seq?: number
}): void {
const scrollState = captureScrollStateForSnapshotReplay()
const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
const colsBeforeReplay = pane.terminal.cols
const rowsBeforeReplay = pane.terminal.rows
const hasSnapshotDimensions =
@ -3191,7 +3167,9 @@ export function connectPanePty(
}
scheduleReattachIdleAgentCursorReset()
}
restoreScrollStateAfterSnapshotReplay(scrollState)
// 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)
}
function requestHiddenOutputRestoreIfNeeded(opts?: { bypassScheduler?: boolean }): boolean {

View File

@ -5,7 +5,7 @@ import {
flushTerminalOutput,
requestTerminalBacklogRecovery
} from '@/lib/pane-manager/pane-terminal-output-scheduler'
import { restoreScrollStateAfterLayout } from '@/lib/pane-manager/pane-scroll'
import { enforceTerminalCurrentScrollIntent } from '@/lib/pane-manager/terminal-scroll-intent'
import { fitAndFocusPanes, fitPanes, focusActivePane } from './pane-helpers'
const VISIBLE_RESUME_FLUSH_CHARS = 256 * 1024
@ -47,7 +47,7 @@ export function resumeTerminalVisibility({
// post-resume fit runs. Capture numeric viewport positions first; the
// restore path avoids content matching so duplicate agent log lines do
// not jump to the wrong history entry.
const viewportPositions = captureViewportPositions(!wasVisible)
captureViewportPositions(!wasVisible)
withSuppressedScrollTracking(() => {
if (shouldUseLightTabResume) {
// Why: intra-worktree tab switches only toggle the overlay. Keeping
@ -61,7 +61,7 @@ export function resumeTerminalVisibility({
} else {
resumeTerminalVisibilityHeavy(manager, isActive)
}
restoreTerminalViewportPositions(manager, viewportPositions)
enforceTerminalViewportIntents(manager)
if (!shouldUseLightTabResume) {
// Why: this clear wipes the glyph atlas shared with other same-config
// terminals; the global reset rebuilds their render models too.
@ -137,14 +137,8 @@ function resumeTerminalVisibilityHeavy(manager: PaneManager, isActive: boolean):
}
}
function restoreTerminalViewportPositions(
manager: PaneManager,
viewportPositions: Map<number, ScrollState>
): void {
function enforceTerminalViewportIntents(manager: PaneManager): void {
for (const pane of manager.getPanes()) {
const position = viewportPositions.get(pane.id)
if (position) {
restoreScrollStateAfterLayout(pane.terminal, position)
}
enforceTerminalCurrentScrollIntent(pane.terminal)
}
}

View File

@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 0),
handleTerminalFileDrop: vi.fn(),
enforceTerminalCurrentScrollIntent: vi.fn(),
pasteTerminalText: vi.fn(),
recordTerminalUserInputForLeaf: vi.fn(),
requestTerminalBacklogRecovery: vi.fn(),
@ -75,6 +76,10 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
restoreScrollStateAfterLayout: mocks.restoreScrollStateAfterLayout
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
enforceTerminalCurrentScrollIntent: mocks.enforceTerminalCurrentScrollIntent
}))
vi.mock('./terminal-drop-handler', () => ({
handleTerminalFileDrop: mocks.handleTerminalFileDrop
}))
@ -234,6 +239,9 @@ describe('useTerminalPaneGlobalEffects', () => {
mocks.restoreScrollStateAfterLayout.mockImplementation((terminal: { name: string }) => {
order.push(`restore:${terminal.name}`)
})
mocks.enforceTerminalCurrentScrollIntent.mockImplementation((terminal: { name: string }) => {
order.push(`intent:${terminal.name}`)
})
mocks.fitAndFocusPanes.mockImplementation(() => order.push('fit-focus'))
// Why: the resume path resets atlases through the live-manager registry
@ -267,10 +275,11 @@ describe('useTerminalPaneGlobalEffects', () => {
'flush:terminal-b',
'resume',
'fit-focus',
'restore:terminal-a',
'restore:terminal-b',
'intent:terminal-a',
'intent:terminal-b',
'reset-atlas'
])
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.flushTerminalOutput).toHaveBeenNthCalledWith(1, terminalA, {
maxChars: 256 * 1024
})
@ -563,7 +572,7 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(window.api.pty.setActiveRendererPty).toHaveBeenCalledWith('pty-active', true)
})
it('restores from the pre-hide scroll state when hidden layout changes the viewport', () => {
it('enforces scroll intent after hidden layout changes the viewport', () => {
const terminalA = { name: 'terminal-a' }
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal: terminalA }]),
@ -618,7 +627,8 @@ describe('useTerminalPaneGlobalEffects', () => {
expect(mocks.captureScrollState).toHaveBeenCalledTimes(2)
expect(manager.suspendRendering).toHaveBeenCalledTimes(1)
expect(mocks.restoreScrollStateAfterLayout).toHaveBeenLastCalledWith(terminalA, preHideState)
expect(mocks.restoreScrollStateAfterLayout).not.toHaveBeenCalled()
expect(mocks.enforceTerminalCurrentScrollIntent).toHaveBeenLastCalledWith(terminalA)
})
it('clears WebGL texture atlases when the active visible terminal regains focus', () => {

View File

@ -71,6 +71,10 @@ 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 { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph'
import { e2eConfig } from '@/lib/e2e-config'
import {
@ -720,6 +724,15 @@ export function useTerminalPaneLifecycle({
return false
}
if (e.type === 'keydown') {
if (e.key === 'PageUp' || e.key === 'Home') {
markTerminalPinnedViewport(pane.terminal)
syncTerminalScrollIntentSoon(pane.terminal, { preservePinnedAtBottom: true })
} else if (e.key === 'PageDown' || e.key === 'End') {
syncTerminalScrollIntentSoon(pane.terminal)
}
}
return !shouldBypassXtermKeyboardEvent(e, {
isMac,
hasSelection: pane.terminal.hasSelection()

View File

@ -11,7 +11,9 @@ const mocks = vi.hoisted(() => ({
baseY: 0
})),
flushTerminalOutput: vi.fn(),
getTerminalOutputEpoch: vi.fn(() => 1)
getTerminalOutputEpoch: vi.fn(() => 1),
getTerminalScrollIntentKind: vi.fn(() => 'followOutput'),
markTerminalFollowOutput: vi.fn()
}))
const reactRefState = vi.hoisted(() => ({
@ -68,6 +70,11 @@ vi.mock('@/lib/pane-manager/pane-scroll', () => ({
getTerminalOutputEpoch: mocks.getTerminalOutputEpoch
}))
vi.mock('@/lib/pane-manager/terminal-scroll-intent', () => ({
getTerminalScrollIntentKind: mocks.getTerminalScrollIntentKind,
markTerminalFollowOutput: mocks.markTerminalFollowOutput
}))
describe('useTerminalScrollVisibilityMemory', () => {
const originalRequestAnimationFrame = globalThis.requestAnimationFrame
const originalCancelAnimationFrame = globalThis.cancelAnimationFrame
@ -75,6 +82,7 @@ describe('useTerminalScrollVisibilityMemory', () => {
beforeEach(() => {
resetHookRefs()
vi.clearAllMocks()
mocks.getTerminalScrollIntentKind.mockReturnValue('followOutput')
})
afterEach(() => {
@ -121,6 +129,42 @@ describe('useTerminalScrollVisibilityMemory', () => {
maxChars: 256 * 1024
})
expect(terminal.scrollToBottom).toHaveBeenCalled()
expect(mocks.markTerminalFollowOutput).toHaveBeenCalledWith(terminal)
})
it('does not turn a pinned viewport into follow-output when pending focus requests catch up', () => {
mocks.getTerminalScrollIntentKind.mockReturnValue('pinnedViewport')
const terminal = {
onScroll: vi.fn(() => ({ dispose: vi.fn() })),
scrollToBottom: vi.fn()
}
const manager = {
getPanes: vi.fn(() => [{ id: 1, terminal }])
}
const animationFrames: FrameRequestCallback[] = []
globalThis.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
animationFrames.push(callback)
return animationFrames.length
})
beginHookRender()
const visibilityMemory = useTerminalScrollVisibilityMemory({
managerRef: { current: manager as never },
isVisibleRef: { current: true },
visibleResumeCompleteRef: { current: true },
paneCount: 1
})
visibilityMemory.scheduleFollowOutputIfNeeded(1)
animationFrames.shift()?.(16)
animationFrames.shift()?.(32)
expect(mocks.flushTerminalOutput).toHaveBeenCalledWith(terminal, {
maxChars: 256 * 1024
})
expect(mocks.cancelDeferredScrollRestore).not.toHaveBeenCalled()
expect(mocks.markTerminalFollowOutput).not.toHaveBeenCalled()
expect(terminal.scrollToBottom).not.toHaveBeenCalled()
})
it('cancels pending follow-output frames on cleanup', () => {

View File

@ -1,11 +1,15 @@
import { useCallback, useEffect, useRef } from 'react'
import type { IDisposable, Terminal } from '@xterm/xterm'
import type { Terminal } from '@xterm/xterm'
import { flushTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler'
import {
cancelDeferredScrollRestore,
captureScrollState,
getTerminalOutputEpoch
} from '@/lib/pane-manager/pane-scroll'
import {
getTerminalScrollIntentKind,
markTerminalFollowOutput
} from '@/lib/pane-manager/terminal-scroll-intent'
import type { PaneManager } from '@/lib/pane-manager/pane-manager'
import type { ScrollState } from '@/lib/pane-manager/pane-manager-types'
@ -37,7 +41,6 @@ export function useTerminalScrollVisibilityMemory({
paneCount
}: UseTerminalScrollVisibilityMemoryArgs): TerminalScrollVisibilityMemory {
const visibleScrollSnapshotsRef = useRef<Map<number, VisibleScrollSnapshot>>(new Map())
const scrollDisposablesRef = useRef<Map<number, IDisposable>>(new Map())
const suppressScrollTrackingRef = useRef(false)
const pendingFollowOutputPaneIdsRef = useRef<Set<number>>(new Set())
const followOutputFrameIdsRef = useRef<number[]>([])
@ -117,10 +120,13 @@ export function useTerminalScrollVisibilityMemory({
const currentEpoch = getTerminalOutputEpoch(pane.terminal)
const hasNewOutput = previous ? currentEpoch > previous.outputEpoch : currentEpoch > 0
if (hasNewOutput) {
cancelDeferredScrollRestore(pane.terminal)
pane.terminal.scrollToBottom()
if (getTerminalScrollIntentKind(pane.terminal) === 'followOutput') {
cancelDeferredScrollRestore(pane.terminal)
markTerminalFollowOutput(pane.terminal)
pane.terminal.scrollToBottom()
didScroll = true
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
didScroll = true
}
pending.delete(pane.id)
}
@ -164,46 +170,15 @@ export function useTerminalScrollVisibilityMemory({
if (!manager) {
return
}
const disposables = scrollDisposablesRef.current
const panes = manager.getPanes()
const livePaneIds = new Set(panes.map((pane) => pane.id))
for (const [paneId, disposable] of disposables) {
for (const paneId of visibleScrollSnapshotsRef.current.keys()) {
if (!livePaneIds.has(paneId)) {
disposable.dispose()
disposables.delete(paneId)
visibleScrollSnapshotsRef.current.delete(paneId)
pendingFollowOutputPaneIdsRef.current.delete(paneId)
}
}
for (const pane of panes) {
if (disposables.has(pane.id)) {
continue
}
const onScroll = (
pane.terminal as Terminal & {
onScroll?: (listener: (position: number) => void) => IDisposable
}
).onScroll
if (typeof onScroll !== 'function') {
continue
}
disposables.set(
pane.id,
onScroll.call(pane.terminal, () => {
if (!isVisibleRef.current || suppressScrollTrackingRef.current) {
return
}
rememberVisibleScrollSnapshot(pane.id, pane.terminal)
})
)
}
return () => {
for (const disposable of disposables.values()) {
disposable.dispose()
}
disposables.clear()
}
}, [isVisibleRef, managerRef, paneCount, rememberVisibleScrollSnapshot])
}, [managerRef, paneCount])
return {
captureViewportPositions,

View File

@ -0,0 +1,131 @@
import { FitAddon } from '@xterm/addon-fit'
import { SearchAddon } from '@xterm/addon-search'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { Terminal } from '@xterm/xterm'
import type { ITerminalOptions } from '@xterm/xterm'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder'
import { attachPaneDrag } from './pane-drag-pointer'
import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
import { ENABLE_WEBGL_RENDERER } from './pane-webgl-renderer'
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
? 'click to open or ⇧+click for system browser'
: 'click to open or Shift+click for system browser'
}
export function createPaneDOM(
id: number,
leafId: TerminalLeafId,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number, options?: { focusTerminal?: boolean }) => void,
onMouseEnter: (id: number, event: MouseEvent) => void
): ManagedPaneInternal {
const container = document.createElement('div')
container.className = 'pane'
container.dataset.paneId = String(id)
container.dataset.leafId = leafId
// Why: CSS owns baseline xterm geometry so title offsets do not race safeFit().
const xtermContainer = document.createElement('div')
xtermContainer.className = 'xterm-container'
container.appendChild(xtermContainer)
const userOpts = options.terminalOptions?.(id) ?? {}
const terminalOpts: ITerminalOptions = {
...buildDefaultTerminalOptions(),
...userOpts
}
const terminal = new Terminal(terminalOpts)
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
const openLinkHint = getTerminalUrlOpenHint()
const linkTooltip = document.createElement('div')
linkTooltip.className = 'pane-link-tooltip'
linkTooltip.classList.add('xterm-hover')
linkTooltip.style.cssText =
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
const dragHandle = document.createElement('div')
dragHandle.className = 'pane-drag-handle'
container.appendChild(dragHandle)
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
const webLinksAddon = new WebLinksAddon(
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
{
hover: (_event, uri) => {
if (uri) {
linkTooltip.textContent = `${uri} (${openLinkHint})`
linkTooltip.style.display = ''
}
},
leave: () => {
linkTooltip.style.display = 'none'
}
}
)
const panePointerDownHandler = (event: PointerEvent): void => {
onPointerDown(id, {
focusTerminal: shouldFocusTerminalFromPanePointerDown(event.target)
})
}
const paneMouseEnterHandler = (event: MouseEvent): void => onMouseEnter(id, event)
const pane: ManagedPaneInternal = {
id,
leafId,
stablePaneId: leafId,
terminal,
container,
xtermContainer,
linkTooltip,
terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity,
terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto',
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,
pendingInitialFitRafId: null,
pendingWebglRefreshRafId: null,
pendingObservedFitRafId: null,
searchAddon,
serializeAddon: new SerializeAddon(),
unicode11Addon,
webLinksAddon,
webglAddon: null,
ligaturesAddon: null,
panePointerDownHandler,
paneMouseEnterHandler,
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
terminalScrollIntentDisposable: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],
pendingSplitScrollTimerId: null,
pendingSplitScrollBufferDisposable: null,
debugLabel: options.debugLabel ?? null
}
container.addEventListener('pointerdown', panePointerDownHandler)
container.addEventListener('mouseenter', paneMouseEnterHandler)
return pane
}

View File

@ -1,179 +1,28 @@
import { Terminal } from '@xterm/xterm'
import type { ITerminalOptions } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
// Upstream packaging bug: @xterm/addon-ligatures declares `"main":
// "lib/addon-ligatures.js"` but ships only the `.mjs` entry, so Vite fails to
// resolve the bare import. Fixed locally via config/patches/@xterm__addon-ligatures*.
// Tracking upstream: https://github.com/xtermjs/xterm.js/issues/5822 and
// https://github.com/xtermjs/xterm.js/pull/5828 — drop the patch once that lands.
import { LigaturesAddon } from '@xterm/addon-ligatures'
import { SearchAddon } from '@xterm/addon-search'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { SerializeAddon } from '@xterm/addon-serialize'
import type { PaneManagerOptions, ManagedPaneInternal } from './pane-manager-types'
import type { TerminalLeafId } from '../../../../shared/stable-pane-id'
import type { DragReorderState } from './pane-drag-reorder'
import type { DragReorderCallbacks } from './pane-drag-reorder'
import { attachPaneDrag } from './pane-drag-pointer'
import type { ManagedPaneInternal } from './pane-manager-types'
import { safeFit } from './pane-tree-ops'
import {
attachPaneFitResizeObserver,
detachPaneFitResizeObserver
} from './pane-fit-resize-observer'
import { clearPendingSplitScrollRestore } from './pane-split-scroll'
import { buildDefaultTerminalOptions } from './pane-terminal-options'
import { activateOrcaTerminalUnicodeProvider } from './pane-terminal-unicode-provider'
import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel'
import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent'
import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync'
import {
ENABLE_WEBGL_RENDERER,
attachWebgl,
cancelPendingWebglRefresh,
disposeWebgl
} from './pane-webgl-renderer'
import { shouldFocusTerminalFromPanePointerDown } from './pane-pointer-focus'
import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer'
// ---------------------------------------------------------------------------
// Pane creation, terminal open/close, addon management
// ---------------------------------------------------------------------------
function getTerminalUrlOpenHint(): string {
return navigator.userAgent.includes('Mac')
? 'click to open or ⇧+click for system browser'
: 'click to open or Shift+click for system browser'
}
export function createPaneDOM(
id: number,
leafId: TerminalLeafId,
options: PaneManagerOptions,
dragState: DragReorderState,
dragCallbacks: DragReorderCallbacks,
onPointerDown: (id: number, options?: { focusTerminal?: boolean }) => void,
onMouseEnter: (id: number, event: MouseEvent) => void
): ManagedPaneInternal {
// Create .pane container
const container = document.createElement('div')
container.className = 'pane'
container.dataset.paneId = String(id)
container.dataset.leafId = leafId
// Create .xterm-container — baseline layout (position, width, height, margin)
// is CSS-driven (see main.css .xterm-container) so that the data-has-title
// attribute override can shift the terminal down without racing safeFit().
const xtermContainer = document.createElement('div')
xtermContainer.className = 'xterm-container'
container.appendChild(xtermContainer)
// Build terminal options
const userOpts = options.terminalOptions?.(id) ?? {}
const terminalOpts: ITerminalOptions = {
...buildDefaultTerminalOptions(),
...userOpts
}
const terminal = new Terminal(terminalOpts)
const fitAddon = new FitAddon()
const searchAddon = new SearchAddon()
const unicode11Addon = new Unicode11Addon()
const openLinkHint = getTerminalUrlOpenHint()
// URL tooltip element — Ghostty-style bottom-left hint on hover
const linkTooltip = document.createElement('div')
linkTooltip.className = 'pane-link-tooltip'
linkTooltip.classList.add('xterm-hover')
linkTooltip.style.cssText =
'display:none;position:absolute;bottom:4px;left:8px;z-index:40;' +
'padding:5px 8px;border-radius:4px;font-size:11px;font-family:inherit;' +
'color:#a1a1aa;background:rgba(24,24,27,0.85);border:1px solid rgba(63,63,70,0.6);' +
'pointer-events:none;max-width:80%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;'
// Ghostty-style drag handle — appears at top of pane on hover when 2+ panes
const dragHandle = document.createElement('div')
dragHandle.className = 'pane-drag-handle'
container.appendChild(dragHandle)
const paneDragCleanup = attachPaneDrag(dragHandle, id, dragState, dragCallbacks)
const webLinksAddon = new WebLinksAddon(
options.onLinkClick ? (event, uri) => options.onLinkClick!(event, uri) : undefined,
{
hover: (_event, uri) => {
if (uri) {
linkTooltip.textContent = `${uri} (${openLinkHint})`
linkTooltip.style.display = ''
}
},
leave: () => {
linkTooltip.style.display = 'none'
}
}
)
const serializeAddon = new SerializeAddon()
const panePointerDownHandler = (event: PointerEvent): void => {
onPointerDown(id, {
focusTerminal: shouldFocusTerminalFromPanePointerDown(event.target)
})
}
const paneMouseEnterHandler = (event: MouseEvent): void => {
onMouseEnter(id, event)
}
const pane: ManagedPaneInternal = {
id,
leafId,
stablePaneId: leafId,
terminal,
container,
xtermContainer,
linkTooltip,
terminalTuiScrollSensitivity: options.terminalTuiScrollSensitivity,
terminalGpuAcceleration: options.terminalGpuAcceleration ?? 'auto',
gpuRenderingEnabled: ENABLE_WEBGL_RENDERER,
webglAttachmentDeferred: false,
webglDisabledAfterContextLoss: false,
hasComplexScriptOutput: false,
fitAddon,
fitResizeObserver: null,
pendingInitialFitRafId: null,
pendingWebglRefreshRafId: null,
pendingObservedFitRafId: null,
searchAddon,
serializeAddon,
unicode11Addon,
webLinksAddon,
webglAddon: null,
ligaturesAddon: null,
panePointerDownHandler,
paneMouseEnterHandler,
paneDragCleanup,
compositionHandler: null,
focusClassSyncCleanup: null,
pendingSplitScrollState: null,
pendingSplitScrollRafIds: [],
pendingSplitScrollTimerId: null,
pendingSplitScrollBufferDisposable: null,
debugLabel: options.debugLabel ?? null
}
// Focus handler: clicking a pane makes it active and explicitly focuses
// the terminal. We must call focus: true here because after DOM reparenting
// (e.g. splitPane moves the original pane into a flex container), xterm.js's
// native click-to-focus on its internal textarea may not fire reliably.
container.addEventListener('pointerdown', panePointerDownHandler)
// Focus-follows-mouse handler: when the setting is enabled, hovering a
// pane makes it active. All gating (feature flag, drag-in-progress,
// window focus, etc.) lives in the PaneManager callback — this layer
// just forwards the event.
container.addEventListener('mouseenter', paneMouseEnterHandler)
return pane
}
export { createPaneDOM } from './pane-dom-creation'
/** Open terminal into its container and load addons. Must be called after the container is in the DOM. */
export function openTerminal(pane: ManagedPaneInternal): void {
@ -203,6 +52,11 @@ export function openTerminal(pane: ManagedPaneInternal): void {
attachTerminalMouseWheelMultiplier(terminal, {
getTuiMouseWheelMultiplier: terminalTuiScrollSensitivity
})
pane.terminalScrollIntentDisposable = attachTerminalScrollIntentTracking(
terminal,
xtermContainer,
pane.leafId
)
// Activate Orca's Unicode 11 width shim *before* any caller-driven write. CJK / emoji /
// ZWJ codepoints get baked into the buffer at the active unicode version on
@ -343,6 +197,8 @@ export function disposePane(
pane.paneDragCleanup = null
pane.focusClassSyncCleanup?.()
pane.focusClassSyncCleanup = null
pane.terminalScrollIntentDisposable?.dispose()
pane.terminalScrollIntentDisposable = null
if (pane.compositionHandler) {
pane.terminal.element?.removeEventListener('compositionstart', pane.compositionHandler, true)
pane.compositionHandler = null

View File

@ -135,6 +135,8 @@ export type ManagedPaneInternal = {
compositionHandler: (() => void) | null
// Stored so disposePane() can remove DOM-renderer focus synchronization.
focusClassSyncCleanup?: (() => void) | null
// Stored so disposePane() can remove user-scroll intent listeners.
terminalScrollIntentDisposable?: IDisposable | null
// Why: splitPane reparents DOM; its delayed restore owns scroll until the
// browser settles, so intermediate fits must not compete with it.
pendingSplitScrollState: ScrollState | null

View File

@ -7,6 +7,10 @@ import {
writeForegroundTerminalChunk,
type ForegroundTerminalOutputTarget
} from './pane-terminal-foreground-render-settle'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from './terminal-scroll-intent'
type TerminalOutputTarget = ForegroundTerminalOutputTarget
@ -600,6 +604,38 @@ function hasDrainableBacklog(): boolean {
return false
}
function writeBackgroundTerminalChunk(terminal: TerminalOutputTarget, data: string): void {
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
if (!scrollIntent) {
terminal.write(data)
return
}
if (terminal.write.length < 2) {
terminal.write(data)
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
return
}
terminal.write(data, () => {
enforceTerminalWriteScrollIntent(terminal, scrollIntent)
})
}
function writeForegroundTerminalChunkWithIntent(
terminal: TerminalOutputTarget,
data: string,
options: {
forceViewportRefresh: boolean
followupViewportRefresh: boolean
}
): void {
const scrollIntent = captureTerminalWriteScrollIntent(terminal)
writeForegroundTerminalChunk(terminal, data, {
forceViewportRefresh: options.forceViewportRefresh,
followupViewportRefresh: options.followupViewportRefresh,
onParsed: () => enforceTerminalWriteScrollIntent(terminal, scrollIntent)
})
}
function takeNextDrainableEntry(): QueueEntry | null {
for (const entry of queuedByTerminal.values()) {
if (!isEntryDrainable(entry)) {
@ -619,7 +655,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
try {
entry.beforeWrite?.(queuedWrite.data)
if (queuedWrite.foreground) {
writeForegroundTerminalChunk(
writeForegroundTerminalChunkWithIntent(
entry.terminal,
queuedWrite.stripTransientCursorShows
? removeTransientCursorShowSequences(queuedWrite.data)
@ -630,7 +666,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null
}
)
} else {
entry.terminal.write(queuedWrite.data)
writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data)
}
} catch {
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;
@ -823,12 +859,12 @@ export function writeTerminalOutput(
debugState.foregroundWriteCount++
}
options.beforeWrite?.(data)
writeForegroundTerminalChunk(
writeForegroundTerminalChunkWithIntent(
terminal,
options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data,
{
forceViewportRefresh: options.forceForegroundRefresh,
followupViewportRefresh: options.followupForegroundRefresh
forceViewportRefresh: options.forceForegroundRefresh === true,
followupViewportRefresh: options.followupForegroundRefresh === true
}
)
return
@ -896,7 +932,7 @@ export function flushTerminalOutput(
try {
entry.beforeWrite?.(queuedWrite.data)
if (queuedWrite.foreground) {
writeForegroundTerminalChunk(
writeForegroundTerminalChunkWithIntent(
terminal,
queuedWrite.stripTransientCursorShows
? removeTransientCursorShowSequences(queuedWrite.data)
@ -907,7 +943,7 @@ export function flushTerminalOutput(
}
)
} else {
terminal.write(queuedWrite.data)
writeBackgroundTerminalChunk(terminal, queuedWrite.data)
}
} catch {
// Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping;

View File

@ -3,13 +3,15 @@ import type {
DropZone,
ManagedPane,
ManagedPaneInternal,
PaneStyleOptions,
ScrollState
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 { captureScrollState, restoreScrollStateAfterLayout } from './pane-scroll'
import {
captureTerminalWriteScrollIntent,
enforceTerminalWriteScrollIntent
} from './terminal-scroll-intent'
export { captureScrollState, restoreScrollState } from './pane-scroll'
@ -58,18 +60,18 @@ function canMeasurePaneForFit(pane: ManagedPane): boolean {
return dims.cols >= MIN_PANE_FIT_COLS && dims.rows >= MIN_PANE_FIT_ROWS
}
function captureScrollStateForFit(pane: ManagedPane): ScrollState | null {
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
? null
: captureScrollState(pane.terminal)
return !(
'pendingSplitScrollState' in pane && (pane as ManagedPaneInternal).pendingSplitScrollState
)
}
export function safeFit(pane: ManagedPane): void {
if (!canMeasurePaneForFit(pane)) {
return
}
let scrollState: ScrollState | null = null
let scrollIntent = null as ReturnType<typeof captureTerminalWriteScrollIntent>
let shouldRestoreScroll = false
try {
// Why: when a mobile client has resized this PTY to phone dimensions,
@ -81,8 +83,10 @@ export function safeFit(pane: ManagedPane): void {
const override = ptyId ? getFitOverrideForPty(ptyId) : null
if (override) {
if (pane.terminal.cols !== override.cols || pane.terminal.rows !== override.rows) {
scrollState = captureScrollStateForFit(pane)
shouldRestoreScroll = true
if (canPreserveScrollIntentForFit(pane)) {
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
shouldRestoreScroll = true
}
pane.terminal.resize(override.cols, override.rows)
}
return
@ -95,15 +99,17 @@ export function safeFit(pane: ManagedPane): void {
// churn, which was causing visible terminal blinking while resizing.
return
}
scrollState = captureScrollStateForFit(pane)
shouldRestoreScroll = true
if (canPreserveScrollIntentForFit(pane)) {
scrollIntent = captureTerminalWriteScrollIntent(pane.terminal)
shouldRestoreScroll = true
}
pane.fitAddon.fit()
} catch {
// Container may not have dimensions yet
} finally {
if (shouldRestoreScroll && scrollState) {
if (shouldRestoreScroll) {
try {
restoreScrollStateAfterLayout(pane.terminal, scrollState)
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.

View File

@ -0,0 +1,317 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
attachTerminalScrollIntentTracking,
captureTerminalWriteScrollIntent,
enforceTerminalCurrentScrollIntent,
enforceTerminalWriteScrollIntent,
getTerminalScrollIntentKind,
markTerminalFollowOutput,
markTerminalPinnedViewport,
syncTerminalScrollIntentFromViewport,
syncTerminalScrollIntentSoon
} from './terminal-scroll-intent'
function createTerminal({
viewportY,
baseY,
type = 'normal'
}: {
viewportY: number
baseY: number
type?: 'normal' | 'alternate'
}) {
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
}
class TestElement extends EventTarget {
parentElement: TestElement | null = null
readonly classList = {
contains: (className: string): boolean => this.className.split(/\s+/).includes(className)
}
constructor(public className = '') {
super()
}
append(child: TestElement): void {
child.parentElement = this
}
closest(selector: string): TestElement | null {
if (!selector.startsWith('.')) {
return null
}
const className = selector.slice(1)
if (this.classList.contains(className)) {
return this
}
return this.parentElement?.closest(selector) ?? null
}
dispatchEvent(event: Event): boolean {
if (!event.target) {
Object.defineProperty(event, 'target', {
configurable: true,
value: this
})
}
const result = super.dispatchEvent(event)
if (event.bubbles && this.parentElement) {
this.parentElement.dispatchEvent(event)
}
return result
}
}
describe('terminal scroll intent', () => {
afterEach(() => {
vi.useRealTimers()
vi.unstubAllGlobals()
})
it('infers followOutput when the viewport is at the bottom', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
})
it('infers pinnedViewport when the viewport is above the bottom', () => {
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
})
it('preserves a pinned viewport after output moves xterm to bottom', () => {
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
markTerminalPinnedViewport(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.baseY = 125
terminal.buffer.active.viewportY = 125
enforceTerminalWriteScrollIntent(terminal, snapshot)
expect(terminal.scrollToLine).toHaveBeenCalledWith(42)
expect(terminal.buffer.active.viewportY).toBe(42)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
})
it('follows output after output advances while following', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
markTerminalFollowOutput(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.baseY = 125
terminal.buffer.active.viewportY = 0
enforceTerminalWriteScrollIntent(terminal, snapshot)
expect(terminal.scrollToBottom).toHaveBeenCalledTimes(1)
expect(terminal.buffer.active.viewportY).toBe(125)
})
it('does not preserve across buffer type changes', () => {
const terminal = createTerminal({ viewportY: 42, baseY: 100 })
markTerminalPinnedViewport(terminal)
const snapshot = captureTerminalWriteScrollIntent(terminal)
terminal.buffer.active.type = 'alternate'
terminal.buffer.active.viewportY = 0
enforceTerminalWriteScrollIntent(terminal, snapshot)
expect(terminal.scrollToLine).not.toHaveBeenCalled()
expect(terminal.buffer.active.viewportY).toBe(0)
})
it('syncs intent from the current viewport after user scroll settles', () => {
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
terminal.buffer.active.viewportY = 50
syncTerminalScrollIntentFromViewport(terminal)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
})
it('tracks upward wheel immediately and records the settled viewport', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
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)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 80
await Promise.resolve()
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(80)
disposable.dispose()
})
it('returns to followOutput after a downward wheel settles at the bottom', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 50, baseY: 100 })
const host = new TestElement() as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
const wheelDown = new Event('wheel') as WheelEvent
Object.defineProperty(wheelDown, 'deltaY', { value: 10 })
host.dispatchEvent(wheelDown)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 100
await Promise.resolve()
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
disposable.dispose()
})
it('keeps sampling briefly after wheel so delayed xterm scroll updates win', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
vi.useFakeTimers()
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)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
await Promise.resolve()
frameCallbacks.shift()?.(16)
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
terminal.buffer.active.viewportY = 76
frameCallbacks.shift()?.(32)
frameCallbacks.shift()?.(48)
terminal.buffer.active.viewportY = 100
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(76)
disposable.dispose()
})
it('keeps a pane-keyed pinned viewport across a remounted empty terminal', () => {
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-1')
markTerminalPinnedViewport(firstTerminal)
const remountedTerminal = createTerminal({ viewportY: 0, baseY: 0 })
const remountedHost = new TestElement() as unknown as HTMLElement
const remountedDisposable = attachTerminalScrollIntentTracking(
remountedTerminal,
remountedHost,
'leaf-1'
)
syncTerminalScrollIntentFromViewport(remountedTerminal)
remountedTerminal.buffer.active.baseY = 100
remountedTerminal.buffer.active.viewportY = 100
enforceTerminalCurrentScrollIntent(remountedTerminal)
expect(remountedTerminal.scrollToLine).toHaveBeenCalledWith(76)
expect(getTerminalScrollIntentKind(remountedTerminal)).toBe('pinnedViewport')
firstDisposable.dispose()
remountedDisposable.dispose()
})
it('tracks pointer-driven scrollbar scrolls without using output scroll as intent', () => {
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const hostElement = new TestElement()
const viewport = new TestElement('xterm-viewport')
hostElement.append(viewport)
const host = hostElement as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
terminal.buffer.active.viewportY = 50
host.dispatchEvent(new Event('scroll'))
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
viewport.dispatchEvent(new Event('pointerdown', { bubbles: true }))
viewport.dispatchEvent(new Event('scroll', { bubbles: true }))
expect(getTerminalScrollIntentKind(terminal)).toBe('pinnedViewport')
disposable.dispose()
})
it('does not treat terminal body pointer activity as scrollbar intent', () => {
vi.stubGlobal('Element', TestElement)
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
const hostElement = new TestElement()
const body = new TestElement()
hostElement.append(body)
const host = hostElement as unknown as HTMLElement
const disposable = attachTerminalScrollIntentTracking(terminal, host)
body.dispatchEvent(new Event('pointerdown', { bubbles: true }))
terminal.buffer.active.viewportY = 50
host.dispatchEvent(new Event('scroll'))
expect(getTerminalScrollIntentKind(terminal)).toBe('followOutput')
disposable.dispose()
})
it('updates a manually pinned intent after xterm-handled keyboard scrolling settles', async () => {
const frameCallbacks: FrameRequestCallback[] = []
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => {
frameCallbacks.push(callback)
return frameCallbacks.length
})
const terminal = createTerminal({ viewportY: 100, baseY: 100 })
markTerminalPinnedViewport(terminal)
terminal.buffer.active.viewportY = 75
syncTerminalScrollIntentSoon(terminal)
await Promise.resolve()
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenLastCalledWith(75)
})
it('enforces current intent once for visibility resume', () => {
const terminal = createTerminal({ viewportY: 40, baseY: 100 })
markTerminalPinnedViewport(terminal)
terminal.buffer.active.viewportY = 0
enforceTerminalCurrentScrollIntent(terminal)
expect(terminal.scrollToLine).toHaveBeenCalledWith(40)
})
})

View File

@ -0,0 +1,293 @@
import type { IDisposable } from '@xterm/xterm'
type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport'
type BufferType = 'normal' | 'alternate'
type TerminalScrollIntentTarget = {
buffer?: {
active?: {
type?: string
viewportY?: number
baseY?: number
}
}
scrollToBottom?: () => void
scrollToLine?: (line: number) => void
}
type TerminalScrollIntentKey = string
type TerminalScrollIntent = {
kind: TerminalScrollIntentKind
bufferType: BufferType
viewportY: number
baseY: number
}
type TerminalScrollIntentWriteSnapshot = {
kind: TerminalScrollIntentKind
bufferType: BufferType
viewportY: number
}
const terminalScrollIntentByTerminal = new WeakMap<
TerminalScrollIntentTarget,
TerminalScrollIntent
>()
const terminalScrollIntentKeyByTerminal = new WeakMap<
TerminalScrollIntentTarget,
TerminalScrollIntentKey
>()
const terminalScrollIntentByKey = new Map<TerminalScrollIntentKey, TerminalScrollIntent>()
const BOTTOM_TOLERANCE_ROWS = 1
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
}
function writeIntent(
terminal: TerminalScrollIntentTarget,
kind: TerminalScrollIntentKind
): TerminalScrollIntent | null {
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return null
}
const intent = { kind, ...snapshot }
terminalScrollIntentByTerminal.set(terminal, intent)
const key = terminalScrollIntentKeyByTerminal.get(terminal)
if (key) {
terminalScrollIntentByKey.set(key, intent)
}
return intent
}
function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollIntent | undefined {
const terminalIntent = terminalScrollIntentByTerminal.get(terminal)
if (terminalIntent) {
return terminalIntent
}
const key = terminalScrollIntentKeyByTerminal.get(terminal)
return key ? terminalScrollIntentByKey.get(key) : undefined
}
function bindTerminalScrollIntentKey(
terminal: TerminalScrollIntentTarget,
key: TerminalScrollIntentKey | undefined
): TerminalScrollIntent | undefined {
if (!key) {
return terminalScrollIntentByTerminal.get(terminal)
}
terminalScrollIntentKeyByTerminal.set(terminal, key)
const existing = terminalScrollIntentByKey.get(key)
if (existing) {
terminalScrollIntentByTerminal.set(terminal, existing)
}
return existing
}
function clampViewportY(viewportY: number, baseY: number): number {
return Math.max(0, Math.min(viewportY, baseY))
}
function safeScrollCall(fn: () => void): boolean {
try {
fn()
return true
} catch (err) {
if (err instanceof TypeError && /dimensions/.test(err.message)) {
return false
}
throw err
}
}
export function markTerminalFollowOutput(terminal: TerminalScrollIntentTarget): void {
writeIntent(terminal, 'followOutput')
}
export function markTerminalPinnedViewport(terminal: TerminalScrollIntentTarget): void {
writeIntent(terminal, 'pinnedViewport')
}
export function syncTerminalScrollIntentFromViewport(
terminal: TerminalScrollIntentTarget,
options: { preservePinnedAtBottom?: boolean } = {}
): void {
const snapshot = readBufferSnapshot(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) {
terminalScrollIntentByTerminal.set(terminal, existing)
return
}
if (
options.preservePinnedAtBottom &&
existing?.kind === 'pinnedViewport' &&
isAtBottom(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))
setTimeout(sync, 80)
}
export function getTerminalScrollIntentKind(
terminal: TerminalScrollIntentTarget
): TerminalScrollIntentKind {
const existing = readStoredIntent(terminal)
if (existing) {
return existing.kind
}
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return 'followOutput'
}
return isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport'
}
export function captureTerminalWriteScrollIntent(
terminal: TerminalScrollIntentTarget
): TerminalScrollIntentWriteSnapshot | null {
const snapshot = readBufferSnapshot(terminal)
if (!snapshot) {
return null
}
const existing = readStoredIntent(terminal)
const kind =
existing?.kind ??
(isAtBottom(snapshot.viewportY, snapshot.baseY) ? 'followOutput' : 'pinnedViewport')
return {
kind,
bufferType: snapshot.bufferType,
viewportY: snapshot.viewportY
}
}
export function enforceTerminalWriteScrollIntent(
terminal: TerminalScrollIntentTarget,
snapshot: TerminalScrollIntentWriteSnapshot | null
): void {
if (!snapshot) {
return
}
const current = readBufferSnapshot(terminal)
if (!current || current.bufferType !== snapshot.bufferType) {
return
}
if (snapshot.kind === 'followOutput') {
if (safeScrollCall(() => terminal.scrollToBottom?.())) {
writeIntent(terminal, 'followOutput')
}
return
}
const targetY = clampViewportY(snapshot.viewportY, current.baseY)
if (current.viewportY !== targetY) {
safeScrollCall(() => terminal.scrollToLine?.(targetY))
}
writeIntent(terminal, 'pinnedViewport')
}
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)
}
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 => {
const target = event.target
pointerScrollActive =
typeof Element !== 'undefined' &&
target instanceof Element &&
(target.classList.contains('xterm-viewport') || target.closest('.xterm-viewport') !== null)
}
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)
}
}
}

View File

@ -0,0 +1,251 @@
import { readFileSync } from 'node:fs'
import path from 'node:path'
export const EMOJI_TABLE_FIXTURE = readFileSync(
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
'utf8'
)
export const NARROW_TERMINAL_MAX_COLS = 120
export function longMarkdownTableScript(runId: string): string {
const names = [
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
]
return `
const rows = ${JSON.stringify(names)}
const widths = [16, 14, 12, 6, 7, 42]
function isCombiningMark(codePoint) {
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
}
function isWideCodePoint(codePoint) {
return codePoint > 0xffff ||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || isCombiningMark(codePoint)) continue
width += isWideCodePoint(codePoint) ? 2 : 1
}
return width
}
function cell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
}
function line(parts) {
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
}
const outputRows = []
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
for (let repeat = 0; repeat < 4; repeat += 1) {
for (const row of rows) outputRows.push(line(row))
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of outputRows) {
await writeStdout(row + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
`
}
export function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}`
return `
const table = ${JSON.stringify(table)}
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
const tableOverhead = preferredWidths.length * 3 + 1
const widthBudget = Math.max(
minimumWidths.reduce((sum, width) => sum + width, 0),
Math.min(
preferredWidths.reduce((sum, width) => sum + width, 0),
(process.stdout.columns || 100) - tableOverhead - 1
)
)
let remaining = widthBudget
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
const widths = preferredWidths.map((preferred, index) => {
const minimum = minimumWidths[index]
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
remaining -= width
remainingPreferred -= preferred
return width
})
const generatedTableWidth = widths.reduce((sum, width) => sum + width, 0) + tableOverhead
const border = {
top: ['┌', '┬', '┐'],
middle: ['├', '┼', '┤'],
bottom: ['└', '┴', '┘'],
vertical: '│',
horizontal: '─'
}
function splitMarkdownRow(row) {
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
}
function isSeparatorRow(row) {
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
}
return width
}
function padCell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
}
function splitToWidth(text, width) {
const parts = []
let line = ''
for (const char of String(text)) {
const next = line + char
if (line && cellWidth(next) > width) {
parts.push(line)
line = char
} else {
line = next
}
}
if (line) parts.push(line)
return parts
}
function wrapCell(value, width) {
const words = String(value).split(/\\s+/)
const lines = []
let line = ''
for (const word of words) {
if (cellWidth(word) > width) {
if (line) {
lines.push(line)
line = ''
}
lines.push(...splitToWidth(word, width))
continue
}
const next = line ? line + ' ' + word : word
if (line && cellWidth(next) > width) {
lines.push(line)
line = word
} else {
line = next
}
}
if (line) lines.push(line)
return lines.length ? lines : ['']
}
function rule(parts) {
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
}
function renderRow(cells) {
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
const height = Math.max(...wrappedCells.map((cell) => cell.length))
const rows = []
for (let line = 0; line < height; line += 1) {
rows.push(
border.vertical +
widths
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
.join(border.vertical) +
border.vertical
)
}
return rows
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
.map(splitMarkdownRow)
const rendered = [rule(border.top)]
for (const [index, row] of parsedRows.entries()) {
rendered.push(...renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const line of rendered) {
await writeStdout(line + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
await writeStdout('${marker}\\r\\n')
`
}
export function emojiFixtureTableWidthMarker(runId: string): string {
return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:`
}
export function narrowSignerMarkdownTableScript(runId: string): string {
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
const rows = [
'| # | Status | Signer | Action |',
'| ---: | --- | --- | --- |',
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
]
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
).flat()
return `
const rows = ${JSON.stringify(repeatedRows)}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
await writeStdout(row + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${marker}\\r\\n')
`
}

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { rmSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import type { Page, TestInfo } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
@ -23,6 +23,14 @@ import {
} from './terminal-column-probes'
import { nodeTerminalCommand } from './terminal-node-command'
import { waitForPtyShellEcho } from './terminal-pty-readiness'
import {
EMOJI_TABLE_FIXTURE,
NARROW_TERMINAL_MAX_COLS,
emojiFixtureMarkdownTableScript,
emojiFixtureTableWidthMarker,
longMarkdownTableScript,
narrowSignerMarkdownTableScript
} from './terminal-long-table-fixtures'
type TerminalRenderDiagnostics = {
cols: number
@ -54,255 +62,6 @@ type LongTableDebugWindow = Window & {
}
}
const EMOJI_TABLE_FIXTURE = readFileSync(
path.join(__dirname, 'fixtures', 'terminal-emoji-table.md'),
'utf8'
)
const NARROW_TERMINAL_MAX_COLS = 120
function longMarkdownTableScript(runId: string): string {
const names = [
['Sam Syntax', 'Compiler', 'Online', '😀', '9200', 'Semicolons are optional (rage ensues)'],
['Tori Token', 'Auth', 'Idle', '🚀', '4800', 'JWT expires during their standup'],
['Uma Unpin', 'Frontend', 'Online', '🔥', '3500', 'Absolute positioning enjoyer'],
['Vic Variable', 'Types', 'AFK', '💡', '6700', 'any is not a type, it is a cry for help'],
['Wally Watchdog', 'Security', 'Online', '📦', '8200', 'Found a vuln in your vuln scanner'],
['Xena XPath', 'DB', 'Idle', '🔐', '7300', 'Indexes everything, including the fridge'],
['Yuki Yank', 'CLI', 'Online', '🎯', '5900', 'rm -rf / is not a party trick'],
['Zane Zealot', 'OSS', 'Offline', '🤖', '10000', 'Contributor to 47 repos, sleeps never'],
['Artie ASCII', 'Docs', 'Online', '🧠', '2900', 'Wrote a novel in README comments'],
['Bianca Batch', 'ML', 'AFK', '💾', '9400', 'Training a model to write PR descriptions'],
['Carlos Cache', 'CDN', 'Idle', '⚙', '4900', 'Stale data is still data'],
['Diana Draft', 'Planning', 'Online', '📚', '1800', 'Needs 3 more sprints to estimate'],
['Edgar Exit', 'Ops', 'Online', '🔧', '7600', 'Graceful shutdown specialist'],
['Fiona Fallback', 'Resilience', 'Idle', '🧲', '5500', 'Circuit breaker connoisseur'],
['Gabe Garbage', 'GC', 'Offline', '🧹', '4100', 'Stop-the-world is my catchphrase'],
['Holly Hotfix', 'Release', 'Online', '🧪', '6300', 'Friday deploy champion'],
['Ira Idempotent', 'API', 'AFK', '🔁', '6900', 'PUT me in coach'],
['Jules Jitter', 'Mobile', 'Idle', '📱', '3200', 'Offline-first, coffee-second'],
['Ken Kafka', 'Streams', 'Online', '📡', '7100', 'Rebalancing is a lifestyle'],
['Luna Latency', 'Edge', 'Offline', '🧭', '4400', 'Response time measured in business days'],
['Max Marshal', 'Memory', 'Online', '🧩', '8700', "Leak-free since '24"],
['Nora Null', 'Safety', 'AFK', '❓', '3800', 'null is a person, not a value'],
['Otto Offset', 'Cursors', 'Idle', '👆', '2600', 'Infinite scroll for the infinite soul'],
['Pam Payload', 'Serialization', 'Online', '📦', '5800', 'JSON.stringify is my yoga'],
['Reed Regex', 'Matching', 'Offline', '🔍', '6800', 'Now I have two problems']
]
return `
const rows = ${JSON.stringify(names)}
const widths = [16, 14, 12, 6, 7, 42]
function isCombiningMark(codePoint) {
return (codePoint >= 0x0300 && codePoint <= 0x036f) ||
(codePoint >= 0xfe00 && codePoint <= 0xfe0f)
}
function isWideCodePoint(codePoint) {
return codePoint > 0xffff ||
(codePoint >= 0x1100 && codePoint <= 0x115f) ||
(codePoint >= 0x2e80 && codePoint <= 0xa4cf) ||
(codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
(codePoint >= 0xf900 && codePoint <= 0xfaff) ||
(codePoint >= 0xfe10 && codePoint <= 0xfe6f) ||
(codePoint >= 0xff00 && codePoint <= 0xff60) ||
(codePoint >= 0xffe0 && codePoint <= 0xffe6)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || isCombiningMark(codePoint)) continue
width += isWideCodePoint(codePoint) ? 2 : 1
}
return width
}
function cell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(1, width - cellWidth(text)))
}
function line(parts) {
return '| ' + parts.map((part, index) => cell(part, widths[index])).join(' | ') + ' |'
}
const outputRows = []
outputRows.push(line(['Name', 'Team', 'Status', 'Icon', 'Score', 'Notes']))
outputRows.push('|-' + widths.map((width) => '-'.repeat(width)).join('-|-') + '-|')
for (let repeat = 0; repeat < 4; repeat += 1) {
for (const row of rows) outputRows.push(line(row))
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of outputRows) {
await writeStdout(row + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('LONG_TABLE_SCROLL_RESTORE_${runId}\\n')
`
}
function emojiFixtureMarkdownTableScript(table: string, runId: string): string {
const marker = `EMOJI_FIXTURE_TABLE_RESTORE_${runId}`
const widthMarker = `EMOJI_FIXTURE_TABLE_WIDTH_${runId}`
return `
const table = ${JSON.stringify(table)}
const minimumWidths = [2, 5, 4, 7, 7, 4, 3, 4]
const preferredWidths = [5, 17, 10, 18, 30, 12, 10, 10]
const tableOverhead = preferredWidths.length * 3 + 1
const widthBudget = Math.max(
minimumWidths.reduce((sum, width) => sum + width, 0),
Math.min(
preferredWidths.reduce((sum, width) => sum + width, 0),
(process.stdout.columns || 100) - tableOverhead - 1
)
)
let remaining = widthBudget
let remainingPreferred = preferredWidths.reduce((sum, width) => sum + width, 0)
const widths = preferredWidths.map((preferred, index) => {
const minimum = minimumWidths[index]
const width = Math.max(minimum, Math.floor((remaining * preferred) / remainingPreferred))
remaining -= width
remainingPreferred -= preferred
return width
})
const generatedTableWidth = widths.reduce((sum, width) => sum + width, 0) + tableOverhead
const border = {
top: ['┌', '┬', '┐'],
middle: ['├', '┼', '┤'],
bottom: ['└', '┴', '┘'],
vertical: '│',
horizontal: '─'
}
function splitMarkdownRow(row) {
return row.trim().slice(1, -1).split('|').map((cell) => cell.trim())
}
function isSeparatorRow(row) {
return /^\\|(?:\\s*:?-+:?\\s*\\|)+\\s*$/.test(row)
}
function cellWidth(text) {
let width = 0
for (const char of String(text)) {
const codePoint = char.codePointAt(0)
if (codePoint === undefined || (codePoint >= 0x0300 && codePoint <= 0x036f)) continue
if (codePoint === 0xfe0f || codePoint === 0x200d) continue
width += codePoint > 0xffff || (codePoint >= 0x1100 && codePoint <= 0x115f) ? 2 : 1
}
return width
}
function padCell(value, width) {
const text = String(value)
return text + ' '.repeat(Math.max(0, width - cellWidth(text)))
}
function splitToWidth(text, width) {
const parts = []
let line = ''
for (const char of String(text)) {
const next = line + char
if (line && cellWidth(next) > width) {
parts.push(line)
line = char
} else {
line = next
}
}
if (line) parts.push(line)
return parts
}
function wrapCell(value, width) {
const words = String(value).split(/\\s+/)
const lines = []
let line = ''
for (const word of words) {
if (cellWidth(word) > width) {
if (line) {
lines.push(line)
line = ''
}
lines.push(...splitToWidth(word, width))
continue
}
const next = line ? line + ' ' + word : word
if (line && cellWidth(next) > width) {
lines.push(line)
line = word
} else {
line = next
}
}
if (line) lines.push(line)
return lines.length ? lines : ['']
}
function rule(parts) {
return parts[0] + widths.map((width) => border.horizontal.repeat(width + 2)).join(parts[1]) + parts[2]
}
function renderRow(cells) {
const wrappedCells = widths.map((width, index) => wrapCell(cells[index] ?? '', width))
const height = Math.max(...wrappedCells.map((cell) => cell.length))
const rows = []
for (let line = 0; line < height; line += 1) {
rows.push(
border.vertical +
widths
.map((width, index) => ' ' + padCell(wrappedCells[index][line] ?? '', width) + ' ')
.join(border.vertical) +
border.vertical
)
}
return rows
}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
const parsedRows = table
.split(/\\r?\\n/)
.filter((row) => row.trim().startsWith('|') && !isSeparatorRow(row))
.map(splitMarkdownRow)
const rendered = [rule(border.top)]
for (const [index, row] of parsedRows.entries()) {
rendered.push(...renderRow(row))
rendered.push(rule(index === parsedRows.length - 1 ? border.bottom : border.middle))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const line of rendered) {
await writeStdout(line + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${widthMarker}:' + generatedTableWidth + '\\r\\n')
await writeStdout('${marker}\\r\\n')
`
}
function emojiFixtureTableWidthMarker(runId: string): string {
return `EMOJI_FIXTURE_TABLE_WIDTH_${runId}:`
}
function narrowSignerMarkdownTableScript(runId: string): string {
const marker = `NARROW_SIGNER_TABLE_RESTORE_${runId}`
const rows = [
'| # | Status | Signer | Action |',
'| ---: | --- | --- | --- |',
'| 1 | signed | did:key:z6Mkuw5kQqz1QvZ9f3d2aB7f19f0cAC7B4F3c9E725aD19cD12e6A8B3F4c5D6e7F8a9B0c1D2e3F4a5B6c7D8e9F0a1B2c3D4e5F6a7B8c9D0e1F2 | approve deployment |',
'| 2 | waiting | did:web:example.signing.service:teams:release:prod:primary-key-2026-06-08-with-extra-qualifiers-and-long-human-readable-suffix | counter-sign |',
'| 3 | signed | 0x742d35Cc6634C0532925a3b844Bc454e4438f44e9E8F12A7C4D9B6530F9D2C8E7A6B5C4D3E2F1A0B998877665544332211 | archive receipt |'
]
const repeatedRows = Array.from({ length: 8 }, (_, index) =>
rows.concat(`| ${index + 4} | signed | signer-row-${index}-${'a'.repeat(96)} | verify |`)
).flat()
return `
const rows = ${JSON.stringify(repeatedRows)}
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (const row of rows) {
await writeStdout(row + '\\r\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('${marker}\\r\\n')
`
}
async function setNarrowTerminalViewport(page: Page): Promise<void> {
await page.setViewportSize({ width: 900, height: 820 })
await page.waitForTimeout(250)

View File

@ -0,0 +1,176 @@
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'
type ViewportSample = { at: number; viewportY: number; baseY: number }
function scrollbackFixtureScript(runId: string): string {
return `
async function writeStdout(chunk) {
await new Promise((resolve) => process.stdout.write(chunk, resolve))
if (process.platform === 'win32') await new Promise((resolve) => setTimeout(resolve, 8))
}
await writeStdout('\\x1b[?2026h\\x1b[2J\\x1b[H')
for (let index = 0; index < 180; index += 1) {
await writeStdout('PINNED_VIEWPORT_SWITCH_${runId}_ROW_' + String(index).padStart(3, '0') + '\\n')
}
await writeStdout('\\x1b[?2026l')
await writeStdout('PINNED_VIEWPORT_SWITCH_${runId}_DONE\\n')
`
}
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 sampleTerminalViewportDuringReturn(
page: Page,
tabId: string,
durationMs: number
): Promise<ViewportSample[]> {
return page.evaluate(
({ tabId, durationMs }) =>
new Promise<ViewportSample[]>((resolve) => {
const samples: ViewportSample[] = []
const startedAt = performance.now()
const sample = (): void => {
const manager = window.__paneManagers?.get(tabId)
const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null
const buffer = pane?.terminal?.buffer?.active
if (buffer) {
samples.push({
at: Math.round(performance.now() - startedAt),
viewportY: buffer.viewportY,
baseY: buffer.baseY
})
}
if (performance.now() - startedAt >= durationMs) {
resolve(samples)
return
}
requestAnimationFrame(sample)
}
requestAnimationFrame(sample)
}),
{ tabId, durationMs }
)
}
test.describe('Terminal pinned viewport worktree switch', () => {
test('does not jump or flash when returning to a viewport pinned just above bottom', 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, 'pinned viewport 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-pinned-viewport-${runId}.mjs`)
writeFileSync(scriptPath, scrollbackFixtureScript(runId))
try {
await sendToTerminal(orcaPage, ptyId, `${nodeTerminalCommand([scriptPath])}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 30_000), {
timeout: 10_000,
message: 'pinned viewport fixture did not reach terminal scrollback'
})
.toContain(`PINNED_VIEWPORT_SWITCH_${runId}_DONE`)
const pinned = await pinActiveTerminalNearBottom(orcaPage)
expect(pinned.baseY).toBeGreaterThan(20)
await orcaPage.waitForTimeout(50)
await switchToWorktree(orcaPage, secondWorktreeId)
await waitForActiveTerminalManager(orcaPage, 30_000)
await orcaPage.waitForTimeout(250)
const samplesPromise = sampleTerminalViewportDuringReturn(orcaPage, pinned.tabId, 450)
await switchToWorktree(orcaPage, firstWorktreeId)
await ensureTerminalVisible(orcaPage)
await waitForActiveTerminalManager(orcaPage, 30_000)
const samples = await samplesPromise
expect(samples.length).toBeGreaterThan(0)
expect(samples.filter((sample) => sample.viewportY <= 1)).toEqual([])
expect(samples.filter((sample) => sample.viewportY >= sample.baseY - 1)).toEqual([])
expect(
samples.filter((sample) => Math.abs(sample.viewportY - pinned.targetViewportY) > 1)
).toEqual([])
} finally {
rmSync(scriptPath, { force: true })
}
})
})