diff --git a/bench/versus.ts b/bench/versus.ts index 945e4b6..f3a3b7a 100644 --- a/bench/versus.ts +++ b/bench/versus.ts @@ -1,7 +1,7 @@ -import { Terminal as XTerm } from '@xterm/xterm'; +import { Terminal as XTermHeadless } from '@xterm/headless'; import { bench, group, run } from 'mitata'; -import { Ghostty, Terminal as GhosttyTerminal } from '../lib'; -import '../happydom'; +import { Ghostty } from '../lib/ghostty'; +import { Terminal as GhosttyHeadless } from '../lib/headless'; function generateColorText(lines: number): string { const colors = [31, 32, 33, 34, 35, 36]; @@ -49,67 +49,69 @@ function generateCursorMovement(ops: number): string { return output; } -const withTerminals = async (fn: (term: GhosttyTerminal | XTerm) => Promise) => { - const ghostty = await Ghostty.load(); - bench('ghostty-web', async () => { - const container = document.createElement('div'); - document.body.appendChild(container); - const term = new GhosttyTerminal({ ghostty }); - await term.open(container); +// Load Ghostty WASM once for all benchmarks +const ghostty = await Ghostty.load(); + +const withTerminals = (fn: (term: GhosttyHeadless | XTermHeadless) => Promise) => { + bench('ghostty-web/headless', async () => { + const term = new GhosttyHeadless({ ghostty }); await fn(term); - await term.dispose(); + term.dispose(); }); - bench('xterm.js', async () => { - const xterm = new XTerm(); - const container = document.createElement('div'); - document.body.appendChild(container); - await xterm.open(container); - await fn(xterm); - await xterm.dispose(); + bench('@xterm/headless', async () => { + const term = new XTermHeadless({ + allowProposedApi: true, + }); + await fn(term); + term.dispose(); }); }; -const throughput = async (prefix: string, data: Record) => { - await Promise.all( - Object.entries(data).map(async ([name, data]) => { - await group(`${prefix}: ${name}`, async () => { - await withTerminals(async (term) => { - await new Promise((resolve) => { - term.write(data, resolve); - }); +const throughput = (prefix: string, data: Record) => { + for (const [name, payload] of Object.entries(data)) { + group(`${prefix}: ${name}`, () => { + withTerminals(async (term) => { + await new Promise((resolve) => { + term.write(payload, resolve); }); }); - }) - ); + }); + } }; -await throughput('raw bytes', { +throughput('raw bytes', { '1KB': generateRawBytes(1024), '10KB': generateRawBytes(10 * 1024), '100KB': generateRawBytes(100 * 1024), '1MB': generateRawBytes(1024 * 1024), }); -await throughput('color text', { +throughput('color text', { '100 lines': generateColorText(100), '1000 lines': generateColorText(1000), '10000 lines': generateColorText(10000), }); -await throughput('complex VT', { +throughput('complex VT', { '100 lines': generateComplexVT(100), '1000 lines': generateComplexVT(1000), '10000 lines': generateComplexVT(10000), }); -await throughput('cursor movement', { +throughput('cursor movement', { '1000 operations': generateCursorMovement(1000), '10000 operations': generateCursorMovement(10000), '100000 operations': generateCursorMovement(100000), }); -await group('read full viewport', async () => { - await withTerminals(async (term) => { +group('read full viewport', () => { + withTerminals(async (term) => { + // Write some content first + await new Promise((resolve) => { + term.write(generateColorText(100), resolve); + }); + + // Then read it back const lines = term.rows; for (let i = 0; i < lines; i++) { const line = term.buffer.active.getLine(i); diff --git a/lib/headless.test.ts b/lib/headless.test.ts new file mode 100644 index 0000000..2d742ff --- /dev/null +++ b/lib/headless.test.ts @@ -0,0 +1,374 @@ +/** + * Tests for headless terminal mode + * + * These tests verify that the headless Terminal class works correctly + * without any DOM dependencies, mirroring @xterm/headless behavior. + */ + +import { afterEach, beforeAll, describe, expect, test } from 'bun:test'; +import { Ghostty } from './ghostty'; +import { Terminal } from './headless'; + +// Test isolation: Each test gets its own Ghostty instance +let ghostty: Ghostty; + +beforeAll(async () => { + ghostty = await Ghostty.load(); +}); + +describe('Headless Terminal', () => { + describe('Construction', () => { + test('creates terminal with default options', () => { + const term = new Terminal({ ghostty } as any); + expect(term.cols).toBe(80); + expect(term.rows).toBe(24); + term.dispose(); + }); + + test('creates terminal with custom dimensions', () => { + const term = new Terminal({ ghostty, cols: 120, rows: 40 } as any); + expect(term.cols).toBe(120); + expect(term.rows).toBe(40); + term.dispose(); + }); + + test('creates terminal with custom scrollback', () => { + const term = new Terminal({ ghostty, scrollback: 5000 } as any); + // Scrollback is set, write enough to test + expect(term).toBeDefined(); + term.dispose(); + }); + }); + + describe('Write Methods', () => { + let term: Terminal; + + beforeAll(() => { + term = new Terminal({ ghostty, cols: 80, rows: 24 } as any); + }); + + afterEach(() => { + term.reset(); + }); + + test('write() writes data to terminal', () => { + term.write('Hello'); + const line = term.buffer.active.getLine(0); + expect(line?.translateToString(true)).toBe('Hello'); + }); + + test('writeln() writes data with newline', () => { + term.writeln('Line 1'); + term.writeln('Line 2'); + const line0 = term.buffer.active.getLine(0); + const line1 = term.buffer.active.getLine(1); + expect(line0?.translateToString(true)).toBe('Line 1'); + expect(line1?.translateToString(true)).toBe('Line 2'); + }); + + test('write() with callback invokes callback', async () => { + let called = false; + term.write('Test', () => { + called = true; + }); + // Wait for microtask + await new Promise((resolve) => queueMicrotask(resolve)); + expect(called).toBe(true); + }); + + test('write() handles convertEol option', () => { + const term2 = new Terminal({ ghostty, cols: 80, rows: 24, convertEol: true } as any); + term2.write('Line1\nLine2'); + const line0 = term2.buffer.active.getLine(0); + const line1 = term2.buffer.active.getLine(1); + expect(line0?.translateToString(true)).toBe('Line1'); + expect(line1?.translateToString(true)).toBe('Line2'); + term2.dispose(); + }); + }); + + describe('Buffer API', () => { + let term: Terminal; + + beforeAll(() => { + term = new Terminal({ ghostty, cols: 80, rows: 24 } as any); + }); + + afterEach(() => { + term.reset(); + }); + + test('buffer.active returns active buffer', () => { + expect(term.buffer.active).toBeDefined(); + expect(term.buffer.active.type).toBe('normal'); + }); + + test('buffer.normal returns normal buffer', () => { + expect(term.buffer.normal).toBeDefined(); + expect(term.buffer.normal.type).toBe('normal'); + }); + + test('buffer.alternate returns alternate buffer', () => { + expect(term.buffer.alternate).toBeDefined(); + expect(term.buffer.alternate.type).toBe('alternate'); + }); + + test('getLine returns buffer line', () => { + term.write('Test content'); + const line = term.buffer.active.getLine(0); + expect(line).toBeDefined(); + expect(line?.translateToString(true)).toBe('Test content'); + }); + + test('getCell returns cell data', () => { + term.write('A'); + const line = term.buffer.active.getLine(0); + const cell = line?.getCell(0); + expect(cell).toBeDefined(); + expect(cell?.getChars()).toBe('A'); + }); + + test('cell attributes are accessible', () => { + // Write bold red text + term.write('\x1b[1;31mBold Red\x1b[0m'); + const line = term.buffer.active.getLine(0); + const cell = line?.getCell(0); + expect(cell?.isBold()).toBe(1); + }); + }); + + describe('Events', () => { + test('onData fires when input() is called with wasUserInput=true', () => { + const term = new Terminal({ ghostty } as any); + let received = ''; + const disposable = term.onData((data) => { + received = data; + }); + + term.input('test', true); + expect(received).toBe('test'); + + disposable.dispose(); + term.dispose(); + }); + + test('onResize fires on resize', () => { + const term = new Terminal({ ghostty, cols: 80, rows: 24 } as any); + let resizeEvent: { cols: number; rows: number } | null = null; + const disposable = term.onResize((e) => { + resizeEvent = e; + }); + + term.resize(100, 30); + expect(resizeEvent).toEqual({ cols: 100, rows: 30 }); + + disposable.dispose(); + term.dispose(); + }); + + test('onBell fires on bell character', () => { + const term = new Terminal({ ghostty } as any); + let bellFired = false; + const disposable = term.onBell(() => { + bellFired = true; + }); + + term.write('\x07'); + expect(bellFired).toBe(true); + + disposable.dispose(); + term.dispose(); + }); + + test('onTitleChange fires on OSC 0/2', () => { + const term = new Terminal({ ghostty } as any); + let title = ''; + const disposable = term.onTitleChange((t) => { + title = t; + }); + + term.write('\x1b]0;My Title\x07'); + expect(title).toBe('My Title'); + + disposable.dispose(); + term.dispose(); + }); + + test('onLineFeed fires on newline', () => { + const term = new Terminal({ ghostty } as any); + let lineFeedFired = false; + const disposable = term.onLineFeed(() => { + lineFeedFired = true; + }); + + term.write('\n'); + expect(lineFeedFired).toBe(true); + + disposable.dispose(); + term.dispose(); + }); + + test('onWriteParsed fires after write', async () => { + const term = new Terminal({ ghostty } as any); + let parsedFired = false; + const disposable = term.onWriteParsed(() => { + parsedFired = true; + }); + + term.write('test'); + // Wait for microtask queue + await new Promise((resolve) => queueMicrotask(resolve)); + expect(parsedFired).toBe(true); + + disposable.dispose(); + term.dispose(); + }); + }); + + describe('Scrolling', () => { + test('scrollLines scrolls viewport', () => { + const term = new Terminal({ ghostty, cols: 80, rows: 24 } as any); + + // Write enough content to have scrollback + for (let i = 0; i < 50; i++) { + term.writeln(`Line ${i}`); + } + + const initialY = term.getViewportY(); + term.scrollLines(-5); + expect(term.getViewportY()).toBe(initialY + 5); + + term.dispose(); + }); + + test('scrollToTop scrolls to start of buffer', () => { + const term = new Terminal({ ghostty, cols: 80, rows: 24 } as any); + + // Write content + for (let i = 0; i < 50; i++) { + term.writeln(`Line ${i}`); + } + + term.scrollToTop(); + const scrollbackLength = term.getScrollbackLength(); + expect(term.getViewportY()).toBe(scrollbackLength); + + term.dispose(); + }); + + test('scrollToBottom scrolls to current output', () => { + const term = new Terminal({ ghostty, cols: 80, rows: 24 } as any); + + // Write content + for (let i = 0; i < 50; i++) { + term.writeln(`Line ${i}`); + } + + term.scrollToTop(); + term.scrollToBottom(); + expect(term.getViewportY()).toBe(0); + + term.dispose(); + }); + }); + + describe('Addons', () => { + test('loadAddon activates addon', () => { + const term = new Terminal({ ghostty } as any); + let activated = false; + + const addon = { + activate: () => { + activated = true; + }, + dispose: () => {}, + }; + + term.loadAddon(addon); + expect(activated).toBe(true); + + term.dispose(); + }); + }); + + describe('Lifecycle', () => { + test('dispose cleans up resources', () => { + const term = new Terminal({ ghostty } as any); + term.write('Test'); + term.dispose(); + + // After dispose, methods should throw + expect(() => term.write('More')).toThrow(); + }); + + test('reset clears terminal state', () => { + const term = new Terminal({ ghostty } as any); + term.write('Content'); + term.reset(); + + // After reset, the terminal is in a fresh state + // Write something new to verify it works + term.write('New'); + const line = term.buffer.active.getLine(0); + expect(line?.translateToString(true)).toBe('New'); + + term.dispose(); + }); + + test('clear clears screen', () => { + const term = new Terminal({ ghostty } as any); + term.write('Content'); + term.clear(); + + // Clear sends escape sequences, cursor should be at home + const cursor = term.buffer.active.cursorY; + expect(cursor).toBe(0); + + term.dispose(); + }); + }); + + describe('ANSI Escape Sequences', () => { + test('handles color sequences', () => { + const term = new Terminal({ ghostty } as any); + + // Red foreground + term.write('\x1b[31mRed\x1b[0m'); + const line = term.buffer.active.getLine(0); + const cell = line?.getCell(0); + expect(cell?.getChars()).toBe('R'); + // Check that foreground color is set (red = 0xCC0000 or similar) + const fgColor = cell?.getFgColor(); + expect(fgColor).toBeDefined(); + + term.dispose(); + }); + + test('handles cursor movement', () => { + const term = new Terminal({ ghostty } as any); + + // Move cursor to position 5,5 + term.write('\x1b[5;5H'); + expect(term.buffer.active.cursorX).toBe(4); // 0-indexed + expect(term.buffer.active.cursorY).toBe(4); // 0-indexed + + term.dispose(); + }); + + test('handles alternate screen buffer', () => { + const term = new Terminal({ ghostty } as any); + + expect(term.buffer.active.type).toBe('normal'); + + // Switch to alternate screen + term.write('\x1b[?1049h'); + expect(term.buffer.active.type).toBe('alternate'); + + // Switch back + term.write('\x1b[?1049l'); + expect(term.buffer.active.type).toBe('normal'); + + term.dispose(); + }); + }); +}); diff --git a/lib/headless.ts b/lib/headless.ts new file mode 100644 index 0000000..6fcb7d0 --- /dev/null +++ b/lib/headless.ts @@ -0,0 +1,143 @@ +/** + * ghostty-web/headless - Headless Terminal Entry Point + * + * Provides a headless terminal that mirrors the @xterm/headless API. + * No DOM, no rendering - just VT parsing and state management. + * + * Usage: + * ```typescript + * import { init, Terminal } from 'ghostty-web/headless'; + * + * await init(); + * const term = new Terminal({ cols: 80, rows: 24 }); + * + * // Write data + * term.write('Hello, World!\r\n'); + * + * // Read buffer state + * const line = term.buffer.active.getLine(0); + * console.log(line?.translateToString()); + * + * // Handle user input + * term.onData(data => pty.write(data)); + * ``` + */ + +import { Ghostty } from './ghostty'; +import type { + IBuffer, + IBufferCell, + IBufferLine, + IBufferNamespace, + IBufferRange, + IDisposable, + IEvent, + ITerminalAddon, + ITerminalOptions, + ITheme, +} from './interfaces'; +import { TerminalCore } from './terminal-core'; + +// Re-export types for API compatibility +export type { + ITerminalOptions, + ITheme, + IDisposable, + IEvent, + IBuffer, + IBufferNamespace, + IBufferLine, + IBufferCell, + ITerminalAddon, + IBufferRange, +}; + +// Module-level Ghostty instance +let ghosttyInstance: Ghostty | null = null; + +/** + * Initialize ghostty-web headless. + * Must be called before creating Terminal instances. + * + * @param wasmPath - Optional path to ghostty-vt.wasm file + * + * @example + * ```typescript + * import { init, Terminal } from 'ghostty-web/headless'; + * + * await init(); + * const term = new Terminal(); + * ``` + */ +export async function init(wasmPath?: string): Promise { + if (ghosttyInstance) { + return; // Already initialized + } + ghosttyInstance = await Ghostty.load(wasmPath); +} + +/** + * Check if ghostty-web headless has been initialized + */ +export function isInitialized(): boolean { + return ghosttyInstance !== null; +} + +/** + * Get the initialized Ghostty instance (for advanced usage) + * @internal + */ +export function getGhostty(): Ghostty { + if (!ghosttyInstance) { + throw new Error( + 'ghostty-web/headless not initialized. Call init() first.\n' + + 'Example:\n' + + ' import { init, Terminal } from "ghostty-web/headless";\n' + + ' await init();\n' + + ' const term = new Terminal();' + ); + } + return ghosttyInstance; +} + +/** + * Headless Terminal - same API as @xterm/headless + * + * A terminal emulator that works without a DOM. + * Provides VT100/ANSI parsing, buffer management, and event handling. + * + * @example + * ```typescript + * import { init, Terminal } from 'ghostty-web/headless'; + * + * await init(); + * const term = new Terminal({ cols: 80, rows: 24, scrollback: 1000 }); + * + * // Write escape sequences + * term.write('\x1b[31mRed text\x1b[0m\r\n'); + * + * // Access buffer + * const line = term.buffer.active.getLine(0); + * const cell = line?.getCell(0); + * console.log(cell?.getChars(), cell?.isBold()); + * + * // Events + * term.onData(data => ws.send(data)); + * term.onTitleChange(title => document.title = title); + * term.onBell(() => console.log('Bell!')); + * ``` + */ +export class Terminal extends TerminalCore { + constructor(options?: ITerminalOptions) { + // Use provided Ghostty instance or module-level instance + const ghostty = options?.ghostty ?? getGhostty(); + super(ghostty, options); + } +} + +// Export Ghostty class for advanced usage +export { Ghostty } from './ghostty'; + +// Export low-level types for advanced usage +export type { GhosttyCell, GhosttyTerminalConfig, RGB, Cursor } from './types'; +export { CellFlags } from './types'; diff --git a/lib/index.ts b/lib/index.ts index bbf54c2..20a47e9 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,7 +1,8 @@ /** - * Public API for @cmux/ghostty-terminal + * Public API for ghostty-web * - * Main entry point following xterm.js conventions + * Main entry point following xterm.js conventions. + * For headless mode (no DOM), use 'ghostty-web/headless' instead. */ import { Ghostty } from './ghostty'; @@ -54,9 +55,12 @@ export function getGhostty(): Ghostty { return ghosttyInstance; } -// Main Terminal class +// Main Terminal class (browser - full functionality) export { Terminal } from './terminal'; +// Core Terminal class (headless-compatible base) +export { TerminalCore } from './terminal-core'; + // xterm.js-compatible interfaces export type { ITerminalOptions, @@ -68,6 +72,10 @@ export type { IBufferRange, IKeyEvent, IUnicodeVersionProvider, + IBufferNamespace, + IBuffer, + IBufferLine, + IBufferCell, } from './interfaces'; // Ghostty WASM components (for advanced usage) diff --git a/lib/scrolling.test.ts b/lib/scrolling.test.ts index 5af1b5c..d80c392 100644 --- a/lib/scrolling.test.ts +++ b/lib/scrolling.test.ts @@ -315,19 +315,17 @@ describe('Terminal Scrolling', () => { expect(dataSent.length).toBe(0); }); - test('should handle terminal not yet opened', async () => { + test('wasmTerm exists before open (headless-compatible)', async () => { const closedTerminal = await createIsolatedTerminal({ cols: 80, rows: 24 }); - // Should not crash when handleWheel is called without wasmTerm - expect(() => { - const wheelEvent = new WheelEvent('wheel', { - deltaY: -100, - bubbles: true, - cancelable: true, - }); - // Can't dispatch without container, but we can test the internal state - expect(closedTerminal.wasmTerm).toBeUndefined(); - }).not.toThrow(); + // With headless-compatible design, wasmTerm exists immediately + // This allows headless mode to work without open() + expect(closedTerminal.wasmTerm).toBeDefined(); + + // The terminal can process writes even before open() + closedTerminal.wasmTerm!.write('Test'); + const line = closedTerminal.wasmTerm!.getLine(0); + expect(line).toBeDefined(); closedTerminal.dispose(); }); diff --git a/lib/terminal-core.ts b/lib/terminal-core.ts new file mode 100644 index 0000000..bb0f0a1 --- /dev/null +++ b/lib/terminal-core.ts @@ -0,0 +1,615 @@ +/** + * TerminalCore - Shared terminal logic between browser and headless + * + * This module contains all the core terminal functionality that works + * without a DOM. It mirrors the @xterm/headless API. + * + * Browser-specific functionality (open, focus, selection, rendering) + * is in Terminal which extends this class. + */ + +import { BufferNamespace } from './buffer'; +import { EventEmitter } from './event-emitter'; +import type { Ghostty, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; +import type { + IBufferNamespace, + IDisposable, + IEvent, + ITerminalAddon, + ITerminalOptions, +} from './interfaces'; + +// ============================================================================ +// TerminalCore - Headless-compatible terminal base class +// ============================================================================ + +/** + * Core terminal class that works without DOM/browser APIs. + * Provides the same API as @xterm/headless. + */ +export class TerminalCore implements IDisposable { + // Public properties + public cols: number; + public rows: number; + + // Buffer API (xterm.js compatibility) + public readonly buffer: IBufferNamespace; + + // Options (public for xterm.js compatibility) + public readonly options!: Required; + + // WASM components + protected ghostty: Ghostty; + public wasmTerm?: GhosttyTerminal; // Public for buffer API and addons + + // Event emitters (protected so browser Terminal can access) + protected dataEmitter = new EventEmitter(); + protected resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); + protected bellEmitter = new EventEmitter(); + protected titleChangeEmitter = new EventEmitter(); + protected scrollEmitter = new EventEmitter(); + protected cursorMoveEmitter = new EventEmitter(); + protected lineFeedEmitter = new EventEmitter(); + protected writeParsedEmitter = new EventEmitter(); + protected binaryEmitter = new EventEmitter(); + + // Public event accessors (xterm.js compatibility) + public readonly onData: IEvent = this.dataEmitter.event; + public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; + public readonly onBell: IEvent = this.bellEmitter.event; + public readonly onTitleChange: IEvent = this.titleChangeEmitter.event; + public readonly onScroll: IEvent = this.scrollEmitter.event; + public readonly onCursorMove: IEvent = this.cursorMoveEmitter.event; + public readonly onLineFeed: IEvent = this.lineFeedEmitter.event; + public readonly onWriteParsed: IEvent = this.writeParsedEmitter.event; + public readonly onBinary: IEvent = this.binaryEmitter.event; + + // Lifecycle state + protected isDisposed = false; + + // Addons + protected addons: ITerminalAddon[] = []; + + // Title tracking + protected currentTitle: string = ''; + + // Cursor tracking + protected lastCursorY: number = 0; + protected lastCursorX: number = 0; + + // Viewport/scrolling state + protected _viewportY: number = 0; + + // Markers (stub implementation) + protected _markers: any[] = []; + + constructor(ghostty: Ghostty, options: ITerminalOptions = {}) { + this.ghostty = ghostty; + + // Create base options object with all defaults + const baseOptions = { + cols: options.cols ?? 80, + rows: options.rows ?? 24, + cursorBlink: options.cursorBlink ?? false, + cursorStyle: options.cursorStyle ?? 'block', + theme: options.theme ?? {}, + scrollback: options.scrollback ?? 10000, + fontSize: options.fontSize ?? 15, + fontFamily: options.fontFamily ?? 'monospace', + allowTransparency: options.allowTransparency ?? false, + convertEol: options.convertEol ?? false, + disableStdin: options.disableStdin ?? false, + smoothScrollDuration: options.smoothScrollDuration ?? 100, + }; + + // Wrap in Proxy to intercept runtime changes + (this.options as any) = new Proxy(baseOptions, { + set: (target: any, prop: string, value: any) => { + const oldValue = target[prop]; + target[prop] = value; + this.handleOptionChange(prop, value, oldValue); + return true; + }, + }); + + this.cols = this.options.cols; + this.rows = this.options.rows; + + // Create WASM terminal + const config = this.buildWasmConfig(); + this.wasmTerm = ghostty.createTerminal(this.cols, this.rows, config); + + // Initialize buffer API + this.buffer = new BufferNamespace(this as any); + } + + /** + * Get markers array (stub implementation) + */ + get markers(): ReadonlyArray { + return this._markers; + } + + // ========================================================================== + // Option Change Handling + // ========================================================================== + + /** + * Handle runtime option changes + */ + protected handleOptionChange(key: string, newValue: any, oldValue: any): void { + if (newValue === oldValue) return; + + switch (key) { + case 'cols': + case 'rows': + this.resize(this.options.cols, this.options.rows); + break; + } + } + + // ========================================================================== + // Write Methods + // ========================================================================== + + /** + * Write data to terminal + */ + write(data: string | Uint8Array, callback?: () => void): void { + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + if (!this.wasmTerm) { + throw new Error('Terminal not initialized'); + } + + // Handle convertEol option + if (this.options.convertEol && typeof data === 'string') { + data = data.replace(/\n/g, '\r\n'); + } + + // Write to WASM terminal + this.wasmTerm.write(data); + + // Process terminal responses (DSR, etc.) + this.processTerminalResponses(); + + // Check for bell character + if (typeof data === 'string' && data.includes('\x07')) { + this.bellEmitter.fire(); + } else if (data instanceof Uint8Array && data.includes(0x07)) { + this.bellEmitter.fire(); + } + + // Check for linefeed + if (typeof data === 'string' && (data.includes('\n') || data.includes('\r\n'))) { + this.lineFeedEmitter.fire(); + } else if (data instanceof Uint8Array && data.includes(0x0a)) { + this.lineFeedEmitter.fire(); + } + + // Check for title changes (OSC 0, 1, 2 sequences) + if (typeof data === 'string' && data.includes('\x1b]')) { + this.checkForTitleChange(data); + } + + // Check for cursor movement + this.checkCursorMove(); + + // Call callback if provided + if (callback) { + queueMicrotask(() => { + callback(); + this.writeParsedEmitter.fire(); + }); + } else { + this.writeParsedEmitter.fire(); + } + } + + /** + * Write data with newline + */ + writeln(data: string | Uint8Array, callback?: () => void): void { + if (typeof data === 'string') { + this.write(data + '\r\n', callback); + } else { + const newData = new Uint8Array(data.length + 2); + newData.set(data); + newData[data.length] = 0x0d; // \r + newData[data.length + 1] = 0x0a; // \n + this.write(newData, callback); + } + } + + /** + * Input data as user input (triggers onData event) + */ + input(data: string, wasUserInput: boolean = true): void { + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + + if (this.options.disableStdin) { + return; + } + + if (wasUserInput) { + this.dataEmitter.fire(data); + } + } + + // ========================================================================== + // Resize & Lifecycle + // ========================================================================== + + /** + * Resize terminal + */ + resize(cols: number, rows: number): void { + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + if (!this.wasmTerm) { + throw new Error('Terminal not initialized'); + } + + if (cols === this.cols && rows === this.rows) { + return; + } + + this.cols = cols; + this.rows = rows; + this.wasmTerm.resize(cols, rows); + this.resizeEmitter.fire({ cols, rows }); + } + + /** + * Reset terminal state (RIS - Reset to Initial State) + */ + reset(): void { + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + if (!this.wasmTerm) { + throw new Error('Terminal not initialized'); + } + + // Send RIS (Reset to Initial State) sequence + // This properly resets the terminal state in WASM including: + // - Clear screen + // - Reset cursor position + // - Reset modes + // - Reset character sets + this.wasmTerm.write('\x1bc'); + + // Reset local state + this.currentTitle = ''; + this._viewportY = 0; + } + + /** + * Clear terminal screen (preserves scrollback) + */ + clear(): void { + if (this.isDisposed) { + throw new Error('Terminal has been disposed'); + } + if (!this.wasmTerm) { + throw new Error('Terminal not initialized'); + } + + // Send ANSI clear screen and cursor home + this.wasmTerm.write('\x1b[2J\x1b[H'); + } + + /** + * Dispose terminal + */ + dispose(): void { + if (this.isDisposed) { + return; + } + + this.isDisposed = true; + + // Dispose addons + for (const addon of this.addons) { + addon.dispose(); + } + this.addons = []; + + // Free WASM terminal + if (this.wasmTerm) { + this.wasmTerm.free(); + this.wasmTerm = undefined; + } + + // Dispose event emitters + this.dataEmitter.dispose(); + this.resizeEmitter.dispose(); + this.bellEmitter.dispose(); + this.titleChangeEmitter.dispose(); + this.scrollEmitter.dispose(); + this.cursorMoveEmitter.dispose(); + this.lineFeedEmitter.dispose(); + this.writeParsedEmitter.dispose(); + this.binaryEmitter.dispose(); + } + + // ========================================================================== + // Scrolling Methods + // ========================================================================== + + /** + * Scroll viewport by lines + */ + scrollLines(amount: number): void { + if (!this.wasmTerm) return; + + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + const maxScroll = scrollbackLength; + + // Calculate new viewport position + const newViewportY = Math.max(0, Math.min(maxScroll, this._viewportY - amount)); + + if (newViewportY !== this._viewportY) { + this._viewportY = newViewportY; + this.scrollEmitter.fire(this._viewportY); + } + } + + /** + * Scroll viewport by pages + */ + scrollPages(pageCount: number): void { + this.scrollLines(pageCount * this.rows); + } + + /** + * Scroll to top of scrollback + */ + scrollToTop(): void { + if (!this.wasmTerm) return; + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + if (scrollbackLength > 0 && this._viewportY !== scrollbackLength) { + this._viewportY = scrollbackLength; + this.scrollEmitter.fire(this._viewportY); + } + } + + /** + * Scroll to bottom (current output) + */ + scrollToBottom(): void { + if (this._viewportY !== 0) { + this._viewportY = 0; + this.scrollEmitter.fire(this._viewportY); + } + } + + /** + * Scroll to specific line + */ + scrollToLine(line: number): void { + if (!this.wasmTerm) return; + const scrollbackLength = this.wasmTerm.getScrollbackLength(); + const newViewportY = Math.max(0, Math.min(scrollbackLength, line)); + + if (newViewportY !== this._viewportY) { + this._viewportY = newViewportY; + this.scrollEmitter.fire(this._viewportY); + } + } + + // ========================================================================== + // Markers (stub implementation) + // ========================================================================== + + /** + * Register a marker at the current cursor position + */ + registerMarker(cursorYOffset: number = 0): any | undefined { + // Stub implementation - would need WASM support for full implementation + return undefined; + } + + // ========================================================================== + // Addons + // ========================================================================== + + /** + * Load an addon + */ + loadAddon(addon: ITerminalAddon): void { + addon.activate(this as any); + this.addons.push(addon); + } + + // ========================================================================== + // Terminal Modes (for compatibility) + // ========================================================================== + + /** + * Query terminal mode state + */ + getMode(mode: number, isAnsi: boolean = false): boolean { + if (!this.wasmTerm) return false; + return this.wasmTerm.getMode(mode, isAnsi); + } + + /** + * Check if bracketed paste mode is enabled + */ + hasBracketedPaste(): boolean { + if (!this.wasmTerm) return false; + return this.wasmTerm.hasBracketedPaste(); + } + + /** + * Check if focus event reporting is enabled + */ + hasFocusEvents(): boolean { + if (!this.wasmTerm) return false; + return this.wasmTerm.hasFocusEvents(); + } + + /** + * Check if mouse tracking is enabled + */ + hasMouseTracking(): boolean { + if (!this.wasmTerm) return false; + return this.wasmTerm.hasMouseTracking(); + } + + // ========================================================================== + // Protected/Internal Methods + // ========================================================================== + + /** + * Get current viewport Y position + */ + public getViewportY(): number { + return this._viewportY; + } + + /** + * Get scrollback length + */ + public getScrollbackLength(): number { + if (!this.wasmTerm) return 0; + return this.wasmTerm.getScrollbackLength(); + } + + /** + * Get scrollback line (for IScrollbackProvider) + */ + public getScrollbackLine(offset: number): any[] | null { + if (!this.wasmTerm) return null; + return this.wasmTerm.getScrollbackLine(offset); + } + + /** + * Get mouse tracking mode as string + */ + protected getMouseTrackingMode(): 'none' | 'x10' | 'vt200' | 'drag' | 'any' { + if (!this.wasmTerm) return 'none'; + if (this.wasmTerm.getMode(1003, false)) return 'any'; + if (this.wasmTerm.getMode(1002, false)) return 'drag'; + if (this.wasmTerm.getMode(1000, false)) return 'vt200'; + if (this.wasmTerm.getMode(9, false)) return 'x10'; + return 'none'; + } + + /** + * Parse CSS color to hex + */ + protected parseColorToHex(color?: string): number { + if (!color) return 0; + + if (color.startsWith('#')) { + let hex = color.slice(1); + if (hex.length === 3) { + hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; + } + const value = Number.parseInt(hex, 16); + return Number.isNaN(value) ? 0 : value; + } + + const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); + if (match) { + const r = Number.parseInt(match[1], 10); + const g = Number.parseInt(match[2], 10); + const b = Number.parseInt(match[3], 10); + return (r << 16) | (g << 8) | b; + } + + return 0; + } + + /** + * Build WASM terminal config + */ + protected buildWasmConfig(): GhosttyTerminalConfig | undefined { + const theme = this.options.theme; + const scrollback = this.options.scrollback; + + if (!theme && scrollback === 10000) { + return undefined; + } + + const palette: number[] = [ + this.parseColorToHex(theme?.black), + this.parseColorToHex(theme?.red), + this.parseColorToHex(theme?.green), + this.parseColorToHex(theme?.yellow), + this.parseColorToHex(theme?.blue), + this.parseColorToHex(theme?.magenta), + this.parseColorToHex(theme?.cyan), + this.parseColorToHex(theme?.white), + this.parseColorToHex(theme?.brightBlack), + this.parseColorToHex(theme?.brightRed), + this.parseColorToHex(theme?.brightGreen), + this.parseColorToHex(theme?.brightYellow), + this.parseColorToHex(theme?.brightBlue), + this.parseColorToHex(theme?.brightMagenta), + this.parseColorToHex(theme?.brightCyan), + this.parseColorToHex(theme?.brightWhite), + ]; + + return { + scrollbackLimit: scrollback, + fgColor: this.parseColorToHex(theme?.foreground), + bgColor: this.parseColorToHex(theme?.background), + cursorColor: this.parseColorToHex(theme?.cursor), + palette, + }; + } + + /** + * Process terminal responses (DSR, etc.) + */ + protected processTerminalResponses(): void { + if (!this.wasmTerm) return; + + const response = this.wasmTerm.readResponse(); + if (response) { + this.dataEmitter.fire(response); + } + } + + /** + * Check for title changes in data + */ + protected checkForTitleChange(data: string): void { + const oscRegex = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g; + let match: RegExpExecArray | null = null; + + // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex pattern + while ((match = oscRegex.exec(data)) !== null) { + const ps = match[1]; + const pt = match[2]; + + if (ps === '0' || ps === '2') { + if (pt !== this.currentTitle) { + this.currentTitle = pt; + this.titleChangeEmitter.fire(pt); + } + } + } + } + + /** + * Check for cursor movement and fire event + */ + protected checkCursorMove(): void { + if (!this.wasmTerm) return; + + const cursor = this.wasmTerm.getCursor(); + if (cursor.x !== this.lastCursorX || cursor.y !== this.lastCursorY) { + this.lastCursorX = cursor.x; + this.lastCursorY = cursor.y; + this.cursorMoveEmitter.fire(); + } + } +} diff --git a/lib/terminal.test.ts b/lib/terminal.test.ts index d56011e..1a8284f 100644 --- a/lib/terminal.test.ts +++ b/lib/terminal.test.ts @@ -276,9 +276,13 @@ describe('Terminal', () => { term.dispose(); }); - test('resize() throws if not open', async () => { + test('resize() works before open (headless-compatible)', async () => { const term = await createIsolatedTerminal(); - expect(() => term.resize(100, 30)).toThrow('must be opened'); + // Resize should work before open() - the WASM terminal exists + term.resize(100, 30); + expect(term.cols).toBe(100); + expect(term.rows).toBe(30); + term.dispose(); }); }); @@ -1613,14 +1617,20 @@ describe('Terminal Modes', () => { term.dispose(); }); - test('getMode() throws when terminal not open', async () => { + test('getMode() works before open (headless-compatible)', async () => { const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); - expect(() => term.getMode(25)).toThrow(); + // Mode queries should work before open() - WASM terminal exists + const visible = term.getMode(25); // cursor visible mode + expect(typeof visible).toBe('boolean'); + term.dispose(); }); - test('hasBracketedPaste() throws when terminal not open', async () => { + test('hasBracketedPaste() works before open (headless-compatible)', async () => { const term = await createIsolatedTerminal({ cols: 80, rows: 24 }); - expect(() => term.hasBracketedPaste()).toThrow(); + // Mode queries should work before open() - WASM terminal exists + const hasBP = term.hasBracketedPaste(); + expect(hasBP).toBe(false); // Default is off + term.dispose(); }); test('alternate screen mode via getMode()', async () => { @@ -2745,24 +2755,6 @@ describe('disableStdin', () => { }); }); -// ========================================================================== -// xterm.js Compatibility: unicode API -// ========================================================================== - -describe('unicode API', () => { - test('activeVersion returns 15.1', async () => { - const term = await createIsolatedTerminal(); - expect(term.unicode.activeVersion).toBe('15.1'); - }); - - test('unicode object is readonly', async () => { - const term = await createIsolatedTerminal(); - // The unicode property should be accessible - expect(term.unicode).toBeDefined(); - expect(typeof term.unicode.activeVersion).toBe('string'); - }); -}); - // ========================================================================== // Grapheme Cluster Support (Unicode complex scripts) // ========================================================================== diff --git a/lib/terminal.ts b/lib/terminal.ts index 3c2fc2f..dafdb6d 100644 --- a/lib/terminal.ts +++ b/lib/terminal.ts @@ -1,7 +1,12 @@ /** - * Terminal - Main terminal emulator class + * Terminal - Full browser terminal emulator * - * Provides an xterm.js-compatible API wrapping Ghostty's WASM terminal emulator. + * Extends TerminalCore with DOM/browser-specific functionality: + * - Canvas rendering + * - Keyboard input handling + * - Selection and clipboard + * - Link detection + * - Scrollbar UI * * Usage: * ```typescript @@ -15,57 +20,37 @@ * ``` */ -import { BufferNamespace } from './buffer'; import { EventEmitter } from './event-emitter'; -import type { Ghostty, GhosttyCell, GhosttyTerminal, GhosttyTerminalConfig } from './ghostty'; +import type { Ghostty, GhosttyCell, GhosttyTerminal } from './ghostty'; import { getGhostty } from './index'; import { InputHandler } from './input-handler'; import type { - IBufferNamespace, IBufferRange, IDisposable, IEvent, IKeyEvent, ITerminalAddon, - ITerminalCore, ITerminalOptions, - IUnicodeVersionProvider, } from './interfaces'; import { LinkDetector } from './link-detector'; import { OSC8LinkProvider } from './providers/osc8-link-provider'; import { UrlRegexProvider } from './providers/url-regex-provider'; import { CanvasRenderer } from './renderer'; import { SelectionManager } from './selection-manager'; +import { TerminalCore } from './terminal-core'; import type { ILink, ILinkProvider } from './types'; // ============================================================================ -// Terminal Class +// Terminal Class - Full Browser Terminal // ============================================================================ -export class Terminal implements ITerminalCore { - // Public properties (xterm.js compatibility) - public cols: number; - public rows: number; +export class Terminal extends TerminalCore { + // Browser-specific properties public element?: HTMLElement; public textarea?: HTMLTextAreaElement; - // Buffer API (xterm.js compatibility) - public readonly buffer: IBufferNamespace; - - // 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; - - // Components (created on open()) - private ghostty?: Ghostty; - public wasmTerm?: GhosttyTerminal; // Made public for link providers - public renderer?: CanvasRenderer; // Made public for FitAddon + // Browser-specific components + public renderer?: CanvasRenderer; // Public for FitAddon private inputHandler?: InputHandler; private selectionManager?: SelectionManager; private canvas?: HTMLCanvasElement; @@ -76,49 +61,30 @@ export class Terminal implements ITerminalCore { private mouseMoveThrottleTimeout?: number; private pendingMouseMove?: MouseEvent; - // Event emitters - private dataEmitter = new EventEmitter(); - private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); - private bellEmitter = new EventEmitter(); + // Browser-specific event emitters private selectionChangeEmitter = new EventEmitter(); private keyEmitter = new EventEmitter(); - private titleChangeEmitter = new EventEmitter(); - private scrollEmitter = new EventEmitter(); private renderEmitter = new EventEmitter<{ start: number; end: number }>(); - private cursorMoveEmitter = new EventEmitter(); - // Public event accessors (xterm.js compatibility) - public readonly onData: IEvent = this.dataEmitter.event; - public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; - public readonly onBell: IEvent = this.bellEmitter.event; + + // Browser-specific events public readonly onSelectionChange: IEvent = this.selectionChangeEmitter.event; public readonly onKey: IEvent = this.keyEmitter.event; - public readonly onTitleChange: IEvent = this.titleChangeEmitter.event; - public readonly onScroll: IEvent = this.scrollEmitter.event; public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event; - public readonly onCursorMove: IEvent = this.cursorMoveEmitter.event; // Lifecycle state private isOpen = false; - private isDisposed = false; private animationFrameId?: number; - // Addons - private addons: ITerminalAddon[] = []; - - // Phase 1: Custom event handlers + // Custom event handlers private customKeyEventHandler?: (event: KeyboardEvent) => boolean; - // Phase 1: Title tracking - private currentTitle: string = ''; - - // Phase 2: Viewport and scrolling state - public viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom, can be fractional during smooth scroll) - private targetViewportY: number = 0; // Target viewport position for smooth scrolling + // Scrolling state + public viewportY: number = 0; + private targetViewportY: number = 0; private scrollAnimationStartTime?: number; private scrollAnimationStartY?: number; private scrollAnimationFrame?: number; private customWheelEventHandler?: (event: WheelEvent) => boolean; - private lastCursorY: number = 0; // Track cursor position for onCursorMove // Scrollbar interaction state private isDraggingScrollbar: boolean = false; @@ -129,66 +95,25 @@ export class Terminal implements ITerminalCore { private scrollbarVisible: boolean = false; private scrollbarOpacity: number = 0; private scrollbarHideTimeout?: number; - private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; // Hide after 1.5 seconds - private readonly SCROLLBAR_FADE_DURATION_MS = 200; // 200ms fade animation + private readonly SCROLLBAR_HIDE_DELAY_MS = 1500; + private readonly SCROLLBAR_FADE_DURATION_MS = 200; constructor(options: ITerminalOptions = {}) { - // Use provided Ghostty instance (for test isolation) or get module-level instance - this.ghostty = options.ghostty ?? getGhostty(); - - // Create base options object with all defaults (excluding ghostty) - const baseOptions = { - cols: options.cols ?? 80, - rows: options.rows ?? 24, - cursorBlink: options.cursorBlink ?? false, - cursorStyle: options.cursorStyle ?? 'block', - theme: options.theme ?? {}, - scrollback: options.scrollback ?? 10000, - fontSize: options.fontSize ?? 15, - fontFamily: options.fontFamily ?? 'monospace', - allowTransparency: options.allowTransparency ?? false, - convertEol: options.convertEol ?? false, - disableStdin: options.disableStdin ?? false, - smoothScrollDuration: options.smoothScrollDuration ?? 100, // Default: 100ms smooth scroll - }; - - // 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); + // Use provided Ghostty instance or get module-level instance + const ghostty = options.ghostty ?? getGhostty(); + super(ghostty, options); } // ========================================================================== - // Option Change Handling (for mutable options) + // Option Change Handling (override for browser-specific 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 { + protected override 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': @@ -221,119 +146,35 @@ export class Terminal implements ITerminalCore { case 'cols': case 'rows': - // Redirect to resize method this.resize(this.options.cols, this.options.rows); break; } } - /** - * Handle font changes (fontSize or fontFamily) - * Updates canvas size to match new font metrics and forces a full re-render - */ private handleFontChange(): void { if (!this.renderer || !this.wasmTerm || !this.canvas) return; - // Clear any active selection since pixel positions have changed if (this.selectionManager) { this.selectionManager.clearSelection(); } - // Resize canvas to match new font metrics this.renderer.resize(this.cols, this.rows); - // Update canvas element dimensions to match renderer const metrics = this.renderer.getMetrics(); this.canvas.width = metrics.width * this.cols; this.canvas.height = metrics.height * this.rows; this.canvas.style.width = `${metrics.width * this.cols}px`; this.canvas.style.height = `${metrics.height * this.rows}px`; - // Force full re-render with new font this.renderer.render(this.wasmTerm, true, this.viewportY, this); } - /** - * Parse a CSS color string to 0xRRGGBB format. - * Returns 0 if the color is undefined or invalid. - */ - private parseColorToHex(color?: string): number { - if (!color) return 0; - - // Handle hex colors (#RGB, #RRGGBB) - if (color.startsWith('#')) { - let hex = color.slice(1); - if (hex.length === 3) { - hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2]; - } - const value = Number.parseInt(hex, 16); - return Number.isNaN(value) ? 0 : value; - } - - // Handle rgb(r, g, b) format - const match = color.match(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/); - if (match) { - const r = Number.parseInt(match[1], 10); - const g = Number.parseInt(match[2], 10); - const b = Number.parseInt(match[3], 10); - return (r << 16) | (g << 8) | b; - } - - return 0; - } - - /** - * Convert terminal options to WASM terminal config. - */ - private buildWasmConfig(): GhosttyTerminalConfig | undefined { - const theme = this.options.theme; - const scrollback = this.options.scrollback; - - // If no theme and default scrollback, use defaults - if (!theme && scrollback === 10000) { - return undefined; - } - - // Build palette array from theme colors - // Order: black, red, green, yellow, blue, magenta, cyan, white, - // brightBlack, brightRed, brightGreen, brightYellow, brightBlue, brightMagenta, brightCyan, brightWhite - const palette: number[] = [ - this.parseColorToHex(theme?.black), - this.parseColorToHex(theme?.red), - this.parseColorToHex(theme?.green), - this.parseColorToHex(theme?.yellow), - this.parseColorToHex(theme?.blue), - this.parseColorToHex(theme?.magenta), - this.parseColorToHex(theme?.cyan), - this.parseColorToHex(theme?.white), - this.parseColorToHex(theme?.brightBlack), - this.parseColorToHex(theme?.brightRed), - this.parseColorToHex(theme?.brightGreen), - this.parseColorToHex(theme?.brightYellow), - this.parseColorToHex(theme?.brightBlue), - this.parseColorToHex(theme?.brightMagenta), - this.parseColorToHex(theme?.brightCyan), - this.parseColorToHex(theme?.brightWhite), - ]; - - return { - scrollbackLimit: scrollback, - fgColor: this.parseColorToHex(theme?.foreground), - bgColor: this.parseColorToHex(theme?.background), - cursorColor: this.parseColorToHex(theme?.cursor), - palette, - }; - } - // ========================================================================== // Lifecycle Methods // ========================================================================== /** * Open terminal in a parent element - * - * Initializes all components and starts rendering. - * Requires a pre-loaded Ghostty instance passed to the constructor. */ open(parent: HTMLElement): void { if (this.isOpen) { @@ -343,44 +184,33 @@ export class Terminal implements ITerminalCore { throw new Error('Terminal has been disposed'); } - // Store parent element this.element = parent; this.isOpen = true; try { - // Make parent focusable if it isn't already + // Make parent focusable if (!parent.hasAttribute('tabindex')) { parent.setAttribute('tabindex', '0'); } - // Mark as contenteditable so browser extensions (Vimium, etc.) recognize - // this as an input element and don't intercept keyboard events. parent.setAttribute('contenteditable', 'true'); - // Prevent actual content editing - we handle input ourselves parent.addEventListener('beforeinput', (e) => e.preventDefault()); - - // Add accessibility attributes for screen readers and extensions parent.setAttribute('role', 'textbox'); parent.setAttribute('aria-label', 'Terminal input'); parent.setAttribute('aria-multiline', 'true'); - // Create WASM terminal with current dimensions and config - const config = this.buildWasmConfig(); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); - // Create canvas element this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; parent.appendChild(this.canvas); - // Create hidden textarea for keyboard input (must be inside parent for event bubbling) + // Create hidden textarea for keyboard input this.textarea = document.createElement('textarea'); this.textarea.setAttribute('autocorrect', 'off'); this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); - this.textarea.setAttribute('tabindex', '0'); // Allow focus for mobile keyboard + this.textarea.setAttribute('tabindex', '0'); this.textarea.setAttribute('aria-label', 'Terminal input'); - // Use clip-path to completely hide the textarea and its caret this.textarea.style.position = 'absolute'; this.textarea.style.left = '0'; this.textarea.style.top = '0'; @@ -390,20 +220,18 @@ export class Terminal implements ITerminalCore { this.textarea.style.border = 'none'; this.textarea.style.margin = '0'; this.textarea.style.opacity = '0'; - this.textarea.style.clipPath = 'inset(50%)'; // Clip everything including caret + this.textarea.style.clipPath = 'inset(50%)'; this.textarea.style.overflow = 'hidden'; this.textarea.style.whiteSpace = 'nowrap'; this.textarea.style.resize = 'none'; parent.appendChild(this.textarea); - // Focus textarea on interaction - preventDefault before focus + // Focus textarea on interaction const textarea = this.textarea; - // Desktop: mousedown this.canvas.addEventListener('mousedown', (ev) => { ev.preventDefault(); textarea.focus(); }); - // Mobile: touchend with preventDefault to suppress iOS caret this.canvas.addEventListener('touchend', (ev) => { ev.preventDefault(); textarea.focus(); @@ -418,106 +246,89 @@ export class Terminal implements ITerminalCore { theme: this.options.theme, }); - // Size canvas to terminal dimensions (use renderer.resize for proper DPI scaling) this.renderer.resize(this.cols, this.rows); // Create input handler this.inputHandler = new InputHandler( - this.ghostty!, + this.ghostty, parent, (data: string) => { - // Check if stdin is disabled if (this.options.disableStdin) { return; } - // Input handler fires data events this.dataEmitter.fire(data); }, () => { - // Input handler can also fire bell this.bellEmitter.fire(); }, (keyEvent: IKeyEvent) => { - // Forward key events this.keyEmitter.fire(keyEvent); }, this.customKeyEventHandler, (mode: number) => { - // Query terminal mode state (e.g., mode 1 for application cursor mode) return this.wasmTerm?.getMode(mode, false) ?? false; } ); - // Create selection manager (pass textarea for context menu positioning) + // Create selection manager this.selectionManager = new SelectionManager( this, this.renderer, - this.wasmTerm, + this.wasmTerm!, this.textarea ); - // Connect selection manager to renderer this.renderer.setSelectionManager(this.selectionManager); - // Forward selection change events this.selectionManager.onSelectionChange(() => { this.selectionChangeEmitter.fire(); }); - // Setup paste event handler on textarea + // Setup paste event handler this.textarea.addEventListener('paste', (e: ClipboardEvent) => { e.preventDefault(); - e.stopPropagation(); // Prevent event from bubbling to parent (InputHandler) + e.stopPropagation(); const text = e.clipboardData?.getData('text'); if (text) { - // Use the paste() method which will handle bracketed paste mode in the future this.paste(text); } }); // Initialize link detection system this.linkDetector = new LinkDetector(this); - - // Register link providers - // OSC8 first (explicit hyperlinks take precedence) this.linkDetector.registerProvider(new OSC8LinkProvider(this)); - // URL regex second (fallback for plain text URLs) this.linkDetector.registerProvider(new UrlRegexProvider(this)); - // Setup mouse event handling for links and scrollbar - // Use capture phase to intercept scrollbar clicks before SelectionManager + // Setup mouse event handling parent.addEventListener('mousedown', this.handleMouseDown, { capture: true }); parent.addEventListener('mousemove', this.handleMouseMove); parent.addEventListener('mouseleave', this.handleMouseLeave); parent.addEventListener('click', this.handleClick); - - // Setup document-level mouseup for scrollbar drag (so drag works even outside canvas) document.addEventListener('mouseup', this.handleMouseUp); - // Setup wheel event handling for scrolling (Phase 2) - // Use capture phase to ensure we get the event before browser scrolling + // Setup wheel event handling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true }); - // Render initial blank screen (force full redraw) - this.renderer.render(this.wasmTerm, true, this.viewportY, this, this.scrollbarOpacity); + // Render initial screen + this.renderer.render(this.wasmTerm!, true, this.viewportY, this, this.scrollbarOpacity); // Start render loop this.startRenderLoop(); - // Focus input (auto-focus so user can start typing immediately) + // Focus input this.focus(); } catch (error) { - // Clean up on error this.isOpen = false; this.cleanupComponents(); throw new Error(`Failed to open terminal: ${error}`); } } - /** - * Write data to terminal - */ - write(data: string | Uint8Array, callback?: () => void): void { + // ========================================================================== + // Write Methods (override for browser-specific behavior) + // ========================================================================== + + override write(data: string | Uint8Array, callback?: () => void): void { this.assertOpen(); // Handle convertEol option @@ -528,185 +339,120 @@ export class Terminal implements ITerminalCore { this.writeInternal(data, callback); } - /** - * Internal write implementation (extracted from write()) - */ private writeInternal(data: string | Uint8Array, callback?: () => void): void { - // Note: We intentionally do NOT clear selection on write - most modern terminals - // preserve selection when new data arrives. Selection is cleared by user actions - // like clicking or typing, not by incoming data. - - // Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data); - // Process any responses generated by the terminal (e.g., DSR cursor position) - // These need to be sent back to the PTY via onData + // Process terminal responses this.processTerminalResponses(); - // Check for bell character (BEL, \x07) - // WASM doesn't expose bell events, so we detect it in the data stream + // Check for bell if (typeof data === 'string' && data.includes('\x07')) { this.bellEmitter.fire(); } else if (data instanceof Uint8Array && data.includes(0x07)) { this.bellEmitter.fire(); } - // Invalidate link cache (content changed) + // Invalidate link cache this.linkDetector?.invalidateCache(); - // Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) + // Auto-scroll to bottom on new output if (this.viewportY !== 0) { this.scrollToBottom(); } - // Check for title changes (OSC 0, 1, 2 sequences) - // This is a simplified implementation - Ghostty WASM may provide this + // Check for title changes if (typeof data === 'string' && data.includes('\x1b]')) { this.checkForTitleChange(data); } - // Call callback if provided + // Call callback if (callback) { - // Queue callback after next render requestAnimationFrame(callback); } - - // Render will happen on next animation frame - } - - /** - * Write data with newline - */ - writeln(data: string | Uint8Array, callback?: () => void): void { - if (typeof data === 'string') { - this.write(data + '\r\n', callback); - } else { - // Append \r\n to Uint8Array - const newData = new Uint8Array(data.length + 2); - newData.set(data); - newData[data.length] = 0x0d; // \r - newData[data.length + 1] = 0x0a; // \n - this.write(newData, callback); - } } /** - * Paste text into terminal (triggers bracketed paste if supported) + * Paste text into terminal */ paste(data: string): void { this.assertOpen(); - // Don't paste if stdin is disabled if (this.options.disableStdin) { return; } - // Check if terminal has bracketed paste mode enabled if (this.wasmTerm!.hasBracketedPaste()) { - // Wrap with bracketed paste sequences (DEC mode 2004) this.dataEmitter.fire('\x1b[200~' + data + '\x1b[201~'); } else { - // Send data directly this.dataEmitter.fire(data); } } /** - * Input data into terminal (as if typed by user) - * - * @param data - Data to input - * @param wasUserInput - If true, triggers onData event (default: false for compat with some apps) + * Input data into terminal */ - input(data: string, wasUserInput: boolean = false): void { + override input(data: string, wasUserInput: boolean = false): void { this.assertOpen(); - // Don't input if stdin is disabled if (this.options.disableStdin) { return; } if (wasUserInput) { - // Trigger onData event as if user typed it this.dataEmitter.fire(data); } else { - // Just write to terminal without triggering onData this.write(data); } } - /** - * Resize terminal - */ - resize(cols: number, rows: number): void { + // ========================================================================== + // Resize (override for browser-specific behavior) + // ========================================================================== + + override resize(cols: number, rows: number): void { + if (!this.isOpen) { + // Just update dimensions if not open + if (cols !== this.cols || rows !== this.rows) { + this.cols = cols; + this.rows = rows; + this.wasmTerm?.resize(cols, rows); + this.resizeEmitter.fire({ cols, rows }); + } + return; + } + this.assertOpen(); if (cols === this.cols && rows === this.rows) { - return; // No change + return; } - // Update dimensions this.cols = cols; this.rows = rows; - // Resize WASM terminal this.wasmTerm!.resize(cols, rows); - - // Resize renderer this.renderer!.resize(cols, rows); - // Update canvas dimensions const metrics = this.renderer!.getMetrics(); this.canvas!.width = metrics.width * cols; this.canvas!.height = metrics.height * rows; this.canvas!.style.width = `${metrics.width * cols}px`; this.canvas!.style.height = `${metrics.height * rows}px`; - // Fire resize event this.resizeEmitter.fire({ cols, rows }); - - // Force full render this.renderer!.render(this.wasmTerm!, true, this.viewportY, this); } - /** - * Clear terminal screen - */ - clear(): void { - this.assertOpen(); - // Send ANSI clear screen and cursor home sequences - this.wasmTerm!.write('\x1b[2J\x1b[H'); - } - - /** - * Reset terminal state - */ - reset(): void { - this.assertOpen(); - - // Free old WASM terminal and create new one - if (this.wasmTerm) { - this.wasmTerm.free(); - } - const config = this.buildWasmConfig(); - this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows, config); - - // Clear renderer - this.renderer!.clear(); - - // Reset title - this.currentTitle = ''; - } + // ========================================================================== + // Focus Methods + // ========================================================================== /** * Focus terminal input */ focus(): void { if (this.isOpen && this.element) { - // Focus immediately for immediate keyboard/wheel event handling this.element.focus(); - - // Also schedule a delayed focus as backup to ensure it sticks - // (some browsers may need this if DOM isn't fully settled) setTimeout(() => { this.element?.focus(); }, 0); @@ -714,7 +460,7 @@ export class Terminal implements ITerminalCore { } /** - * Blur terminal (remove focus) + * Blur terminal */ blur(): void { if (this.isOpen && this.element) { @@ -722,99 +468,51 @@ export class Terminal implements ITerminalCore { } } - /** - * Load an addon - */ - loadAddon(addon: ITerminalAddon): void { - addon.activate(this); - this.addons.push(addon); - } - // ========================================================================== - // Selection API (xterm.js compatible) + // Selection API // ========================================================================== - /** - * Get the selected text as a string - */ public getSelection(): string { return this.selectionManager?.getSelection() || ''; } - /** - * Check if there's an active selection - */ public hasSelection(): boolean { return this.selectionManager?.hasSelection() || false; } - /** - * Clear the current selection - */ public clearSelection(): void { this.selectionManager?.clearSelection(); } - /** - * Select all text in the terminal - */ public selectAll(): void { this.selectionManager?.selectAll(); } - /** - * Select text at specific column and row with length - */ public select(column: number, row: number, length: number): void { this.selectionManager?.select(column, row, length); } - /** - * Select entire lines from start to end - */ public selectLines(start: number, end: number): void { this.selectionManager?.selectLines(start, end); } - /** - * Get selection position as buffer range - */ - /** - * Get the current viewport Y position. - * - * This is the number of lines scrolled back from the bottom of the - * scrollback buffer. It may be fractional during smooth scrolling. - */ - public getViewportY(): number { - return this.viewportY; - } - public getSelectionPosition(): IBufferRange | undefined { return this.selectionManager?.getSelectionPosition(); } // ========================================================================== - // Phase 1: Custom Event Handlers + // Custom Event Handlers // ========================================================================== - /** - * Attach a custom keyboard event handler - * Returns true to prevent default handling - */ public attachCustomKeyEventHandler( customKeyEventHandler: (event: KeyboardEvent) => boolean ): void { this.customKeyEventHandler = customKeyEventHandler; - // Update input handler if already created if (this.inputHandler) { this.inputHandler.setCustomKeyEventHandler(customKeyEventHandler); } } - /** - * Attach a custom wheel event handler (Phase 2) - * Returns true to prevent default handling - */ public attachCustomWheelEventHandler( customWheelEventHandler?: (event: WheelEvent) => boolean ): void { @@ -822,23 +520,9 @@ export class Terminal implements ITerminalCore { } // ========================================================================== - // Link Detection Methods + // Link Detection // ========================================================================== - /** - * Register a custom link provider - * Multiple providers can be registered to detect different types of links - * - * @example - * ```typescript - * term.registerLinkProvider({ - * provideLinks(y, callback) { - * // Detect URLs, file paths, etc. - * callback(detectedLinks); - * } - * }); - * ``` - */ public registerLinkProvider(provider: ILinkProvider): void { if (!this.linkDetector) { throw new Error('Terminal must be opened before registering link providers'); @@ -847,87 +531,59 @@ export class Terminal implements ITerminalCore { } // ========================================================================== - // Phase 2: Scrolling Methods + // Scrolling Methods (override for browser-specific behavior) // ========================================================================== - /** - * Scroll viewport by a number of lines - * @param amount Number of lines to scroll (positive = down, negative = up) - */ - public scrollLines(amount: number): void { + override scrollLines(amount: number): void { if (!this.wasmTerm) { throw new Error('Terminal not open'); } const scrollbackLength = this.getScrollbackLength(); const maxScroll = scrollbackLength; - - // Calculate new viewport position - // viewportY = 0 means at bottom (no scroll) - // viewportY > 0 means scrolled up into history - // amount < 0 (scroll up) should INCREASE viewportY - // amount > 0 (scroll down) should DECREASE viewportY - // So we SUBTRACT amount (negative amount becomes positive change) const newViewportY = Math.max(0, Math.min(maxScroll, this.viewportY - amount)); if (newViewportY !== this.viewportY) { this.viewportY = newViewportY; + this._viewportY = newViewportY; this.scrollEmitter.fire(this.viewportY); - // Show scrollbar when scrolling (with auto-hide) if (scrollbackLength > 0) { this.showScrollbar(); } } } - /** - * Scroll viewport by a number of pages - * @param amount Number of pages to scroll (positive = down, negative = up) - */ - public scrollPages(amount: number): void { - this.scrollLines(amount * this.rows); - } - - /** - * Scroll viewport to the top of the scrollback buffer - */ - public scrollToTop(): void { + override scrollToTop(): void { const scrollbackLength = this.getScrollbackLength(); if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) { this.viewportY = scrollbackLength; + this._viewportY = scrollbackLength; this.scrollEmitter.fire(this.viewportY); this.showScrollbar(); } } - /** - * Scroll viewport to the bottom (current output) - */ - public scrollToBottom(): void { + override scrollToBottom(): void { if (this.viewportY !== 0) { this.viewportY = 0; + this._viewportY = 0; this.scrollEmitter.fire(this.viewportY); - // Show scrollbar briefly when scrolling to bottom if (this.getScrollbackLength() > 0) { this.showScrollbar(); } } } - /** - * Scroll viewport to a specific line in the buffer - * @param line Line number (0 = top of scrollback, scrollbackLength = bottom) - */ - public scrollToLine(line: number): void { + override scrollToLine(line: number): void { const scrollbackLength = this.getScrollbackLength(); const newViewportY = Math.max(0, Math.min(scrollbackLength, line)); if (newViewportY !== this.viewportY) { this.viewportY = newViewportY; + this._viewportY = newViewportY; this.scrollEmitter.fire(this.viewportY); - // Show scrollbar when scrolling to specific line if (scrollbackLength > 0) { this.showScrollbar(); } @@ -935,22 +591,26 @@ export class Terminal implements ITerminalCore { } /** - * Smoothly scroll to a target viewport position - * @param targetY Target viewport Y position (in lines, can be fractional) + * Get current viewport Y position (override for browser Terminal) + */ + public override getViewportY(): number { + return this.viewportY; + } + + /** + * Smoothly scroll to a target position */ private smoothScrollTo(targetY: number): void { if (!this.wasmTerm) return; const scrollbackLength = this.getScrollbackLength(); const maxScroll = scrollbackLength; - - // Clamp target to valid range const newTarget = Math.max(0, Math.min(maxScroll, targetY)); - // If smooth scrolling is disabled (duration = 0), jump immediately const duration = this.options.smoothScrollDuration ?? 100; if (duration === 0) { this.viewportY = newTarget; + this._viewportY = newTarget; this.targetViewportY = newTarget; this.scrollEmitter.fire(Math.floor(this.viewportY)); @@ -960,40 +620,29 @@ export class Terminal implements ITerminalCore { return; } - // Update target (accumulate if animation running) this.targetViewportY = newTarget; - // If animation is already running, don't restart it - // Just let it continue toward the updated target - // This prevents choppy restarts during continuous scrolling if (this.scrollAnimationFrame) { return; } - // Start new animation this.scrollAnimationStartTime = Date.now(); this.scrollAnimationStartY = this.viewportY; this.animateScroll(); } - /** - * Animation loop for smooth scrolling - * Uses asymptotic approach - moves a fraction of remaining distance each frame - */ private animateScroll = (): void => { if (!this.wasmTerm || this.scrollAnimationStartTime === undefined) { return; } const duration = this.options.smoothScrollDuration ?? 100; - - // Calculate distance to target const distance = this.targetViewportY - this.viewportY; const absDistance = Math.abs(distance); - // If very close, snap to target if (absDistance < 0.01) { this.viewportY = this.targetViewportY; + this._viewportY = this.targetViewportY; this.scrollEmitter.fire(Math.floor(this.viewportY)); const scrollbackLength = this.getScrollbackLength(); @@ -1001,31 +650,25 @@ export class Terminal implements ITerminalCore { this.showScrollbar(); } - // Animation complete this.scrollAnimationFrame = undefined; this.scrollAnimationStartTime = undefined; this.scrollAnimationStartY = undefined; return; } - // Move a fraction of the remaining distance - // At 60fps, move ~1/6 of distance per frame for ~100ms total duration - // This creates smooth deceleration toward target - const framesForDuration = (duration / 1000) * 60; // Convert ms to frame count - const moveRatio = 1 - (1 / framesForDuration) ** 2; // Ease-out + const framesForDuration = (duration / 1000) * 60; + const moveRatio = 1 - (1 / framesForDuration) ** 2; this.viewportY += distance * moveRatio; + this._viewportY = this.viewportY; - // Fire scroll event (use floor to convert fractional to integer for API) const intViewportY = Math.floor(this.viewportY); this.scrollEmitter.fire(intViewportY); - // Show scrollbar during animation const scrollbackLength = this.getScrollbackLength(); if (scrollbackLength > 0) { this.showScrollbar(); } - // Continue animation this.scrollAnimationFrame = requestAnimationFrame(this.animateScroll); }; @@ -1033,15 +676,11 @@ export class Terminal implements ITerminalCore { // Lifecycle // ========================================================================== - /** - * Dispose terminal and clean up resources - */ - dispose(): void { + override dispose(): void { if (this.isDisposed) { return; } - this.isDisposed = true; this.isOpen = false; // Stop render loop @@ -1056,63 +695,40 @@ export class Terminal implements ITerminalCore { this.scrollAnimationFrame = undefined; } - // Clear mouse move throttle timeout + // Clear mouse move throttle if (this.mouseMoveThrottleTimeout) { clearTimeout(this.mouseMoveThrottleTimeout); this.mouseMoveThrottleTimeout = undefined; } this.pendingMouseMove = undefined; - // Dispose addons - for (const addon of this.addons) { - addon.dispose(); - } - this.addons = []; - - // Clean up components + // Clean up browser components this.cleanupComponents(); - // Dispose event emitters - this.dataEmitter.dispose(); - this.resizeEmitter.dispose(); - this.bellEmitter.dispose(); + // Dispose browser-specific emitters this.selectionChangeEmitter.dispose(); this.keyEmitter.dispose(); - this.titleChangeEmitter.dispose(); - this.scrollEmitter.dispose(); this.renderEmitter.dispose(); - this.cursorMoveEmitter.dispose(); + + // Call parent dispose + super.dispose(); } // ========================================================================== // Private Methods // ========================================================================== - /** - * Start the render loop - */ private startRenderLoop(): void { const loop = () => { if (!this.isDisposed && this.isOpen) { - // Render using WASM's native dirty tracking - // The render() method: - // 1. Calls update() once to sync state and check dirty flags - // 2. Only redraws dirty rows when forceAll=false - // 3. Always calls clearDirty() at the end this.renderer!.render(this.wasmTerm!, false, this.viewportY, this, this.scrollbarOpacity); - // Check for cursor movement (Phase 2: onCursorMove event) - // Note: getCursor() reads from already-updated render state (from render() above) const cursor = this.wasmTerm!.getCursor(); if (cursor.y !== this.lastCursorY) { this.lastCursorY = cursor.y; this.cursorMoveEmitter.fire(); } - // Note: onRender event is intentionally not fired in the render loop - // to avoid performance issues. For now, consumers can use requestAnimationFrame - // if they need frame-by-frame updates. - this.animationFrameId = requestAnimationFrame(loop); } }; @@ -1121,57 +737,46 @@ export class Terminal implements ITerminalCore { /** * Get a line from native WASM scrollback buffer - * Implements IScrollbackProvider */ - public getScrollbackLine(offset: number): GhosttyCell[] | null { + public override getScrollbackLine(offset: number): GhosttyCell[] | null { if (!this.wasmTerm) return null; return this.wasmTerm.getScrollbackLine(offset); } /** * Get scrollback length from native WASM - * Implements IScrollbackProvider */ - public getScrollbackLength(): number { + public override getScrollbackLength(): number { if (!this.wasmTerm) return 0; return this.wasmTerm.getScrollbackLength(); } - /** - * Clean up components (called on dispose or error) - */ private cleanupComponents(): void { - // Dispose selection manager if (this.selectionManager) { this.selectionManager.dispose(); this.selectionManager = undefined; } - // Dispose input handler if (this.inputHandler) { this.inputHandler.dispose(); this.inputHandler = undefined; } - // Dispose renderer if (this.renderer) { this.renderer.dispose(); this.renderer = undefined; } - // Remove canvas from DOM if (this.canvas && this.canvas.parentNode) { this.canvas.parentNode.removeChild(this.canvas); this.canvas = undefined; } - // Remove textarea from DOM if (this.textarea && this.textarea.parentNode) { this.textarea.parentNode.removeChild(this.textarea); this.textarea = undefined; } - // Remove event listeners if (this.element) { this.element.removeEventListener('wheel', this.handleWheel); this.element.removeEventListener('mousedown', this.handleMouseDown, { capture: true }); @@ -1179,45 +784,30 @@ export class Terminal implements ITerminalCore { this.element.removeEventListener('mouseleave', this.handleMouseLeave); this.element.removeEventListener('click', this.handleClick); - // Remove contenteditable and accessibility attributes added in open() this.element.removeAttribute('contenteditable'); this.element.removeAttribute('role'); this.element.removeAttribute('aria-label'); this.element.removeAttribute('aria-multiline'); } - // Remove document-level listeners (only if opened) if (this.isOpen && typeof document !== 'undefined') { document.removeEventListener('mouseup', this.handleMouseUp); } - // Clean up scrollbar timers if (this.scrollbarHideTimeout) { window.clearTimeout(this.scrollbarHideTimeout); this.scrollbarHideTimeout = undefined; } - // Dispose link detector if (this.linkDetector) { this.linkDetector.dispose(); this.linkDetector = undefined; } - // Free WASM terminal - if (this.wasmTerm) { - this.wasmTerm.free(); - this.wasmTerm = undefined; - } - - // Clear references - this.ghostty = undefined; this.element = undefined; this.textarea = undefined; } - /** - * Assert terminal is open (throw if not) - */ private assertOpen(): void { if (this.isDisposed) { throw new Error('Terminal has been disposed'); @@ -1227,14 +817,13 @@ export class Terminal implements ITerminalCore { } } - /** - * Handle mouse move for link hover detection and scrollbar dragging - * Throttled to avoid blocking scroll events (except when dragging scrollbar) - */ + // ========================================================================== + // Mouse Event Handlers + // ========================================================================== + private handleMouseMove = (e: MouseEvent): void => { if (!this.canvas || !this.renderer || !this.wasmTerm) return; - // If dragging scrollbar, handle immediately without throttling if (this.isDraggingScrollbar) { this.processScrollbarDrag(e); return; @@ -1242,7 +831,6 @@ export class Terminal implements ITerminalCore { if (!this.linkDetector) return; - // Throttle to ~60fps (16ms) to avoid blocking scroll/other events if (this.mouseMoveThrottleTimeout) { this.pendingMouseMove = e; return; @@ -1260,43 +848,29 @@ export class Terminal implements ITerminalCore { }, 16); }; - /** - * Process mouse move for link detection (internal, called by throttled handler) - */ private processMouseMove(e: MouseEvent): void { if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; - // Convert mouse coordinates to terminal cell position const rect = this.canvas.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); - // Get hyperlink_id directly from the cell at this position - // Must account for viewportY (scrollback position) - const viewportRow = y; // Row in the viewport (0 to rows-1) + const viewportRow = y; let hyperlinkId = 0; - // When scrolled, fetch from scrollback or screen based on position - // NOTE: viewportY may be fractional during smooth scrolling. The renderer - // uses Math.floor(viewportY) when mapping viewport rows to scrollback vs - // screen; we mirror that logic here so link hit-testing matches what the - // user sees on screen. let line: GhosttyCell[] | null = null; const rawViewportY = this.getViewportY(); const viewportY = Math.max(0, Math.floor(rawViewportY)); if (viewportY > 0) { const scrollbackLength = this.wasmTerm.getScrollbackLength(); if (viewportRow < viewportY) { - // Mouse is over scrollback content const scrollbackOffset = scrollbackLength - viewportY + viewportRow; line = this.wasmTerm.getScrollbackLine(scrollbackOffset); } else { - // Mouse is over screen content (bottom part of viewport) const screenRow = viewportRow - viewportY; line = this.wasmTerm.getLine(screenRow); } } else { - // At bottom - just use screen buffer line = this.wasmTerm.getLine(viewportRow); } @@ -1304,74 +878,48 @@ export class Terminal implements ITerminalCore { hyperlinkId = line[x].hyperlink_id; } - // Update renderer for underline rendering const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; if (hyperlinkId !== previousHyperlinkId) { this.renderer.setHoveredHyperlinkId(hyperlinkId); - - // The 60fps render loop will pick up the change automatically - // No need to force a render - this keeps performance smooth } - // Check if there's a link at this position (for click handling and cursor) - // Buffer API expects absolute buffer coordinates (including scrollback) - // When scrolled, we need to adjust the buffer row based on viewportY const scrollbackLength = this.wasmTerm.getScrollbackLength(); let bufferRow: number; - // Use floored viewportY for buffer mapping (must match renderer & selection) const rawViewportYForBuffer = this.getViewportY(); const viewportYForBuffer = Math.max(0, Math.floor(rawViewportYForBuffer)); if (viewportYForBuffer > 0) { - // When scrolled, the buffer row depends on where in the viewport we are if (viewportRow < viewportYForBuffer) { - // Mouse is over scrollback content bufferRow = scrollbackLength - viewportYForBuffer + viewportRow; } else { - // Mouse is over screen content (bottom part of viewport) const screenRow = viewportRow - viewportYForBuffer; bufferRow = scrollbackLength + screenRow; } } else { - // At bottom - buffer row is scrollback + screen row bufferRow = scrollbackLength + viewportRow; } - // Make async call non-blocking - don't await this.linkDetector .getLinkAt(x, bufferRow) .then((link) => { - // Update hover state for cursor changes and click handling if (link !== this.currentHoveredLink) { - // Notify old link we're leaving this.currentHoveredLink?.hover?.(false); - - // Update current link this.currentHoveredLink = link; - - // Notify new link we're entering link?.hover?.(true); - // Update cursor style if (this.element) { this.element.style.cursor = link ? 'pointer' : 'text'; } - // Update renderer for underline (for regex URLs without hyperlink_id) if (this.renderer) { if (link) { - // Convert buffer coordinates to viewport coordinates const scrollbackLength = this.wasmTerm?.getScrollbackLength() || 0; - - // Calculate viewport Y for start and end positions - // Use floored viewportY so overlay rows match renderer & selection const rawViewportYForLinks = this.getViewportY(); const viewportYForLinks = Math.max(0, Math.floor(rawViewportYForLinks)); const startViewportY = link.range.start.y - scrollbackLength + viewportYForLinks; const endViewportY = link.range.end.y - scrollbackLength + viewportYForLinks; - // Only show underline if link is visible in viewport if (startViewportY < this.rows && endViewportY >= 0) { this.renderer.setHoveredLinkRange({ startX: link.range.start.x, @@ -1393,55 +941,36 @@ export class Terminal implements ITerminalCore { }); } - /** - * Handle mouse leave to clear link hover - */ private handleMouseLeave = (): void => { - // Clear hyperlink underline if (this.renderer && this.wasmTerm) { const previousHyperlinkId = (this.renderer as any).hoveredHyperlinkId || 0; if (previousHyperlinkId > 0) { this.renderer.setHoveredHyperlinkId(0); - - // The 60fps render loop will pick up the change automatically } - // Clear regex link underline this.renderer.setHoveredLinkRange(null); } if (this.currentHoveredLink) { - // Notify link we're leaving this.currentHoveredLink.hover?.(false); - - // Clear hovered link this.currentHoveredLink = undefined; - // Reset cursor if (this.element) { this.element.style.cursor = 'text'; } } }; - /** - * Handle mouse click for link activation - */ private handleClick = async (e: MouseEvent): Promise => { - // For more reliable clicking, detect the link at click time - // rather than relying on cached hover state (avoids async races) if (!this.canvas || !this.renderer || !this.linkDetector || !this.wasmTerm) return; - // Get click position const rect = this.canvas.getBoundingClientRect(); const x = Math.floor((e.clientX - rect.left) / this.renderer.charWidth); const y = Math.floor((e.clientY - rect.top) / this.renderer.charHeight); - // Calculate buffer row (same logic as processMouseMove) const viewportRow = y; const scrollbackLength = this.wasmTerm.getScrollbackLength(); let bufferRow: number; - // Use floored viewportY for buffer mapping (must match renderer & selection) const rawViewportYForClick = this.getViewportY(); const viewportYForClick = Math.max(0, Math.floor(rawViewportYForClick)); @@ -1456,111 +985,87 @@ export class Terminal implements ITerminalCore { bufferRow = scrollbackLength + viewportRow; } - // Get the link at this position const link = await this.linkDetector.getLinkAt(x, bufferRow); if (link) { - // Activate link link.activate(e); - - // Prevent default action if modifier key held if (e.ctrlKey || e.metaKey) { e.preventDefault(); } } }; - /** - * Handle wheel events for scrolling (Phase 2) - */ + // ========================================================================== + // Wheel Event Handler + // ========================================================================== + private handleWheel = (e: WheelEvent): void => { - // Always prevent default browser scrolling e.preventDefault(); e.stopPropagation(); - // Allow custom handler to override if (this.customWheelEventHandler && this.customWheelEventHandler(e)) { return; } - // Check if in alternate screen mode (vim, less, htop, etc.) const isAltScreen = this.wasmTerm?.isAlternateScreen() ?? false; if (isAltScreen) { - // Alternate screen: send arrow keys to the application - // Applications like vim handle scrolling internally - // Standard: ~3 arrow presses per wheel "click" const direction = e.deltaY > 0 ? 'down' : 'up'; - const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); // Cap at 5 + const count = Math.min(Math.abs(Math.round(e.deltaY / 33)), 5); for (let i = 0; i < count; i++) { if (direction === 'up') { - this.dataEmitter.fire('\x1B[A'); // Up arrow + this.dataEmitter.fire('\x1B[A'); } else { - this.dataEmitter.fire('\x1B[B'); // Down arrow + this.dataEmitter.fire('\x1B[B'); } } } else { - // Normal screen: scroll viewport through history with smooth scrolling - // Handle different deltaMode values for better trackpad/mouse support let deltaLines: number; if (e.deltaMode === WheelEvent.DOM_DELTA_PIXEL) { - // Pixel mode (trackpads): convert pixels to lines - // Use actual line height from renderer for accurate conversion const lineHeight = this.renderer?.getMetrics()?.height ?? 20; deltaLines = e.deltaY / lineHeight; } else if (e.deltaMode === WheelEvent.DOM_DELTA_LINE) { - // Line mode (some mice): use directly deltaLines = e.deltaY; } else if (e.deltaMode === WheelEvent.DOM_DELTA_PAGE) { - // Page mode (rare): convert pages to lines deltaLines = e.deltaY * this.rows; } else { - // Fallback: assume pixel mode with legacy divisor deltaLines = e.deltaY / 33; } - // Use smooth scrolling for any amount (no rounding needed) if (deltaLines !== 0) { - // Calculate target position - // deltaY > 0 = scroll down (decrease viewportY) - // deltaY < 0 = scroll up (increase viewportY) const targetY = this.viewportY - deltaLines; this.smoothScrollTo(targetY); } } }; - /** - * Handle mouse down for scrollbar interaction - */ + // ========================================================================== + // Scrollbar Handlers + // ========================================================================== + private handleMouseDown = (e: MouseEvent): void => { if (!this.canvas || !this.renderer || !this.wasmTerm) return; const scrollbackLength = this.wasmTerm.getScrollbackLength(); - if (scrollbackLength === 0) return; // No scrollbar if no scrollback + if (scrollbackLength === 0) return; const rect = this.canvas.getBoundingClientRect(); const mouseX = e.clientX - rect.left; const mouseY = e.clientY - rect.top; - // Calculate scrollbar dimensions (match renderer's logic) - // Use rect dimensions which are already in CSS pixels const canvasWidth = rect.width; const canvasHeight = rect.height; const scrollbarWidth = 8; const scrollbarX = canvasWidth - scrollbarWidth - 4; const scrollbarPadding = 4; - // Check if click is in scrollbar area if (mouseX >= scrollbarX && mouseX <= scrollbarX + scrollbarWidth) { - // Prevent default and stop propagation to prevent text selection e.preventDefault(); e.stopPropagation(); - e.stopImmediatePropagation(); // Stop SelectionManager from seeing this event + e.stopImmediatePropagation(); - // Calculate scrollbar thumb position and size const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; const visibleRows = this.rows; const totalLines = scrollbackLength + visibleRows; @@ -1568,52 +1073,40 @@ export class Terminal implements ITerminalCore { const scrollPosition = this.viewportY / scrollbackLength; const thumbY = scrollbarPadding + (scrollbarTrackHeight - thumbHeight) * (1 - scrollPosition); - // Check if click is on thumb if (mouseY >= thumbY && mouseY <= thumbY + thumbHeight) { - // Start dragging thumb this.isDraggingScrollbar = true; this.scrollbarDragStart = mouseY; this.scrollbarDragStartViewportY = this.viewportY; - // Prevent text selection during drag if (this.canvas) { this.canvas.style.userSelect = 'none'; this.canvas.style.webkitUserSelect = 'none'; } } else { - // Click on track - jump to position const relativeY = mouseY - scrollbarPadding; - const scrollFraction = 1 - relativeY / scrollbarTrackHeight; // Inverted: top = 1, bottom = 0 + const scrollFraction = 1 - relativeY / scrollbarTrackHeight; const targetViewportY = Math.round(scrollFraction * scrollbackLength); this.scrollToLine(Math.max(0, Math.min(scrollbackLength, targetViewportY))); } } }; - /** - * Handle mouse up for scrollbar drag - */ private handleMouseUp = (): void => { if (this.isDraggingScrollbar) { this.isDraggingScrollbar = false; this.scrollbarDragStart = null; - // Restore text selection if (this.canvas) { this.canvas.style.userSelect = ''; this.canvas.style.webkitUserSelect = ''; } - // Schedule auto-hide after drag ends if (this.scrollbarVisible && this.getScrollbackLength() > 0) { - this.showScrollbar(); // Reset the hide timer + this.showScrollbar(); } } }; - /** - * Process scrollbar drag movement - */ private processScrollbarDrag(e: MouseEvent): void { if (!this.canvas || !this.renderer || !this.wasmTerm || this.scrollbarDragStart === null) return; @@ -1624,11 +1117,8 @@ export class Terminal implements ITerminalCore { const rect = this.canvas.getBoundingClientRect(); const mouseY = e.clientY - rect.top; - // Calculate how much the mouse moved const deltaY = mouseY - this.scrollbarDragStart; - // Convert mouse delta to viewport delta - // Use rect height which is already in CSS pixels const canvasHeight = rect.height; const scrollbarPadding = 4; const scrollbarTrackHeight = canvasHeight - scrollbarPadding * 2; @@ -1636,8 +1126,6 @@ export class Terminal implements ITerminalCore { const totalLines = scrollbackLength + visibleRows; const thumbHeight = Math.max(20, (visibleRows / totalLines) * scrollbarTrackHeight); - // Calculate scroll fraction from thumb movement - // Note: thumb moves in opposite direction to viewport (thumb down = scroll down = viewportY decreases) const scrollFraction = -deltaY / (scrollbarTrackHeight - thumbHeight); const viewportDelta = Math.round(scrollFraction * scrollbackLength); @@ -1645,27 +1133,24 @@ export class Terminal implements ITerminalCore { this.scrollToLine(Math.max(0, Math.min(scrollbackLength, newViewportY))); } - /** - * Show scrollbar with fade-in and schedule auto-hide - */ + // ========================================================================== + // Scrollbar Visibility + // ========================================================================== + private showScrollbar(): void { - // Clear any existing hide timeout if (this.scrollbarHideTimeout) { window.clearTimeout(this.scrollbarHideTimeout); this.scrollbarHideTimeout = undefined; } - // If not visible, start fade-in if (!this.scrollbarVisible) { this.scrollbarVisible = true; this.scrollbarOpacity = 0; this.fadeInScrollbar(); } else { - // Already visible, just ensure it's fully opaque this.scrollbarOpacity = 1; } - // Schedule auto-hide (unless dragging) if (!this.isDraggingScrollbar) { this.scrollbarHideTimeout = window.setTimeout(() => { this.hideScrollbar(); @@ -1673,9 +1158,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Hide scrollbar with fade-out - */ private hideScrollbar(): void { if (this.scrollbarHideTimeout) { window.clearTimeout(this.scrollbarHideTimeout); @@ -1687,9 +1169,6 @@ export class Terminal implements ITerminalCore { } } - /** - * Fade in scrollbar - */ private fadeInScrollbar(): void { const startTime = Date.now(); const animate = () => { @@ -1697,7 +1176,6 @@ export class Terminal implements ITerminalCore { const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); this.scrollbarOpacity = progress; - // Trigger render to show updated opacity if (this.renderer && this.wasmTerm) { this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); } @@ -1709,9 +1187,6 @@ export class Terminal implements ITerminalCore { animate(); } - /** - * Fade out scrollbar - */ private fadeOutScrollbar(): void { const startTime = Date.now(); const startOpacity = this.scrollbarOpacity; @@ -1720,7 +1195,6 @@ export class Terminal implements ITerminalCore { const progress = Math.min(elapsed / this.SCROLLBAR_FADE_DURATION_MS, 1); this.scrollbarOpacity = startOpacity * (1 - progress); - // Trigger render to show updated opacity if (this.renderer && this.wasmTerm) { this.renderer.render(this.wasmTerm, false, this.viewportY, this, this.scrollbarOpacity); } @@ -1730,7 +1204,6 @@ export class Terminal implements ITerminalCore { } else { this.scrollbarVisible = false; this.scrollbarOpacity = 0; - // Final render to clear scrollbar completely if (this.renderer && this.wasmTerm) { this.renderer.render(this.wasmTerm, false, this.viewportY, this, 0); } @@ -1738,92 +1211,4 @@ export class Terminal implements ITerminalCore { }; animate(); } - - /** - * Process any pending terminal responses and emit them via onData. - * - * This handles escape sequences that require the terminal to send a response - * back to the PTY, such as: - * - DSR 6 (cursor position): Shell sends \x1b[6n, terminal responds with \x1b[row;colR - * - DSR 5 (operating status): Shell sends \x1b[5n, terminal responds with \x1b[0n - * - * Without this, shells like nushell that rely on cursor position queries - * will hang waiting for a response that never comes. - */ - private processTerminalResponses(): void { - if (!this.wasmTerm) return; - - // Read any pending responses from the WASM terminal - const response = this.wasmTerm.readResponse(); - if (response) { - // Send response back to the PTY via onData - // This is the same path as user keyboard input - this.dataEmitter.fire(response); - } - } - - /** - * Check for title changes in written data (OSC sequences) - * Simplified implementation - looks for OSC 0, 1, 2 - */ - private checkForTitleChange(data: string): void { - // OSC sequences: ESC ] Ps ; Pt BEL or ESC ] Ps ; Pt ST - // OSC 0 = icon + title, OSC 1 = icon, OSC 2 = title - const oscRegex = /\x1b\]([012]);([^\x07\x1b]*?)(?:\x07|\x1b\\)/g; - let match: RegExpExecArray | null = null; - - // biome-ignore lint/suspicious/noAssignInExpressions: Standard regex pattern - while ((match = oscRegex.exec(data)) !== null) { - const ps = match[1]; - const pt = match[2]; - - // OSC 0 and OSC 2 set the title - if (ps === '0' || ps === '2') { - if (pt !== this.currentTitle) { - this.currentTitle = pt; - this.titleChangeEmitter.fire(pt); - } - } - } - } - - // ============================================================================ - // Terminal Modes - // ============================================================================ - - /** - * Query terminal mode state - * - * @param mode Mode number (e.g., 2004 for bracketed paste) - * @param isAnsi True for ANSI modes, false for DEC modes (default: false) - * @returns true if mode is enabled - */ - public getMode(mode: number, isAnsi: boolean = false): boolean { - this.assertOpen(); - return this.wasmTerm!.getMode(mode, isAnsi); - } - - /** - * Check if bracketed paste mode is enabled - */ - public hasBracketedPaste(): boolean { - this.assertOpen(); - return this.wasmTerm!.hasBracketedPaste(); - } - - /** - * Check if focus event reporting is enabled - */ - public hasFocusEvents(): boolean { - this.assertOpen(); - return this.wasmTerm!.hasFocusEvents(); - } - - /** - * Check if mouse tracking is enabled - */ - public hasMouseTracking(): boolean { - this.assertOpen(); - return this.wasmTerm!.hasMouseTracking(); - } } diff --git a/package.json b/package.json index 0b93cab..2118b5e 100644 --- a/package.json +++ b/package.json @@ -3,14 +3,19 @@ "version": "0.3.0", "description": "Web-based terminal emulator using Ghostty's VT100 parser via WebAssembly", "type": "module", - "main": "./dist/ghostty-web.umd.cjs", - "module": "./dist/ghostty-web.js", + "main": "./dist/ghostty-web.cjs.js", + "module": "./dist/ghostty-web.es.js", "types": "./dist/index.d.ts", "exports": { ".": { "types": "./dist/index.d.ts", - "import": "./dist/ghostty-web.js", - "require": "./dist/ghostty-web.umd.cjs" + "import": "./dist/ghostty-web.es.js", + "require": "./dist/ghostty-web.cjs.js" + }, + "./headless": { + "types": "./dist/headless.d.ts", + "import": "./dist/headless.es.js", + "require": "./dist/headless.cjs.js" }, "./ghostty-vt.wasm": "./ghostty-vt.wasm" }, diff --git a/vite.config.js b/vite.config.js index bdd3c48..d0691bf 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,3 +1,4 @@ +import { resolve } from 'path'; import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; @@ -10,18 +11,21 @@ export default defineConfig({ dts({ include: ['lib/**/*.ts'], exclude: ['lib/**/*.test.ts'], - rollupTypes: true, // Bundle all .d.ts into single file - copyDtsFiles: false, // Don't copy individual .d.ts files + rollupTypes: true, // Don't bundle - we need separate .d.ts for each entry + copyDtsFiles: true, }), ], build: { lib: { - entry: 'lib/index.ts', + entry: { + 'ghostty-web': resolve(__dirname, 'lib/index.ts'), + headless: resolve(__dirname, 'lib/headless.ts'), + }, name: 'GhosttyWeb', fileName: (format) => { - return format === 'es' ? 'ghostty-web.js' : 'ghostty-web.umd.cjs'; + return format === 'es' ? 'ghostty-web.js' : 'ghostty-web.cjs'; }, - formats: ['es', 'umd'], + formats: ['es', 'cjs'], }, rollupOptions: { external: [], // No external dependencies