diff --git a/demo/colors-demo.html b/demo/colors-demo.html index 24110b0..2df6842 100644 --- a/demo/colors-demo.html +++ b/demo/colors-demo.html @@ -163,7 +163,7 @@

Color Demonstrations

// Initialization // ========================================================================= - async function init() { + function init() { try { // Create terminal with dark theme term = new Terminal({ @@ -201,7 +201,7 @@

Color Demonstrations

// Open terminal const container = document.getElementById('terminal-container'); - await term.open(container); + term.open(container); fitAddon.fit(); // Handle resize diff --git a/demo/index.html b/demo/index.html index d2001e6..53394a5 100644 --- a/demo/index.html +++ b/demo/index.html @@ -138,7 +138,7 @@ let ws; let fitAddon; - async function initTerminal() { + function initTerminal() { term = new Terminal({ cursorBlink: true, fontSize: 14, @@ -153,7 +153,7 @@ fitAddon = new FitAddon(); term.loadAddon(fitAddon); - await term.open(document.getElementById('terminal-container')); + term.open(document.getElementById('terminal-container')); fitAddon.fit(); // Handle window resize @@ -161,8 +161,13 @@ fitAddon.fit(); }); - // Connect to PTY server - connectWebSocket(); + // Handle terminal resize - MUST be registered before terminal becomes ready! + term.onResize((size) => { + if (ws && ws.readyState === WebSocket.OPEN) { + // Send resize as control sequence (server expects this format) + ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); + } + }); // Handle user input term.onData((data) => { @@ -176,6 +181,14 @@ term.onScroll((ydisp) => { console.log('Scroll position:', ydisp); }); + + // Connect to PTY server AFTER terminal is ready + // This ensures term.cols/rows have been updated by FitAddon + // since the PTY server doesn't support dynamic resize + term.onReady(() => { + console.log('[Demo] Terminal ready, connecting with size:', term.cols, 'x', term.rows); + connectWebSocket(); + }); } function connectWebSocket() { @@ -210,14 +223,6 @@ } }, 3000); }; - - // Handle terminal resize - term.onResize((size) => { - if (ws && ws.readyState === WebSocket.OPEN) { - // Send resize as control sequence (server expects this format) - ws.send(JSON.stringify({ type: 'resize', cols: size.cols, rows: size.rows })); - } - }); } function updateConnectionStatus(connected) { diff --git a/demo/scrollbar-test.html b/demo/scrollbar-test.html index 2686d20..648fe66 100644 --- a/demo/scrollbar-test.html +++ b/demo/scrollbar-test.html @@ -47,7 +47,7 @@

Scrollbar Test

