From cd1d4dfff39d5207c7ee143213e007d53f65cd95 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 24 Jun 2026 20:16:14 -0700 Subject: [PATCH] Fix terminal scroll intent across workspace switches (#6319) Co-authored-by: Orca --- docs/terminal-scroll-intent-architecture.md | 329 ++++++++++++++++++ .../terminal-pane/keyboard-handlers.ts | 9 + .../terminal-pane/pty-connection.test.ts | 6 + .../terminal-pane/pty-connection.ts | 40 +-- .../terminal-visibility-resume.ts | 16 +- .../use-terminal-pane-global-effects.test.ts | 18 +- .../use-terminal-pane-lifecycle.ts | 13 + ...-terminal-scroll-visibility-memory.test.ts | 46 ++- .../use-terminal-scroll-visibility-memory.ts | 51 +-- .../src/lib/pane-manager/pane-dom-creation.ts | 131 +++++++ .../src/lib/pane-manager/pane-lifecycle.ts | 166 +-------- .../lib/pane-manager/pane-manager-types.ts | 2 + .../pane-terminal-output-scheduler.ts | 50 ++- .../src/lib/pane-manager/pane-tree-ops.ts | 34 +- .../terminal-scroll-intent.test.ts | 317 +++++++++++++++++ .../pane-manager/terminal-scroll-intent.ts | 293 ++++++++++++++++ tests/e2e/terminal-long-table-fixtures.ts | 251 +++++++++++++ ...terminal-long-table-scroll-restore.spec.ts | 259 +------------- ...al-pinned-viewport-worktree-switch.spec.ts | 176 ++++++++++ 19 files changed, 1696 insertions(+), 511 deletions(-) create mode 100644 docs/terminal-scroll-intent-architecture.md create mode 100644 src/renderer/src/lib/pane-manager/pane-dom-creation.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts create mode 100644 src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts create mode 100644 tests/e2e/terminal-long-table-fixtures.ts create mode 100644 tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts diff --git a/docs/terminal-scroll-intent-architecture.md b/docs/terminal-scroll-intent-architecture.md new file mode 100644 index 000000000..b686f2405 --- /dev/null +++ b/docs/terminal-scroll-intent-architecture.md @@ -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. diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index 7ea67c0d9..ebedf8bed 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index da2705e52..c0d45d842 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 4133374fa..7277541f5 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -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 { diff --git a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts index 87db96c3a..5feee00f7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts +++ b/src/renderer/src/components/terminal-pane/terminal-visibility-resume.ts @@ -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 -): 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) } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts index df9f4ba45..75245c1dd 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index cfd5b6f39..f26d9adc1 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -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() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts index c30d96a53..3cd22968f 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.test.ts @@ -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', () => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts index 182ef2741..05afb66d5 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-scroll-visibility-memory.ts @@ -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>(new Map()) - const scrollDisposablesRef = useRef>(new Map()) const suppressScrollTrackingRef = useRef(false) const pendingFollowOutputPaneIdsRef = useRef>(new Set()) const followOutputFrameIdsRef = useRef([]) @@ -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, diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts new file mode 100644 index 000000000..1a8d12c6f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -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 +} diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index f2fc5846a..eb296a1d6 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-manager-types.ts b/src/renderer/src/lib/pane-manager/pane-manager-types.ts index fb95e66b5..85c45366d 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-types.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-types.ts @@ -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 diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 9d260cd36..6295c37f3 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -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; diff --git a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts index 2aed9fcaa..3725f1f4f 100644 --- a/src/renderer/src/lib/pane-manager/pane-tree-ops.ts +++ b/src/renderer/src/lib/pane-manager/pane-tree-ops.ts @@ -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 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. diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts new file mode 100644 index 000000000..f132e786d --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.test.ts @@ -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) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts new file mode 100644 index 000000000..35537c11f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -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() + +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) + } + } +} diff --git a/tests/e2e/terminal-long-table-fixtures.ts b/tests/e2e/terminal-long-table-fixtures.ts new file mode 100644 index 000000000..0ebbab7ea --- /dev/null +++ b/tests/e2e/terminal-long-table-fixtures.ts @@ -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') +` +} diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 3292ea279..1af4b1ecf 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -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 { await page.setViewportSize({ width: 900, height: 820 }) await page.waitForTimeout(250) diff --git a/tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts b/tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts new file mode 100644 index 000000000..bef0852db --- /dev/null +++ b/tests/e2e/terminal-pinned-viewport-worktree-switch.spec.ts @@ -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 { + 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('.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('.xterm-viewport') + ?.dispatchEvent(new Event('scroll', { bubbles: true })) + return { tabId, targetViewportY, baseY: buffer.baseY } + }) +} + +async function sampleTerminalViewportDuringReturn( + page: Page, + tabId: string, + durationMs: number +): Promise { + return page.evaluate( + ({ tabId, durationMs }) => + new Promise((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 }) + } + }) +})