Skip to content

Commit f9fd565

Browse files
authored
refactor: simplify Terminal API with module-level init() (#60)
1 parent 29b4e5b commit f9fd565

15 files changed

+485
-736
lines changed

README.md

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,11 @@ After install, using `ghostty-web` is as simple as
5656
<body>
5757
<div id="terminal"></div>
5858
<script type="module">
59-
import { Terminal } from 'ghostty-web';
59+
import { init, Terminal } from 'ghostty-web';
60+
61+
await init();
6062
const term = new Terminal();
61-
await term.open(document.getElementById('terminal'));
63+
term.open(document.getElementById('terminal'));
6264
term.write('Hello from \x1B[1;3;31mghostty-web\x1B[0m $ ');
6365
</script>
6466
</body>
@@ -108,7 +110,10 @@ machine, and screen buffer) to WebAssembly, providing:
108110
### Basic Terminal
109111

110112
```typescript
111-
import { Terminal, FitAddon } from 'ghostty-web';
113+
import { init, Terminal, FitAddon } from 'ghostty-web';
114+
115+
// Initialize WASM (call once at app startup)
116+
await init();
112117

113118
const term = new Terminal({
114119
cursorBlink: true,
@@ -122,7 +127,7 @@ const term = new Terminal({
122127
const fitAddon = new FitAddon();
123128
term.loadAddon(fitAddon);
124129

125-
await term.open(document.getElementById('terminal'));
130+
term.open(document.getElementById('terminal'));
126131
fitAddon.fit();
127132

128133
// Handle user input

demo/colors-demo.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ <h2>Color Demonstrations</h2>
153153
</div>
154154

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

159159
let term;
@@ -163,8 +163,11 @@ <h2>Color Demonstrations</h2>
163163
// Initialization
164164
// =========================================================================
165165

166-
function init() {
166+
async function initApp() {
167167
try {
168+
// Initialize WASM
169+
await init();
170+
168171
// Create terminal with dark theme
169172
term = new Terminal({
170173
cols: 120,
@@ -541,7 +544,7 @@ <h2>Color Demonstrations</h2>
541544
window.clearTerminal = clearTerminal;
542545

543546
// Initialize on page load
544-
init();
547+
initApp();
545548
</script>
546549
</body>
547550
</html>

demo/index.html

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,17 @@
131131
</div>
132132

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

137137
let term;
138138
let ws;
139139
let fitAddon;
140140

141141
async function initTerminal() {
142+
// Initialize WASM
143+
await init();
144+
142145
term = new Terminal({
143146
cursorBlink: true,
144147
fontSize: 14,
@@ -153,7 +156,7 @@
153156
fitAddon = new FitAddon();
154157
term.loadAddon(fitAddon);
155158

156-
await term.open(document.getElementById('terminal-container'));
159+
term.open(document.getElementById('terminal-container'));
157160
fitAddon.fit();
158161
fitAddon.observeResize(); // Auto-fit when container resizes
159162

@@ -162,7 +165,7 @@
162165
fitAddon.fit();
163166
});
164167

165-
// Handle terminal resize - MUST be registered before terminal becomes ready!
168+
// Handle terminal resize
166169
term.onResize((size) => {
167170
if (ws && ws.readyState === WebSocket.OPEN) {
168171
// Send resize as control sequence (server expects this format)
@@ -183,13 +186,9 @@
183186
console.log('Scroll position:', ydisp);
184187
});
185188

186-
// Connect to PTY server AFTER terminal is ready
187-
// This ensures term.cols/rows have been updated by FitAddon
188-
// since the PTY server doesn't support dynamic resize
189-
term.onReady(() => {
190-
console.log('[Demo] Terminal ready, connecting with size:', term.cols, 'x', term.rows);
191-
connectWebSocket();
192-
});
189+
// Connect to PTY server - terminal is ready immediately after open()
190+
console.log('[Demo] Terminal ready, connecting with size:', term.cols, 'x', term.rows);
191+
connectWebSocket();
193192
}
194193

195194
function connectWebSocket() {

demo/scrollbar-test.html

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,11 @@ <h2>Scrollbar Test</h2>
3535
<div id="terminal"></div>
3636

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

41+
await init();
42+
4143
const term = new Terminal({
4244
fontSize: 14,
4345
fontFamily: 'Monaco, Menlo, monospace',

lib/addons/fit.test.ts

Lines changed: 2 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -163,10 +163,10 @@ describe('FitAddon', () => {
163163
});
164164

165165
// ==========================================================================
166-
// onReady Auto-Retry Tests
166+
// Dimension Calculation Tests
167167
// ==========================================================================
168168

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

172172
beforeEach(() => {
@@ -177,97 +177,6 @@ describe('onReady Auto-Retry', () => {
177177
addon.dispose();
178178
});
179179

180-
test('subscribes to onReady during activation', () => {
181-
let subscribed = false;
182-
183-
const mockTerminal = {
184-
cols: 80,
185-
rows: 24,
186-
onReady: (listener: () => void) => {
187-
subscribed = true;
188-
return { dispose: () => {} };
189-
},
190-
};
191-
192-
addon.activate(mockTerminal as any);
193-
expect(subscribed).toBe(true);
194-
});
195-
196-
test('calls fit() when onReady fires', () => {
197-
let readyCallback: (() => void) | null = null;
198-
let fitCallCount = 0;
199-
200-
// Create a mock element with computed dimensions
201-
const mockElement = document.createElement('div');
202-
Object.defineProperty(mockElement, 'clientWidth', { value: 800, configurable: true });
203-
Object.defineProperty(mockElement, 'clientHeight', { value: 400, configurable: true });
204-
205-
const mockTerminal = {
206-
cols: 80,
207-
rows: 24,
208-
element: mockElement,
209-
renderer: {
210-
getMetrics: () => ({ width: 9, height: 16, baseline: 12 }),
211-
},
212-
resize: (cols: number, rows: number) => {
213-
fitCallCount++;
214-
mockTerminal.cols = cols;
215-
mockTerminal.rows = rows;
216-
},
217-
onReady: (listener: () => void) => {
218-
readyCallback = listener;
219-
return { dispose: () => {} };
220-
},
221-
};
222-
223-
addon.activate(mockTerminal as any);
224-
225-
// Before ready, fit() may not resize (depending on implementation)
226-
const initialFitCount = fitCallCount;
227-
228-
// Simulate terminal becoming ready
229-
if (readyCallback) {
230-
readyCallback();
231-
}
232-
233-
// fit() should have been called via onReady handler
234-
expect(fitCallCount).toBeGreaterThan(initialFitCount);
235-
});
236-
237-
test('disposes onReady subscription on dispose()', () => {
238-
let disposed = false;
239-
240-
const mockTerminal = {
241-
cols: 80,
242-
rows: 24,
243-
onReady: (listener: () => void) => {
244-
return {
245-
dispose: () => {
246-
disposed = true;
247-
},
248-
};
249-
},
250-
};
251-
252-
addon.activate(mockTerminal as any);
253-
expect(disposed).toBe(false);
254-
255-
addon.dispose();
256-
expect(disposed).toBe(true);
257-
});
258-
259-
test('handles terminal without onReady gracefully', () => {
260-
const terminalWithoutReady = {
261-
cols: 80,
262-
rows: 24,
263-
resize: () => {},
264-
};
265-
266-
expect(() => addon.activate(terminalWithoutReady as any)).not.toThrow();
267-
expect(() => addon.fit()).not.toThrow();
268-
expect(() => addon.dispose()).not.toThrow();
269-
});
270-
271180
test('fit() calculates correct dimensions from container', () => {
272181
// Create a mock element with known dimensions
273182
// FitAddon subtracts 15px for scrollbar, so we need to account for that

lib/addons/fit.ts

Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,25 +44,12 @@ export class FitAddon implements ITerminalAddon {
4444
private _lastCols?: number;
4545
private _lastRows?: number;
4646
private _isResizing: boolean = false;
47-
private _pendingFit: boolean = false;
48-
private _readyDisposable?: { dispose: () => void };
4947

5048
/**
5149
* Activate the addon (called by Terminal.loadAddon)
5250
*/
5351
public activate(terminal: ITerminalCore): void {
5452
this._terminal = terminal;
55-
56-
// Subscribe to onReady event if available (xterm.js compatibility)
57-
const terminalWithEvents = terminal as any;
58-
if (terminalWithEvents.onReady && typeof terminalWithEvents.onReady === 'function') {
59-
this._readyDisposable = terminalWithEvents.onReady(() => {
60-
// Terminal is ready - always call fit when ready
61-
// This handles the case where fit() was called before terminal was ready
62-
this._pendingFit = false;
63-
this.fit();
64-
});
65-
}
6653
}
6754

6855
/**
@@ -81,12 +68,6 @@ export class FitAddon implements ITerminalAddon {
8168
this._resizeDebounceTimer = undefined;
8269
}
8370

84-
// Dispose onReady subscription
85-
if (this._readyDisposable) {
86-
this._readyDisposable.dispose();
87-
this._readyDisposable = undefined;
88-
}
89-
9071
// Clear stored dimensions
9172
this._lastCols = undefined;
9273
this._lastRows = undefined;
@@ -108,18 +89,9 @@ export class FitAddon implements ITerminalAddon {
10889

10990
const dims = this.proposeDimensions();
11091
if (!dims || !this._terminal) {
111-
// Check if terminal exists but renderer isn't ready yet
112-
const terminal = this._terminal as any;
113-
if (this._terminal && terminal.element && !terminal.renderer) {
114-
// Mark fit as pending - will be called from onReady handler
115-
this._pendingFit = true;
116-
}
11792
return;
11893
}
11994

120-
// Clear pending flag if we get here
121-
this._pendingFit = false;
122-
12395
// Access terminal to check current dimensions
12496
const terminal = this._terminal as any;
12597
const currentCols = terminal.cols;

lib/buffer.test.ts

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
11
/**
22
* Buffer API tests
3+
*
4+
* Test Isolation Pattern:
5+
* Uses createIsolatedTerminal() to ensure each test gets its own WASM instance.
36
*/
47

58
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
6-
import { Terminal } from './terminal';
7-
8-
/**
9-
* Helper to open terminal and wait for WASM to be ready.
10-
*/
11-
async function openAndWaitForReady(term: Terminal, container: HTMLElement): Promise<void> {
12-
term.open(container);
13-
await new Promise<void>((resolve) => term.onReady(resolve));
14-
}
9+
import type { Terminal } from './terminal';
10+
import { createIsolatedTerminal } from './test-helpers';
1511

1612
describe('Buffer API', () => {
1713
let term: Terminal | null = null;
@@ -22,8 +18,8 @@ describe('Buffer API', () => {
2218
if (typeof document !== 'undefined') {
2319
container = document.createElement('div');
2420
document.body.appendChild(container);
25-
term = new Terminal({ cols: 80, rows: 24 });
26-
await openAndWaitForReady(term, container);
21+
term = await createIsolatedTerminal({ cols: 80, rows: 24 });
22+
term.open(container);
2723
}
2824
});
2925

lib/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,56 @@
44
* Main entry point following xterm.js conventions
55
*/
66

7+
import { Ghostty } from './ghostty';
8+
9+
// Module-level Ghostty instance (initialized by init())
10+
let ghosttyInstance: Ghostty | null = null;
11+
12+
/**
13+
* Initialize the ghostty-web library by loading the WASM module.
14+
* Must be called before creating any Terminal instances.
15+
*
16+
* This creates a shared WASM instance that all Terminal instances will use.
17+
* For test isolation, pass a Ghostty instance directly to Terminal constructor.
18+
*
19+
* @example
20+
* ```typescript
21+
* import { init, Terminal } from 'ghostty-web';
22+
*
23+
* await init();
24+
* const term = new Terminal();
25+
* term.open(document.getElementById('terminal'));
26+
* ```
27+
*/
28+
export async function init(): Promise<void> {
29+
if (ghosttyInstance) {
30+
return; // Already initialized
31+
}
32+
ghosttyInstance = await Ghostty.load();
33+
}
34+
35+
/**
36+
* Get the initialized Ghostty instance.
37+
* Throws if init() hasn't been called.
38+
* @internal
39+
*/
40+
export function getGhostty(): Ghostty {
41+
if (!ghosttyInstance) {
42+
throw new Error(
43+
'ghostty-web not initialized. Call init() before creating Terminal instances.\n' +
44+
'Example:\n' +
45+
' import { init, Terminal } from "ghostty-web";\n' +
46+
' await init();\n' +
47+
' const term = new Terminal();\n\n' +
48+
'For tests, pass a Ghostty instance directly:\n' +
49+
' import { Ghostty, Terminal } from "ghostty-web";\n' +
50+
' const ghostty = await Ghostty.load();\n' +
51+
' const term = new Terminal({ ghostty });'
52+
);
53+
}
54+
return ghosttyInstance;
55+
}
56+
757
// Main Terminal class
858
export { Terminal } from './terminal';
959

0 commit comments

Comments
 (0)