Something went wrong. Try again.
Ghostty for the web with xterm.js API compatibility
Something went wrong. Try again.
13 kB · 503 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504/** * InputHandler - Converts browser keyboard events to terminal input * * Handles: * - Keyboard event listening on a container element * - Mapping KeyboardEvent.code to USB HID Key codes * - Extracting modifier keys (Ctrl, Alt, Shift, Meta) * - Encoding keys using Ghostty's KeyEncoder * - Emitting data for Terminal to send to PTY * * Limitations: * - Does not handle IME/composition events (CJK input) - to be added later * - Captures all keyboard input (preventDefault on everything) */
import type { Ghostty } from './ghostty';import type { KeyEncoder } from './ghostty';import { Key, KeyAction, Mods } from './types';
/** * Map KeyboardEvent.code values to USB HID Key enum values * Based on: https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code */const KEY_MAP: Record<string, Key> = { // Letters KeyA: Key.A, KeyB: Key.B, KeyC: Key.C, KeyD: Key.D, KeyE: Key.E, KeyF: Key.F, KeyG: Key.G, KeyH: Key.H, KeyI: Key.I, KeyJ: Key.J, KeyK: Key.K, KeyL: Key.L, KeyM: Key.M, KeyN: Key.N, KeyO: Key.O, KeyP: Key.P, KeyQ: Key.Q, KeyR: Key.R, KeyS: Key.S, KeyT: Key.T, KeyU: Key.U, KeyV: Key.V, KeyW: Key.W, KeyX: Key.X, KeyY: Key.Y, KeyZ: Key.Z,
// Numbers Digit1: Key.ONE, Digit2: Key.TWO, Digit3: Key.THREE, Digit4: Key.FOUR, Digit5: Key.FIVE, Digit6: Key.SIX, Digit7: Key.SEVEN, Digit8: Key.EIGHT, Digit9: Key.NINE, Digit0: Key.ZERO,
// Special keys Enter: Key.ENTER, Escape: Key.ESCAPE, Backspace: Key.BACKSPACE, Tab: Key.TAB, Space: Key.SPACE,
// Punctuation Minus: Key.MINUS, Equal: Key.EQUAL, BracketLeft: Key.BRACKET_LEFT, BracketRight: Key.BRACKET_RIGHT, Backslash: Key.BACKSLASH, Semicolon: Key.SEMICOLON, Quote: Key.QUOTE, Backquote: Key.GRAVE, Comma: Key.COMMA, Period: Key.PERIOD, Slash: Key.SLASH,
// Function keys CapsLock: Key.CAPS_LOCK, F1: Key.F1, F2: Key.F2, F3: Key.F3, F4: Key.F4, F5: Key.F5, F6: Key.F6, F7: Key.F7, F8: Key.F8, F9: Key.F9, F10: Key.F10, F11: Key.F11, F12: Key.F12,
// Special function keys PrintScreen: Key.PRINT_SCREEN, ScrollLock: Key.SCROLL_LOCK, Pause: Key.PAUSE, Insert: Key.INSERT, Home: Key.HOME, PageUp: Key.PAGE_UP, Delete: Key.DELETE, End: Key.END, PageDown: Key.PAGE_DOWN,
// Arrow keys ArrowRight: Key.RIGHT, ArrowLeft: Key.LEFT, ArrowDown: Key.DOWN, ArrowUp: Key.UP,
// Keypad NumLock: Key.NUM_LOCK, NumpadDivide: Key.KP_DIVIDE, NumpadMultiply: Key.KP_MULTIPLY, NumpadSubtract: Key.KP_MINUS, NumpadAdd: Key.KP_PLUS, NumpadEnter: Key.KP_ENTER, Numpad1: Key.KP_1, Numpad2: Key.KP_2, Numpad3: Key.KP_3, Numpad4: Key.KP_4, Numpad5: Key.KP_5, Numpad6: Key.KP_6, Numpad7: Key.KP_7, Numpad8: Key.KP_8, Numpad9: Key.KP_9, Numpad0: Key.KP_0, NumpadDecimal: Key.KP_PERIOD,
// International IntlBackslash: Key.NON_US_BACKSLASH, ContextMenu: Key.APPLICATION,
// Additional function keys F13: Key.F13, F14: Key.F14, F15: Key.F15, F16: Key.F16, F17: Key.F17, F18: Key.F18, F19: Key.F19, F20: Key.F20, F21: Key.F21, F22: Key.F22, F23: Key.F23, F24: Key.F24,};
/** * InputHandler class * Attaches keyboard event listeners to a container and converts * keyboard events to terminal input data */export class InputHandler { private encoder: KeyEncoder; private container: HTMLElement; private onDataCallback: (data: string) => void; private onBellCallback: () => void; private keydownListener: ((e: KeyboardEvent) => void) | null = null; private keypressListener: ((e: KeyboardEvent) => void) | null = null; private pasteListener: ((e: ClipboardEvent) => void) | null = null; private isDisposed = false;
/** * Create a new InputHandler * @param ghostty - Ghostty instance (for creating KeyEncoder) * @param container - DOM element to attach listeners to * @param onData - Callback for terminal data (escape sequences to send to PTY) * @param onBell - Callback for bell/beep event */ constructor( ghostty: Ghostty, container: HTMLElement, onData: (data: string) => void, onBell: () => void ) { this.encoder = ghostty.createKeyEncoder(); this.container = container; this.onDataCallback = onData; this.onBellCallback = onBell;
// Attach event listeners this.attach(); }
/** * Attach keyboard event listeners to container */ private attach(): void { // Make container focusable so it can receive keyboard events (browser only) if ( typeof this.container.hasAttribute === 'function' && typeof this.container.setAttribute === 'function' ) { if (!this.container.hasAttribute('tabindex')) { this.container.setAttribute('tabindex', '0'); }
// Add visual focus indication (only if style exists - for browser environments) if (this.container.style) { this.container.style.outline = 'none'; // Remove default outline } }
this.keydownListener = this.handleKeyDown.bind(this); this.container.addEventListener('keydown', this.keydownListener);
this.pasteListener = this.handlePaste.bind(this); this.container.addEventListener('paste', this.pasteListener); }
/** * Map KeyboardEvent.code to USB HID Key enum value * @param code - KeyboardEvent.code value * @returns Key enum value or null if unmapped */ private mapKeyCode(code: string): Key | null { return KEY_MAP[code] ?? null; }
/** * Extract modifier flags from KeyboardEvent * @param event - KeyboardEvent * @returns Mods flags */ private extractModifiers(event: KeyboardEvent): Mods { let mods = Mods.NONE;
if (event.shiftKey) mods |= Mods.SHIFT; if (event.ctrlKey) mods |= Mods.CTRL; if (event.altKey) mods |= Mods.ALT; if (event.metaKey) mods |= Mods.SUPER;
// Note: CapsLock and NumLock are not in KeyboardEvent modifiers // They would need to be tracked separately if needed // For now, we don't set CAPSLOCK or NUMLOCK flags
return mods; }
/** * Check if this is a printable character with no special modifiers * @param event - KeyboardEvent * @returns true if printable character */ private isPrintableCharacter(event: KeyboardEvent): boolean { // If Ctrl, Alt, or Meta (Cmd on Mac) is pressed, it's not a simple printable character // Exception: AltGr (Ctrl+Alt on some keyboards) can produce printable characters if (event.ctrlKey && !event.altKey) return false; if (event.altKey && !event.ctrlKey) return false; if (event.metaKey) return false; // Cmd key on Mac
// If key produces a single printable character return event.key.length === 1; }
/** * Handle keydown event * @param event - KeyboardEvent */ private handleKeyDown(event: KeyboardEvent): void { if (this.isDisposed) return;
// Allow Ctrl+V and Cmd+V to trigger paste event (don't preventDefault) if ((event.ctrlKey || event.metaKey) && event.code === 'KeyV') { // Let the browser's native paste event fire console.log('[InputHandler] ⌨️ Ctrl/Cmd+V detected, allowing paste event'); return; }
// Allow Cmd+C for copy (on Mac, Cmd+C should copy, not send interrupt) // SelectionManager handles the actual copying // Note: Ctrl+C on all platforms sends interrupt signal (0x03) if (event.metaKey && event.code === 'KeyC') { // Let browser/SelectionManager handle copy console.log('[InputHandler] ⌨️ Cmd+C detected, allowing copy'); return; }
// For printable characters without modifiers, send the character directly // This handles: a-z, A-Z (with shift), 0-9, punctuation, etc. if (this.isPrintableCharacter(event)) { event.preventDefault(); this.onDataCallback(event.key); return; }
// Map the physical key code const key = this.mapKeyCode(event.code); if (key === null) { // Unknown key - ignore it return; }
// Extract modifiers const mods = this.extractModifiers(event);
// Handle simple special keys that produce standard sequences if (mods === Mods.NONE || mods === Mods.SHIFT) { let simpleOutput: string | null = null;
switch (key) { case Key.ENTER: simpleOutput = '\r'; // Carriage return break; case Key.TAB: simpleOutput = '\t'; // Tab break; case Key.BACKSPACE: simpleOutput = '\x7F'; // DEL (most terminals use 0x7F for backspace) break; case Key.ESCAPE: simpleOutput = '\x1B'; // ESC break; // Arrow keys (CSI sequences) case Key.UP: simpleOutput = '\x1B[A'; break; case Key.DOWN: simpleOutput = '\x1B[B'; break; case Key.RIGHT: simpleOutput = '\x1B[C'; break; case Key.LEFT: simpleOutput = '\x1B[D'; break; // Navigation keys case Key.HOME: simpleOutput = '\x1B[H'; break; case Key.END: simpleOutput = '\x1B[F'; break; case Key.INSERT: simpleOutput = '\x1B[2~'; break; case Key.DELETE: simpleOutput = '\x1B[3~'; break; case Key.PAGE_UP: simpleOutput = '\x1B[5~'; break; case Key.PAGE_DOWN: simpleOutput = '\x1B[6~'; break; // Function keys case Key.F1: simpleOutput = '\x1BOP'; break; case Key.F2: simpleOutput = '\x1BOQ'; break; case Key.F3: simpleOutput = '\x1BOR'; break; case Key.F4: simpleOutput = '\x1BOS'; break; case Key.F5: simpleOutput = '\x1B[15~'; break; case Key.F6: simpleOutput = '\x1B[17~'; break; case Key.F7: simpleOutput = '\x1B[18~'; break; case Key.F8: simpleOutput = '\x1B[19~'; break; case Key.F9: simpleOutput = '\x1B[20~'; break; case Key.F10: simpleOutput = '\x1B[21~'; break; case Key.F11: simpleOutput = '\x1B[23~'; break; case Key.F12: simpleOutput = '\x1B[24~'; break; }
if (simpleOutput !== null) { event.preventDefault(); this.onDataCallback(simpleOutput); return; } }
// Determine action (we only care about PRESS for now, not RELEASE or REPEAT) const action = KeyAction.PRESS;
// For non-printable keys or keys with modifiers, encode using Ghostty try { // For letter/number keys, even with modifiers, pass the base character // This helps the encoder produce correct control sequences (e.g., Ctrl+A = 0x01) // For special keys (Enter, Arrow keys, etc.), don't pass utf8 const utf8 = event.key.length === 1 && event.key.charCodeAt(0) < 128 ? event.key.toLowerCase() // Use lowercase for consistency : undefined;
const encoded = this.encoder.encode({ action, key, mods, utf8, });
// Convert Uint8Array to string const decoder = new TextDecoder(); const data = decoder.decode(encoded);
// Prevent default browser behavior event.preventDefault(); event.stopPropagation();
// Emit the data if (data.length > 0) { this.onDataCallback(data); } } catch (error) { // Encoding failed - log but don't crash console.warn('Failed to encode key:', event.code, error); } }
/** * Handle paste event from clipboard * @param event - ClipboardEvent */ private handlePaste(event: ClipboardEvent): void { if (this.isDisposed) return;
// Prevent default paste behavior event.preventDefault(); event.stopPropagation();
// Get clipboard data const clipboardData = event.clipboardData; if (!clipboardData) { console.warn('No clipboard data available'); return; }
// Get text from clipboard const text = clipboardData.getData('text/plain'); if (!text) { console.warn('No text in clipboard'); return; }
console.log( '[InputHandler] 📋 Pasting text:', text.substring(0, 50) + (text.length > 50 ? '...' : '') );
// Send the text to the terminal // Note: For bracketed paste mode, we would wrap this in \x1b[200~ ... \x1b[201~ // but for now, send raw text this.onDataCallback(text); }
/** * Dispose the InputHandler and remove event listeners */ dispose(): void { if (this.isDisposed) return;
if (this.keydownListener) { this.container.removeEventListener('keydown', this.keydownListener); this.keydownListener = null; }
if (this.keypressListener) { this.container.removeEventListener('keypress', this.keypressListener); this.keypressListener = null; }
if (this.pasteListener) { this.container.removeEventListener('paste', this.pasteListener); this.pasteListener = null; }
this.isDisposed = true; }
/** * Check if handler is disposed */ isActive(): boolean { return !this.isDisposed; }}