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
4 changes: 2 additions & 2 deletions demo/colors-demo.html
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ <h2>Color Demonstrations</h2>
// Initialization
// =========================================================================

async function init() {
function init() {
try {
// Create terminal with dark theme
term = new Terminal({
Expand Down Expand Up @@ -201,7 +201,7 @@ <h2>Color Demonstrations</h2>

// Open terminal
const container = document.getElementById('terminal-container');
await term.open(container);
term.open(container);
fitAddon.fit();

// Handle resize
Expand Down
29 changes: 17 additions & 12 deletions demo/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@
let ws;
let fitAddon;

async function initTerminal() {
function initTerminal() {
term = new Terminal({
cursorBlink: true,
fontSize: 14,
Expand All @@ -153,16 +153,21 @@
fitAddon = new FitAddon();
term.loadAddon(fitAddon);

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

// Handle window resize
window.addEventListener('resize', () => {
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) => {
Expand All @@ -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() {
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion demo/scrollbar-test.html
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ <h2>Scrollbar Test</h2>
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
Expand Down
164 changes: 164 additions & 0 deletions lib/addons/fit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
28 changes: 28 additions & 0 deletions lib/addons/fit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}
}

/**
Expand All @@ -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;
Expand All @@ -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;
Expand Down
10 changes: 9 additions & 1 deletion lib/buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
term.open(container);
await new Promise<void>((resolve) => term.onReady(resolve));
}

describe('Buffer API', () => {
let term: Terminal | null = null;
let container: HTMLElement | null = null;
Expand All @@ -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);
}
});

Expand Down
1 change: 1 addition & 0 deletions lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export type {
IEvent,
IBufferRange,
IKeyEvent,
IUnicodeVersionProvider,
} from './interfaces';

// Ghostty WASM components (for advanced usage)
Expand Down
7 changes: 7 additions & 0 deletions lib/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// ============================================================================
Expand Down
Loading