Fix terminal scroll intent across workspace switches (#6319)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
9f8a27de33
commit
cd1d4dfff3
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
`
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue