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();