/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import { useEffect, useRef } from 'react'; import { useStdin } from 'ink'; import readline from 'readline'; import { PassThrough } from 'stream'; export interface Key { name: string; ctrl: boolean; meta: boolean; shift: boolean; paste: boolean; sequence: string; } /** * A hook that listens for keypress events from stdin, providing a * key object that mirrors the one from Node's `readline` module, * adding a 'paste' flag for characters input as part of a bracketed * paste (when enabled). * * Pastes are currently sent as a single key event where the full paste * is in the sequence field. * * @param onKeypress - The callback function to execute on each keypress. * @param options - Options to control the hook's behavior. * @param options.isActive - Whether the hook should be actively listening for input. */ export function useKeypress( onKeypress: (key: Key) => void, { isActive }: { isActive: boolean }, ) { const { stdin, setRawMode } = useStdin(); const onKeypressRef = useRef(onKeypress); useEffect(() => { onKeypressRef.current = onKeypress; }, [onKeypress]); useEffect(() => { if (!isActive || !stdin.isTTY) { return; } setRawMode(true); const keypressStream = new PassThrough(); let usePassthrough = false; const nodeMajorVersion = parseInt(process.versions.node.split('.')[0], 10); if ( nodeMajorVersion < 20 || process.env['PASTE_WORKAROUND'] === '1' || process.env['PASTE_WORKAROUND'] === 'true' ) { // Prior to node 20, node's built-in readline does not support bracketed // paste mode. We hack by detecting it with our own handler. usePassthrough = true; } let isPaste = false; let pasteBuffer = Buffer.alloc(0); const handleKeypress = (_: unknown, key: Key) => { if (key.name === 'paste-start') { isPaste = true; } else if (key.name === 'paste-end') { isPaste = false; onKeypressRef.current({ name: '', ctrl: false, meta: false, shift: false, paste: true, sequence: pasteBuffer.toString(), }); pasteBuffer = Buffer.alloc(0); } else { if (isPaste) { pasteBuffer = Buffer.concat([pasteBuffer, Buffer.from(key.sequence)]); } else { // Handle special keys if (key.name === 'return' && key.sequence === '\x1B\r') { key.meta = true; } onKeypressRef.current({ ...key, paste: isPaste }); } } }; const handleRawKeypress = (data: Buffer) => { const PASTE_MODE_PREFIX = Buffer.from('\x1B[200~'); const PASTE_MODE_SUFFIX = Buffer.from('\x1B[201~'); let pos = 0; while (pos < data.length) { const prefixPos = data.indexOf(PASTE_MODE_PREFIX, pos); const suffixPos = data.indexOf(PASTE_MODE_SUFFIX, pos); // Determine which marker comes first, if any. const isPrefixNext = prefixPos !== -1 && (suffixPos === -1 || prefixPos < suffixPos); const isSuffixNext = suffixPos !== -1 && (prefixPos === -1 || suffixPos < prefixPos); let nextMarkerPos = -1; let markerLength = 0; if (isPrefixNext) { nextMarkerPos = prefixPos; } else if (isSuffixNext) { nextMarkerPos = suffixPos; } markerLength = PASTE_MODE_SUFFIX.length; if (nextMarkerPos === -1) { keypressStream.write(data.slice(pos)); return; } const nextData = data.slice(pos, nextMarkerPos); if (nextData.length > 0) { keypressStream.write(nextData); } const createPasteKeyEvent = ( name: 'paste-start' | 'paste-end', ): Key => ({ name, ctrl: false, meta: false, shift: false, paste: false, sequence: '', }); if (isPrefixNext) { handleKeypress(undefined, createPasteKeyEvent('paste-start')); } else if (isSuffixNext) { handleKeypress(undefined, createPasteKeyEvent('paste-end')); } pos = nextMarkerPos + markerLength; } }; let rl: readline.Interface; if (usePassthrough) { rl = readline.createInterface({ input: keypressStream, escapeCodeTimeout: 0, }); readline.emitKeypressEvents(keypressStream, rl); keypressStream.on('keypress', handleKeypress); stdin.on('data', handleRawKeypress); } else { rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); readline.emitKeypressEvents(stdin, rl); stdin.on('keypress', handleKeypress); } return () => { if (usePassthrough) { keypressStream.removeListener('keypress', handleKeypress); stdin.removeListener('data', handleRawKeypress); } else { stdin.removeListener('keypress', handleKeypress); } rl.close(); setRawMode(false); // If we are in the middle of a paste, send what we have. if (isPaste) { onKeypressRef.current({ name: '', ctrl: false, meta: false, shift: false, paste: true, sequence: pasteBuffer.toString(), }); pasteBuffer = Buffer.alloc(0); } }; }, [isActive, stdin, setRawMode]); }