From 66621694cc47b35d0f9941c230d7c19b69229bb9 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 26 Jun 2026 14:02:41 +0000 Subject: [PATCH 01/10] feat: preserve scroll position on write Change-Id: I3460285d7e278bbd0a3a02a056738ae734c8eb1a Signed-off-by: Thomas Kosiewski --- lib/interfaces.ts | 1 + lib/scrolling.test.ts | 928 ++++++++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 300 +++++++++++++- 3 files changed, 1226 insertions(+), 3 deletions(-) diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 4c6fbe43..974b96f7 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -21,6 +21,7 @@ export interface ITerminalOptions { // Scrolling options smoothScrollDuration?: number; // Duration in ms for smooth scroll animation (default: 100, 0 = instant) + preserveScrollOnWrite?: boolean; // Preserve scrolled-up viewport on write (default: false) // Internal: Ghostty WASM instance (optional, for test isolation) // If not provided, uses the module-level instance from init() diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 5af1b5c5..58b3ee05 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -6,6 +6,7 @@ */ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import type { ITerminalOptions } from './interfaces'; import type { Terminal } from './terminal'; import { createIsolatedTerminal } from './test-helpers'; @@ -680,3 +681,930 @@ describe('Custom Wheel Event Handler', () => { expect((term as any).viewportY).toBeGreaterThan(0); }); }); + +type ScrollTestOptions = Omit; + +async function createPreserveScrollTestTerminal( + options: ScrollTestOptions = {} +): Promise<{ term: Terminal; container: HTMLDivElement }> { + const container = document.createElement('div'); + document.body.appendChild(container); + const term = await createIsolatedTerminal({ + cols: 20, + rows: 5, + scrollback: 100, + smoothScrollDuration: 0, + ...options, + }); + term.open(container); + return { term, container }; +} + +function disposePreserveScrollTestTerminal(term: Terminal, container: HTMLDivElement): void { + term.dispose(); + document.body.removeChild(container); +} + +function writeNumberedLines(term: Terminal, count: number, start = 0): void { + for (let i = start; i < start + count; i++) { + term.write(`Line ${i.toString().padStart(3, '0')}\r\n`); + } +} + +function cellsToText(cells: Array<{ codepoint: number }> | null | undefined): string { + if (!cells) return ''; + + return cells + .map((cell) => { + const codepoint = cell.codepoint; + if (codepoint <= 0 || codepoint > 0x10ffff) return ''; + if (codepoint >= 0xd800 && codepoint <= 0xdfff) return ''; + return String.fromCodePoint(codepoint); + }) + .join('') + .trimEnd(); +} + +function getVisibleLineText(term: Terminal, row: number): string { + if (!term.wasmTerm) { + throw new Error('Terminal must be open before reading visible lines'); + } + + const viewportY = Math.max(0, Math.floor(term.getViewportY())); + const scrollbackLength = term.getScrollbackLength(); + + if (viewportY > 0 && row < viewportY) { + const scrollbackOffset = scrollbackLength - viewportY + row; + return cellsToText(term.getScrollbackLine(scrollbackOffset)); + } + + const screenRow = viewportY > 0 ? row - viewportY : row; + return cellsToText(term.wasmTerm.getLine(screenRow)); +} + +function clampViewportY(viewportY: number, scrollbackLength: number): number { + return Math.max(0, Math.min(viewportY, scrollbackLength)); +} + +function makeSignatureLine( + text: string +): Array<{ codepoint: number; flags: number; width: number }> { + return Array.from({ length: 20 }, (_, index) => ({ + codepoint: index < text.length ? text.codePointAt(index)! : 0, + flags: 0, + width: 1, + })); +} + +describe('preserveScrollOnWrite', () => { + test('write() scrolls to bottom by default when viewport is scrolled up', async () => { + const { term, container } = await createPreserveScrollTestTerminal(); + + try { + expect(term.options.preserveScrollOnWrite).toBe(false); + + writeNumberedLines(term, 12); + expect(term.getScrollbackLength()).toBeGreaterThanOrEqual(3); + + term.scrollLines(-3); + expect(term.getViewportY()).toBe(3); + + term.write('Line 012\r\n'); + + expect(term.getViewportY()).toBe(0); + } finally { + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() does not snapshot cursor for default auto-scroll mode', async () => { + const { term, container } = await createPreserveScrollTestTerminal(); + const originalGetCursor = term.wasmTerm!.getCursor.bind(term.wasmTerm); + + try { + term.wasmTerm!.getCursor = (() => { + throw new Error('getCursor should not be called when preserveScrollOnWrite is disabled'); + }) as typeof term.wasmTerm.getCursor; + + term.write('x'); + expect(term.getViewportY()).toBe(0); + } finally { + term.wasmTerm!.getCursor = originalGetCursor; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() preserves scrolled-up viewport on opt-in and emits onScroll', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + try { + writeNumberedLines(term, 12); + term.scrollLines(-3); + + const beforeViewportY = term.getViewportY(); + const beforeScrollbackLength = term.getScrollbackLength(); + const beforeTopLine = getVisibleLineText(term, 0); + const scrollEvents: number[] = []; + const scrollDisposable = term.onScroll((value) => scrollEvents.push(value)); + + try { + term.write('Line 012\r\n'); + + const afterScrollbackLength = term.getScrollbackLength(); + const expectedViewportY = clampViewportY( + beforeViewportY + (afterScrollbackLength - beforeScrollbackLength), + afterScrollbackLength + ); + + expect(afterScrollbackLength).toBeGreaterThan(beforeScrollbackLength); + expect(term.getViewportY()).toBe(expectedViewportY); + expect(getVisibleLineText(term, 0)).toBe(beforeTopLine); + expect(scrollEvents.at(-1)).toBe(Math.floor(term.getViewportY())); + } finally { + scrollDisposable.dispose(); + } + } finally { + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() scrolls to bottom instead of preserving when entering alternate screen', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + try { + writeNumberedLines(term, 12); + term.scrollLines(-3); + expect(term.getViewportY()).toBe(3); + + term.write('\x1b[?1049h'); + + expect(term.wasmTerm?.isAlternateScreen()).toBe(true); + expect(term.getViewportY()).toBe(0); + } finally { + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() preserves viewport when capped scrollback evicts old rows', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine); + const newScrollback = ['old-1', 'old-2', 'old-3', 'old-4', 'new-5'].map(makeSignatureLine); + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 3; + (term as any).targetViewportY = 3; + (term as any).getScrollbackLength = () => 5; + (term as any).getScrollbackLine = (offset: number) => + (afterWrite ? newScrollback : oldScrollback)[offset] ?? null; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('Line 005\r\n'); + + expect(term.getViewportY()).toBe(4); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() preserves viewport when a batched write reaches the scrollback cap', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 5, + }); + + const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3'].map(makeSignatureLine); + const newScrollback = ['old-2', 'old-3', 'new-4', 'new-5', 'new-6'].map(makeSignatureLine); + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 2; + (term as any).targetViewportY = 2; + (term as any).getScrollbackLength = () => (afterWrite ? 5 : 4); + (term as any).getScrollbackLine = (offset: number) => + (afterWrite ? newScrollback : oldScrollback)[offset] ?? null; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('Line 004\r\nLine 005\r\nLine 006\r\n'); + + expect(term.getViewportY()).toBe(5); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() falls back to scrollback delta when preserved anchor disappears', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine); + const newScrollback = ['new-0', 'new-1', 'new-2', 'new-3', 'new-4'].map(makeSignatureLine); + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 3; + (term as any).targetViewportY = 3; + (term as any).getScrollbackLength = () => 5; + (term as any).getScrollbackLine = (offset: number) => + (afterWrite ? newScrollback : oldScrollback)[offset] ?? null; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('\x1bc'); + + expect(term.getViewportY()).toBe(3); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() can preserve anchors shifted by a large capped write', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 300 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(Array.from({ length: 300 }, (_, index) => `Line ${index}\r\n`).join('')); + + expect(term.getViewportY()).toBe(800); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() keeps split control estimate state in sync when preservation is skipped', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + try { + term.viewportY = 1; + (term as any).targetViewportY = 1; + term.write('\x1b['); + expect((term as any).preserveScrollEstimateCarry).toBe('\x1b['); + + term.write('300S'); + expect((term as any).preserveScrollEstimateCarry).toBe(''); + } finally { + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() estimates CSI scroll-up rows split across writes', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let writeCount = 0; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = writeCount >= 2 ? offset + 300 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + writeCount++; + }) as typeof term.wasmTerm.write; + + term.write('\x1b['); + expect(term.getViewportY()).toBe(500); + + term.write('300S'); + expect(term.getViewportY()).toBe(800); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() estimates CSI scroll-up rows for capped anchor preservation', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 300 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('\x1b[300S'); + + expect(term.getViewportY()).toBe(800); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() tracks cursor columns across bare LF estimates', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetCursor = term.wasmTerm!.getCursor.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + term.wasmTerm!.getCursor = (() => ({ + x: 10, + y: 0, + visible: true, + })) as typeof term.wasmTerm.getCursor; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 77 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'x'.repeat(11)}\n`.repeat(50)); + + expect(term.getViewportY()).toBe(577); + } finally { + term.wasmTerm!.write = originalWrite; + term.wasmTerm!.getCursor = originalGetCursor; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() preserves UTF-8 decoder state across binary chunks', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + const bytes = new TextEncoder().encode(`${'🚀'.repeat(11)}\r\n`.repeat(100)); + let writeCount = 0; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = writeCount >= 2 ? offset + 200 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + writeCount++; + }) as typeof term.wasmTerm.write; + + term.write(bytes.slice(0, 1)); + expect(term.getViewportY()).toBe(500); + + term.write(bytes.slice(1)); + expect(term.getViewportY()).toBe(700); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() estimates emoji cell widths for capped anchor preservation', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 200 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'🚀'.repeat(11)}\r\n`.repeat(100)); + + expect(term.getViewportY()).toBe(700); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() estimates wide-character cell widths for capped anchor preservation', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 200 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'界'.repeat(11)}\r\n`.repeat(100)); + + expect(term.getViewportY()).toBe(700); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() does not overcount exact-width lines as wrapped rows', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 100 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'x'.repeat(20)}\r\n`.repeat(100)); + + expect(term.getViewportY()).toBe(600); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() adds printable and control scroll estimates for capped anchor preservation', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 300 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'line\r\n'.repeat(100)}\x1b[200S`); + + expect(term.getViewportY()).toBe(800); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() prefers expected capped anchor offset over nearby duplicates', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 1; + (term as any).targetViewportY = 1; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + if (!afterWrite) { + return makeSignatureLine(offset === 999 ? 'prompt' : `old-${offset}`); + } + return makeSignatureLine(offset === 998 || offset === 999 ? 'prompt' : `new-${offset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('prompt\r\n'); + + expect(term.getViewportY()).toBe(2); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() bounds anchor search when capped scrollback anchor is not found', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + let postWriteLineReads = 0; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + if (afterWrite) { + postWriteLineReads++; + } + return makeSignatureLine(`${afterWrite ? 'new' : 'old'}-${offset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('x'.repeat(50000)); + + expect(term.getViewportY()).toBe(500); + expect(postWriteLineReads).toBeLessThanOrEqual(101); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() estimates IND/NEL controls for capped anchor preservation', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 300 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('\x1bD'.repeat(300)); + + expect(term.getViewportY()).toBe(800); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() clamps tab estimates at the row edge', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 100 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'x'.repeat(16)}\t\r\n`.repeat(100)); + + expect(term.getViewportY()).toBe(600); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() ignores non-printing C0 controls when estimating capped anchor shifts', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 1 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'\b'.repeat(300)}done\r\n`); + + expect(term.getViewportY()).toBe(501); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() ignores control sequence bytes when estimating capped anchor shifts', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 1 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'\x1b[31m'.repeat(300)}Line\x1b[0m\r\n`); + + expect(term.getViewportY()).toBe(501); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() follows output while smooth scroll target is bottom', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + let afterWrite = false; + + try { + term.viewportY = 0.5; + (term as any).targetViewportY = 0; + (term as any).getScrollbackLength = () => (afterWrite ? 11 : 10); + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('Line 011\r\n'); + + expect(term.getViewportY()).toBe(0); + expect((term as any).targetViewportY).toBe(0); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() does not infer evictions for current-line updates', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + + try { + term.viewportY = 1; + (term as any).targetViewportY = 1; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => + makeSignatureLine(offset === 998 || offset === 999 ? 'prompt' : `line-${offset}`); + term.wasmTerm!.write = (() => {}) as typeof term.wasmTerm.write; + + term.write('x'); + + expect(term.getViewportY()).toBe(1); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('write() does not move preserved viewport when scrollback does not grow', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + try { + writeNumberedLines(term, 12); + term.scrollLines(-3); + + const beforeViewportY = term.getViewportY(); + const beforeScrollbackLength = term.getScrollbackLength(); + const scrollEvents: number[] = []; + const scrollDisposable = term.onScroll((value) => scrollEvents.push(value)); + + try { + term.write('x'); + + expect(term.getScrollbackLength()).toBe(beforeScrollbackLength); + expect(term.getViewportY()).toBe(beforeViewportY); + expect(scrollEvents).toEqual([]); + } finally { + scrollDisposable.dispose(); + } + } finally { + disposePreserveScrollTestTerminal(term, container); + } + }); + + test('preserveScrollOnWrite can be enabled at runtime through terminal options', async () => { + const { term, container } = await createPreserveScrollTestTerminal(); + + try { + writeNumberedLines(term, 12); + term.scrollLines(-3); + term.write('Default mode\r\n'); + expect(term.getViewportY()).toBe(0); + + term.options.preserveScrollOnWrite = true; + term.scrollLines(-3); + + const beforeViewportY = term.getViewportY(); + const beforeScrollbackLength = term.getScrollbackLength(); + term.write('Opt-in mode\r\n'); + + const afterScrollbackLength = term.getScrollbackLength(); + expect(term.getViewportY()).toBe( + clampViewportY( + beforeViewportY + (afterScrollbackLength - beforeScrollbackLength), + afterScrollbackLength + ) + ); + } finally { + disposePreserveScrollTestTerminal(term, container); + } + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index 92d5f3d0..32d895b1 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -38,6 +38,11 @@ import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; import type { ILink, ILinkProvider } from './types'; +interface PreserveScrollAnchor { + topOffset: number; + lineSignatures: string[]; +} + // ============================================================================ // Terminal Class // ============================================================================ @@ -127,6 +132,10 @@ export class Terminal implements ITerminalCore { private scrollbarDragStart: number | null = null; private scrollbarDragStartViewportY: number = 0; + private preserveScrollEstimateDecoder = new TextDecoder(); + private preserveScrollEstimateCarry: string = ''; + private readonly PRESERVE_SCROLL_ANCHOR_SEARCH_RADIUS = 50; + // Scrollbar visibility/auto-hide state private scrollbarVisible: boolean = false; private scrollbarOpacity: number = 0; @@ -152,6 +161,7 @@ export class Terminal implements ITerminalCore { convertEol: options.convertEol ?? false, disableStdin: options.disableStdin ?? false, smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll + preserveScrollOnWrite: options.preserveScrollOnWrite ?? false, }; // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) @@ -559,6 +569,20 @@ 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. + const savedViewportY = this.viewportY; + const savedTargetViewportY = this.targetViewportY; + const savedScrollbackLength = this.getScrollbackLength(); + const wasAlternateScreen = this.wasmTerm!.isAlternateScreen(); + const shouldPreserveScroll = + this.options.preserveScrollOnWrite && + Math.floor(savedViewportY) > 0 && + Math.floor(savedTargetViewportY) > 0 && + !wasAlternateScreen; + const savedCursorX = shouldPreserveScroll ? this.wasmTerm!.getCursor().x : 0; + const preserveScrollAnchor = shouldPreserveScroll + ? this.createPreserveScrollAnchor(savedViewportY) + : undefined; + // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); @@ -577,9 +601,52 @@ 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) { - this.scrollToBottom(); + // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior), unless the + // caller opts into preserving a scrolled-up viewport during streaming writes. + if (shouldPreserveScroll && !this.wasmTerm!.isAlternateScreen()) { + const newScrollbackLength = this.getScrollbackLength(); + const scrollbackDelta = newScrollbackLength - savedScrollbackLength; + const mayHaveEvictedScrollback = newScrollbackLength >= this.options.scrollback; + const estimatedScrollbackShift = this.estimatePreserveScrollShift(data, savedCursorX); + const estimatedEvictedRows = Math.max( + 0, + estimatedScrollbackShift - Math.max(0, scrollbackDelta) + ); + // If scrollback grew below the configured cap, retained rows keep their offsets, + // so the signed delta is sufficient and avoids scanning on every append. + const anchoredViewportY = + preserveScrollAnchor && (scrollbackDelta <= 0 || mayHaveEvictedScrollback) + ? this.findAnchoredViewportY( + preserveScrollAnchor, + newScrollbackLength, + estimatedEvictedRows + ) + : undefined; + const nextViewportY = + anchoredViewportY ?? + this.clampViewportY(savedViewportY + scrollbackDelta, newScrollbackLength); + const viewportDelta = nextViewportY - savedViewportY; + const nextTargetViewportY = this.clampViewportY( + savedTargetViewportY + viewportDelta, + newScrollbackLength + ); + + if (nextViewportY !== this.viewportY || nextTargetViewportY !== this.targetViewportY) { + this.viewportY = nextViewportY; + this.targetViewportY = nextTargetViewportY; + this.scrollEmitter.fire(Math.floor(this.viewportY)); + + if (newScrollbackLength > 0) { + this.showScrollbar(); + } + } + } else { + if (this.options.preserveScrollOnWrite) { + this.updatePreserveScrollEstimateState(data); + } + if (this.viewportY !== 0) { + this.scrollToBottom(); + } } // Check for title changes (OSC 0, 1, 2 sequences) @@ -604,6 +671,229 @@ export class Terminal implements ITerminalCore { // Render will happen on next animation frame } + private createPreserveScrollAnchor(viewportY: number): PreserveScrollAnchor | undefined { + const scrollbackLength = this.getScrollbackLength(); + const flooredViewportY = Math.max(0, Math.floor(viewportY)); + const visibleScrollbackRows = Math.min(flooredViewportY, this.rows); + const topOffset = scrollbackLength - flooredViewportY; + + if (visibleScrollbackRows <= 0 || topOffset < 0) { + return undefined; + } + + const lineCount = Math.min(visibleScrollbackRows, 5, scrollbackLength - topOffset); + const lineSignatures: string[] = []; + + for (let row = 0; row < lineCount; row++) { + const signature = this.getLineSignature(this.getScrollbackLine(topOffset + row)); + if (signature === undefined) { + return undefined; + } + lineSignatures.push(signature); + } + + return lineSignatures.length > 0 ? { topOffset, lineSignatures } : undefined; + } + + private estimatePreserveScrollShift(data: string | Uint8Array, initialColumn: number): number { + const completeText = this.updatePreserveScrollEstimateState(data); + const printableText = this.stripControlSequencesForScrollEstimate(completeText); + const printableRows = this.estimatePrintableRowMovement(printableText, initialColumn); + const scrollUpRows = this.estimateCsiScrollUpRows(completeText); + const indexRows = this.estimateIndexControlRows(completeText); + + return printableRows + scrollUpRows + indexRows; + } + + private updatePreserveScrollEstimateState(data: string | Uint8Array): string { + const decodedText = + typeof data === 'string' + ? data + : this.preserveScrollEstimateDecoder.decode(data, { stream: true }); + const text = this.preserveScrollEstimateCarry + decodedText; + this.preserveScrollEstimateCarry = this.getTrailingControlSequencePrefix(text); + + return text.slice(0, text.length - this.preserveScrollEstimateCarry.length); + } + + private getTrailingControlSequencePrefix(data: string): string { + const escIndex = data.lastIndexOf('\x1b'); + const c1CsiIndex = data.lastIndexOf('\x9b'); + const start = Math.max(escIndex, c1CsiIndex); + if (start < 0) { + return ''; + } + + const suffix = data.slice(start); + if (/^\x1b\[[0-?]*[ -/]*$/.test(suffix) || /^\x9b[0-?]*[ -/]*$/.test(suffix)) { + return suffix; + } + if (/^\x1b\][^\x07]*(?:\x1b)?$/.test(suffix)) { + return suffix; + } + if (/^\x1bP[\s\S]*(?:\x1b)?$/.test(suffix)) { + return suffix; + } + + return suffix === '\x1b' ? suffix : ''; + } + + private stripControlSequencesForScrollEstimate(data: string): string { + return data + .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') + .replace(/\x1bP[\s\S]*?(?:\x1b\\|\x07)/g, '') + .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1b[@-Z\\-_]/g, '') + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); + } + + private estimatePrintableRowMovement(data: string, initialColumn: number): number { + let rows = 0; + let columns = Math.max(0, Math.min(Math.floor(initialColumn), Math.max(1, this.cols) - 1)); + const maxColumns = Math.max(1, this.cols); + + for (const char of data) { + if (char === '\r') { + columns = 0; + continue; + } + + if (char === '\n') { + rows++; + continue; + } + + const width = this.estimateCellWidth(char.codePointAt(0)!, columns, maxColumns); + if (width === 0) { + continue; + } + + if (columns > 0 && columns + width > maxColumns) { + rows++; + columns = 0; + } + + columns += width; + while (columns > maxColumns) { + rows++; + columns -= maxColumns; + } + } + + return rows; + } + + private estimateCellWidth(codepoint: number, currentColumn: number, maxColumns: number): number { + if (codepoint === 0x09) { + const remainder = currentColumn % 8; + const nextTabWidth = remainder === 0 ? 8 : 8 - remainder; + return Math.min(nextTabWidth, Math.max(0, maxColumns - currentColumn)); + } + + if (this.isCombiningCodepoint(codepoint)) { + return 0; + } + + return this.isWideCodepoint(codepoint) ? 2 : 1; + } + + private isCombiningCodepoint(codepoint: number): boolean { + return ( + (codepoint >= 0x0300 && codepoint <= 0x036f) || + (codepoint >= 0x1ab0 && codepoint <= 0x1aff) || + (codepoint >= 0x1dc0 && codepoint <= 0x1dff) || + (codepoint >= 0x20d0 && codepoint <= 0x20ff) || + (codepoint >= 0xfe20 && codepoint <= 0xfe2f) + ); + } + + private isWideCodepoint(codepoint: number): boolean { + return ( + codepoint >= 0x1100 && + (codepoint <= 0x115f || + codepoint === 0x2329 || + codepoint === 0x232a || + (codepoint >= 0x2e80 && codepoint <= 0xa4cf && codepoint !== 0x303f) || + (codepoint >= 0xac00 && codepoint <= 0xd7a3) || + (codepoint >= 0xf900 && codepoint <= 0xfaff) || + (codepoint >= 0xfe10 && codepoint <= 0xfe19) || + (codepoint >= 0xfe30 && codepoint <= 0xfe6f) || + (codepoint >= 0xff00 && codepoint <= 0xff60) || + (codepoint >= 0xffe0 && codepoint <= 0xffe6) || + (codepoint >= 0x1f300 && codepoint <= 0x1faff) || + (codepoint >= 0x20000 && codepoint <= 0x3fffd)) + ); + } + + private estimateCsiScrollUpRows(data: string): number { + let rows = 0; + + for (const match of data.matchAll(/(?:\x1b\[|\x9b)(\d*)S/g)) { + rows += match[1] === '' ? 1 : Number.parseInt(match[1], 10); + } + + return rows; + } + + private estimateIndexControlRows(data: string): number { + return (data.match(/\x1b[DE]|[\x84\x85]/g) ?? []).length; + } + + private findAnchoredViewportY( + anchor: PreserveScrollAnchor, + scrollbackLength: number, + estimatedEvictedRows: number + ): number | undefined { + const lastOffset = Math.min(anchor.topOffset, scrollbackLength - anchor.lineSignatures.length); + const expectedOffset = Math.max(0, lastOffset - estimatedEvictedRows); + const firstOffset = Math.max(0, expectedOffset - this.PRESERVE_SCROLL_ANCHOR_SEARCH_RADIUS); + const startOffset = Math.min( + lastOffset, + expectedOffset + this.PRESERVE_SCROLL_ANCHOR_SEARCH_RADIUS + ); + + for (let distance = 0; distance <= this.PRESERVE_SCROLL_ANCHOR_SEARCH_RADIUS; distance++) { + const lowerOffset = expectedOffset - distance; + if (lowerOffset >= firstOffset && this.scrollbackMatchesAnchor(anchor, lowerOffset)) { + return this.clampViewportY(scrollbackLength - lowerOffset, scrollbackLength); + } + + const upperOffset = expectedOffset + distance; + if ( + distance > 0 && + upperOffset <= startOffset && + this.scrollbackMatchesAnchor(anchor, upperOffset) + ) { + return this.clampViewportY(scrollbackLength - upperOffset, scrollbackLength); + } + } + + return undefined; + } + + private scrollbackMatchesAnchor(anchor: PreserveScrollAnchor, offset: number): boolean { + for (let row = 0; row < anchor.lineSignatures.length; row++) { + const signature = this.getLineSignature(this.getScrollbackLine(offset + row)); + if (signature !== anchor.lineSignatures[row]) { + return false; + } + } + + return true; + } + + private getLineSignature(cells: GhosttyCell[] | null): string | undefined { + if (!cells) { + return undefined; + } + + return cells.map((cell) => `${cell.codepoint}:${cell.flags}:${cell.width}`).join(','); + } + + private clampViewportY(viewportY: number, scrollbackLength: number): number { + return Math.max(0, Math.min(viewportY, scrollbackLength)); + } + /** * Write data with newline */ @@ -927,6 +1217,7 @@ export class Terminal implements ITerminalCore { if (newViewportY !== this.viewportY) { this.viewportY = newViewportY; + this.targetViewportY = newViewportY; this.scrollEmitter.fire(this.viewportY); // Show scrollbar when scrolling (with auto-hide) @@ -951,6 +1242,7 @@ export class Terminal implements ITerminalCore { const scrollbackLength = this.getScrollbackLength(); if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) { this.viewportY = scrollbackLength; + this.targetViewportY = scrollbackLength; this.scrollEmitter.fire(this.viewportY); this.showScrollbar(); } @@ -962,6 +1254,7 @@ export class Terminal implements ITerminalCore { public scrollToBottom(): void { if (this.viewportY !== 0) { this.viewportY = 0; + this.targetViewportY = 0; this.scrollEmitter.fire(this.viewportY); // Show scrollbar briefly when scrolling to bottom if (this.getScrollbackLength() > 0) { @@ -980,6 +1273,7 @@ export class Terminal implements ITerminalCore { if (newViewportY !== this.viewportY) { this.viewportY = newViewportY; + this.targetViewportY = newViewportY; this.scrollEmitter.fire(this.viewportY); // Show scrollbar when scrolling to specific line From e3f653139c1e9a0d53dbbafb3b0840bf9455a487 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 26 Jun 2026 16:56:13 +0000 Subject: [PATCH 02/10] fix: avoid snapping preserved fractional scroll Change-Id: If0412a81b48ea28fa5779919578c6c1744e5adad Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 29 +++++++++++++++++++++++++++++ lib/terminal.ts | 23 ++++++++++++++--------- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 58b3ee05..eb7a2fa2 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1552,6 +1552,35 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() preserves fractional viewport on no-growth writes', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + + try { + term.viewportY = 3.5; + (term as any).targetViewportY = 4.5; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => makeSignatureLine(`line-${offset}`); + term.wasmTerm!.write = (() => {}) as typeof term.wasmTerm.write; + + term.write('x'); + + expect(term.getViewportY()).toBe(3.5); + expect((term as any).targetViewportY).toBe(4.5); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() does not move preserved viewport when scrollback does not grow', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index 32d895b1..5ccead63 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -612,24 +612,29 @@ export class Terminal implements ITerminalCore { 0, estimatedScrollbackShift - Math.max(0, scrollbackDelta) ); + const scrollbackOffsetsUnchanged = scrollbackDelta === 0 && estimatedEvictedRows === 0; // If scrollback grew below the configured cap, retained rows keep their offsets, - // so the signed delta is sufficient and avoids scanning on every append. + // so the signed delta is sufficient and avoids scanning on every append. If a + // no-growth write could not have evicted rows, keep fractional smooth-scroll + // positions intact instead of snapping through the integer anchor path. const anchoredViewportY = - preserveScrollAnchor && (scrollbackDelta <= 0 || mayHaveEvictedScrollback) + !scrollbackOffsetsUnchanged && + preserveScrollAnchor && + (scrollbackDelta <= 0 || mayHaveEvictedScrollback) ? this.findAnchoredViewportY( preserveScrollAnchor, newScrollbackLength, estimatedEvictedRows ) : undefined; - const nextViewportY = - anchoredViewportY ?? - this.clampViewportY(savedViewportY + scrollbackDelta, newScrollbackLength); + const nextViewportY = scrollbackOffsetsUnchanged + ? this.clampViewportY(savedViewportY, newScrollbackLength) + : (anchoredViewportY ?? + this.clampViewportY(savedViewportY + scrollbackDelta, newScrollbackLength)); const viewportDelta = nextViewportY - savedViewportY; - const nextTargetViewportY = this.clampViewportY( - savedTargetViewportY + viewportDelta, - newScrollbackLength - ); + const nextTargetViewportY = scrollbackOffsetsUnchanged + ? this.clampViewportY(savedTargetViewportY, newScrollbackLength) + : this.clampViewportY(savedTargetViewportY + viewportDelta, newScrollbackLength); if (nextViewportY !== this.viewportY || nextTargetViewportY !== this.targetViewportY) { this.viewportY = nextViewportY; From 82e2fd7d515716d5b9f25ee7d4d7c54e2742d9f4 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Fri, 26 Jun 2026 17:02:52 +0000 Subject: [PATCH 03/10] fix: carry split string controls Change-Id: Ifbd2f32e8e35430746dbe7859c74aec48e00ec23 Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 40 ++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 47 +++++++++++++++++++++++++++++++++++-------- 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index eb7a2fa2..9341b0ab 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1001,6 +1001,46 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() carries string control sequences split at ST escape byte', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => makeSignatureLine(`line-${offset}`); + term.wasmTerm!.write = (() => {}) as typeof term.wasmTerm.write; + + const oscPrefix = `\x1b]0;${'title'.repeat(50)}\x1b`; + term.write(oscPrefix); + expect((term as any).preserveScrollEstimateCarry).toBe(oscPrefix); + + term.write('\\'); + expect((term as any).preserveScrollEstimateCarry).toBe(''); + expect(term.getViewportY()).toBe(500); + + const dcsPrefix = `\x1bP${'payload'.repeat(50)}\x1b`; + term.write(dcsPrefix); + expect((term as any).preserveScrollEstimateCarry).toBe(dcsPrefix); + + term.write('\\'); + expect((term as any).preserveScrollEstimateCarry).toBe(''); + expect(term.getViewportY()).toBe(500); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() estimates CSI scroll-up rows split across writes', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index 5ccead63..d10a7063 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -722,6 +722,11 @@ export class Terminal implements ITerminalCore { } private getTrailingControlSequencePrefix(data: string): string { + const stringPrefix = this.getTrailingStringControlSequencePrefix(data); + if (stringPrefix !== '') { + return stringPrefix; + } + const escIndex = data.lastIndexOf('\x1b'); const c1CsiIndex = data.lastIndexOf('\x9b'); const start = Math.max(escIndex, c1CsiIndex); @@ -733,20 +738,46 @@ export class Terminal implements ITerminalCore { if (/^\x1b\[[0-?]*[ -/]*$/.test(suffix) || /^\x9b[0-?]*[ -/]*$/.test(suffix)) { return suffix; } - if (/^\x1b\][^\x07]*(?:\x1b)?$/.test(suffix)) { - return suffix; - } - if (/^\x1bP[\s\S]*(?:\x1b)?$/.test(suffix)) { - return suffix; - } return suffix === '\x1b' ? suffix : ''; } + private getTrailingStringControlSequencePrefix(data: string): string { + const introducers = ['\x1b]', '\x1bP', '\x9d', '\x90']; + const starts = introducers + .flatMap((introducer) => { + const matches: Array<{ index: number; introducer: string }> = []; + let index = data.indexOf(introducer); + while (index !== -1) { + matches.push({ index, introducer }); + index = data.indexOf(introducer, index + introducer.length); + } + return matches; + }) + .sort((left, right) => left.index - right.index); + + for (const { index, introducer } of starts) { + const content = data.slice(index + introducer.length); + if (this.findStringControlTerminatorIndex(content) === -1) { + return data.slice(index); + } + } + + return ''; + } + + private findStringControlTerminatorIndex(data: string): number { + const terminatorIndexes = [data.indexOf('\x07'), data.indexOf('\x1b\\'), data.indexOf('\x9c')] + .filter((index) => index !== -1) + .sort((left, right) => left - right); + + return terminatorIndexes[0] ?? -1; + } + private stripControlSequencesForScrollEstimate(data: string): string { return data - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') - .replace(/\x1bP[\s\S]*?(?:\x1b\\|\x07)/g, '') + .replace(/(?:\x1b\]|\x9d)[^\x07\x9c]*(?:\x07|\x1b\\|\x9c)/g, '') + .replace(/(?:\x1bP|\x90)[\s\S]*?(?:\x1b\\|\x07|\x9c)/g, '') .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, '') .replace(/\x1b[@-Z\\-_]/g, '') .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); From 8fa1331faeb79bd32ead9571dc73a20fd29ffbb1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 11:31:14 +0000 Subject: [PATCH 04/10] fix: avoid default write path scroll probes Change-Id: I58ddd0c9825b6d0c90ddfd965e35e2a4a9a1814b Signed-off-by: Thomas Kosiewski --- lib/terminal.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/terminal.ts b/lib/terminal.ts index d10a7063..c7a7f09f 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -571,13 +571,13 @@ export class Terminal implements ITerminalCore { const savedViewportY = this.viewportY; const savedTargetViewportY = this.targetViewportY; - const savedScrollbackLength = this.getScrollbackLength(); - const wasAlternateScreen = this.wasmTerm!.isAlternateScreen(); - const shouldPreserveScroll = + const preserveCandidate = this.options.preserveScrollOnWrite && Math.floor(savedViewportY) > 0 && - Math.floor(savedTargetViewportY) > 0 && - !wasAlternateScreen; + Math.floor(savedTargetViewportY) > 0; + const wasAlternateScreen = preserveCandidate ? this.wasmTerm!.isAlternateScreen() : false; + const shouldPreserveScroll = preserveCandidate && !wasAlternateScreen; + const savedScrollbackLength = shouldPreserveScroll ? this.getScrollbackLength() : 0; const savedCursorX = shouldPreserveScroll ? this.wasmTerm!.getCursor().x : 0; const preserveScrollAnchor = shouldPreserveScroll ? this.createPreserveScrollAnchor(savedViewportY) From fef42074fac3374b6ce69693a8af6d1679cb7561 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 11:41:59 +0000 Subject: [PATCH 05/10] fix: account for pending wrap in scroll preservation Change-Id: I8e012a4d13ca25dff713c89f72abd443aa3e4c1f Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 39 +++++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 13 +++++++++++++ 2 files changed, 52 insertions(+) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 9341b0ab..3283998f 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -882,6 +882,45 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() preserves capped viewport when pending wrap evicts a row', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + }); + + const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine); + const newScrollback = ['old-1', 'old-2', 'old-3', 'old-4', 'new-5'].map(makeSignatureLine); + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetCursor = term.wasmTerm!.getCursor.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 3; + (term as any).targetViewportY = 3; + (term as any).getScrollbackLength = () => 5; + (term as any).getScrollbackLine = (offset: number) => + (afterWrite ? newScrollback : oldScrollback)[offset] ?? null; + term.wasmTerm!.getCursor = (() => ({ + ...originalGetCursor(), + x: term.cols - 1, + })) as typeof term.wasmTerm.getCursor; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('x'); + + expect(term.getViewportY()).toBe(4); + } finally { + term.wasmTerm!.write = originalWrite; + term.wasmTerm!.getCursor = originalGetCursor; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() preserves viewport when a batched write reaches the scrollback cap', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index c7a7f09f..83fc1add 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -787,18 +787,30 @@ export class Terminal implements ITerminalCore { let rows = 0; let columns = Math.max(0, Math.min(Math.floor(initialColumn), Math.max(1, this.cols) - 1)); const maxColumns = Math.max(1, this.cols); + let maybePendingWrap = columns >= maxColumns - 1; for (const char of data) { if (char === '\r') { columns = 0; + maybePendingWrap = false; continue; } if (char === '\n') { rows++; + if (columns >= maxColumns) { + columns = maxColumns - 1; + } + maybePendingWrap = false; continue; } + if (maybePendingWrap) { + rows++; + columns = 0; + maybePendingWrap = false; + } + const width = this.estimateCellWidth(char.codePointAt(0)!, columns, maxColumns); if (width === 0) { continue; @@ -814,6 +826,7 @@ export class Terminal implements ITerminalCore { rows++; columns -= maxColumns; } + maybePendingWrap = columns >= maxColumns; } return rows; From e851df4e8876cc2c996e2224fcc8ace8655a4a5e Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 11:50:17 +0000 Subject: [PATCH 06/10] fix: ignore C1 controls in scroll estimates Change-Id: I9015dc3480366cab0dafa24f53faa243ac74d857 Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 34 ++++++++++++++++++++++++++++++++++ lib/terminal.ts | 2 +- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 3283998f..5eeb27c1 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1472,6 +1472,40 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() does not count C1 IND/NEL controls as printable columns', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 2000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 2000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 1200 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write('\x85'.repeat(1200)); + + expect(term.getViewportY()).toBe(1700); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() clamps tab estimates at the row edge', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index 83fc1add..9bdb6004 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -780,7 +780,7 @@ export class Terminal implements ITerminalCore { .replace(/(?:\x1bP|\x90)[\s\S]*?(?:\x1b\\|\x07|\x9c)/g, '') .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, '') .replace(/\x1b[@-Z\\-_]/g, '') - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ''); + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ''); } private estimatePrintableRowMovement(data: string, initialColumn: number): number { From 9150a26da51b8d2aa7b340b7393cd0e9c8d20dd8 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 11:57:03 +0000 Subject: [PATCH 07/10] fix: preserve OSC hyperlink labels in scroll estimates Change-Id: I4d4f723896004bfe89539130982cc7e6951c116c Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 36 ++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 2 +- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 5eeb27c1..8f2eaaee 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1608,6 +1608,42 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() preserves printable OSC 8 labels between ST-terminated controls', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 120 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + const openHyperlink = '\x1b]8;;https://example.com\x1b\\'; + const closeHyperlink = '\x1b]8;;\x1b\\'; + term.write(`${openHyperlink}${'label\r\n'.repeat(120)}${closeHyperlink}`); + + expect(term.getViewportY()).toBe(620); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() follows output while smooth scroll target is bottom', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index 9bdb6004..7104b2cd 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -776,7 +776,7 @@ export class Terminal implements ITerminalCore { private stripControlSequencesForScrollEstimate(data: string): string { return data - .replace(/(?:\x1b\]|\x9d)[^\x07\x9c]*(?:\x07|\x1b\\|\x9c)/g, '') + .replace(/(?:\x1b\]|\x9d)[\s\S]*?(?:\x07|\x1b\\|\x9c)/g, '') .replace(/(?:\x1bP|\x90)[\s\S]*?(?:\x1b\\|\x07|\x9c)/g, '') .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, '') .replace(/\x1b[@-Z\\-_]/g, '') From 05a09cefd641e89d0acd3764f7c764c6e87ab4ec Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 12:08:26 +0000 Subject: [PATCH 08/10] fix: probe pending wrap without shifting estimate Change-Id: I933d583055761c436ca94a8aa941a2daa6095a39 Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 36 +++++++++++++++++++++++++++++ lib/terminal.ts | 54 ++++++++++++++++++++++++++----------------- 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 8f2eaaee..46e1169e 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -885,6 +885,7 @@ describe('preserveScrollOnWrite', () => { test('write() preserves capped viewport when pending wrap evicts a row', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, + scrollback: 5, }); const oldScrollback = ['old-0', 'old-1', 'old-2', 'old-3', 'old-4'].map(makeSignatureLine); @@ -921,6 +922,41 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() does not move duplicate capped viewport for last-column in-place updates', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 5, + }); + + const duplicateScrollback = Array.from({ length: 5 }, () => makeSignatureLine('')); + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetCursor = term.wasmTerm!.getCursor.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + + try { + term.viewportY = 3; + (term as any).targetViewportY = 3; + (term as any).getScrollbackLength = () => 5; + (term as any).getScrollbackLine = (offset: number) => duplicateScrollback[offset] ?? null; + term.wasmTerm!.getCursor = (() => ({ + ...originalGetCursor(), + x: term.cols - 1, + })) as typeof term.wasmTerm.getCursor; + term.wasmTerm!.write = (() => {}) as typeof term.wasmTerm.write; + + term.write('x'); + + expect(term.getViewportY()).toBe(3); + } finally { + term.wasmTerm!.write = originalWrite; + term.wasmTerm!.getCursor = originalGetCursor; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() preserves viewport when a batched write reaches the scrollback cap', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index 7104b2cd..7b22dd89 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -607,12 +607,15 @@ export class Terminal implements ITerminalCore { const newScrollbackLength = this.getScrollbackLength(); const scrollbackDelta = newScrollbackLength - savedScrollbackLength; const mayHaveEvictedScrollback = newScrollbackLength >= this.options.scrollback; - const estimatedScrollbackShift = this.estimatePreserveScrollShift(data, savedCursorX); - const estimatedEvictedRows = Math.max( - 0, - estimatedScrollbackShift - Math.max(0, scrollbackDelta) - ); - const scrollbackOffsetsUnchanged = scrollbackDelta === 0 && estimatedEvictedRows === 0; + const scrollEstimate = this.estimatePreserveScrollShift(data, savedCursorX); + const estimatedEvictedRows = Math.max(0, scrollEstimate.rows - Math.max(0, scrollbackDelta)); + const shouldProbePendingWrap = + scrollbackDelta === 0 && + mayHaveEvictedScrollback && + savedCursorX >= Math.max(0, this.cols - 1) && + scrollEstimate.hasPrintableCells; + const scrollbackOffsetsUnchanged = + scrollbackDelta === 0 && estimatedEvictedRows === 0 && !shouldProbePendingWrap; // If scrollback grew below the configured cap, retained rows keep their offsets, // so the signed delta is sufficient and avoids scanning on every append. If a // no-growth write could not have evicted rows, keep fractional smooth-scroll @@ -700,14 +703,36 @@ export class Terminal implements ITerminalCore { return lineSignatures.length > 0 ? { topOffset, lineSignatures } : undefined; } - private estimatePreserveScrollShift(data: string | Uint8Array, initialColumn: number): number { + private estimatePreserveScrollShift( + data: string | Uint8Array, + initialColumn: number + ): { rows: number; hasPrintableCells: boolean } { const completeText = this.updatePreserveScrollEstimateState(data); const printableText = this.stripControlSequencesForScrollEstimate(completeText); const printableRows = this.estimatePrintableRowMovement(printableText, initialColumn); const scrollUpRows = this.estimateCsiScrollUpRows(completeText); const indexRows = this.estimateIndexControlRows(completeText); - return printableRows + scrollUpRows + indexRows; + return { + rows: printableRows + scrollUpRows + indexRows, + hasPrintableCells: this.hasPrintableCells(printableText), + }; + } + + private hasPrintableCells(data: string): boolean { + const maxColumns = Math.max(1, this.cols); + + for (const char of data) { + if (char === '\r' || char === '\n') { + continue; + } + + if (this.estimateCellWidth(char.codePointAt(0)!, 0, maxColumns) > 0) { + return true; + } + } + + return false; } private updatePreserveScrollEstimateState(data: string | Uint8Array): string { @@ -787,30 +812,18 @@ export class Terminal implements ITerminalCore { let rows = 0; let columns = Math.max(0, Math.min(Math.floor(initialColumn), Math.max(1, this.cols) - 1)); const maxColumns = Math.max(1, this.cols); - let maybePendingWrap = columns >= maxColumns - 1; for (const char of data) { if (char === '\r') { columns = 0; - maybePendingWrap = false; continue; } if (char === '\n') { rows++; - if (columns >= maxColumns) { - columns = maxColumns - 1; - } - maybePendingWrap = false; continue; } - if (maybePendingWrap) { - rows++; - columns = 0; - maybePendingWrap = false; - } - const width = this.estimateCellWidth(char.codePointAt(0)!, columns, maxColumns); if (width === 0) { continue; @@ -826,7 +839,6 @@ export class Terminal implements ITerminalCore { rows++; columns -= maxColumns; } - maybePendingWrap = columns >= maxColumns; } return rows; From 766800175233a03eba0fa15bea4e7bc1ff13c323 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 12:18:40 +0000 Subject: [PATCH 09/10] fix: estimate scroll by grapheme clusters Change-Id: I84165d47e7aca2452b33dfb15df5771664310630 Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 34 +++++++++++++++ lib/terminal.ts | 98 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 123 insertions(+), 9 deletions(-) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 46e1169e..354ad2e2 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1298,6 +1298,40 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() estimates emoji grapheme clusters as one wide cell', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 120 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'🏳️‍🌈'.repeat(10)}\r\n`.repeat(120)); + + expect(term.getViewportY()).toBe(620); + } finally { + term.wasmTerm!.write = originalWrite; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() estimates wide-character cell widths for capped anchor preservation', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index 7b22dd89..d846fb5f 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -136,6 +136,8 @@ export class Terminal implements ITerminalCore { private preserveScrollEstimateCarry: string = ''; private readonly PRESERVE_SCROLL_ANCHOR_SEARCH_RADIUS = 50; + private readonly preserveScrollGraphemeSegmenter = this.createGraphemeSegmenter(); + // Scrollbar visibility/auto-hide state private scrollbarVisible: boolean = false; private scrollbarOpacity: number = 0; @@ -722,12 +724,12 @@ export class Terminal implements ITerminalCore { private hasPrintableCells(data: string): boolean { const maxColumns = Math.max(1, this.cols); - for (const char of data) { - if (char === '\r' || char === '\n') { + for (const grapheme of this.segmentGraphemes(data)) { + if (grapheme === '\r' || grapheme === '\n') { continue; } - if (this.estimateCellWidth(char.codePointAt(0)!, 0, maxColumns) > 0) { + if (this.estimateGraphemeCellWidth(grapheme, 0, maxColumns) > 0) { return true; } } @@ -813,18 +815,18 @@ export class Terminal implements ITerminalCore { let columns = Math.max(0, Math.min(Math.floor(initialColumn), Math.max(1, this.cols) - 1)); const maxColumns = Math.max(1, this.cols); - for (const char of data) { - if (char === '\r') { + for (const grapheme of this.segmentGraphemes(data)) { + if (grapheme === '\r') { columns = 0; continue; } - if (char === '\n') { + if (grapheme === '\n') { rows++; continue; } - const width = this.estimateCellWidth(char.codePointAt(0)!, columns, maxColumns); + const width = this.estimateGraphemeCellWidth(grapheme, columns, maxColumns); if (width === 0) { continue; } @@ -844,6 +846,71 @@ export class Terminal implements ITerminalCore { return rows; } + private createGraphemeSegmenter(): + | { segment(input: string): Iterable<{ segment: string }> } + | undefined { + const Segmenter = (Intl as unknown as { Segmenter?: new (...args: any[]) => any }).Segmenter; + return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : undefined; + } + + private segmentGraphemes(data: string): string[] { + const segments = this.preserveScrollGraphemeSegmenter + ? Array.from(this.preserveScrollGraphemeSegmenter.segment(data), (segment) => segment.segment) + : Array.from(data); + + const controlSafeSegments: string[] = []; + for (const segment of segments) { + let printableSegment = ''; + for (const char of segment) { + if (char === '\r' || char === '\n' || char === '\t') { + if (printableSegment !== '') { + controlSafeSegments.push(printableSegment); + printableSegment = ''; + } + controlSafeSegments.push(char); + continue; + } + + printableSegment += char; + } + + if (printableSegment !== '') { + controlSafeSegments.push(printableSegment); + } + } + + return controlSafeSegments; + } + + private estimateGraphemeCellWidth( + grapheme: string, + currentColumn: number, + maxColumns: number + ): number { + if (grapheme === '\t') { + return this.estimateCellWidth(0x09, currentColumn, maxColumns); + } + + let hasSpacingCodepoint = false; + let hasWideCodepoint = false; + + for (const char of grapheme) { + const codepoint = char.codePointAt(0)!; + if (this.isZeroWidthCodepoint(codepoint)) { + continue; + } + + hasSpacingCodepoint = true; + hasWideCodepoint ||= this.isWideCodepoint(codepoint) || this.isRegionalIndicator(codepoint); + } + + if (!hasSpacingCodepoint) { + return 0; + } + + return hasWideCodepoint ? 2 : 1; + } + private estimateCellWidth(codepoint: number, currentColumn: number, maxColumns: number): number { if (codepoint === 0x09) { const remainder = currentColumn % 8; @@ -851,11 +918,20 @@ export class Terminal implements ITerminalCore { return Math.min(nextTabWidth, Math.max(0, maxColumns - currentColumn)); } - if (this.isCombiningCodepoint(codepoint)) { + if (this.isZeroWidthCodepoint(codepoint)) { return 0; } - return this.isWideCodepoint(codepoint) ? 2 : 1; + return this.isWideCodepoint(codepoint) || this.isRegionalIndicator(codepoint) ? 2 : 1; + } + + private isZeroWidthCodepoint(codepoint: number): boolean { + return ( + this.isCombiningCodepoint(codepoint) || + codepoint === 0x200d || + (codepoint >= 0xfe00 && codepoint <= 0xfe0f) || + (codepoint >= 0xe0100 && codepoint <= 0xe01ef) + ); } private isCombiningCodepoint(codepoint: number): boolean { @@ -868,6 +944,10 @@ export class Terminal implements ITerminalCore { ); } + private isRegionalIndicator(codepoint: number): boolean { + return codepoint >= 0x1f1e6 && codepoint <= 0x1f1ff; + } + private isWideCodepoint(codepoint: number): boolean { return ( codepoint >= 0x1100 && From 0b5d1bbbbc5df971b613522f9586011e0360afc7 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Sun, 28 Jun 2026 12:27:05 +0000 Subject: [PATCH 10/10] fix: reset scroll estimates after NEL controls Change-Id: I6beab0dae7d2812928fab8c08a190400e81a30c9 Signed-off-by: Thomas Kosiewski --- lib/scrolling.test.ts | 40 ++++++++++++++++++++++++++++++++++++++++ lib/terminal.ts | 1 + 2 files changed, 41 insertions(+) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 354ad2e2..8a06da4a 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1542,6 +1542,46 @@ describe('preserveScrollOnWrite', () => { } }); + test('write() resets estimated column after NEL controls', async () => { + const { term, container } = await createPreserveScrollTestTerminal({ + preserveScrollOnWrite: true, + scrollback: 1000, + }); + + const originalWrite = term.wasmTerm!.write.bind(term.wasmTerm); + const originalGetCursor = term.wasmTerm!.getCursor.bind(term.wasmTerm); + const originalGetScrollbackLength = term.getScrollbackLength.bind(term); + const originalGetScrollbackLine = term.getScrollbackLine.bind(term); + let afterWrite = false; + + try { + term.viewportY = 500; + (term as any).targetViewportY = 500; + (term as any).getScrollbackLength = () => 1000; + (term as any).getScrollbackLine = (offset: number) => { + const shiftedOffset = afterWrite ? offset + 120 : offset; + return makeSignatureLine(`line-${shiftedOffset}`); + }; + term.wasmTerm!.getCursor = (() => ({ + ...originalGetCursor(), + x: 10, + })) as typeof term.wasmTerm.getCursor; + term.wasmTerm!.write = (() => { + afterWrite = true; + }) as typeof term.wasmTerm.write; + + term.write(`${'\x1bE'}${'x'.repeat(20)}`.repeat(120)); + + expect(term.getViewportY()).toBe(620); + } finally { + term.wasmTerm!.write = originalWrite; + term.wasmTerm!.getCursor = originalGetCursor; + (term as any).getScrollbackLength = originalGetScrollbackLength; + (term as any).getScrollbackLine = originalGetScrollbackLine; + disposePreserveScrollTestTerminal(term, container); + } + }); + test('write() does not count C1 IND/NEL controls as printable columns', async () => { const { term, container } = await createPreserveScrollTestTerminal({ preserveScrollOnWrite: true, diff --git a/lib/terminal.ts b/lib/terminal.ts index d846fb5f..26eadb0f 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -806,6 +806,7 @@ export class Terminal implements ITerminalCore { .replace(/(?:\x1b\]|\x9d)[\s\S]*?(?:\x07|\x1b\\|\x9c)/g, '') .replace(/(?:\x1bP|\x90)[\s\S]*?(?:\x1b\\|\x07|\x9c)/g, '') .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, '') + .replace(/\x1bE|\x85/g, '\r') .replace(/\x1b[@-Z\\-_]/g, '') .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ''); }