diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 1594918..6ca415c 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -24,6 +24,13 @@ export interface ITerminalOptions { // Scrolling options smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant) + /** + * When true, the viewport stays locked on the same scrollback content as + * new output arrives — instead of auto-scrolling to the bottom. Mirrors + * the behaviour of modern terminals (kitty, alacritty). Default: false + * (preserves the xterm.js-style auto-scroll behaviour for back-compat). + */ + preserveScrollOnWrite?: boolean; // Internal: Ghostty WASM instance (optional, for test isolation) // If not provided, uses the module-level instance from init() diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 6f9a093..1ed8440 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -3021,3 +3021,72 @@ describe('Synchronous open()', () => { term.dispose(); }); }); + +describe('preserveScrollOnWrite option', () => { + let container: HTMLElement | null = null; + + beforeEach(() => { + if (typeof document !== 'undefined') { + container = document.createElement('div'); + document.body.appendChild(container); + } + }); + + afterEach(() => { + if (container && container.parentNode) { + container.parentNode.removeChild(container); + container = null; + } + }); + + test('default (false): writes auto-scroll viewport to bottom (legacy behaviour)', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ cols: 80, rows: 5, scrollback: 50000 }); + term.open(container); + + // Fill scrollback so viewportY can move off zero + for (let i = 0; i < 200; i++) term.write(`line ${i}\r\n`); + + // Simulate user scrolling up + const before = term.wasmTerm!.getScrollbackLength(); + term.scrollLines(-10); + expect(term.viewportY).toBeGreaterThan(0); + + // New output arrives — legacy behaviour snaps the viewport back to bottom + term.write('new output\r\n'); + expect(term.viewportY).toBe(0); + expect(term.wasmTerm!.getScrollbackLength()).toBeGreaterThanOrEqual(before); + + term.dispose(); + }); + + test('preserveScrollOnWrite=true: viewport stays locked on the same content', async () => { + if (!container) return; + + const term = await createIsolatedTerminal({ + cols: 80, + rows: 5, + scrollback: 50000, + preserveScrollOnWrite: true, + }); + term.open(container); + + for (let i = 0; i < 200; i++) term.write(`line ${i}\r\n`); + + term.scrollLines(-10); + const savedViewportY = term.viewportY; + const savedScrollback = term.wasmTerm!.getScrollbackLength(); + expect(savedViewportY).toBeGreaterThan(0); + + term.write('extra line\r\n'); + const newScrollback = term.wasmTerm!.getScrollbackLength(); + const delta = newScrollback - savedScrollback; + + // viewportY should have shifted by the scrollback delta (or clamped) — NOT snapped to 0 + expect(term.viewportY).not.toBe(0); + expect(term.viewportY).toBe(Math.max(0, Math.min(savedViewportY + delta, newScrollback))); + + term.dispose(); + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index 0021955..902ea94 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -152,6 +152,7 @@ export class Terminal implements ITerminalCore { disableStdin: options.disableStdin ?? false, smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll focusOnOpen: options.focusOnOpen ?? true, + preserveScrollOnWrite: options.preserveScrollOnWrite ?? false, }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) @@ -560,6 +561,15 @@ export class Terminal implements ITerminalCore { // preserve selection when new data arrives. Selection is cleared by user actions // like clicking or typing, not by incoming data. + // Save scroll state before writing, ONLY when preserveScrollOnWrite is + // active. viewportY is relative to the bottom, so if new lines push + // content into scrollback we need to bump viewportY by the same amount + // to keep the viewport locked on the same content. + const preserveScroll = this.options.preserveScrollOnWrite === true; + const savedViewportY = preserveScroll ? this.viewportY : 0; + const savedScrollback = + preserveScroll && savedViewportY > 0 ? this.wasmTerm!.getScrollbackLength() : 0; + // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); @@ -578,8 +588,22 @@ export class Terminal implements ITerminalCore { // Invalidate link cache (content changed) this.linkDetector?.invalidateCache(); - // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) - if (this.viewportY !== 0) { + if (preserveScroll) { + // New behaviour: lock the viewport to its current content as the + // scrollback grows. Clamp to the current scrollback length in case + // old lines were dropped by the scrollback limit. + if (savedViewportY > 0) { + const newScrollback = this.wasmTerm!.getScrollbackLength(); + const delta = newScrollback - savedScrollback; + const newViewportY = Math.max(0, Math.min(savedViewportY + delta, newScrollback)); + if (newViewportY !== savedViewportY) { + this.viewportY = newViewportY; + this.scrollEmitter.fire(this.viewportY); + if (newScrollback > 0) this.showScrollbar(); + } + } + } else if (this.viewportY !== 0) { + // Default xterm.js-style behaviour: auto-scroll to bottom on new output. this.scrollToBottom(); }