Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
69 changes: 69 additions & 0 deletions lib/terminal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3021,3 +3021,72 @@
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) {

Check warning on line 3036 in lib/terminal.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer using an optional chain expression instead, as it's more concise and easier to read.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5TrSlOW21o6EbfkSMW&open=AZ5TrSlOW21o6EbfkSMW&pullRequest=5
container.parentNode.removeChild(container);

Check warning on line 3037 in lib/terminal.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `childNode.remove()` over `parentNode.removeChild(childNode)`.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5TrSlOW21o6EbfkSMX&open=AZ5TrSlOW21o6EbfkSMX&pullRequest=5
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();
});
});
28 changes: 26 additions & 2 deletions lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@
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)
Expand Down Expand Up @@ -555,11 +556,20 @@
/**
* Internal write implementation (extracted from write())
*/
private writeInternal(data: string | Uint8Array, callback?: () => void): void {

Check failure on line 559 in lib/terminal.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 21 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5TrSi2W21o6EbfkSMV&open=AZ5TrSi2W21o6EbfkSMV&pullRequest=5
// Note: We intentionally do NOT clear selection on write - most modern terminals
// 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);

Expand All @@ -578,8 +588,22 @@
// 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();
}

Expand Down