Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
23 kB · 774 lines
TypeScript
at vimscroll
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775/** * Terminal - Main terminal emulator class * * Provides an xterm.js-compatible API wrapping Ghostty's WASM terminal emulator. * * Usage: * ```typescript * const term = new Terminal({ cols: 80, rows: 24 }); * await term.open(document.getElementById('container')); * term.write('Hello, World!\n'); * term.onData(data => console.log('User typed:', data)); * ``` */
import { EventEmitter } from './event-emitter';import { Ghostty, type GhosttyCell, type GhosttyTerminal } from './ghostty';import { InputHandler } from './input-handler';import type { IBufferRange, IDisposable, IEvent, IKeyEvent, ITerminalAddon, ITerminalCore, ITerminalOptions,} from './interfaces';import { CanvasRenderer } from './renderer';import { SelectionManager } from './selection-manager';
// ============================================================================// Terminal Class// ============================================================================
export class Terminal implements ITerminalCore { // Public properties (xterm.js compatibility) public cols: number; public rows: number; public element?: HTMLElement; public textarea?: HTMLTextAreaElement;
// Options private options: Required<Omit<ITerminalOptions, 'wasmPath'>> & { wasmPath?: string; };
// Components (created on open()) private ghostty?: Ghostty; private wasmTerm?: GhosttyTerminal; private renderer?: CanvasRenderer; private inputHandler?: InputHandler; private selectionManager?: SelectionManager; private canvas?: HTMLCanvasElement;
// Event emitters private dataEmitter = new EventEmitter<string>(); private resizeEmitter = new EventEmitter<{ cols: number; rows: number }>(); private bellEmitter = new EventEmitter<void>(); private selectionChangeEmitter = new EventEmitter<void>(); private keyEmitter = new EventEmitter<IKeyEvent>(); private titleChangeEmitter = new EventEmitter<string>(); private scrollEmitter = new EventEmitter<number>(); private renderEmitter = new EventEmitter<{ start: number; end: number }>(); private cursorMoveEmitter = new EventEmitter<void>();
// Public event accessors (xterm.js compatibility) public readonly onData: IEvent<string> = this.dataEmitter.event; public readonly onResize: IEvent<{ cols: number; rows: number }> = this.resizeEmitter.event; public readonly onBell: IEvent<void> = this.bellEmitter.event; public readonly onSelectionChange: IEvent<void> = this.selectionChangeEmitter.event; public readonly onKey: IEvent<IKeyEvent> = this.keyEmitter.event; public readonly onTitleChange: IEvent<string> = this.titleChangeEmitter.event; public readonly onScroll: IEvent<number> = this.scrollEmitter.event; public readonly onRender: IEvent<{ start: number; end: number }> = this.renderEmitter.event; public readonly onCursorMove: IEvent<void> = this.cursorMoveEmitter.event;
// Lifecycle state private isOpen = false; private isDisposed = false; private animationFrameId?: number;
// Addons private addons: ITerminalAddon[] = [];
// Phase 1: Custom event handlers private customKeyEventHandler?: (event: KeyboardEvent) => boolean;
// Phase 1: Title tracking private currentTitle: string = '';
// Phase 2: Viewport and scrolling state private viewportY: number = 0; // Top line of viewport in scrollback buffer (0 = at bottom) private customWheelEventHandler?: (event: WheelEvent) => boolean; private lastCursorY: number = 0; // Track cursor position for onCursorMove
constructor(options: ITerminalOptions = {}) { // Set default options this.options = { cols: options.cols ?? 80, rows: options.rows ?? 24, cursorBlink: options.cursorBlink ?? false, cursorStyle: options.cursorStyle ?? 'block', theme: options.theme ?? {}, scrollback: options.scrollback ?? 1000, fontSize: options.fontSize ?? 15, fontFamily: options.fontFamily ?? 'monospace', allowTransparency: options.allowTransparency ?? false, convertEol: options.convertEol ?? false, disableStdin: options.disableStdin ?? false, wasmPath: options.wasmPath, // Optional - Ghostty.load() handles defaults };
this.cols = this.options.cols; this.rows = this.options.rows; }
// ========================================================================== // Lifecycle Methods // ==========================================================================
/** * Open terminal in a parent element * This initializes all components and starts rendering */ async open(parent: HTMLElement): Promise<void> { if (this.isOpen) { throw new Error('Terminal is already open'); } if (this.isDisposed) { throw new Error('Terminal has been disposed'); }
try { // Store parent element this.element = parent;
// Make parent focusable if it isn't already if (!parent.hasAttribute('tabindex')) { parent.setAttribute('tabindex', '0'); }
// Load Ghostty WASM this.ghostty = await Ghostty.load(this.options.wasmPath);
// Create WASM terminal this.wasmTerm = this.ghostty.createTerminal(this.options.cols, this.options.rows);
// Create canvas element this.canvas = document.createElement('canvas'); this.canvas.style.display = 'block'; parent.appendChild(this.canvas);
// Create renderer this.renderer = new CanvasRenderer(this.canvas, { fontSize: this.options.fontSize, fontFamily: this.options.fontFamily, cursorStyle: this.options.cursorStyle, cursorBlink: this.options.cursorBlink, 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, 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 );
// Create selection manager this.selectionManager = new SelectionManager(this, this.renderer, this.wasmTerm);
// Connect selection manager to renderer this.renderer.setSelectionManager(this.selectionManager);
// Forward selection change events this.selectionManager.onSelectionChange(() => { this.selectionChangeEmitter.fire(); });
// Setup wheel event handling for scrolling (Phase 2) // Use capture phase to ensure we get the event before browser scrolling parent.addEventListener('wheel', this.handleWheel, { passive: false, capture: true });
// Mark as open this.isOpen = true;
// Render initial blank screen this.renderer.render(this.wasmTerm, true, this.viewportY, this);
// Start render loop this.startRenderLoop();
// Focus input (auto-focus so user can start typing immediately) this.focus(); } catch (error) { // Clean up on error this.cleanupComponents(); throw new Error(`Failed to open terminal: ${error}`); } }
/** * Write data to terminal */ write(data: string | Uint8Array, callback?: () => void): void { this.assertOpen();
// Handle convertEol option if (this.options.convertEol && typeof data === 'string') { data = data.replace(/\n/g, '\r\n'); }
// Clear selection when writing new data (standard terminal behavior) if (this.selectionManager?.hasSelection()) { this.selectionManager.clearSelection(); }
// Write directly to WASM terminal (handles VT parsing internally) this.wasmTerm!.write(data);
// Phase 2: Auto-scroll to bottom on new output (xterm.js behavior) 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 if (typeof data === 'string' && data.includes('\x1b]')) { this.checkForTitleChange(data); }
// Call callback if provided 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(data: string): void { this.assertOpen();
// Don't paste if stdin is disabled if (this.options.disableStdin) { return; }
// TODO: Check if terminal has bracketed paste mode enabled // For now, just send the data directly // In full implementation: wrap with \x1b[200~ and \x1b[201~ 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: 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 { this.assertOpen();
if (cols === this.cols && rows === this.rows) { return; // No change }
// 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(); } this.wasmTerm = this.ghostty!.createTerminal(this.cols, this.rows);
// Clear renderer this.renderer!.clear();
// Reset title this.currentTitle = ''; }
/** * Focus terminal input */ focus(): void { if (this.isOpen && this.element) { // Focus the container element to receive keyboard events // Use setTimeout to ensure DOM is fully ready setTimeout(() => { this.element?.focus(); }, 0); } }
/** * Blur terminal (remove focus) */ blur(): void { if (this.isOpen && this.element) { this.element.blur(); } }
/** * Load an addon */ loadAddon(addon: ITerminalAddon): void { addon.activate(this); this.addons.push(addon); }
// ========================================================================== // Selection API (xterm.js compatible) // ==========================================================================
/** * 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 */ public getSelectionPosition(): IBufferRange | undefined { return this.selectionManager?.getSelectionPosition(); }
// ========================================================================== // Phase 1: 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 { this.customWheelEventHandler = customWheelEventHandler; }
// ========================================================================== // Phase 2: Scrolling Methods // ==========================================================================
/** * Scroll viewport by a number of lines * @param amount Number of lines to scroll (positive = down, negative = up) */ public 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.scrollEmitter.fire(this.viewportY); } }
/** * 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 { const scrollbackLength = this.getScrollbackLength(); if (scrollbackLength > 0 && this.viewportY !== scrollbackLength) { this.viewportY = scrollbackLength; this.scrollEmitter.fire(this.viewportY); } }
/** * Scroll viewport to the bottom (current output) */ public scrollToBottom(): void { if (this.viewportY !== 0) { this.viewportY = 0; this.scrollEmitter.fire(this.viewportY); } }
/** * Scroll viewport to a specific line in the buffer * @param line Line number (0 = top of scrollback, scrollbackLength = bottom) */ public 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.scrollEmitter.fire(this.viewportY); } }
// ========================================================================== // Lifecycle // ==========================================================================
/** * Dispose terminal and clean up resources */ dispose(): void { if (this.isDisposed) { return; }
this.isDisposed = true; this.isOpen = false;
// Stop render loop if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; }
// Dispose addons for (const addon of this.addons) { addon.dispose(); } this.addons = [];
// Clean up components this.cleanupComponents();
// Dispose event emitters this.dataEmitter.dispose(); this.resizeEmitter.dispose(); this.bellEmitter.dispose(); this.selectionChangeEmitter.dispose(); this.keyEmitter.dispose(); this.titleChangeEmitter.dispose(); this.scrollEmitter.dispose(); this.renderEmitter.dispose(); this.cursorMoveEmitter.dispose(); }
// ========================================================================== // Private Methods // ==========================================================================
/** * Start the render loop */ private startRenderLoop(): void { const loop = () => { if (!this.isDisposed && this.isOpen) { // Check for cursor movement (Phase 2: onCursorMove event) const cursor = this.wasmTerm!.getCursor(); if (cursor.y !== this.lastCursorY) { this.lastCursorY = cursor.y; this.cursorMoveEmitter.fire(); }
// Render only dirty lines for 60 FPS performance this.renderer!.render(this.wasmTerm!, false, this.viewportY, this);
// Note: onRender event is intentionally not fired in the render loop // to avoid performance issues. It will be added in Phase 3 with // proper dirty tracking. For now, consumers can use requestAnimationFrame // if they need frame-by-frame updates.
this.animationFrameId = requestAnimationFrame(loop); } }; loop(); }
/** * Get a line from native WASM scrollback buffer * Implements IScrollbackProvider */ public 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 { 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 wheel event listener if (this.element) { this.element.removeEventListener('wheel', this.handleWheel); }
// 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.isOpen) { throw new Error('Terminal must be opened before use. Call terminal.open(parent) first.'); } if (this.isDisposed) { throw new Error('Terminal has been disposed'); } }
/** * Handle wheel events for scrolling (Phase 2) */ 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
for (let i = 0; i < count; i++) { if (direction === 'up') { this.dataEmitter.fire('\x1B[A'); // Up arrow } else { this.dataEmitter.fire('\x1B[B'); // Down arrow } } } else { // Normal screen: scroll viewport through history // deltaY > 0 = scroll down, < 0 = scroll up // Typical wheel delta is ±100 per "click", scale to 3 lines per click const lines = Math.round(e.deltaY / 33); // ~3 lines per wheel click if (lines !== 0) { this.scrollLines(lines); } } };
/** * 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); } } } }}