From c2fb04bd30286ddd01cb8baba15268758588eb16 Mon Sep 17 00:00:00 2001 From: Matt Moran Date: Wed, 6 May 2026 10:31:37 +0100 Subject: [PATCH] Add smart scroll to agent terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming pty output yanked the user back to the bottom on every write because xterm follows the cursor. Snapshot the user's distance from the bottom before each write and restore it in the post-parse callback so they can read past output while the agent keeps streaming. Returning to the bottom re-engages auto-follow. Detection runs against the live .xterm-viewport scrollTop — the only source of truth that updates synchronously with user input. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/terminal/smartScroll.ts | 166 ++++++++++++ .../components/terminal/useTerminal.ts | 48 +--- tests/e2e/smart-scroll.spec.ts | 96 +++++++ .../terminal/smartScroll.integration.test.ts | 207 +++++++++++++++ .../components/terminal/smartScroll.test.ts | 244 ++++++++++++++++++ 5 files changed, 723 insertions(+), 38 deletions(-) create mode 100644 src/renderer/components/terminal/smartScroll.ts create mode 100644 tests/e2e/smart-scroll.spec.ts create mode 100644 tests/unit/components/terminal/smartScroll.integration.test.ts create mode 100644 tests/unit/components/terminal/smartScroll.test.ts diff --git a/src/renderer/components/terminal/smartScroll.ts b/src/renderer/components/terminal/smartScroll.ts new file mode 100644 index 0000000..7cd19c1 --- /dev/null +++ b/src/renderer/components/terminal/smartScroll.ts @@ -0,0 +1,166 @@ +/** + * Smart scroll: keep the viewport stuck to the bottom while the agent streams + * output, but let the user scroll up to read past content without being yanked + * back. xterm's default is to follow the cursor on writes, which is what + * yanks them back — we counter that by snapshotting the user's distance from + * the bottom before each write and restoring it after the parser finishes. + */ + +import type { Terminal } from '@xterm/xterm' + +export interface BufferState { + /** Buffer line index of the top of the viewport. */ + viewportY: number + /** Buffer line index of the top of the bottom-most page (i.e. fully scrolled down). */ + baseY: number +} + +/** Lines of slack near the bottom that still count as "anchored". */ +export const ANCHOR_THRESHOLD = 3 + +export function isAnchoredToBottom(buf: BufferState, threshold = ANCHOR_THRESHOLD): boolean { + return buf.viewportY >= buf.baseY - threshold +} + +export function linesFromBottom(buf: BufferState): number { + return Math.max(0, buf.baseY - buf.viewportY) +} + +export type ScrollAction = + | { type: 'none' } + | { type: 'scrollToBottom' } + | { type: 'scrollLines'; delta: number } + +export function computeScrollAction(args: { + wasAnchored: boolean + savedLinesFromBottom: number + buf: BufferState + visible: boolean +}): ScrollAction { + const { wasAnchored, savedLinesFromBottom, buf, visible } = args + + if (wasAnchored) { + return visible ? { type: 'scrollToBottom' } : { type: 'none' } + } + + const targetY = Math.max(0, buf.baseY - savedLinesFromBottom) + const delta = targetY - buf.viewportY + return delta === 0 ? { type: 'none' } : { type: 'scrollLines', delta } +} + +/** Pixel slack near the bottom that still counts as "anchored" in DOM space. */ +export const DOM_ANCHOR_TOLERANCE_PX = 8 + +export interface DomScrollState { + scrollTop: number + scrollHeight: number + clientHeight: number +} + +export function isDomAnchored(s: DomScrollState, tolerance = DOM_ANCHOR_TOLERANCE_PX): boolean { + return s.scrollTop + s.clientHeight >= s.scrollHeight - tolerance +} + +export function distanceFromBottom(s: DomScrollState): number { + return Math.max(0, s.scrollHeight - s.clientHeight - s.scrollTop) +} + +export type DomScrollAction = + | { type: 'none' } + | { type: 'scrollToBottom' } + | { type: 'setScrollTop'; scrollTop: number } + +/** + * Decide what to do with the DOM viewport after a write completes. Pure so we + * can unit-test exhaustively without a real Terminal. + */ +export function computeDomScrollAction(args: { + wasAnchored: boolean + savedDistanceFromBottom: number + state: DomScrollState + visible: boolean +}): DomScrollAction { + const { wasAnchored, savedDistanceFromBottom, state, visible } = args + + if (wasAnchored) { + return visible ? { type: 'scrollToBottom' } : { type: 'none' } + } + + const target = Math.max(0, state.scrollHeight - state.clientHeight - savedDistanceFromBottom) + // No-op if we are already there (avoid spurious scroll events). + if (Math.abs(state.scrollTop - target) < 1) return { type: 'none' } + return { type: 'setScrollTop', scrollTop: target } +} + +export interface SmartScrollController { + /** Write `data` to the terminal, preserving the user's scroll position. */ + write: (data: string) => void + /** True when the viewport is anchored to the bottom. */ + isAnchored: () => boolean + /** Tear down listeners. */ + dispose: () => void +} + +/** + * Attach smart-scroll behaviour to an xterm Terminal. + * + * Detection is done against the live DOM scroll position of `.xterm-viewport` + * — that is the only source of truth that updates synchronously with user + * input (wheel, scrollbar drag, touch, keyboard). xterm's `onScroll` event is + * driven off the *buffer*'s viewportY which is updated asynchronously from + * the DOM scroll, so we cannot rely on it during a stream of writes. + */ +export function attachSmartScroll( + term: Terminal, + getVisible: () => boolean, +): SmartScrollController { + function getViewport(): HTMLElement | null { + return term.element?.querySelector('.xterm-viewport') ?? null + } + + function readState(): DomScrollState | null { + const v = getViewport() + if (!v) return null + return { scrollTop: v.scrollTop, scrollHeight: v.scrollHeight, clientHeight: v.clientHeight } + } + + function isAnchored(): boolean { + const s = readState() + return s ? isDomAnchored(s) : true + } + + function write(data: string): void { + const stateBefore = readState() + const wasAnchored = stateBefore ? isDomAnchored(stateBefore) : true + const savedDistanceFromBottom = stateBefore ? distanceFromBottom(stateBefore) : 0 + + term.write(data, () => { + const v = getViewport() + const stateAfter = readState() + if (!v || !stateAfter) return + + const action = computeDomScrollAction({ + wasAnchored, + savedDistanceFromBottom, + state: stateAfter, + visible: getVisible(), + }) + if (action.type === 'scrollToBottom') { + term.scrollToBottom() + // term.scrollToBottom updates the buffer; force the DOM scrollbar to + // match in case xterm hasn't repainted yet. + v.scrollTop = stateAfter.scrollHeight + } else if (action.type === 'setScrollTop') { + v.scrollTop = action.scrollTop + } + }) + } + + return { + write, + isAnchored, + dispose: () => { + // No persistent listeners; reserved for future use. + }, + } +} diff --git a/src/renderer/components/terminal/useTerminal.ts b/src/renderer/components/terminal/useTerminal.ts index 8ec5604..99aad78 100644 --- a/src/renderer/components/terminal/useTerminal.ts +++ b/src/renderer/components/terminal/useTerminal.ts @@ -8,6 +8,7 @@ import { useTerminalStore } from '../../stores/terminalStore' import { useSettingsStore } from '../../stores/settingsStore' import { useSessionStore } from '../../stores/sessionStore' import { THEMES } from '../../../shared/themes' +import { attachSmartScroll, type SmartScrollController } from './smartScroll' interface UseTerminalOptions { terminalId: string | null @@ -19,7 +20,7 @@ interface UseTerminalOptions { // Global registry — keeps xterm instances alive for the lifetime of the app const terminalInstances = new Map< string, - { term: Terminal; fitAddon: FitAddon; attached: boolean; visible: boolean; anchoredToBottom: boolean; unsubData: (() => void) | null; unsubExit: (() => void) | null; cleanupWheel: (() => void) | null } + { term: Terminal; fitAddon: FitAddon; attached: boolean; visible: boolean; smartScroll: SmartScrollController; unsubData: (() => void) | null; unsubExit: (() => void) | null } >() /** Force the xterm viewport scrollbar to sync with the buffer position */ @@ -40,7 +41,7 @@ export function destroyTerminal(terminalId: string): void { if (!instance) return instance.unsubData?.() instance.unsubExit?.() - instance.cleanupWheel?.() + instance.smartScroll.dispose() instance.term.dispose() terminalInstances.delete(terminalId) } @@ -112,44 +113,16 @@ export function useTerminal({ terminalId, sessionId, sessionName, visible = true } }) - // Track user-initiated scrolls only (wheel/keyboard), not programmatic ones - // anchoredToBottom is stored on the instance object so the visibility effect can read it - let userScrolling = false - - const el = containerRef.current! - const wheelHandler = () => { - userScrolling = true - requestAnimationFrame(() => { - const buf = term.buffer.active - const inst = terminalInstances.get(terminalId) - if (inst) inst.anchoredToBottom = buf.viewportY >= buf.baseY - 3 - userScrolling = false - }) - } - el.addEventListener('wheel', wheelHandler) - - // Also catch keyboard scrolling (Shift+PageUp/Down etc) - term.onKey(({ domEvent }) => { - if (domEvent.key === 'PageUp' || domEvent.key === 'PageDown' || - (domEvent.shiftKey && (domEvent.key === 'ArrowUp' || domEvent.key === 'ArrowDown'))) { - requestAnimationFrame(() => { - const buf = term.buffer.active - const inst = terminalInstances.get(terminalId) - if (inst) inst.anchoredToBottom = buf.viewportY >= buf.baseY - 3 - }) - } + // Smart scroll: see smartScroll.ts. The controller intercepts writes to + // preserve the user's scroll position when they have scrolled up. + const smartScroll = attachSmartScroll(term, () => { + return terminalInstances.get(terminalId)?.visible ?? false }) // Receive data from pty — always active, even when hidden const unsubData = window.api.terminal.onData((id, data) => { if (id !== terminalId) return - term.write(data) - - // Only scroll while visible — scrollToBottom on a hidden element desyncs the scrollbar - const inst = terminalInstances.get(terminalId) - if (inst?.visible && inst.anchoredToBottom && !userScrolling) { - term.scrollToBottom() - } + smartScroll.write(data) // Intervention detection lineBuffer.current += data @@ -173,8 +146,7 @@ export function useTerminal({ terminalId, sessionId, sessionName, visible = true term.writeln(`\r\n[Process exited with code ${code}]`) }) - const cleanupWheel = () => el.removeEventListener('wheel', wheelHandler) - terminalInstances.set(terminalId, { term, fitAddon, attached: true, visible: true, anchoredToBottom: true, unsubData, unsubExit, cleanupWheel }) + terminalInstances.set(terminalId, { term, fitAddon, attached: true, visible: true, smartScroll, unsubData, unsubExit }) // Initial fit + scroll to bottom requestAnimationFrame(() => { @@ -213,7 +185,7 @@ export function useTerminal({ terminalId, sessionId, sessionName, visible = true const { cols, rows } = instance.term window.api.terminal.resize(terminalId, cols, rows) - if (instance.anchoredToBottom) { + if (instance.smartScroll.isAnchored()) { instance.term.scrollToBottom() // Force the viewport scrollbar to match — scrollToBottom alone can desync // when the terminal received writes while hidden diff --git a/tests/e2e/smart-scroll.spec.ts b/tests/e2e/smart-scroll.spec.ts new file mode 100644 index 0000000..34a7142 --- /dev/null +++ b/tests/e2e/smart-scroll.spec.ts @@ -0,0 +1,96 @@ +import { test, expect, type Page } from '@playwright/test' + +/** + * E2E coverage for the smart-scroll behaviour in the agent terminal. + * + * Bug: while a session's pty streams output, the xterm viewport followed the + * cursor on every write — so a user who scrolled up to read past content was + * yanked back to the bottom on the next chunk. + * + * Fix: see src/renderer/components/terminal/smartScroll.ts. This test drives + * the actual app against the mock backend (which streams ~50 lines over a few + * seconds) and asserts the user-set scroll position survives subsequent + * writes. + */ + +async function bootApp(page: Page) { + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await page.evaluate(() => localStorage.clear()) + await page.goto('/', { waitUntil: 'domcontentloaded' }) + await expect(page.getByText('CodeCrucible').first()).toBeVisible({ timeout: 10_000 }) +} + +async function openTerminalSession(page: Page) { + // Activating a session card auto-spawns a terminal (TerminalPanel useEffect). + const card = page.getByText('fix-terminal-resize').first() + await card.click() +} + +async function viewport(page: Page) { + return page.locator('.xterm-viewport').first() +} + +test.describe('Agent terminal — smart scroll', () => { + test.beforeEach(async ({ page }) => bootApp(page)) + + test('scrolling up while data streams keeps the user at their position', async ({ page }) => { + await openTerminalSession(page) + const vp = await viewport(page) + await expect(vp).toBeVisible({ timeout: 10_000 }) + + // Wait until the viewport has accumulated enough content to be scrollable. + await expect + .poll(async () => vp.evaluate((el: HTMLElement) => el.scrollHeight - el.clientHeight), { + timeout: 5_000, + }) + .toBeGreaterThan(20) + + // Scroll up to roughly the top while the mock is still streaming. + await vp.evaluate((el: HTMLElement) => { + el.scrollTop = 0 + }) + const scrollTopAfterUserScroll = await vp.evaluate((el: HTMLElement) => el.scrollTop) + expect(scrollTopAfterUserScroll).toBe(0) + + // Let more data stream in. The mock pushes one chunk every 80ms for ~50 + // chunks; 1.5s gives us plenty of writes after we scrolled. + await page.waitForTimeout(1500) + + // Without smart-scroll, scrollTop would have been forced back near + // (scrollHeight - clientHeight). With it, we should still be near the top. + const finalScrollTop = await vp.evaluate((el: HTMLElement) => el.scrollTop) + const finalDistanceFromBottom = await vp.evaluate( + (el: HTMLElement) => el.scrollHeight - el.clientHeight - el.scrollTop, + ) + expect(finalScrollTop).toBeLessThan(20) + expect(finalDistanceFromBottom).toBeGreaterThan(20) + }) + + test('returning to the bottom re-engages auto-follow', async ({ page }) => { + await openTerminalSession(page) + const vp = await viewport(page) + await expect(vp).toBeVisible({ timeout: 10_000 }) + + await expect + .poll(async () => vp.evaluate((el: HTMLElement) => el.scrollHeight - el.clientHeight), { + timeout: 5_000, + }) + .toBeGreaterThan(20) + + // Scroll up, then back to the bottom. + await vp.evaluate((el: HTMLElement) => { + el.scrollTop = 0 + }) + await page.waitForTimeout(200) + await vp.evaluate((el: HTMLElement) => { + el.scrollTop = el.scrollHeight + }) + + // After streaming continues, we should remain anchored at the bottom. + await page.waitForTimeout(1000) + const distanceFromBottom = await vp.evaluate( + (el: HTMLElement) => el.scrollHeight - el.clientHeight - el.scrollTop, + ) + expect(distanceFromBottom).toBeLessThan(10) + }) +}) diff --git a/tests/unit/components/terminal/smartScroll.integration.test.ts b/tests/unit/components/terminal/smartScroll.integration.test.ts new file mode 100644 index 0000000..da0587c --- /dev/null +++ b/tests/unit/components/terminal/smartScroll.integration.test.ts @@ -0,0 +1,207 @@ +/** + * Controller integration: drives `attachSmartScroll` end-to-end with a fake + * Terminal whose `.xterm-viewport` element has stubbed scroll metrics. We + * cannot rely on jsdom's layout (scrollHeight/clientHeight are 0 without a + * real layout engine), so the harness instruments the viewport's properties + * to simulate growing content and user scrolls. + * + * E2E coverage in tests/e2e/smart-scroll.spec.ts exercises the same code + * path against a real browser with real layout. + */ + +import { describe, it, expect, vi } from 'vitest' +import { attachSmartScroll } from '@renderer/components/terminal/smartScroll' + +interface FakeViewport extends HTMLElement { + _scrollTop: number + _scrollHeight: number + _clientHeight: number +} + +function makeFakeViewport(scrollHeight: number, clientHeight: number): FakeViewport { + const el = document.createElement('div') as FakeViewport + el.classList.add('xterm-viewport') + el._scrollTop = scrollHeight - clientHeight // start at bottom + el._scrollHeight = scrollHeight + el._clientHeight = clientHeight + Object.defineProperty(el, 'scrollTop', { + get() { return el._scrollTop }, + set(v: number) { el._scrollTop = v }, + configurable: true, + }) + Object.defineProperty(el, 'scrollHeight', { + get() { return el._scrollHeight }, + configurable: true, + }) + Object.defineProperty(el, 'clientHeight', { + get() { return el._clientHeight }, + configurable: true, + }) + return el +} + +interface FakeTerm { + element: HTMLElement + /** Pending write callbacks queued by `term.write(data, cb)`. */ + pendingCallbacks: Array<() => void> + /** Simulate xterm processing the write: grow scrollHeight + follow cursor. */ + growBy: (px: number) => void + /** Resolve all pending write callbacks (parser drained). */ + flush: () => void + scrollToBottom: ReturnType + scrollLines: ReturnType + write: ReturnType +} + +function makeFakeTerm(scrollHeight = 1000, clientHeight = 200): FakeTerm { + const root = document.createElement('div') + const viewport = makeFakeViewport(scrollHeight, clientHeight) + root.appendChild(viewport) + + const pendingCallbacks: Array<() => void> = [] + + const term: FakeTerm = { + element: root, + pendingCallbacks, + growBy(px: number) { + viewport._scrollHeight += px + // xterm follows the cursor: scrollTop is forced to (scrollHeight - clientHeight) + viewport._scrollTop = viewport._scrollHeight - viewport._clientHeight + }, + flush() { + const cbs = pendingCallbacks.splice(0) + for (const cb of cbs) cb() + }, + scrollToBottom: vi.fn(() => { + viewport._scrollTop = viewport._scrollHeight - viewport._clientHeight + }), + scrollLines: vi.fn(), + write: vi.fn((_data: string, cb?: () => void) => { + if (cb) pendingCallbacks.push(cb) + }), + } + return term +} + +function setUserScrollTop(term: FakeTerm, value: number) { + const v = term.element.querySelector('.xterm-viewport') as FakeViewport + v._scrollTop = value +} + +function getScrollTop(term: FakeTerm): number { + return (term.element.querySelector('.xterm-viewport') as FakeViewport)._scrollTop +} + +describe('attachSmartScroll controller', () => { + it('reports anchored when the viewport starts at the bottom', () => { + const term = makeFakeTerm() + const ctrl = attachSmartScroll(term as never, () => true) + expect(ctrl.isAnchored()).toBe(true) + }) + + it('scrolls to bottom after a write when the user was anchored', () => { + const term = makeFakeTerm() + const ctrl = attachSmartScroll(term as never, () => true) + + ctrl.write('hello\r\n') + expect(term.write).toHaveBeenCalledTimes(1) + + // Simulate xterm growing the buffer (it "auto-followed" to bottom) + term.growBy(20) + term.flush() + + expect(term.scrollToBottom).toHaveBeenCalled() + }) + + it('does NOT scrollToBottom when anchored but hidden — would desync the scrollbar', () => { + const term = makeFakeTerm() + const visible = { v: false } + const ctrl = attachSmartScroll(term as never, () => visible.v) + + ctrl.write('hidden write\r\n') + term.growBy(20) + term.flush() + + expect(term.scrollToBottom).not.toHaveBeenCalled() + }) + + it('preserves the user scroll position when they had scrolled up', () => { + // Arrange: viewport 1000 high, 200 visible. User scrolls to top (scrollTop=0). + // Distance from bottom = 1000 - 200 - 0 = 800px. + const term = makeFakeTerm(1000, 200) + const ctrl = attachSmartScroll(term as never, () => true) + setUserScrollTop(term, 0) + expect(ctrl.isAnchored()).toBe(false) + + // Act: write streams in. Buffer grows by 300px. xterm follows cursor: + // scrollTop is yanked to (1300 - 200) = 1100. + ctrl.write('streamed data\r\n') + term.growBy(300) + term.flush() + + // Assert: the controller restored scrollTop to keep the user 800px above + // the bottom — i.e. (1300 - 200 - 800) = 300. + expect(getScrollTop(term)).toBe(300) + }) + + it('restores correctly across multiple sequential writes', () => { + const term = makeFakeTerm(1000, 200) + const ctrl = attachSmartScroll(term as never, () => true) + setUserScrollTop(term, 0) + + for (let i = 0; i < 5; i++) { + ctrl.write(`chunk ${i}\r\n`) + term.growBy(100) + term.flush() + } + + // After 5 writes growing 100px each, scrollHeight = 1500, clientHeight = 200. + // User was 800px from bottom; controller should keep them there: scrollTop + // = 1500 - 200 - 800 = 500. + expect(getScrollTop(term)).toBe(500) + }) + + it('re-engages auto-follow once the user scrolls back to the bottom', () => { + const term = makeFakeTerm(1000, 200) + const ctrl = attachSmartScroll(term as never, () => true) + setUserScrollTop(term, 0) + expect(ctrl.isAnchored()).toBe(false) + + // User scrolls back to the bottom + setUserScrollTop(term, 800) + expect(ctrl.isAnchored()).toBe(true) + + ctrl.write('after re-anchor\r\n') + term.growBy(50) + term.flush() + + expect(term.scrollToBottom).toHaveBeenCalled() + }) + + it('preserves position even while hidden', () => { + const term = makeFakeTerm(1000, 200) + const visible = { v: false } + const ctrl = attachSmartScroll(term as never, () => visible.v) + setUserScrollTop(term, 0) + + ctrl.write('hidden stream\r\n') + term.growBy(300) + term.flush() + + // 1300 - 200 - 800 = 300 + expect(getScrollTop(term)).toBe(300) + }) + + it('does not call setScrollTop when the new position would equal the current one (no spurious writes)', () => { + const term = makeFakeTerm(1000, 200) + const ctrl = attachSmartScroll(term as never, () => true) + setUserScrollTop(term, 0) + + // Empty write — buffer doesn't grow, no auto-follow happens. + ctrl.write('') + term.flush() + + // scrollTop should be untouched. + expect(getScrollTop(term)).toBe(0) + }) +}) diff --git a/tests/unit/components/terminal/smartScroll.test.ts b/tests/unit/components/terminal/smartScroll.test.ts new file mode 100644 index 0000000..2f6b076 --- /dev/null +++ b/tests/unit/components/terminal/smartScroll.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from 'vitest' +import { + ANCHOR_THRESHOLD, + DOM_ANCHOR_TOLERANCE_PX, + computeDomScrollAction, + computeScrollAction, + distanceFromBottom, + isAnchoredToBottom, + isDomAnchored, + linesFromBottom, +} from '@renderer/components/terminal/smartScroll' + +describe('isAnchoredToBottom', () => { + it('is true when the viewport sits exactly at the bottom', () => { + expect(isAnchoredToBottom({ viewportY: 100, baseY: 100 })).toBe(true) + }) + + it('is true within the default threshold of the bottom', () => { + expect(isAnchoredToBottom({ viewportY: 100 - ANCHOR_THRESHOLD, baseY: 100 })).toBe(true) + }) + + it('is false beyond the default threshold', () => { + expect(isAnchoredToBottom({ viewportY: 100 - ANCHOR_THRESHOLD - 1, baseY: 100 })).toBe(false) + }) + + it('respects a custom threshold', () => { + expect(isAnchoredToBottom({ viewportY: 90, baseY: 100 }, 10)).toBe(true) + expect(isAnchoredToBottom({ viewportY: 89, baseY: 100 }, 10)).toBe(false) + }) + + it('treats viewportY past baseY as anchored (defensive)', () => { + expect(isAnchoredToBottom({ viewportY: 105, baseY: 100 })).toBe(true) + }) +}) + +describe('linesFromBottom', () => { + it('is 0 when viewport is at the bottom', () => { + expect(linesFromBottom({ viewportY: 100, baseY: 100 })).toBe(0) + }) + + it('measures distance up from the bottom', () => { + expect(linesFromBottom({ viewportY: 70, baseY: 100 })).toBe(30) + }) + + it('clamps at 0 when viewport overshoots baseY', () => { + expect(linesFromBottom({ viewportY: 110, baseY: 100 })).toBe(0) + }) +}) + +describe('computeScrollAction', () => { + describe('user was anchored to the bottom', () => { + it('scrolls to bottom while visible', () => { + expect( + computeScrollAction({ + wasAnchored: true, + savedLinesFromBottom: 0, + buf: { viewportY: 150, baseY: 150 }, + visible: true, + }), + ).toEqual({ type: 'scrollToBottom' }) + }) + + it('does nothing while hidden — scrollToBottom on a hidden xterm desyncs the scrollbar', () => { + expect( + computeScrollAction({ + wasAnchored: true, + savedLinesFromBottom: 0, + buf: { viewportY: 150, baseY: 150 }, + visible: false, + }), + ).toEqual({ type: 'none' }) + }) + }) + + describe('user had scrolled up', () => { + it('restores the offset after xterm followed the cursor to the bottom', () => { + // Before the write the user was viewing 30 lines above the bottom + // (baseY=100, viewportY=70). The pty pushed 50 lines, so baseY is now + // 150 and xterm has yanked the viewport to viewportY=150 to follow the + // cursor. We want the user back at viewportY=120 (still 30 lines above + // bottom), i.e. delta = -30. + const action = computeScrollAction({ + wasAnchored: false, + savedLinesFromBottom: 30, + buf: { viewportY: 150, baseY: 150 }, + visible: true, + }) + expect(action).toEqual({ type: 'scrollLines', delta: -30 }) + }) + + it('returns none when the viewport already sits at the saved offset', () => { + // xterm did not auto-scroll (e.g. write contained no cursor advance). + expect( + computeScrollAction({ + wasAnchored: false, + savedLinesFromBottom: 30, + buf: { viewportY: 120, baseY: 150 }, + visible: true, + }), + ).toEqual({ type: 'none' }) + }) + + it('clamps the target so we never scroll above row 0', () => { + expect( + computeScrollAction({ + wasAnchored: false, + savedLinesFromBottom: 999, + buf: { viewportY: 50, baseY: 100 }, + visible: true, + }), + ).toEqual({ type: 'scrollLines', delta: -50 }) + }) + + it('still restores while hidden — the user position must survive a tab switch', () => { + expect( + computeScrollAction({ + wasAnchored: false, + savedLinesFromBottom: 30, + buf: { viewportY: 150, baseY: 150 }, + visible: false, + }), + ).toEqual({ type: 'scrollLines', delta: -30 }) + }) + + it('emits a positive delta when xterm under-scrolled past the saved offset', () => { + // Hypothetical: viewportY ended up above the saved target, push it down. + const action = computeScrollAction({ + wasAnchored: false, + savedLinesFromBottom: 10, + buf: { viewportY: 100, baseY: 150 }, + visible: true, + }) + // target = 150 - 10 = 140, current = 100, delta = +40 + expect(action).toEqual({ type: 'scrollLines', delta: 40 }) + }) + }) +}) + +describe('isDomAnchored', () => { + it('is true when scrollTop sits at the bottom', () => { + expect(isDomAnchored({ scrollTop: 800, clientHeight: 200, scrollHeight: 1000 })).toBe(true) + }) + + it('is true within the default tolerance of the bottom', () => { + expect( + isDomAnchored({ scrollTop: 800 - DOM_ANCHOR_TOLERANCE_PX, clientHeight: 200, scrollHeight: 1000 }), + ).toBe(true) + }) + + it('is false past the tolerance', () => { + expect( + isDomAnchored({ scrollTop: 800 - DOM_ANCHOR_TOLERANCE_PX - 1, clientHeight: 200, scrollHeight: 1000 }), + ).toBe(false) + }) + + it('is true when there is nothing to scroll', () => { + expect(isDomAnchored({ scrollTop: 0, clientHeight: 200, scrollHeight: 200 })).toBe(true) + }) +}) + +describe('distanceFromBottom', () => { + it('is 0 at the bottom', () => { + expect(distanceFromBottom({ scrollTop: 800, clientHeight: 200, scrollHeight: 1000 })).toBe(0) + }) + + it('measures pixels above the bottom', () => { + expect(distanceFromBottom({ scrollTop: 0, clientHeight: 200, scrollHeight: 1000 })).toBe(800) + }) + + it('clamps at 0 if scrollTop overshoots (defensive)', () => { + expect(distanceFromBottom({ scrollTop: 9999, clientHeight: 200, scrollHeight: 1000 })).toBe(0) + }) +}) + +describe('computeDomScrollAction', () => { + it('scrolls to bottom when the user was anchored and we are visible', () => { + expect( + computeDomScrollAction({ + wasAnchored: true, + savedDistanceFromBottom: 0, + state: { scrollTop: 800, scrollHeight: 1200, clientHeight: 200 }, + visible: true, + }), + ).toEqual({ type: 'scrollToBottom' }) + }) + + it('does nothing when anchored but hidden', () => { + expect( + computeDomScrollAction({ + wasAnchored: true, + savedDistanceFromBottom: 0, + state: { scrollTop: 800, scrollHeight: 1200, clientHeight: 200 }, + visible: false, + }), + ).toEqual({ type: 'none' }) + }) + + it('restores the user position after the buffer grew', () => { + // User had 200px above bottom. After write, scrollHeight grew from 1000 + // to 1200 and xterm yanked scrollTop to (1200 - 200) = 1000. Restore to + // (1200 - 200 - 200) = 800 to keep them at the same content. + expect( + computeDomScrollAction({ + wasAnchored: false, + savedDistanceFromBottom: 200, + state: { scrollTop: 1000, scrollHeight: 1200, clientHeight: 200 }, + visible: true, + }), + ).toEqual({ type: 'setScrollTop', scrollTop: 800 }) + }) + + it('still restores while hidden — user position must persist across tab switches', () => { + expect( + computeDomScrollAction({ + wasAnchored: false, + savedDistanceFromBottom: 200, + state: { scrollTop: 1000, scrollHeight: 1200, clientHeight: 200 }, + visible: false, + }), + ).toEqual({ type: 'setScrollTop', scrollTop: 800 }) + }) + + it('returns none when already at the saved offset', () => { + expect( + computeDomScrollAction({ + wasAnchored: false, + savedDistanceFromBottom: 200, + state: { scrollTop: 800, scrollHeight: 1200, clientHeight: 200 }, + visible: true, + }), + ).toEqual({ type: 'none' }) + }) + + it('clamps the target so it never goes below 0', () => { + expect( + computeDomScrollAction({ + wasAnchored: false, + savedDistanceFromBottom: 9999, + state: { scrollTop: 50, scrollHeight: 500, clientHeight: 200 }, + visible: true, + }), + ).toEqual({ type: 'setScrollTop', scrollTop: 0 }) + }) +})