Skip to content

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.

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 a Buffer.

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
});

This is where it gets interesting. Keys are not all single characters:

Key pressedBytes receivedHex
aa61
A (Shift+a)A41
Enter\r0d
Backspace\x7f7f
Escape\x1b1b
Tab\t09
Ctrl+C\x0303
Ctrl+D\x0404
Arrow Up\x1b[A1b 5b 41
Arrow Down\x1b[B1b 5b 42
Arrow Right\x1b[C1b 5b 43
Arrow Left\x1b[D1b 5b 44
F1\x1bOP1b 4f 50
Delete\x1b[3~1b 5b 33 7e
Home\x1b[H1b 5b 48
End\x1b[F1b 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.

Let’s build a key parser:

examples/module-2/01-keypress.ts
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 };
}

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.

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);
}

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);
});