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 }) + }) +})