2.1 Raw Mode & Keypress Events
In Module 1 every program was static. This lesson adds the other half of the TUI loop: reading keyboard input. By the end you will have a keypress handler that understands arrow keys, Enter, Escape, Ctrl+C, and printable characters.
Enabling Raw Mode
Section titled “Enabling Raw Mode”process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');Three calls, all necessary:
setRawMode(true)— switches stdin from cooked to raw mode. Every keystroke arrives immediately, unprocessed.resume()— Node.js pauses stdin streams by default to avoid memory leaks. Resume it to start receiving data.setEncoding('utf8')— tells Node to decode bytes as UTF-8 strings. Without this you get aBuffer.
Receiving Input
Section titled “Receiving Input”Once raw mode is enabled, stdin emits 'data' events for every keystroke (or paste, or escape sequence):
process.stdin.on('data', (key: string) => { // key is a string containing the raw bytes});What Keys Look Like
Section titled “What Keys Look Like”This is where it gets interesting. Keys are not all single characters:
| Key pressed | Bytes received | Hex |
|---|---|---|
a | a | 61 |
A (Shift+a) | A | 41 |
Enter | \r | 0d |
Backspace | \x7f | 7f |
Escape | \x1b | 1b |
Tab | \t | 09 |
Ctrl+C | \x03 | 03 |
Ctrl+D | \x04 | 04 |
Arrow Up | \x1b[A | 1b 5b 41 |
Arrow Down | \x1b[B | 1b 5b 42 |
Arrow Right | \x1b[C | 1b 5b 43 |
Arrow Left | \x1b[D | 1b 5b 44 |
F1 | \x1bOP | 1b 4f 50 |
Delete | \x1b[3~ | 1b 5b 33 7e |
Home | \x1b[H | 1b 5b 48 |
End | \x1b[F | 1b 5b 46 |
Page Up | \x1b[5~ | 1b 5b 35 7e |
Page Down | \x1b[6~ | 1b 5b 36 7e |
Arrow keys and function keys are escape sequences — they start with \x1b (ESC). This is why you see \x1b in both ANSI output codes and input sequences. Context tells them apart: on stdin they are keys, on stdout they are commands.
Parsing Keys
Section titled “Parsing Keys”Let’s build a key parser:
export type Key = { name: string; // 'a', 'enter', 'up', 'ctrl+c', etc. ctrl: boolean; shift: boolean; raw: string; // original string from stdin};
export function parseKey(raw: string): Key { const ctrl = false; const shift = false;
// Named escape sequences if (raw === '\x1b[A') return { name: 'up', ctrl: false, shift: false, raw }; if (raw === '\x1b[B') return { name: 'down', ctrl: false, shift: false, raw }; if (raw === '\x1b[C') return { name: 'right', ctrl: false, shift: false, raw }; if (raw === '\x1b[D') return { name: 'left', ctrl: false, shift: false, raw }; if (raw === '\x1b[H') return { name: 'home', ctrl: false, shift: false, raw }; if (raw === '\x1b[F') return { name: 'end', ctrl: false, shift: false, raw }; if (raw === '\x1b[5~') return { name: 'pageup', ctrl: false, shift: false, raw }; if (raw === '\x1b[6~') return { name: 'pagedown', ctrl: false, shift: false, raw }; if (raw === '\x1b[3~') return { name: 'delete', ctrl: false, shift: false, raw }; if (raw === '\x1bOP') return { name: 'f1', ctrl: false, shift: false, raw }; if (raw === '\x1bOQ') return { name: 'f2', ctrl: false, shift: false, raw }; if (raw === '\x1bOR') return { name: 'f3', ctrl: false, shift: false, raw }; if (raw === '\x1bOS') return { name: 'f4', ctrl: false, shift: false, raw };
// Special single characters if (raw === '\r' || raw === '\n') return { name: 'enter', ctrl: false, shift: false, raw }; if (raw === '\x7f') return { name: 'backspace', ctrl: false, shift: false, raw }; if (raw === '\x1b') return { name: 'escape', ctrl: false, shift: false, raw }; if (raw === '\t') return { name: 'tab', ctrl: false, shift: false, raw };
// Ctrl+letter: codes \x01–\x1a (Ctrl+A=1, Ctrl+B=2, ..., Ctrl+Z=26) if (raw.length === 1 && raw.charCodeAt(0) >= 1 && raw.charCodeAt(0) <= 26) { const letter = String.fromCharCode(raw.charCodeAt(0) + 96); // 'a'–'z' return { name: `ctrl+${letter}`, ctrl: true, shift: false, raw }; }
// Printable character const isUpperCase = raw.length === 1 && raw >= 'A' && raw <= 'Z'; return { name: raw, ctrl: false, shift: isUpperCase, raw };}A Minimal Interactive Program
Section titled “A Minimal Interactive Program”Let’s test it — a program that shows the last key pressed:
import chalk from 'chalk';import ansiEscapes from 'ansi-escapes';import { parseKey } from './keypress-parser';
process.stdout.write(ansiEscapes.enterAlternativeScreen);process.stdout.write(ansiEscapes.cursorHide);process.stdout.write(ansiEscapes.clearScreen);
function cleanup() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.stdin.setRawMode(true);process.stdin.resume();process.stdin.setEncoding('utf8');
process.stdout.write(ansiEscapes.cursorTo(0, 0));process.stdout.write(chalk.bold.cyan('Key Inspector') + '\n');process.stdout.write(chalk.dim('Press any key. Press Ctrl+Q to quit.\n\n'));
process.stdin.on('data', (raw: string) => { const key = parseKey(raw);
if (key.name === 'ctrl+q') { cleanup(); return; }
process.stdout.write(ansiEscapes.cursorTo(0, 4)); process.stdout.write(ansiEscapes.eraseEndLine); process.stdout.write( chalk.yellow('Key name: ') + chalk.white(key.name.padEnd(20)) + '\n' ); process.stdout.write(ansiEscapes.eraseEndLine); process.stdout.write( chalk.yellow('Raw bytes: ') + chalk.green( [...raw].map(c => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`).join(' ') ) + '\n' );});Run it and press various keys — arrows, function keys, Ctrl combinations. You will see exactly what bytes each key produces.
Why setRawMode Requires a TTY
Section titled “Why setRawMode Requires a TTY”setRawMode only works when stdin is a TTY (real terminal). If you pipe input to your program (echo "x" | npx tsx app.ts), stdin is a pipe, not a TTY, and setRawMode will throw.
Always guard it:
if (process.stdin.isTTY) { process.stdin.setRawMode(true);}Cleaning Up Raw Mode
Section titled “Cleaning Up Raw Mode”When your program exits, always restore cooked mode:
process.stdin.setRawMode(false);If you leave raw mode active, the user’s shell will be broken — they will see no echo when they type, and Ctrl+C will not work.
The cleanup pattern:
function cleanup() { process.stdin.setRawMode(false); process.stdout.write(ansiEscapes.cursorShow); process.stdout.write(ansiEscapes.exitAlternativeScreen); process.exit(0);}
process.on('SIGINT', cleanup);process.on('SIGTERM', cleanup);process.on('exit', () => { // Belt-and-suspenders: always runs, even on uncaught errors if (process.stdin.isTTY) process.stdin.setRawMode(false);});