Skip to main content
Glama
keyboard.ts11 kB
/** * Keyboard Input Module * * Provides raw mode keyboard capture for single-key navigation. * Handles ANSI escape sequences for special keys and ensures * proper terminal cleanup on exit. * * @package WP_Navigator_Pro * @since 2.5.0 * * @example * import { createKeyboardInput } from './keyboard.js'; * * const keyboard = createKeyboardInput(); * if (keyboard.setup()) { * const key = await keyboard.waitForKey(); * console.log('Pressed:', key.key); * keyboard.cleanup(); * } */ // ============================================================================= // Types // ============================================================================= /** * Represents a single key press event */ export interface KeyEvent { /** Normalized key name (e.g., 'b', 'enter', 'escape', 'up', 'down') */ key: string; /** Raw bytes received from stdin */ raw: Buffer; /** Whether Ctrl modifier was held */ ctrl: boolean; /** Whether the key is a navigation key (b, h, q, r, enter) */ isNavKey: boolean; } /** * Keyboard input controller interface */ export interface KeyboardInput { /** Enable raw mode input. Returns false if not supported (non-TTY). */ setup(): boolean; /** Restore normal terminal mode. MUST be called before exit. */ cleanup(): void; /** Wait for a single key press. Resolves when a key is pressed. */ waitForKey(): Promise<KeyEvent>; /** Check if keyboard input is currently active (raw mode enabled) */ isActive(): boolean; /** Check if raw mode is supported in current environment */ isSupported(): boolean; } /** * Options for keyboard input creation */ export interface KeyboardInputOptions { /** Input stream (default: process.stdin) */ input?: NodeJS.ReadStream; /** Whether to auto-register cleanup handlers (default: true) */ autoCleanup?: boolean; } // ============================================================================= // Key Parsing Constants // ============================================================================= /** * ANSI escape sequence mappings for special keys * Most terminals send these sequences for arrow keys, etc. */ const ESCAPE_SEQUENCES: Record<string, string> = { // Arrow keys (most terminals) '\x1b[A': 'up', '\x1b[B': 'down', '\x1b[C': 'right', '\x1b[D': 'left', // Arrow keys (alternative sequences) '\x1bOA': 'up', '\x1bOB': 'down', '\x1bOC': 'right', '\x1bOD': 'left', // Function keys '\x1bOP': 'f1', '\x1bOQ': 'f2', '\x1bOR': 'f3', '\x1bOS': 'f4', // Navigation keys '\x1b[H': 'home', '\x1b[F': 'end', '\x1b[5~': 'pageup', '\x1b[6~': 'pagedown', '\x1b[2~': 'insert', '\x1b[3~': 'delete', // Escape key alone (when followed by nothing) '\x1b': 'escape', }; /** * Single-byte control character mappings */ const CONTROL_CHARS: Record<number, string> = { 0: 'ctrl+@', // Ctrl+@ 1: 'ctrl+a', 2: 'ctrl+b', 3: 'ctrl+c', // SIGINT 4: 'ctrl+d', // EOF 5: 'ctrl+e', 6: 'ctrl+f', 7: 'ctrl+g', 8: 'backspace', // Ctrl+H or Backspace 9: 'tab', // Ctrl+I or Tab 10: 'enter', // Ctrl+J or Line Feed 11: 'ctrl+k', 12: 'ctrl+l', 13: 'enter', // Ctrl+M or Carriage Return 14: 'ctrl+n', 15: 'ctrl+o', 16: 'ctrl+p', 17: 'ctrl+q', 18: 'ctrl+r', 19: 'ctrl+s', 20: 'ctrl+t', 21: 'ctrl+u', 22: 'ctrl+v', 23: 'ctrl+w', 24: 'ctrl+x', 25: 'ctrl+y', 26: 'ctrl+z', // SIGTSTP 27: 'escape', // ESC 127: 'backspace', // DEL }; /** * Navigation keys that the wizard responds to */ const NAVIGATION_KEYS = new Set(['b', 'h', 'q', 'r', 'enter', 'escape', 'y', 'n']); // ============================================================================= // Key Parsing // ============================================================================= /** * Parse raw bytes into a KeyEvent * * @param data - Raw bytes from stdin * @returns Parsed key event */ export function parseKey(data: Buffer): KeyEvent { const raw = data; const str = data.toString('utf8'); // Check for escape sequences first (multi-byte) if (str.length > 1 && str.startsWith('\x1b')) { const mappedKey = ESCAPE_SEQUENCES[str]; if (mappedKey) { return { key: mappedKey, raw, ctrl: false, isNavKey: NAVIGATION_KEYS.has(mappedKey), }; } // Unknown escape sequence - return the escape itself return { key: 'escape', raw, ctrl: false, isNavKey: true, }; } // Single byte input if (data.length === 1) { const byte = data[0]; // Control characters (0-31 and 127) if (byte < 32 || byte === 127) { const mapped = CONTROL_CHARS[byte]; const isCtrl = mapped?.startsWith('ctrl+') ?? false; const key = mapped ?? `ctrl+${String.fromCharCode(byte + 64).toLowerCase()}`; return { key, raw, ctrl: isCtrl, isNavKey: NAVIGATION_KEYS.has(key), }; } // Regular printable character const char = str.toLowerCase(); return { key: char, raw, ctrl: false, isNavKey: NAVIGATION_KEYS.has(char), }; } // Multi-byte UTF-8 character (emoji, unicode, etc.) return { key: str, raw, ctrl: false, isNavKey: false, }; } // ============================================================================= // Keyboard Input Factory // ============================================================================= /** * Create a new keyboard input controller * * @param options - Keyboard input configuration * @returns KeyboardInput instance */ export function createKeyboardInput(options: KeyboardInputOptions = {}): KeyboardInput { const { input = process.stdin, autoCleanup = true } = options; // State let isRawMode = false; let wasRawMode = false; let cleanupRegistered = false; let dataHandler: ((data: Buffer) => void) | null = null; /** * Check if raw mode is supported */ function isSupported(): boolean { return typeof input.setRawMode === 'function' && input.isTTY === true; } /** * Enable raw mode for single-key capture */ function setup(): boolean { if (!isSupported()) { return false; } if (isRawMode) { return true; // Already set up } try { // Save previous raw mode state wasRawMode = input.isRaw ?? false; // Enable raw mode input.setRawMode!(true); input.resume(); isRawMode = true; // Register cleanup handlers if not already done if (autoCleanup && !cleanupRegistered) { registerCleanupHandlers(); cleanupRegistered = true; } return true; } catch { return false; } } /** * Restore normal terminal mode */ function cleanup(): void { if (!isRawMode) { return; } try { // Remove data handler if present if (dataHandler) { input.removeListener('data', dataHandler); dataHandler = null; } // Restore raw mode state if (isSupported()) { input.setRawMode!(wasRawMode); if (!wasRawMode) { input.pause(); } } isRawMode = false; } catch { // Ignore cleanup errors (terminal might be gone) } } /** * Register process exit handlers for cleanup */ function registerCleanupHandlers(): void { // Normal exit process.on('exit', cleanup); // Ctrl+C process.on('SIGINT', () => { cleanup(); process.exit(130); // Standard exit code for SIGINT }); // Kill signal process.on('SIGTERM', () => { cleanup(); process.exit(143); // Standard exit code for SIGTERM }); // Uncaught exception - cleanup before crashing process.on('uncaughtException', (err) => { cleanup(); console.error('Uncaught exception:', err); process.exit(1); }); } /** * Wait for a single key press */ function waitForKey(): Promise<KeyEvent> { return new Promise((resolve, reject) => { if (!isRawMode) { reject(new Error('Keyboard not set up. Call setup() first.')); return; } // One-time data handler const handler = (data: Buffer): void => { input.removeListener('data', handler); dataHandler = null; resolve(parseKey(data)); }; dataHandler = handler; input.on('data', handler); }); } /** * Check if raw mode is currently active */ function isActive(): boolean { return isRawMode; } return { setup, cleanup, waitForKey, isActive, isSupported, }; } // ============================================================================= // Utility Functions // ============================================================================= /** * Wait for a specific key or set of keys * * @param keyboard - Keyboard input instance * @param validKeys - Set of valid key names to accept * @returns The pressed key event */ export async function waitForKeys( keyboard: KeyboardInput, validKeys: Set<string> ): Promise<KeyEvent> { while (true) { const event = await keyboard.waitForKey(); if (validKeys.has(event.key)) { return event; } // Ignore invalid keys, wait for next } } /** * Wait for Enter key * * @param keyboard - Keyboard input instance * @returns The enter key event */ export async function waitForEnter(keyboard: KeyboardInput): Promise<KeyEvent> { return waitForKeys(keyboard, new Set(['enter'])); } /** * Wait for Y/N confirmation * * @param keyboard - Keyboard input instance * @returns true if Y was pressed, false if N */ export async function waitForYesNo(keyboard: KeyboardInput): Promise<boolean> { const event = await waitForKeys(keyboard, new Set(['y', 'n', 'enter', 'escape'])); return event.key === 'y'; } /** * Check if a key event is a quit signal (Ctrl+C or Q) * * @param event - Key event to check * @returns true if this is a quit signal */ export function isQuitKey(event: KeyEvent): boolean { return event.key === 'q' || event.key === 'ctrl+c'; } /** * Check if a key event is a back navigation key * * @param event - Key event to check * @returns true if this is a back key */ export function isBackKey(event: KeyEvent): boolean { return event.key === 'b' || event.key === 'escape'; } /** * Check if a key event is a help key * * @param event - Key event to check * @returns true if this is a help key */ export function isHelpKey(event: KeyEvent): boolean { return event.key === 'h' || event.key === '?'; } /** * Check if a key event is a retry key * * @param event - Key event to check * @returns true if this is a retry key */ export function isRetryKey(event: KeyEvent): boolean { return event.key === 'r'; } /** * Check if a key event is a continue/confirm key * * @param event - Key event to check * @returns true if this is a continue key */ export function isContinueKey(event: KeyEvent): boolean { return event.key === 'enter' || event.key === 'y'; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/littlebearapps/wp-navigator-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server