const fitAddon = new FitAddon(); term.loadAddon(fitAddon); - await term.open(document.getElementById('terminal')); + term.open(document.getElementById('terminal')); fitAddon.fit(); // Write lots of lines to create scrollback diff --git a/lib/addons/fit.test.ts b/lib/addons/fit.test.ts index 4b569ff..105d7b0 100644 --- a/lib/addons/fit.test.ts +++ b/lib/addons/fit.test.ts @@ -161,3 +161,167 @@ describe('FitAddon', () => { expect(resizeCallCount).toBe(0); // Still 0 because no element }); }); + +// ========================================================================== +// onReady Auto-Retry Tests +// ========================================================================== + +describe('onReady Auto-Retry', () => { + let addon: FitAddon; + + beforeEach(() => { + addon = new FitAddon(); + }); + + afterEach(() => { + addon.dispose(); + }); + + test('subscribes to onReady during activation', () => { + let subscribed = false; + + const mockTerminal = { + cols: 80, + rows: 24, + onReady: (listener: () => void) => { + subscribed = true; + return { dispose: () => {} }; + }, + }; + + addon.activate(mockTerminal as any); + expect(subscribed).toBe(true); + }); + + test('calls fit() when onReady fires', () => { + let readyCallback: (() => void) | null = null; + let fitCallCount = 0; + + // Create a mock element with computed dimensions + const mockElement = document.createElement('div'); + Object.defineProperty(mockElement, 'clientWidth', { value: 800, configurable: true }); + Object.defineProperty(mockElement, 'clientHeight', { value: 400, configurable: true }); + + const mockTerminal = { + cols: 80, + rows: 24, + element: mockElement, + renderer: { + getMetrics: () => ({ width: 9, height: 16, baseline: 12 }), + }, + resize: (cols: number, rows: number) => { + fitCallCount++; + mockTerminal.cols = cols; + mockTerminal.rows = rows; + }, + onReady: (listener: () => void) => { + readyCallback = listener; + return { dispose: () => {} }; + }, + }; + + addon.activate(mockTerminal as any); + + // Before ready, fit() may not resize (depending on implementation) + const initialFitCount = fitCallCount; + + // Simulate terminal becoming ready + if (readyCallback) { + readyCallback(); + } + + // fit() should have been called via onReady handler + expect(fitCallCount).toBeGreaterThan(initialFitCount); + }); + + test('disposes onReady subscription on dispose()', () => { + let disposed = false; + + const mockTerminal = { + cols: 80, + rows: 24, + onReady: (listener: () => void) => { + return { + dispose: () => { + disposed = true; + }, + }; + }, + }; + + addon.activate(mockTerminal as any); + expect(disposed).toBe(false); + + addon.dispose(); + expect(disposed).toBe(true); + }); + + test('handles terminal without onReady gracefully', () => { + const terminalWithoutReady = { + cols: 80, + rows: 24, + resize: () => {}, + }; + + expect(() => addon.activate(terminalWithoutReady as any)).not.toThrow(); + expect(() => addon.fit()).not.toThrow(); + expect(() => addon.dispose()).not.toThrow(); + }); + + test('fit() calculates correct dimensions from container', () => { + // Create a mock element with known dimensions + // FitAddon subtracts 15px for scrollbar, so we need to account for that + const mockElement = document.createElement('div'); + Object.defineProperty(mockElement, 'clientWidth', { value: 900, configurable: true }); + Object.defineProperty(mockElement, 'clientHeight', { value: 480, configurable: true }); + + let resizedCols = 0; + let resizedRows = 0; + + const mockTerminal = { + cols: 80, + rows: 24, + element: mockElement, + renderer: { + // 9px wide chars, 16px tall + getMetrics: () => ({ width: 9, height: 16, baseline: 12 }), + }, + resize: (cols: number, rows: number) => { + resizedCols = cols; + resizedRows = rows; + mockTerminal.cols = cols; + mockTerminal.rows = rows; + }, + }; + + addon.activate(mockTerminal as any); + addon.fit(); + + // Expected: (900 - 15 scrollbar) / 9 = 98 cols, 480 / 16 = 30 rows + expect(resizedCols).toBe(98); + expect(resizedRows).toBe(30); + }); + + test('proposeDimensions returns correct values', () => { + // FitAddon subtracts 15px for scrollbar width + const mockElement = document.createElement('div'); + Object.defineProperty(mockElement, 'clientWidth', { value: 720, configurable: true }); + Object.defineProperty(mockElement, 'clientHeight', { value: 384, configurable: true }); + + const mockTerminal = { + cols: 80, + rows: 24, + element: mockElement, + renderer: { + getMetrics: () => ({ width: 8, height: 16, baseline: 12 }), + }, + resize: () => {}, + }; + + addon.activate(mockTerminal as any); + const dims = addon.proposeDimensions(); + + // Expected: (720 - 15 scrollbar) / 8 = 88 cols, 384 / 16 = 24 rows + expect(dims).toEqual({ cols: 88, rows: 24 }); + }); +}); diff --git a/lib/addons/fit.ts b/lib/addons/fit.ts index ea53265..d49db31 100644 --- a/lib/addons/fit.ts +++ b/lib/addons/fit.ts @@ -44,12 +44,25 @@ export class FitAddon implements ITerminalAddon { private _lastCols?: number; private _lastRows?: number; private _isResizing: boolean = false; + private _pendingFit: boolean = false; + private _readyDisposable?: { dispose: () => void }; /** * Activate the addon (called by Terminal.loadAddon) */ public activate(terminal: ITerminalCore): void { this._terminal = terminal; + + // Subscribe to onReady event if available (xterm.js compatibility) + const terminalWithEvents = terminal as any; + if (terminalWithEvents.onReady && typeof terminalWithEvents.onReady === 'function') { + this._readyDisposable = terminalWithEvents.onReady(() => { + // Terminal is ready - always call fit when ready + // This handles the case where fit() was called before terminal was ready + this._pendingFit = false; + this.fit(); + }); + } } /** @@ -68,6 +81,12 @@ export class FitAddon implements ITerminalAddon { this._resizeDebounceTimer = undefined; } + // Dispose onReady subscription + if (this._readyDisposable) { + this._readyDisposable.dispose(); + this._readyDisposable = undefined; + } + // Clear stored dimensions this._lastCols = undefined; this._lastRows = undefined; @@ -89,9 +108,18 @@ export class FitAddon implements ITerminalAddon { const dims = this.proposeDimensions(); if (!dims || !this._terminal) { + // Check if terminal exists but renderer isn't ready yet + const terminal = this._terminal as any; + if (this._terminal && terminal.element && !terminal.renderer) { + // Mark fit as pending - will be called from onReady handler + this._pendingFit = true; + } return; } + // Clear pending flag if we get here + this._pendingFit = false; + // Access terminal to check current dimensions const terminal = this._terminal as any; const currentCols = terminal.cols; diff --git a/lib/buffer.test.ts b/lib/buffer.test.ts index 7244867..1fb46de 100644 --- a/lib/buffer.test.ts +++ b/lib/buffer.test.ts @@ -5,6 +5,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { Terminal } from './terminal'; +/** + * Helper to open terminal and wait for WASM to be ready. + */ +async function openAndWaitForReady(term: Terminal, container: HTMLElement): Promise { + term.open(container); + await new Promise((resolve) => term.onReady(resolve)); +} + describe('Buffer API', () => { let term: Terminal | null = null; let container: HTMLElement | null = null; @@ -15,7 +23,7 @@ describe('Buffer API', () => { container = document.createElement('div'); document.body.appendChild(container); term = new Terminal({ cols: 80, rows: 24 }); - await term.open(container); + await openAndWaitForReady(term, container); } }); diff --git a/lib/index.ts b/lib/index.ts index 2931b45..660a6be 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -17,6 +17,7 @@ export type { IEvent, IBufferRange, IKeyEvent, + IUnicodeVersionProvider, } from './interfaces'; // Ghostty WASM components (for advanced usage) diff --git a/lib/interfaces.ts b/lib/interfaces.ts index 28ca5fe..d6901fa 100644 --- a/lib/interfaces.ts +++ b/lib/interfaces.ts @@ -83,6 +83,13 @@ export interface IKeyEvent { domEvent: KeyboardEvent; } +/** + * Unicode version provider (xterm.js compatibility) + */ +export interface IUnicodeVersionProvider { + readonly activeVersion: string; +} + // ============================================================================ // Buffer API Interfaces (xterm.js compatibility) // ============================================================================ diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 106d4b0..be2b71e 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { Terminal } from './terminal'; +/** + * Helper to open terminal and wait for WASM to be ready. + */ +async function openAndWaitForReady(term: Terminal, container: HTMLElement): Promise { + term.open(container); + await new Promise((resolve) => term.onReady(resolve)); +} + describe('Terminal Scrolling', () => { let terminal: Terminal; let container: HTMLElement; @@ -9,7 +17,7 @@ describe('Terminal Scrolling', () => { container = document.createElement('div'); document.body.appendChild(container); terminal = new Terminal({ cols: 80, rows: 24 }); - await terminal.open(container); + await openAndWaitForReady(terminal, container); }); afterEach(() => { @@ -337,7 +345,7 @@ describe('Scrolling Methods', () => { container = document.createElement('div'); document.body.appendChild(container); term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container); }); afterEach(() => { @@ -491,12 +499,12 @@ describe('Scroll Events', () => { container = document.createElement('div'); document.body.appendChild(container); term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container); }); afterEach(() => { - term.dispose(); - document.body.removeChild(container); + term!.dispose(); + document.body.removeChild(container!); term = null; container = null; }); @@ -588,12 +596,12 @@ describe('Custom Wheel Event Handler', () => { container = document.createElement('div'); document.body.appendChild(container); term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container); }); afterEach(() => { - term.dispose(); - document.body.removeChild(container); + term!.dispose(); + document.body.removeChild(container!); term = null; container = null; }); diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index 52983a4..ae62e77 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -12,6 +12,15 @@ import { Terminal } from './terminal'; // Mock DOM environment for basic tests // Note: Some tests will be skipped if DOM is not fully available +/** + * Helper to open terminal and wait for WASM to be ready. + * Use this when tests need to access wasmTerm or WASM-dependent features. + */ +async function openAndWaitForReady(term: Terminal, container: HTMLElement): Promise { + term.open(container); + await new Promise((resolve) => term.onReady(resolve)); +} + describe('Terminal', () => { let container: HTMLElement | null = null; @@ -74,7 +83,7 @@ describe('Terminal', () => { test('cannot write after disposal', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); term.dispose(); expect(() => term.write('test')).toThrow('Terminal has been disposed'); @@ -82,18 +91,20 @@ describe('Terminal', () => { test('cannot open twice', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); - await expect(term.open(container)).rejects.toThrow('already open'); + // open() is synchronous and throws immediately + expect(() => term.open(container!)).toThrow('already open'); term.dispose(); }); - test('cannot open after disposal', async () => { + test('cannot open after disposal', () => { const term = new Terminal(); term.dispose(); - await expect(term.open(container)).rejects.toThrow('has been disposed'); + // open() is synchronous and throws immediately + expect(() => term.open(container!)).toThrow('has been disposed'); }); }); @@ -108,7 +119,7 @@ describe('Terminal', () => { const term = new Terminal(); expect(term.element).toBeUndefined(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.element).toBe(container); term.dispose(); @@ -142,7 +153,7 @@ describe('Terminal', () => { test('onResize fires when terminal is resized', async () => { const term = new Terminal({ cols: 80, rows: 24 }); - await term.open(container); + await openAndWaitForReady(term, container!); let resizeEvent: { cols: number; rows: number } | null = null; term.onResize((e) => { @@ -160,7 +171,7 @@ describe('Terminal', () => { test('onBell fires on bell character', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); let bellFired = false; term.onBell(() => { @@ -181,7 +192,7 @@ describe('Terminal', () => { describe('Writing', () => { test('write() does not throw after open', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.write('Hello, World!')).not.toThrow(); @@ -190,7 +201,7 @@ describe('Terminal', () => { test('write() accepts string', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.write('test string')).not.toThrow(); @@ -199,7 +210,7 @@ describe('Terminal', () => { test('write() accepts Uint8Array', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); const data = new TextEncoder().encode('test'); expect(() => term.write(data)).not.toThrow(); @@ -209,7 +220,7 @@ describe('Terminal', () => { test('writeln() adds newline', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.writeln('test line')).not.toThrow(); @@ -220,7 +231,7 @@ describe('Terminal', () => { describe('Resizing', () => { test('resize() updates dimensions', async () => { const term = new Terminal({ cols: 80, rows: 24 }); - await term.open(container); + await openAndWaitForReady(term, container!); term.resize(100, 30); @@ -232,7 +243,7 @@ describe('Terminal', () => { test('resize() with same dimensions is no-op', async () => { const term = new Terminal({ cols: 80, rows: 24 }); - await term.open(container); + await openAndWaitForReady(term, container!); let resizeCount = 0; term.onResize(() => resizeCount++); @@ -253,7 +264,7 @@ describe('Terminal', () => { describe('Control Methods', () => { test('clear() does not throw', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.clear()).not.toThrow(); @@ -262,7 +273,7 @@ describe('Terminal', () => { test('reset() does not throw', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.reset()).not.toThrow(); @@ -271,7 +282,7 @@ describe('Terminal', () => { test('focus() does not throw', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.focus()).not.toThrow(); @@ -287,7 +298,7 @@ describe('Terminal', () => { describe('Addons', () => { test('loadAddon() accepts addon', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); const mockAddon = { activate: (terminal: any) => { @@ -305,7 +316,7 @@ describe('Terminal', () => { test('loadAddon() calls activate', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); let activateCalled = false; const mockAddon = { @@ -324,7 +335,7 @@ describe('Terminal', () => { test('dispose() calls addon dispose', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); let disposeCalled = false; const mockAddon = { @@ -344,7 +355,7 @@ describe('Terminal', () => { describe('Integration', () => { test('can write ANSI sequences', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); // Should not throw on ANSI escape sequences expect(() => term.write('\x1b[1;31mRed bold text\x1b[0m')).not.toThrow(); @@ -356,7 +367,7 @@ describe('Terminal', () => { test('can handle cursor movement sequences', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.write('\x1b[5;10H')).not.toThrow(); // Move cursor expect(() => term.write('\x1b[2A')).not.toThrow(); // Move up 2 @@ -367,7 +378,7 @@ describe('Terminal', () => { test('multiple write calls work', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => { term.write('Line 1\r\n'); @@ -382,7 +393,7 @@ describe('Terminal', () => { describe('Disposal', () => { test('dispose() can be called multiple times', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); term.dispose(); expect(() => term.dispose()).not.toThrow(); @@ -390,7 +401,7 @@ describe('Terminal', () => { test('dispose() cleans up canvas element', async () => { const term = new Terminal(); - await term.open(container); + await openAndWaitForReady(term, container!); const initialChildCount = container.children.length; expect(initialChildCount).toBeGreaterThan(0); @@ -425,7 +436,7 @@ describe('paste()', () => { if (!container) return; const term = new Terminal({ cols: 80, rows: 24 }); if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedData = ''; term.onData((data) => { @@ -442,7 +453,7 @@ describe('paste()', () => { const term = new Terminal({ cols: 80, rows: 24, disableStdin: true }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedData = ''; term.onData((data) => { @@ -485,7 +496,7 @@ describe('blur()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); expect(() => term.blur()).not.toThrow(); term.dispose(); @@ -501,7 +512,7 @@ describe('blur()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); const blurSpy = { called: false }; if (term.element) { @@ -541,7 +552,7 @@ describe('input()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.input('test data'); @@ -555,7 +566,7 @@ describe('input()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedData = ''; term.onData((data) => { @@ -572,7 +583,7 @@ describe('input()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedData = ''; term.onData((data) => { @@ -589,7 +600,7 @@ describe('input()', () => { const term = new Terminal({ cols: 80, rows: 24, disableStdin: true }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedData = ''; term.onData((data) => { @@ -626,7 +637,7 @@ describe('select()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.select(0, 0, 10); @@ -638,7 +649,7 @@ describe('select()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); // Select 100 chars starting at column 0 (wraps to next line) term.select(0, 0, 100); @@ -654,7 +665,7 @@ describe('select()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let fired = false; term.onSelectionChange(() => { @@ -671,7 +682,7 @@ describe('select()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); // Create a selection term.select(0, 0, 10); @@ -714,7 +725,7 @@ describe('selectLines()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.selectLines(0, 2); @@ -731,7 +742,7 @@ describe('selectLines()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.selectLines(5, 2); // End before start @@ -746,7 +757,7 @@ describe('selectLines()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let fired = false; term.onSelectionChange(() => { @@ -783,7 +794,7 @@ describe('getSelectionPosition()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); const pos = term.getSelectionPosition(); @@ -795,7 +806,7 @@ describe('getSelectionPosition()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.select(5, 3, 10); const pos = term.getSelectionPosition(); @@ -810,7 +821,7 @@ describe('getSelectionPosition()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.select(0, 0, 10); term.clearSelection(); @@ -844,7 +855,7 @@ describe('onKey event', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.onKey).toBeTruthy(); expect(typeof term.onKey).toBe('function'); @@ -855,7 +866,7 @@ describe('onKey event', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let keyEvent: any = null; term.onKey((e) => { @@ -896,7 +907,7 @@ describe('onTitleChange event', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.onTitleChange).toBeTruthy(); expect(typeof term.onTitleChange).toBe('function'); @@ -907,7 +918,7 @@ describe('onTitleChange event', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedTitle = ''; term.onTitleChange((title) => { @@ -925,7 +936,7 @@ describe('onTitleChange event', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedTitle = ''; term.onTitleChange((title) => { @@ -943,7 +954,7 @@ describe('onTitleChange event', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let receivedTitle = ''; term.onTitleChange((title) => { @@ -981,7 +992,7 @@ describe('attachCustomKeyEventHandler()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); const handler = (e: KeyboardEvent) => false; expect(() => term.attachCustomKeyEventHandler(handler)).not.toThrow(); @@ -992,7 +1003,7 @@ describe('attachCustomKeyEventHandler()', () => { const term = new Terminal({ cols: 80, rows: 24 }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); const handler = (e: KeyboardEvent) => false; expect(() => term.attachCustomKeyEventHandler(handler)).not.toThrow(); @@ -1023,7 +1034,7 @@ describe('Terminal Options', () => { const term = new Terminal({ cols: 80, rows: 24, convertEol: true }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); term.write('line1\nline2'); @@ -1038,7 +1049,7 @@ describe('Terminal Options', () => { const term = new Terminal({ cols: 80, rows: 24, disableStdin: true }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let received = false; term.onData(() => { @@ -1055,7 +1066,7 @@ describe('Terminal Options', () => { const term = new Terminal({ cols: 80, rows: 24, disableStdin: true }); // Using shared container from beforeEach if (!container) return; - await term.open(container); + await openAndWaitForReady(term, container!); let received = false; term.onData(() => { @@ -1092,14 +1103,14 @@ describe('Buffer Access API', () => { test('isAlternateScreen() starts false', async () => { if (!container) throw new Error('DOM environment not available - check happydom setup'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.wasmTerm?.isAlternateScreen()).toBe(false); }); test('isAlternateScreen() detects alternate screen mode', async () => { if (!container) throw new Error('DOM environment not available - check happydom setup'); - await term.open(container); + await openAndWaitForReady(term, container!); // Enter alternate screen (DEC Private Mode 1049 - like vim does) term.write('\x1b[?1049h'); @@ -1113,7 +1124,7 @@ describe('Buffer Access API', () => { test('isRowWrapped() returns false for normal line breaks', async () => { if (!container) throw new Error('DOM environment not available - check happydom setup'); - await term.open(container); + await openAndWaitForReady(term, container!); term.write('Line 1\r\nLine 2\r\n'); expect(term.wasmTerm?.isRowWrapped(0)).toBe(false); @@ -1127,7 +1138,7 @@ describe('Buffer Access API', () => { // Create narrow terminal to force wrapping const narrowTerm = new Terminal({ cols: 20, rows: 10 }); const narrowContainer = document.createElement('div'); - await narrowTerm.open(narrowContainer); + await openAndWaitForReady(narrowTerm, narrowContainer); try { // Write text longer than terminal width (no newline) @@ -1146,7 +1157,7 @@ describe('Buffer Access API', () => { test('isRowWrapped() handles edge cases', async () => { if (!container) throw new Error('DOM environment not available - check happydom setup'); - await term.open(container); + await openAndWaitForReady(term, container!); // Row 0 can never be wrapped (nothing to wrap from) expect(term.wasmTerm?.isRowWrapped(0)).toBe(false); @@ -1162,7 +1173,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.hasBracketedPaste()).toBe(false); term.write('\x1b[?2004h'); @@ -1177,7 +1188,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); let receivedData = ''; term.onData((data) => { @@ -1198,7 +1209,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.getMode(25)).toBe(true); // Cursor visible term.write('\x1b[?25l'); @@ -1211,7 +1222,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.hasFocusEvents()).toBe(false); term.write('\x1b[?1004h'); @@ -1224,7 +1235,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.hasMouseTracking()).toBe(false); term.write('\x1b[?1000h'); @@ -1237,7 +1248,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.getMode(4, true)).toBe(false); // Insert mode term.write('\x1b[4h'); @@ -1250,7 +1261,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); term.write('\x1b[?2004h\x1b[?1004h\x1b[?1000h'); expect(term.hasBracketedPaste()).toBe(true); @@ -1274,7 +1285,7 @@ describe('Terminal Modes', () => { if (typeof document === 'undefined') return; const term = new Terminal({ cols: 80, rows: 24 }); const container = document.createElement('div'); - await term.open(container); + await openAndWaitForReady(term, container!); expect(term.getMode(1049)).toBe(false); term.write('\x1b[?1049h'); @@ -1305,7 +1316,7 @@ describe('Selection with Scrollback', () => { if (!container) return; const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container!); // Write 100 lines with unique identifiable content // Lines 0-99, where each line has "Line XXX: content" @@ -1358,7 +1369,7 @@ describe('Selection with Scrollback', () => { if (!container) return; const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container!); // Write 100 lines for (let i = 0; i < 100; i++) { @@ -1398,7 +1409,7 @@ describe('Selection with Scrollback', () => { if (!container) return; const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container!); // Write 100 lines for (let i = 0; i < 100; i++) { @@ -1429,7 +1440,7 @@ describe('Selection with Scrollback', () => { if (!container) return; const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container!); // Write 100 simple numbered lines for (let i = 0; i < 100; i++) { @@ -1471,7 +1482,7 @@ describe('Selection with Scrollback', () => { if (!container) return; const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); - await term.open(container); + await openAndWaitForReady(term, container!); // Write 100 lines for (let i = 0; i < 100; i++) { @@ -1506,3 +1517,649 @@ describe('Selection with Scrollback', () => { term.dispose(); }); }); +// ========================================================================== +// xterm.js Compatibility: Public Mutable Options +// ========================================================================== + +describe('Public Mutable Options', () => { + test('options are publicly accessible and reflect initial values', () => { + const term = new Terminal({ cols: 100, rows: 30, scrollback: 5000 }); + expect(term.options).toBeDefined(); + expect(term.options.cols).toBe(100); + expect(term.options.rows).toBe(30); + expect(term.options.scrollback).toBe(5000); + }); + + test('options can be mutated at runtime', () => { + const term = new Terminal(); + expect(term.options.disableStdin).toBe(false); + term.options.disableStdin = true; + expect(term.options.disableStdin).toBe(true); + term.options.disableStdin = false; + expect(term.options.disableStdin).toBe(false); + }); +}); + +// ========================================================================== +// xterm.js Compatibility: Options Proxy Triggering handleOptionChange +// ========================================================================== + +describe('Options Proxy handleOptionChange', () => { + 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('changing cursorStyle updates renderer', async () => { + if (!container) return; + + const term = new Terminal({ cursorStyle: 'block' }); + await openAndWaitForReady(term, container); + + // Verify initial state + expect(term.options.cursorStyle).toBe('block'); + + // Change cursor style via options proxy + term.options.cursorStyle = 'underline'; + + // Verify option was updated + expect(term.options.cursorStyle).toBe('underline'); + + // Access renderer to verify it was updated + // @ts-ignore - accessing private for test + const renderer = term.renderer; + expect(renderer).toBeDefined(); + // @ts-ignore - accessing private for test + expect(renderer.cursorStyle).toBe('underline'); + + term.dispose(); + }); + + test('changing cursorBlink starts/stops blink timer', async () => { + if (!container) return; + + const term = new Terminal({ cursorBlink: false }); + await openAndWaitForReady(term, container); + + // Verify initial state + expect(term.options.cursorBlink).toBe(false); + + // Enable cursor blink + term.options.cursorBlink = true; + expect(term.options.cursorBlink).toBe(true); + + // @ts-ignore - accessing private for test + const renderer = term.renderer; + // @ts-ignore - accessing private for test + expect(renderer.cursorBlink).toBe(true); + // @ts-ignore - accessing private for test + expect(renderer.cursorBlinkInterval).toBeDefined(); + + // Disable cursor blink + term.options.cursorBlink = false; + expect(term.options.cursorBlink).toBe(false); + // @ts-ignore - accessing private for test + expect(renderer.cursorBlink).toBe(false); + + term.dispose(); + }); + + test('changing cols/rows triggers resize', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + await openAndWaitForReady(term, container); + + let resizeEventFired = false; + let resizedCols = 0; + let resizedRows = 0; + + term.onResize(({ cols, rows }) => { + resizeEventFired = true; + resizedCols = cols; + resizedRows = rows; + }); + + // Change dimensions via options proxy + term.options.cols = 100; + + expect(resizeEventFired).toBe(true); + expect(resizedCols).toBe(100); + expect(term.cols).toBe(100); + + // Reset and test rows + resizeEventFired = false; + term.options.rows = 40; + + expect(resizeEventFired).toBe(true); + expect(resizedRows).toBe(40); + expect(term.rows).toBe(40); + + term.dispose(); + }); + + test('handleOptionChange not called before terminal is open', () => { + const term = new Terminal({ cursorStyle: 'block' }); + + // Changing options before open() should not throw + // (handleOptionChange checks isOpen internally) + expect(() => { + term.options.cursorStyle = 'underline'; + }).not.toThrow(); + + expect(term.options.cursorStyle).toBe('underline'); + }); +}); + +// ========================================================================== +// xterm.js Compatibility: disableStdin Functionality +// ========================================================================== + +describe('disableStdin', () => { + 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('blocks keyboard input from firing onData when enabled', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + const receivedData: string[] = []; + term.onData((data) => receivedData.push(data)); + + // Enable disableStdin + term.options.disableStdin = true; + + // Simulate keyboard input by calling the internal method + // Since we can't easily simulate keyboard events, we test via paste() and input() + term.paste('should-not-appear'); + term.input('also-should-not-appear', true); + + expect(receivedData).toHaveLength(0); + + term.dispose(); + }); + + test('allows input when disableStdin is false', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + const receivedData: string[] = []; + term.onData((data) => receivedData.push(data)); + + // disableStdin defaults to false + expect(term.options.disableStdin).toBe(false); + + // Paste should work + term.paste('hello'); + expect(receivedData.length).toBeGreaterThan(0); + expect(receivedData.join('')).toContain('hello'); + + term.dispose(); + }); + + test('can toggle disableStdin at runtime', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + const receivedData: string[] = []; + term.onData((data) => receivedData.push(data)); + + // Start with input enabled + term.paste('first'); + expect(receivedData.join('')).toContain('first'); + + // Disable input + term.options.disableStdin = true; + const countBefore = receivedData.length; + term.paste('blocked'); + expect(receivedData.length).toBe(countBefore); // No new data + + // Re-enable input + term.options.disableStdin = false; + term.paste('second'); + expect(receivedData.join('')).toContain('second'); + + term.dispose(); + }); + + test('blocks real keyboard events when disableStdin is true', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + const receivedData: string[] = []; + term.onData((data) => receivedData.push(data)); + + // Enable disableStdin + term.options.disableStdin = true; + + // Simulate a real keyboard event on the container + const keyEvent = new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + keyCode: 65, + bubbles: true, + cancelable: true, + }); + container.dispatchEvent(keyEvent); + + // No data should be received + expect(receivedData).toHaveLength(0); + + term.dispose(); + }); + + test('allows real keyboard events when disableStdin is false', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + const receivedData: string[] = []; + term.onData((data) => receivedData.push(data)); + + // disableStdin defaults to false + expect(term.options.disableStdin).toBe(false); + + // Simulate a real keyboard event on the container + const keyEvent = new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + keyCode: 65, + bubbles: true, + cancelable: true, + }); + container.dispatchEvent(keyEvent); + + // Data should be received + expect(receivedData.length).toBeGreaterThan(0); + + term.dispose(); + }); + + test('keyboard events blocked after toggling disableStdin on', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + const receivedData: string[] = []; + term.onData((data) => receivedData.push(data)); + + // First verify keyboard works + const keyEvent1 = new KeyboardEvent('keydown', { + key: 'a', + code: 'KeyA', + keyCode: 65, + bubbles: true, + cancelable: true, + }); + container.dispatchEvent(keyEvent1); + expect(receivedData.length).toBeGreaterThan(0); + + const countBefore = receivedData.length; + + // Now disable stdin + term.options.disableStdin = true; + + // Send another key + const keyEvent2 = new KeyboardEvent('keydown', { + key: 'b', + code: 'KeyB', + keyCode: 66, + bubbles: true, + cancelable: true, + }); + container.dispatchEvent(keyEvent2); + + // Count should not have increased + expect(receivedData.length).toBe(countBefore); + + term.dispose(); + }); +}); + +// ========================================================================== +// xterm.js Compatibility: unicode API +// ========================================================================== + +describe('unicode API', () => { + test('activeVersion returns 15.1', () => { + const term = new Terminal(); + expect(term.unicode.activeVersion).toBe('15.1'); + }); + + test('unicode object is readonly', () => { + const term = new Terminal(); + // The unicode property should be accessible + expect(term.unicode).toBeDefined(); + expect(typeof term.unicode.activeVersion).toBe('string'); + }); +}); + +// ========================================================================== +// xterm.js Compatibility: onReady Event +// ========================================================================== + +describe('onReady Event', () => { + 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('fires after WASM is loaded and terminal is ready', async () => { + if (!container) return; + + const term = new Terminal(); + let firedAt = 0; + const openedAt = Date.now(); + + term.onReady(() => { + firedAt = Date.now(); + }); + + term.open(container); + + // Wait for ready + await new Promise((r) => setTimeout(r, 300)); + + expect(firedAt).toBeGreaterThan(0); + expect(firedAt).toBeGreaterThanOrEqual(openedAt); + + term.dispose(); + }); + + test('late subscribers fire immediately when already ready', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + + // Wait for terminal to be ready + await new Promise((r) => term.onReady(r)); + + // Now subscribe late - should fire immediately + let firedImmediately = false; + let callOrder = 0; + + term.onReady(() => { + firedImmediately = true; + callOrder = 1; + }); + + // Check synchronously - should have fired already + expect(firedImmediately).toBe(true); + expect(callOrder).toBe(1); + + term.dispose(); + }); + + test('multiple subscribers all receive the event', async () => { + if (!container) return; + + const term = new Terminal(); + let count = 0; + + term.onReady(() => count++); + term.onReady(() => count++); + term.onReady(() => count++); + + term.open(container); + await new Promise((r) => setTimeout(r, 300)); + + expect(count).toBe(3); + + term.dispose(); + }); + + test('wasmTerm is available when onReady fires', async () => { + if (!container) return; + + const term = new Terminal(); + let wasmTermAvailable = false; + + term.onReady(() => { + wasmTermAvailable = term.wasmTerm !== undefined; + }); + + term.open(container); + await new Promise((r) => setTimeout(r, 300)); + + expect(wasmTermAvailable).toBe(true); + + term.dispose(); + }); +}); + +// ========================================================================== +// xterm.js Compatibility: Write Queueing +// ========================================================================== + +describe('Write Queueing', () => { + 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('writes before ready are queued and processed after', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + + // Write immediately after open (before WASM is loaded) + term.write('Line1\r\n'); + term.write('Line2\r\n'); + term.write('Line3\r\n'); + + // Wait for ready + await new Promise((r) => term.onReady(r)); + + // All writes should have been processed + const line0 = term.buffer.active.getLine(0)?.translateToString().trim(); + const line1 = term.buffer.active.getLine(1)?.translateToString().trim(); + const line2 = term.buffer.active.getLine(2)?.translateToString().trim(); + + expect(line0).toBe('Line1'); + expect(line1).toBe('Line2'); + expect(line2).toBe('Line3'); + + term.dispose(); + }); + + test('write callbacks are called after processing', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + + const callbackOrder: number[] = []; + + term.write('First', () => callbackOrder.push(1)); + term.write('Second', () => callbackOrder.push(2)); + term.write('Third', () => callbackOrder.push(3)); + + await new Promise((r) => term.onReady(r)); + // Give callbacks time to fire + await new Promise((r) => setTimeout(r, 50)); + + expect(callbackOrder).toEqual([1, 2, 3]); + + term.dispose(); + }); + + test('writes after ready go directly without queueing', async () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + await new Promise((r) => term.onReady(r)); + + // Write after ready + term.write('DirectWrite\r\n'); + + // Should be immediately visible + const line = term.buffer.active.getLine(0)?.translateToString().trim(); + expect(line).toBe('DirectWrite'); + + term.dispose(); + }); +}); + +// ========================================================================== +// xterm.js Compatibility: Synchronous open() +// ========================================================================== + +describe('Synchronous open()', () => { + 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('open() returns synchronously', () => { + if (!container) return; + + const term = new Terminal(); + const startTime = Date.now(); + + // open() should return immediately (not wait for WASM) + term.open(container); + + const elapsed = Date.now() - startTime; + // Should return in under 50ms (WASM loading takes longer) + expect(elapsed).toBeLessThan(50); + + term.dispose(); + }); + + test('element is set immediately after open()', () => { + if (!container) return; + + const term = new Terminal(); + term.open(container); + + expect(term.element).toBe(container); + + term.dispose(); + }); + + test('cols and rows are available immediately after open()', () => { + if (!container) return; + + const term = new Terminal({ cols: 100, rows: 50 }); + term.open(container); + + expect(term.cols).toBe(100); + expect(term.rows).toBe(50); + + term.dispose(); + }); + + test('resize queues and applies after ready', async () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + term.open(container); + + // Wait for terminal to be ready + await new Promise((r) => term.onReady(r)); + + // Resize after ready should work + term.resize(120, 40); + + expect(term.cols).toBe(120); + expect(term.rows).toBe(40); + + term.dispose(); + }); + + test('FitAddon can update cols/rows before ready', () => { + if (!container) return; + + const term = new Terminal({ cols: 80, rows: 24 }); + + // Load FitAddon + const { FitAddon } = require('./addons/fit'); + const fitAddon = new FitAddon(); + term.loadAddon(fitAddon); + + term.open(container); + + // At this point, cols/rows are set from options + // FitAddon may update them synchronously via the resize path + expect(term.cols).toBeGreaterThan(0); + expect(term.rows).toBeGreaterThan(0); + + term.dispose(); + }); +}); diff --git a/lib/terminal.ts b/lib/terminal.ts index 3582c21..cb6cbfc 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -6,7 +6,7 @@ * Usage: * ```typescript * const term = new Terminal({ cols: 80, rows: 24 }); - * await term.open(document.getElementById('container')); + * term.open(document.getElementById('container')); // Synchronous - no await needed! * term.write('Hello, World!\n'); * term.onData(data => console.log('User typed:', data)); * ``` @@ -25,6 +25,7 @@ import type { ITerminalAddon, ITerminalCore, ITerminalOptions, + IUnicodeVersionProvider, } from './interfaces'; import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; @@ -47,11 +48,22 @@ export class Terminal implements ITerminalCore { // Buffer API (xterm.js compatibility) public readonly buffer: IBufferNamespace; - // Options - private options: Required> & { + // Unicode API (xterm.js compatibility) + public readonly unicode: IUnicodeVersionProvider = { + get activeVersion(): string { + return '15.1'; // Ghostty supports Unicode 15.1 + }, + }; + + // Options (public for xterm.js compatibility) + public readonly options!: Required> & { wasmPath?: string; }; + // WASM loading state (for synchronous open()) + private wasmLoadPromise?: Promise; + private pendingWrites: Array<{ data: string | Uint8Array; callback?: () => void }> = []; + // Components (created on open()) private ghostty?: Ghostty; public wasmTerm?: GhosttyTerminal; // Made public for link providers @@ -76,6 +88,7 @@ export class Terminal implements ITerminalCore { private scrollEmitter = new EventEmitter(); private renderEmitter = new EventEmitter<{ start: number; end: number }>(); private cursorMoveEmitter = new EventEmitter(); + private readyEmitter = new EventEmitter(); // Public event accessors (xterm.js compatibility) public readonly onData: IEvent = this.dataEmitter.event; @@ -88,8 +101,20 @@ export class Terminal implements ITerminalCore { public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event; public readonly onCursorMove: IEvent = this.cursorMoveEmitter.event; + // onReady is special - fires immediately if terminal is already ready + // This is critical for FitAddon which may subscribe before or after terminal is ready + public readonly onReady: IEvent = (listener: () => void) => { + if (this.isReady) { + // Terminal already ready, fire immediately for late subscribers + listener(); + } + // Also subscribe for future events + return this.readyEmitter.event(listener); + }; + // Lifecycle state private isOpen = false; + private isReady = false; private isDisposed = false; private animationFrameId?: number; @@ -124,8 +149,8 @@ export class Terminal implements ITerminalCore { private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation constructor(options: ITerminalOptions = {}) { - // Set default options - this.options = { + // Create base options object with all defaults + const baseOptions = { cols: options.cols ?? 80, rows: options.rows ?? 24, cursorBlink: options.cursorBlink ?? false, @@ -141,11 +166,75 @@ export class Terminal implements ITerminalCore { wasmPath: options.wasmPath, // Optional - Ghostty.load() handles defaults }; + // Wrap in Proxy to intercept runtime changes (xterm.js compatibility) + (this.options as any) = new Proxy(baseOptions, { + set: (target: any, prop: string, value: any) => { + const oldValue = target[prop]; + target[prop] = value; + + // Apply runtime changes if terminal is open + if (this.isOpen) { + this.handleOptionChange(prop, value, oldValue); + } + + return true; + }, + }); + this.cols = this.options.cols; this.rows = this.options.rows; // Initialize buffer API this.buffer = new BufferNamespace(this); + + // Start WASM loading immediately (makes open() synchronous) + this.wasmLoadPromise = Ghostty.load(this.options.wasmPath); + } + + // ========================================================================== + // Option Change Handling (for mutable options) + // ========================================================================== + + /** + * Handle runtime option changes (called when options are modified after terminal is open) + * This enables xterm.js compatibility where options can be changed at runtime + */ + private handleOptionChange(key: string, newValue: any, oldValue: any): void { + if (newValue === oldValue) return; + + switch (key) { + case 'disableStdin': + // Input handler already checks this.options.disableStdin dynamically + // No action needed + break; + + case 'cursorBlink': + case 'cursorStyle': + if (this.renderer) { + this.renderer.setCursorStyle(this.options.cursorStyle); + this.renderer.setCursorBlink(this.options.cursorBlink); + } + break; + + case 'theme': + if (this.renderer) { + console.warn('ghostty-web: theme changes after open() are not yet fully supported'); + } + break; + + case 'fontSize': + case 'fontFamily': + if (this.renderer) { + console.warn('ghostty-web: font changes after open() are not yet fully supported'); + } + break; + + case 'cols': + case 'rows': + // Redirect to resize method + this.resize(this.options.cols, this.options.rows); + break; + } } // ========================================================================== @@ -155,8 +244,12 @@ export class Terminal implements ITerminalCore { /** * Open terminal in a parent element * This initializes all components and starts rendering + * + * Note: This method is synchronous for xterm.js compatibility. + * WASM loading happens in the constructor, and terminal setup happens + * asynchronously in the background. Use onReady event to know when ready. */ - async open(parent: HTMLElement): Promise { + open(parent: HTMLElement): void { if (this.isOpen) { throw new Error('Terminal is already open'); } @@ -164,20 +257,36 @@ export class Terminal implements ITerminalCore { throw new Error('Terminal has been disposed'); } - try { - // Store parent element - this.element = parent; + // Store parent element + this.element = parent; + + // Mark as open immediately (terminal will become ready when WASM loads) + this.isOpen = true; + + // Wait for WASM to load, then setup terminal + this.wasmLoadPromise!.then((ghostty) => { + this.ghostty = ghostty; + this.setupTerminal(parent); + }).catch((err) => { + console.error('Failed to load Ghostty WASM:', err); + this.isOpen = false; + throw new Error(`Failed to load Ghostty WASM: ${err}`); + }); + } + /** + * Setup terminal once WASM is loaded + * This is called asynchronously after open() returns + */ + private setupTerminal(parent: HTMLElement): void { + try { // Make parent focusable if it isn't already if (!parent.hasAttribute('tabindex')) { parent.setAttribute('tabindex', '0'); } - // Load Ghostty WASM - this.ghostty = await Ghostty.load(this.options.wasmPath); - - // Create WASM terminal - this.wasmTerm = this.ghostty.createTerminal(this.options.cols, this.options.rows); + // Create WASM terminal with current dimensions (may have been updated by FitAddon before ready) + this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows); // Create canvas element this.canvas = document.createElement('canvas'); @@ -221,7 +330,7 @@ export class Terminal implements ITerminalCore { // Create input handler this.inputHandler = new InputHandler( - this.ghostty, + this.ghostty!, parent, (data: string) => { // Check if stdin is disabled @@ -292,9 +401,6 @@ export class Terminal implements ITerminalCore { // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); - // Mark as open - this.isOpen = true; - // Render initial blank screen this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); @@ -303,6 +409,16 @@ export class Terminal implements ITerminalCore { // Focus input (auto-focus so user can start typing immediately) this.focus(); + + // Mark as ready and fire event (must be after all components initialized) + this.isReady = true; + this.readyEmitter.fire(); + + // Process any writes that came in before WASM was ready + while (this.pendingWrites.length > 0) { + const pending = this.pendingWrites.shift()!; + this.writeInternal(pending.data, pending.callback); + } } catch (error) { // Clean up on error this.cleanupComponents(); @@ -321,6 +437,25 @@ export class Terminal implements ITerminalCore { data = data.replace(/\n/g, '\r\n'); } + // Queue writes if WASM not ready yet + if (!this.wasmTerm) { + this.pendingWrites.push({ data, callback }); + return; + } + + // Process any pending writes first (FIFO order) + while (this.pendingWrites.length > 0) { + const pending = this.pendingWrites.shift()!; + this.writeInternal(pending.data, pending.callback); + } + + this.writeInternal(data, callback); + } + + /** + * Internal write implementation (extracted from write()) + */ + private writeInternal(data: string | Uint8Array, callback?: () => void): void { // Clear selection when writing new data (standard terminal behavior) if (this.selectionManager?.hasSelection()) { this.selectionManager.clearSelection();