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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,11 @@ After install, using `ghostty-web` is as simple as
<body>
<div id="terminal"></div>
<script type="module">
import { Terminal } from 'ghostty-web';
import { init, Terminal } from 'ghostty-web';

await init();
const term = new Terminal();
await term.open(document.getElementById('terminal'));
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mghostty-web\x1B[0m $ ');
</script>
</body>
Expand Down Expand Up @@ -108,7 +110,10 @@ machine, and screen buffer) to WebAssembly, providing:
### Basic Terminal

```typescript
import { Terminal, FitAddon } from 'ghostty-web';
import { init, Terminal, FitAddon } from 'ghostty-web';

// Initialize WASM (call once at app startup)
await init();

const term = new Terminal({
cursorBlink: true,
Expand All @@ -122,7 +127,7 @@ const term = new Terminal({
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);

await term.open(document.getElementById('terminal'));
term.open(document.getElementById('terminal'));
fitAddon.fit();

// Handle user input
Expand Down
9 changes: 6 additions & 3 deletions demo/colors-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ <h2>Color Demonstrations</h2>
</div>

<script type="module">
import { Terminal } from '../lib/index.ts';
import { init, Terminal } from '../lib/index.ts';
import { FitAddon } from '../lib/addons/fit.ts';

let term;
Expand All @@ -163,8 +163,11 @@ <h2>Color Demonstrations</h2>
// Initialization
// =========================================================================

function init() {
async function initApp() {
try {
// Initialize WASM
await init();

// Create terminal with dark theme
term = new Terminal({
cols: 120,
Expand Down Expand Up @@ -541,7 +544,7 @@ <h2>Color Demonstrations</h2>
window.clearTerminal = clearTerminal;

// Initialize on page load
init();
initApp();
</script>
</body>
</html>
19 changes: 9 additions & 10 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,17 @@
</div>

<script type="module">
import { Terminal } from '../lib/terminal.ts';
import { init, Terminal } from '../lib/index.ts';
import { FitAddon } from '../lib/addons/fit.ts';

let term;
let ws;
let fitAddon;

async function initTerminal() {
// Initialize WASM
await init();

term = new Terminal({
cursorBlink: true,
fontSize: 14,
Expand All @@ -153,7 +156,7 @@
fitAddon = new FitAddon();
term.loadAddon(fitAddon);

await term.open(document.getElementById('terminal-container'));
term.open(document.getElementById('terminal-container'));
fitAddon.fit();
fitAddon.observeResize(); // Auto-fit when container resizes

Expand All @@ -162,7 +165,7 @@
fitAddon.fit();
});

// Handle terminal resize - MUST be registered before terminal becomes ready!
// Handle terminal resize
term.onResize((size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
// Send resize as control sequence (server expects this format)
Expand All @@ -183,13 +186,9 @@
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();
});
// Connect to PTY server - terminal is ready immediately after open()
console.log('[Demo] Terminal ready, connecting with size:', term.cols, 'x', term.rows);
connectWebSocket();
}

function connectWebSocket() {
Expand Down
4 changes: 3 additions & 1 deletion demo/scrollbar-test.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,11 @@ <h2>Scrollbar Test</h2>
<div id="terminal"></div>

<script type="module">
import { Terminal } from '../lib/terminal.ts';
import { init, Terminal } from '../lib/index.ts';
import { FitAddon } from '../lib/addons/fit.ts';

await init();

const term = new Terminal({
fontSize: 14,
fontFamily: 'Monaco, Menlo, monospace',
Expand Down
95 changes: 2 additions & 93 deletions lib/addons/fit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,10 +163,10 @@ describe('FitAddon', () => {
});

// ==========================================================================
// onReady Auto-Retry Tests
// Dimension Calculation Tests
// ==========================================================================

describe('onReady Auto-Retry', () => {
describe('Dimension Calculation', () => {
let addon: FitAddon;

beforeEach(() => {
Expand All @@ -177,97 +177,6 @@ describe('onReady Auto-Retry', () => {
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
Expand Down
28 changes: 0 additions & 28 deletions lib/addons/fit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,25 +44,12 @@ 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();
});
}
}

/**
Expand All @@ -81,12 +68,6 @@ 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;
Expand All @@ -108,18 +89,9 @@ 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;
Expand Down
18 changes: 7 additions & 11 deletions lib/buffer.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
/**
* Buffer API tests
*
* Test Isolation Pattern:
* Uses createIsolatedTerminal() to ensure each test gets its own WASM instance.
*/

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<void> {
term.open(container);
await new Promise<void>((resolve) => term.onReady(resolve));
}
import type { Terminal } from './terminal';
import { createIsolatedTerminal } from './test-helpers';

describe('Buffer API', () => {
let term: Terminal | null = null;
Expand All @@ -22,8 +18,8 @@ describe('Buffer API', () => {
if (typeof document !== 'undefined') {
container = document.createElement('div');
document.body.appendChild(container);
term = new Terminal({ cols: 80, rows: 24 });
await openAndWaitForReady(term, container);
term = await createIsolatedTerminal({ cols: 80, rows: 24 });
term.open(container);
}
});

Expand Down
50 changes: 50 additions & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,56 @@
* Main entry point following xterm.js conventions
*/

import { Ghostty } from './ghostty';

// Module-level Ghostty instance (initialized by init())
let ghosttyInstance: Ghostty | null = null;

/**
* Initialize the ghostty-web library by loading the WASM module.
* Must be called before creating any Terminal instances.
*
* This creates a shared WASM instance that all Terminal instances will use.
* For test isolation, pass a Ghostty instance directly to Terminal constructor.
*
* @example
* ```typescript
* import { init, Terminal } from 'ghostty-web';
*
* await init();
* const term = new Terminal();
* term.open(document.getElementById('terminal'));
* ```
*/
export async function init(): Promise<void> {
if (ghosttyInstance) {
return; // Already initialized
}
ghosttyInstance = await Ghostty.load();
}

/**
* Get the initialized Ghostty instance.
* Throws if init() hasn't been called.
* @internal
*/
export function getGhostty(): Ghostty {
if (!ghosttyInstance) {
throw new Error(
'ghostty-web not initialized. Call init() before creating Terminal instances.\n' +
'Example:\n' +
' import { init, Terminal } from "ghostty-web";\n' +
' await init();\n' +
' const term = new Terminal();\n\n' +
'For tests, pass a Ghostty instance directly:\n' +
' import { Ghostty, Terminal } from "ghostty-web";\n' +
' const ghostty = await Ghostty.load();\n' +
' const term = new Terminal({ ghostty });'
);
}
return ghosttyInstance;
}

// Main Terminal class
export { Terminal } from './terminal';

Expand Down
Loading