From 6a6824a42eae27f2f68b4161558758c60d4a0fe4 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sat, 23 May 2026 04:11:21 -0300 Subject: [PATCH] feat: add preserveScrollOnWrite option to lock viewport on new output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user has scrolled into the scrollback and new output arrives, the legacy xterm.js-style behaviour auto-scrolls back to the bottom (losing the user's reading position). Modern terminals (kitty, alacritty) instead lock the viewport on the same content so the user keeps reading where they were. This commit ports the upstream fix as a new opt-in option: - Add `preserveScrollOnWrite?: boolean` to `ITerminalOptions` (default: `false`, preserves the current xterm.js-compat behaviour). - When `true`, save `viewportY` and `getScrollbackLength()` before the WASM write, compute the scrollback delta after, and shift `viewportY` by that delta — clamped to the new scrollback length in case old lines were dropped by the scrollback limit. Re-fires `scrollEmitter` and briefly shows the scrollbar when the viewport actually shifts. - Add two regression tests covering both behaviours. This is an adaptation of upstream PR #150 (which made the new behaviour unconditional). Resolves coder/ghostty-web#127 (request to make auto-scroll configurable). Co-authored-by: Sauyon Lee Inspired-by: https://github.com/coder/ghostty-web/pull/150 --- lib/interfaces.ts | 7 +++++ lib/terminal.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 28 ++++++++++++++++-- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 1594918f..6ca415c7 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 6f9a0938..1ed84401 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 00219551..902ea94c 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(); }