Most developers use a terminal for years before realizing they don't actually know what a terminal is. They know how to use one. They couldn't define one. This is fine, in the same way that most people drive cars without understanding combustion. But every so often, something breaks in a way that makes the distinction matter, and then you're stuck.
The terms "terminal," "shell," "tty," and "console" get used interchangeably. They shouldn't be, but the fact that they are tells you something interesting: these concepts are so tightly coupled in practice that generations of developers have gotten by without separating them. The useful question isn't just "what do these words mean" but "why does knowing the difference matter."
This piece covers the full stack: from the vocabulary that trips people up, all the way down to building a TUI (Text User Interface) app from scratch. If you've ever wondered what vim is actually doing when it takes over your screen, or why Raw Mode exists, or what ANSI escape sequences are, this is the piece.
They used to be the same thing
This is the key insight. All four words originally described the same physical object.
In the 1960s, you interacted with a computer by sitting at a machine with a keyboard and a printer. That machine had three names depending on who was talking about it:
1 ┌─────────────────────────────────────
2 │ ┌───────────────────────────────
3 │ │ $ hello world_ │ <-- "Console" (a teletypewriter)
4 │ └───────────────────────────────
5 │ ┌───────────────────────────────
6 │ │ ┌───┐┌───┐┌───┐┌───┐┌───┐
7 │ │ │ Q ││ W ││ E ││ R ││ T │...
8 │ │ └───┘└───┘└───┘└───┘└───┘
9 │ └───────────────────────────────
10 └─────────────────────────────────────
11 │
12 │ wire
13 │
14 ┌───────┴────────
15 │ MAINFRAME
16 │ COMPUTER
17 └────────────────Three names, one thing. That's why they blur together. As hardware evolved into software, each word drifted toward its own meaning. But the overlap never fully went away.
Part 1: The Vocabulary
Console
"The console is the physical input/output device of a computer." Originally a keyboard and display directly connected to the machine, it's treated by the OS as "the system's primary standard I/O terminal." On servers, it's what appears when you connect a monitor directly. It's the terminal of last resort — the one the kernel writes panic messages to, because it's guaranteed to exist when everything else is broken.
1 Remote access (not console):
2
3 You --> laptop --> SSH --> internet --> server
4 │
5 ┌────┴────
6 │ server
7 └────┬────
8 │
9 Console access (the real deal):
10 │
11 You --> keyboard ──────────────────────>
12 monitor <──────────────────────
13
14 ^ This is the console. Direct. Physical.
15 Works even when the network is down.The word has gotten looser over time. Browser dev tools are "the console." macOS has a log viewer called Console. But the original meaning is always the same: direct, physical access to the machine.
Terminal
A terminal is the display surface you interact with. When you open iTerm2, Alacritty, Windows Terminal, or GNOME Terminal, you're running a terminal emulator — a program that does in software what the old hardware terminals did in circuits. The "emulator" part usually gets dropped, but it's worth remembering, because it clarifies the abstraction. Your terminal app is pretending to be a device.
Key insight: The terminal doesn't understand your commands. It has no idea what ls or git push means. It doesn't parse anything. It's a display surface with a keyboard attached.
1 ┌─ TERMINAL (what you see) ───────────────
2 │
3 │ Owns:
4 │ - Rendering text on screen
5 │ - Colors and fonts
6 │ - Scrollback history
7 │ - Copy-paste
8 │ - Interpreting ANSI escape sequences
9 │
10 │ Does NOT own:
11 │ - Understanding commands
12 │ - Running programs
13 │ - Tab completion
14 │ - Your prompt
15 │
16 └─────────────────────────────────────────
17
18 Examples:
19 macOS: iTerm2, Terminal.app, Alacritty, kitty
20 Linux: GNOME Terminal, Konsole
21 Windows: Windows Terminal, ConEmu
22 Web/Node: xtermThis is a clean separation, and clean separations are worth noticing. They tend to be load-bearing.
Shell
The shell is the program running inside your terminal. It's the command-line interpreter, the thing that takes the text you type, figures out what program to run, runs it, and manages the lifecycle of that process.
You type git status. Your terminal sends that text to the shell. The shell parses it, finds the git program, spawns a process, and wires up the I/O. The output flows back through the terminal to your eyes. The shell handled the logic. The terminal handled the pixels.
1 You type: git status
2 │
3 ▼
4 ┌─ TERMINAL ───────────────────────
5 │ Converts keystrokes to bytes
6 │ Sends text to the shell
7 └──────────────┬───────────────────
8 │
9 ┌─ SHELL ──────┴───────────────────
10 │ Parses: git status
11 │ Finds: /bin/git
12 │ Spawns the process
13 │ Manages its lifecycle
14 └──────────────┬───────────────────
15 │ output bytes
16 ▼
17 ┌─ TERMINAL ───────────────────────
18 │ Receives output
19 │ Renders it on your screen
20 │
21 │ On branch main
22 │ nothing to commit
23 └──────────────────────────────────The shell's main jobs are: command parsing (token splitting, redirect processing), environment variable management, process launching and control (job control), and providing scripting functionality.
There are many shells, and which one you use is a meaningful choice.
- Bash is the default, exists on virtually any Unix-like system.
- Zsh is the default on macOS and popular for its extensibility.
- PowerShell takes a different approach entirely on Windows.
The crucial thing: your terminal and your shell are independent. You can swap either one without touching the other.
1 ┌──────────────┐ ┌──────────────
2 │ iTerm2 │─────────┐ ┌─────│ Bash
3 │ Alacritty │─────────┼────┼─────│ Zsh
4 │ Kitty │─────────┼────┼─────│ Fish
5 │ Win Terminal│─────────┘ └─────│ PowerShell
6 └──────────────┘ └──────────────
7 They don't care about each other.CLI (Command Line Interface)
CLI is the broader category. It's any interface where commands go in as text and output comes back as text, as opposed to a GUI. Shells, REPLs, and TUI apps are all types of CLI. When someone says "command line," they usually mean the actual input line where you type a single command, like ls -l or git commit -m "msg". The shell parses that string and executes it.
TTY (TeleTYpewriter)
TTY is the most technical of the four terms. It originally stood for teletypewriter — the physical device. Now it refers to terminal devices in general, including virtual terminals.
Type tty right now and you'll see something like /dev/ttys003 or /dev/pts/1. That's your current session's device file.
1 ┌──────────────┐ ┌──────────────┐ ┌──────────────
2 │ │ │ │
3 │ Terminal │◄───►│ TTY device │◄───►│ Shell
4 │ (iTerm2) │ │ /dev/pts/1 │ │ (bash)
5 │ │ │ │
6 └──────────────┘ └──────────────┘ └──────────────
7 user-space kernel user-space
8
9 The TTY sits in between. It's the kernel's
10 middleman that handles the data flow.For everyday purposes, "tty" and "terminal" mean the same thing. The difference only shows up if you're writing kernel code or building a terminal emulator.
PTY (Pseudo Terminal)
A PTY is a virtual terminal device used by terminal emulators. Two device files operate as a pair: the manager side (operated by the terminal emulator) and the subsidiary side (where shells and TUI apps connect).
1 ┌─────────────────┐ ┌──────────────────
2 │ PTY Manager │ │ PTY Subsidiary
3 │ (master side) │◄───────►│ (slave side)
4 │ │
5 │ Operated by │ │ Where shells &
6 │ the terminal │ │ TUI apps
7 │ emulator │ │ connect
8 └─────────────────┘ └──────────────────In POSIX.1-2024, the master side is called "manager" and the slave side is called "subsidiary."
Since shells and TUI apps treat the subsidiary side as a "real terminal," they can use the same API (termios, etc.) as physical terminals. Applications don't need to know whether they're talking to a physical or virtual terminal.
Device file examples:
- Linux:
/dev/pts/0,/dev/pts/1… - macOS:
/dev/ttys000,/dev/ttys001…
TUI (Text User Interface)
A TUI is a character-based interface that provides an interactive UI using the entire screen. Unlike a regular CLI (shell) that executes commands line by line, a TUI controls the entire screen and redraws constantly. Think of vim, less, htop, or nmtui. They take over your whole terminal window and redraw it constantly.
1 ┌─────────────────────────────────────────────────────
2 │
3 │ CLI (shell) TUI (vim, htop)
4 │
5 │ $ ls -la ┌──────────────────────
6 │ total 48 │ htop - 3.2.1
7 │ drwxr-xr-x 5 user ... │ CPU[|||||||| 38.2%]
8 │ -rw-r--r-- 1 user ... │ Mem[||||| 512/8G]
9 │ $ _ │
10 │ │ PID USER CPU% MEM
11 │ Text flows line by line │ 1234 root 12.3 1.2
12 │ downward. That's it. │ 5678 user 8.1 0.9
13 │ └──────────────────────
14 │ Full screen. Interactive.
15 │ Redraws constantly.| Aspect | CLI (shell) | TUI | |--------|------------|-----| | Cursor | Moves to next line automatically | Moves anywhere via ANSI escapes | | Echo | On (chars shown as you type) | Off (app draws its own UI) | | Terminal mode | Canonical Mode | Raw Mode | | Examples | bash, zsh, fish, Python REPL | vim, less, htop, nmtui |
Part 2: The Plumbing Under the Hood
This is where most articles stop. But if you ever want to build a TUI app, or even just understand what vim is doing to your terminal, you need to go one layer deeper.
POSIX (Portable Operating System Interface)
POSIX is a standard specification for maintaining compatibility across Unix-like systems. Established by IEEE, it defines APIs for system calls, terminal control, file I/O, threads, and more. Most commands and system calls in macOS and Linux are POSIX-compliant.
POSIX-compliant OSes include Linux (Ubuntu, Debian, Red Hat, etc.), macOS (Darwin), BSD systems (FreeBSD, OpenBSD), Solaris, and AIX. The latest specification is POSIX.1-2024 (IEEE Std 1003.1-2024).
POSIX Terminal Interface
The standard API defined by POSIX for terminal input/output control. The termios structure and its related functions (tcgetattr(), tcsetattr()) are used to configure terminal modes, define special characters, and set baud rate. This standardization means the same code works across POSIX-compliant OSes.
Unix Terminal Interface
The traditional mechanism for terminal control in Unix-like OSes. The TTY driver manages terminal devices within the kernel, and the line discipline handles input/output processing. POSIX is a standardization of this Unix terminal interface.
TTY Line Discipline
Line discipline is a software layer in the kernel that sits between the TTY driver and user processes, handling terminal input/output processing.
1 ┌────────────────────
2 │ Terminal
3 │ Emulator
4 └────────┬───────────
5 │
6 ┌────────▼───────────
7 │ PTY (manager)
8 └────────┬───────────
9 │
10 ┌────────▼────────────────────────────────────────
11 │ Line Discipline
12 │
13 │ 1. Line editing
14 │ Backspace = delete char
15 │ Ctrl+U = delete entire line
16 │ Ctrl+W = delete word
17 │
18 │ 2. Echo back
19 │ Input chars automatically sent back to screen
20 │ So the user can see what they're typing
21 │
22 │ 3. Special character processing
23 │ Ctrl+C --> SIGINT (kill process)
24 │ Ctrl+Z --> SIGTSTP (suspend process)
25 │ Ctrl+D --> EOF (end of input)
26 │
27 │ 4. Character conversion
28 │ Newline code conversion (CR <--> LF)
29 │
30 │ 5. Input buffering
31 │ Canonical mode: buffer until Enter
32 │ Non-canonical: pass through immediately
33 │
34 └────────┬──────────────────────────────────────────
35 │
36 ┌────────▼───────────
37 │ PTY (subsidiary)
38 └────────┬───────────
39 │
40 ┌────────▼───────────
41 │ Shell / TUI App
42 └────────────────────termios
termios is the POSIX standard terminal control structure. It's how you talk to the line discipline.
1 termios structure
2 ┌────────────────────────────────────────────────────
3 │
4 │ c_iflag (Input flags)
5 │ Newline conversion, flow control, etc.
6 │
7 │ c_oflag (Output flags)
8 │ Output processing settings
9 │
10 │ c_cflag (Control flags)
11 │ Baud rate, character size, etc.
12 │
13 │ c_lflag (Local flags)
14 │ Echo, canonical mode, signal generation, etc.
15 │
16 │ c_cc (Special characters)
17 │ Definitions for Ctrl+C, Ctrl+Z, EOF, etc.
18 │
19 │ VMIN / VTIME (Timeout)
20 │ Read control in non-canonical mode
21 │
22 └────────────────────────────────────────────────────In TUI development, termios is used to implement Raw Mode.
ioctl (Input/Output Control)
ioctl is a general-purpose device control system call that instructs device drivers to perform special operations beyond normal read/write, including getting and setting termios and window size.
tcgetattr / tcsetattr
High-level API functions for terminal control defined in the POSIX standard. They let you write more portable code than using ioctl directly.
In Node.js, you don't need to call these directly. The high-level equivalent is built in:
1// High-level: Node.js handles termios internally
2process.stdin.setRawMode(true); // Equivalent to MakeRaw / tcsetattr
3process.stdin.setRawMode(false); // Equivalent to Restore / tcsetattrFor low-level access to termios from JavaScript, you'd use a native addon or FFI. More on this in the implementation section.
The Full I/O Flow
Here's everything connected, from your fingers to the application and back:
1 ┌──────┐ ┌───────────────────────
2 │ User │────>│ Terminal Emulator
3 │ │ │ (iTerm2, xterm, etc.)
4 └──────┘ └──────────┬────────────
5 │
6 ┌──────────▼────────────
7 │ PTY (manager side)
8 └──────────┬────────────
9 │
10 ┌──────────▼────────────
11 │ TTY + Line Discipline
12 │ (ICANON/ECHO/ISIG flags)
13 └──────────┬────────────
14 │
15 ┌──────────▼────────────
16 │ TUI App
17 │ (vim, htop)
18 └──────────┬────────────
19 │
20 ┌───────────────┼───────────────
21 │ │ │
22 ▼ ▼ ▼
23 termios config Output (ANSI Rendering
24 changes escape seqs) on screen
25 (Raw Mode, etc.) back to via Terminal
26 Terminal EmulatorPart 3: Terminal Behavior for TUI Development
When you open a terminal, it starts in canonical mode (ICANON), where input is buffered line by line and passed to the program after you press Enter.
1$ stty -a | grep icanon
2# icanon isig iexten echo echoe echok echoke -echonl echoctl
3# ^ Canonical mode is ONCharacters you type are automatically echoed to the screen and sent to the shell when Enter is pressed:
1$ ls
2example.txtBut TUI apps like vim or less switch the terminal to non-canonical mode. Key input is passed to the application immediately, character by character, and the application handles its own rendering.
1$ vim
2
3# In another terminal, check vim's terminal mode:
4$ ps aux | grep vim # find vim's terminal
5$ stty -a < /dev/ttys049 | grep icanon
6# -icanon -isig -iexten -echo -echoe echok echoke -echonl echoctl
7# ^ Canonical mode is OFF. Echo is OFF. Signals are OFF.The Five Elements of TUI Development
To build a TUI app, you need to understand and control five things:
1 ┌─────────────────────────────────────────────
2 │
3 │ 1. Terminal Mode Settings
4 │ Raw Mode, Canonical Mode, etc.
5 │
6 │ 2. Input Processing
7 │ Parsing keys, escape sequences, Ctrl+
8 │
9 │ 3. Screen Control
10 │ ANSI escape sequences for rendering
11 │
12 │ 4. Terminal Size Management
13 │ Detecting and responding to resizes
14 │
15 │ 5. Buffering
16 │ Batch writes to prevent flicker
17 │
18 └─────────────────────────────────────────────1. Terminal Mode Settings
The line discipline has three operating modes. You switch between them by setting flags in the termios structure.
Canonical Mode (Cooked Mode): The default. Input is buffered line by line, line editing works (Backspace, Ctrl+U, Ctrl+W), echo is on, and special characters like Ctrl+C generate signals.
Non-Canonical Mode: Canonical mode disabled (ICANON off). Input arrives character by character instead of line by line. Line editing is off. But echo and signal generation are configurable.
Raw Mode: Almost everything disabled. No echo, no signals, no newline conversion. What TUI apps use.
| Aspect | Canonical (Cooked) | Non-Canonical | Raw | |--------|-------------------|---------------|-----| | Input buffering | Line-based | Char-based | Char-based | | Line editing | Enabled | Disabled | Disabled | | Echo back | Enabled | Configurable | Disabled | | Signals (Ctrl+C) | Enabled | Configurable | Disabled | | Newline conv. | Enabled | Configurable | Disabled | | Main uses | Shell, interactive | Custom CLI | TUI, games |
stty's -cooked is synonymous with raw. Raw Mode is the opposite of Cooked Mode.
2. Input Processing
Input processing means figuring out "what key was pressed." Normal characters are simple, but arrow keys, function keys, and mouse events are sent as multi-byte escape sequences. Your TUI app needs to parse these.
Special Key Escape Sequences:
1 ┌──────────────┬──────────────┬──────────────────
2 │ Key │ Sequence │ Byte Sequence
3 ├──────────────┼──────────────┼──────────────────
4 │ Up (↑) │ ESC[A │ \x1b[A
5 │ Down (↓) │ ESC[B │ \x1b[B
6 │ Right (→) │ ESC[C │ \x1b[C
7 │ Left (←) │ ESC[D │ \x1b[D
8 │ Home │ ESC[H │ \x1b[H
9 │ End │ ESC[F │ \x1b[F
10 │ Page Up │ ESC[5~ │ \x1b[5~
11 │ Page Down │ ESC[6~ │ \x1b[6~
12 │ F1-F4 │ ESC[OP-OS │ \x1b[OP, etc.
13 └──────────────┴──────────────┴──────────────────Control Characters:
1 ┌──────────────┬──────────┬─────────────────
2 │ Character │ ASCII │ Description
3 ├──────────────┼──────────┼─────────────────
4 │ Ctrl+C │ 3 │ SIGINT
5 │ Ctrl+D │ 4 │ EOF
6 │ Ctrl+Z │ 26 │ SIGTSTP
7 │ Enter │ 13 / 10 │ CR / LF
8 │ Tab │ 9 │ Tab
9 │ Backspace │ 127 │ DEL
10 └──────────────┴──────────┴─────────────────3. Screen Control (ANSI Escape Sequences)
ANSI escape sequences are special byte sequences starting with ESC (\x1b) that control cursor movement, colors, and screen clearing. They were standardized in ANSI X3.64 and implemented in VT100 terminals, which is why they're everywhere.
Cursor Control:
1 ┌──────────────────────┬───────────────────────────────
2 │ Sequence │ Description
3 ├──────────────────────┼───────────────────────────────
4 │ ESC[H │ Move to home position (1,1)
5 │ ESC[{row};{col}H │ Move to position (1-indexed)
6 │ ESC[{n}A │ Move n rows up
7 │ ESC[{n}B │ Move n rows down
8 │ ESC[{n}C │ Move n columns right
9 │ ESC[{n}D │ Move n columns left
10 │ ESC[s │ Save cursor position
11 │ ESC[u │ Restore cursor position
12 │ ESC[?25l │ Hide cursor
13 │ ESC[?25h │ Show cursor
14 └──────────────────────┴───────────────────────────────Clearing:
1 ┌──────────────────────┬───────────────────────────────────
2 │ Sequence │ Description
3 ├──────────────────────┼───────────────────────────────────
4 │ ESC[2J │ Clear entire screen
5 │ ESC[H │ Move cursor to home
6 │ ESC[K │ Clear from cursor to end of line
7 │ ESC[1K │ Clear from start of line to cur.
8 │ ESC[2K │ Clear entire line
9 └──────────────────────┴───────────────────────────────────Basic Styles:
1 ┌──────────────────────┬──────────────
2 │ Sequence │ Description
3 ├──────────────────────┼──────────────
4 │ ESC[0m │ Reset all
5 │ ESC[1m │ Bold
6 │ ESC[4m │ Underline
7 │ ESC[7m │ Reverse
8 └──────────────────────┴──────────────Foreground Colors:
1 ┌──────────────────────┬──────────────
2 │ Sequence │ Color
3 ├──────────────────────┼──────────────
4 │ ESC[30m │ Black
5 │ ESC[31m │ Red
6 │ ESC[32m │ Green
7 │ ESC[33m │ Yellow
8 │ ESC[34m │ Blue
9 │ ESC[35m │ Magenta
10 │ ESC[36m │ Cyan
11 │ ESC[37m │ White
12 └──────────────────────┴──────────────Background Colors:
1 ┌──────────────────────┬──────────────
2 │ Sequence │ Color
3 ├──────────────────────┼──────────────
4 │ ESC[40m │ Black
5 │ ESC[41m │ Red
6 │ ESC[42m │ Green
7 │ ESC[43m │ Yellow
8 │ ESC[44m │ Blue
9 │ ESC[45m │ Magenta
10 │ ESC[46m │ Cyan
11 │ ESC[47m │ White
12 └──────────────────────┴──────────────Extended Color Modes:
1 ┌──────────────────────────────┬────────────────────────────────
2 │ Sequence │ Description
3 ├──────────────────────────────┼────────────────────────────────
4 │ ESC[38;5;{n}m │ 256-color foreground
5 │ ESC[48;5;{n}m │ 256-color background
6 │ ESC[38;2;{r};{g};{b}m │ 24-bit (True Color) foreground
7 │ ESC[48;2;{r};{g};{b}m │ 24-bit (True Color) background
8 └──────────────────────────────┴────────────────────────────────Alternate Screen Buffer:
1 ┌──────────────────────┬─────────────────────────────────────────
2 │ Sequence │ Description
3 ├──────────────────────┼─────────────────────────────────────────
4 │ ESC[?1049h │ Switch to alternate screen buffer
5 │ │ (used by vim, less, etc.)
6 │ ESC[?1049l │ Return to normal screen buffer
7 └──────────────────────┴─────────────────────────────────────────This is why vim can take over your entire screen and then your terminal looks normal after you quit. It switches to an alternate buffer on startup and switches back on exit.
4. Terminal Size Management
TUI apps need to know the terminal size to lay out their UI. When users resize windows, the terminal emulator notifies the kernel via ioctl(TIOCSWINSZ). The kernel sends SIGWINCH to connected processes, which call ioctl(TIOCGWINSZ) to get the new size and redraw.
1 User resizes window
2 │
3 ▼
4 Terminal Emulator
5 │ ioctl(TIOCSWINSZ)
6 ▼
7 Kernel TTY structure (winsize)
8 │ SIGWINCH signal
9 ▼
10 Process (bash, vim, less)
11 │
12 │ Receive signal
13 │ Call ioctl(TIOCGWINSZ) to get new size
14 │ Redraw
15 ▼
16 Updated screenTerminal size is managed by the winsize structure held in the kernel's TTY structure. When the size changes, the terminal emulator notifies via ioctl(TIOCSWINSZ), and the kernel sends SIGWINCH to connected processes.
5. Buffering
When you're sending many control sequences, sending them one by one is slow and causes visible flicker. By buffering all your writes and flushing them in one batch, you get smooth, flicker-free rendering. Every serious TUI app does this.
Part 4: Building a TUI in JavaScript/TypeScript
Now let's actually build one. We'll implement all five elements using Node.js.
High-Level Implementation
This uses Node.js's built-in process.stdin.setRawMode(). It abstracts away termios details.
1// Structure to manage terminal state
2interface Terminal {
3 width: number;
4 height: number;
5 buffer: string;
6}
7
8interface KeyEvent {
9 char: string | null;
10 key: string | null;
11}
12
13// ─────────────────────────────────────────────
14// 1. Terminal Mode Settings
15// ─────────────────────────────────────────────
16
17function initTerminal(): Terminal {
18 // Set to Raw Mode (equivalent to term.MakeRaw in Go)
19 process.stdin.setRawMode(true);
20 process.stdin.resume();
21 process.stdin.setEncoding("utf8");
22
23 const { columns, rows } = process.stdout;
24
25 return {
26 width: columns || 80,
27 height: rows || 24,
28 buffer: "",
29 };
30}
31
32function restoreTerminal(): void {
33 write("\x1b[?25h"); // Show cursor
34 write("\x1b[0m"); // Reset color
35 flush();
36 process.stdin.setRawMode(false);
37}
38
39// ─────────────────────────────────────────────
40// 4. Terminal Size Management
41// ─────────────────────────────────────────────
42
43function getTerminalSize(): { width: number; height: number } {
44 return {
45 width: process.stdout.columns || 80,
46 height: process.stdout.rows || 24,
47 };
48}
49
50// ─────────────────────────────────────────────
51// 2. Input Processing
52// ─────────────────────────────────────────────
53
54function parseKey(data: string): KeyEvent {
55 // Ctrl+C
56 if (data === "\x03") {
57 return { char: null, key: "CTRL_C" };
58 }
59
60 // Escape sequences (arrow keys, etc.)
61 if (data === "\x1b[A") return { char: null, key: "UP" };
62 if (data === "\x1b[B") return { char: null, key: "DOWN" };
63 if (data === "\x1b[C") return { char: null, key: "RIGHT" };
64 if (data === "\x1b[D") return { char: null, key: "LEFT" };
65 if (data === "\x1b[H") return { char: null, key: "HOME" };
66 if (data === "\x1b[F") return { char: null, key: "END" };
67 if (data === "\x1b[5~") return { char: null, key: "PAGE_UP" };
68 if (data === "\x1b[6~") return { char: null, key: "PAGE_DOWN" };
69
70 // Bare ESC
71 if (data === "\x1b") return { char: null, key: "ESC" };
72
73 // Normal character
74 return { char: data, key: null };
75}
76
77// ─────────────────────────────────────────────
78// 3. Screen Control (ANSI escape sequences)
79// ─────────────────────────────────────────────
80
81let outputBuffer = "";
82
83function bufferWrite(s: string): void {
84 outputBuffer += s;
85}
86
87function clear(): void {
88 bufferWrite("\x1b[2J\x1b[H");
89}
90
91function moveTo(row: number, col: number): void {
92 bufferWrite(`\x1b[${row};${col}H`);
93}
94
95function setColor(fg: number): void {
96 bufferWrite(`\x1b[${fg}m`);
97}
98
99function writeText(s: string): void {
100 bufferWrite(s);
101}
102
103// ─────────────────────────────────────────────
104// 5. Buffering
105// ─────────────────────────────────────────────
106
107function flush(): void {
108 process.stdout.write(outputBuffer);
109 outputBuffer = "";
110}
111
112function write(s: string): void {
113 process.stdout.write(s);
114}
115
116// ─────────────────────────────────────────────
117// Main
118// ─────────────────────────────────────────────
119
120function main(): void {
121 // 1. Initialize terminal (enable Raw Mode)
122 const term = initTerminal();
123
124 // Switch to alternate screen buffer
125 write("\x1b[?1049h");
126 write("\x1b[?25l"); // Hide cursor
127
128 process.on("exit", () => {
129 restoreTerminal();
130 });
131
132 process.on("SIGINT", () => {
133 restoreTerminal();
134 process.exit(0);
135 });
136
137 // 4. Detect window size changes (SIGWINCH)
138 process.on("SIGWINCH", () => {
139 const size = getTerminalSize();
140 term.width = size.width;
141 term.height = size.height;
142 });
143
144 // Cursor position
145 let x = Math.floor(term.width / 2);
146 let y = Math.floor(term.height / 2);
147
148 function render(): void {
149 // Clear screen
150 clear();
151
152 // Title
153 moveTo(1, Math.floor(term.width / 2) - 10);
154 setColor(36); // Cyan
155 writeText("TUI Demo (press 'q' to quit)");
156
157 // Display information
158 moveTo(3, 2);
159 setColor(33); // Yellow
160 writeText(`Terminal Size: ${term.width}x${term.height}`);
161
162 moveTo(4, 2);
163 writeText(`Cursor Position: (${x}, ${y})`);
164
165 // Draw frame
166 for (let row = 5; row < term.height - 1; row++) {
167 moveTo(row, 1);
168 setColor(34); // Blue
169 writeText("|");
170 moveTo(row, term.width);
171 writeText("|");
172 }
173
174 // Display cursor marker
175 moveTo(y, x);
176 setColor(32); // Green
177 writeText("●");
178
179 // Instructions
180 moveTo(term.height, 2);
181 setColor(37); // White
182 writeText("Arrow keys: move | q: quit");
183
184 // Flush buffer (reflect to screen all at once)
185 flush();
186 }
187
188 // Initial render
189 render();
190
191 // 2. Listen for key input
192 process.stdin.on("data", (data: string) => {
193 const { char, key } = parseKey(data);
194
195 // Process key
196 switch (key) {
197 case "CTRL_C":
198 restoreTerminal();
199 process.exit(0);
200 return;
201 case "UP":
202 if (y > 5) y--;
203 break;
204 case "DOWN":
205 if (y < term.height - 1) y++;
206 break;
207 case "LEFT":
208 if (x > 2) x--;
209 break;
210 case "RIGHT":
211 if (x < term.width - 1) x++;
212 break;
213 }
214
215 if (char === "q" || char === "Q") {
216 restoreTerminal();
217 process.exit(0);
218 return;
219 }
220
221 render();
222 });
223}
224
225main();Save this as program.ts and run it:
1npx tsx program.tsArrow keys move the cursor marker (●), q or Ctrl+C quits.
Low-Level Implementation: Direct termios Manipulation
The high-level version uses process.stdin.setRawMode(true), which handles termios internally. To see what's happening underneath, here's a low-level version using Node.js's execSync to directly manipulate termios flags via stty.
1import { execSync } from "child_process";
2import process from "process";
3
4// ─────────────────────────────────────────────
5// Low-level termios manipulation via stty
6// ─────────────────────────────────────────────
7
8// termios flag reference (what each flag controls):
9//
10// Input flags (c_iflag):
11// IGNBRK - Ignore break condition
12// BRKINT - Signal interrupt on break
13// PARMRK - Mark parity errors
14// ISTRIP - Strip 8th bit
15// INLCR - Translate NL to CR on input
16// IGNCR - Ignore CR on input
17// ICRNL - Translate CR to NL on input
18// IXON - Enable XON/XOFF flow control
19//
20// Output flags (c_oflag):
21// OPOST - Output processing (newline handling)
22//
23// Local flags (c_lflag):
24// ECHO - Echo input characters
25// ICANON - Canonical mode (line buffering)
26// ISIG - Enable signals (Ctrl+C, Ctrl+Z)
27// IEXTEN - Extended input processing
28//
29// Control flags (c_cflag):
30// CSIZE - Character size mask
31// PARENB - Parity enable
32// CS8 - 8 bits per character
33
34function saveTerminalState(): string {
35 // Save all current termios settings
36 return execSync("stty -g", {
37 stdio: ["inherit", "pipe", "pipe"],
38 encoding: "utf8"
39 }).trim();
40}
41
42function restoreTerminalState(savedState: string): void {
43 // Restore saved termios settings
44 execSync(`stty ${savedState}`, { stdio: "inherit" });
45}
46
47function enableRawMode(): void {
48 execSync("stty raw -echo -isig -icanon -iexten -opost min 1 time 0", {
49 stdio: "inherit",
50 });
51}
52
53function getTerminalSize(): { width: number; height: number } {
54 try {
55 const output = execSync("stty size", {
56 stdio: ["inherit", "pipe", "pipe"],
57 encoding: "utf8"
58 }).trim();
59 const [rows, cols] = output.split(" ").map(Number);
60 return { width: cols || 80, height: rows || 24 };
61 } catch {
62 return { width: 80, height: 24 };
63 }
64}
65
66// ─────────────────────────────────────────────
67// Screen control and buffering (same as high-level)
68// ─────────────────────────────────────────────
69
70let outputBuffer = "";
71
72function bufferWrite(s: string): void {
73 outputBuffer += s;
74}
75
76function flush(): void {
77 process.stdout.write(outputBuffer);
78 outputBuffer = "";
79}
80
81function clear(): void {
82 bufferWrite("\x1b[2J\x1b[H");
83}
84
85function moveTo(row: number, col: number): void {
86 bufferWrite(`\x1b[${row};${col}H`);
87}
88
89function setColor(fg: number): void {
90 bufferWrite(`\x1b[${fg}m`);
91}
92
93function writeText(s: string): void {
94 bufferWrite(s);
95}
96
97// ─────────────────────────────────────────────
98// Input processing (raw bytes)
99// ─────────────────────────────────────────────
100
101interface KeyEvent {
102 char: string | null;
103 key: string | null;
104}
105
106function parseKey(buf: Buffer): KeyEvent {
107 // Ctrl+C
108 if (buf[0] === 3) {
109 return { char: null, key: "CTRL_C" };
110 }
111
112 // Escape sequences
113 if (buf[0] === 27 && buf[1] === 91) {
114 // ESC[ = 0x1b 0x5b
115 switch (buf[2]) {
116 case 65: return { char: null, key: "UP" }; // A
117 case 66: return { char: null, key: "DOWN" }; // B
118 case 67: return { char: null, key: "RIGHT" }; // C
119 case 68: return { char: null, key: "LEFT" }; // D
120 }
121 }
122
123 if (buf[0] === 27) return { char: null, key: "ESC" };
124
125 // Normal character
126 return { char: String.fromCharCode(buf[0]), key: null };
127}
128
129// ─────────────────────────────────────────────
130// Main
131// ─────────────────────────────────────────────
132
133function main(): void {
134 // Save original termios state
135 const savedState = saveTerminalState();
136
137 // Enable Raw Mode (direct termios flag manipulation)
138 enableRawMode();
139
140 // Read stdin as raw bytes
141 process.stdin.resume();
142
143 const cleanup = (): void => {
144 // Switch back from alternate screen buffer
145 process.stdout.write("\x1b[?1049l");
146 // Show cursor
147 process.stdout.write("\x1b[?25h");
148 // Reset color
149 process.stdout.write("\x1b[0m");
150 flush();
151 restoreTerminalState(savedState);
152 };
153
154 process.on("exit", cleanup);
155
156 // Detect window size changes (SIGWINCH)
157 let { width, height } = getTerminalSize();
158 process.on("SIGWINCH", () => {
159 const size = getTerminalSize();
160 width = size.width;
161 height = size.height;
162 });
163
164 // Switch to alternate screen buffer
165 process.stdout.write("\x1b[?1049h");
166 process.stdout.write("\x1b[?25l"); // Hide cursor
167
168 // Cursor position
169 let x = Math.floor(width / 2);
170 let y = Math.floor(height / 2);
171
172 function render(): void {
173 clear();
174
175 // Title
176 moveTo(1, Math.floor(width / 2) - 15);
177 setColor(36); // Cyan
178 writeText("TUI Demo (Low-level termios API)");
179
180 // termios info
181 moveTo(3, 2);
182 setColor(33); // Yellow
183 writeText("Using stty (tcgetattr/tcsetattr under the hood)");
184
185 moveTo(4, 2);
186 writeText(`Terminal Size: ${width}x${height}`);
187
188 moveTo(5, 2);
189 writeText(`Cursor Position: (${x}, ${y})`);
190
191 // Explanation of configured flags
192 moveTo(7, 2);
193 setColor(37); // White
194 writeText("Raw Mode flags:");
195 moveTo(8, 4);
196 writeText("- ICANON off: Line buffering disabled");
197 moveTo(9, 4);
198 writeText("- ECHO off: Echo back disabled");
199 moveTo(10, 4);
200 writeText("- ISIG off: Signal generation disabled");
201 moveTo(11, 4);
202 writeText("- VMIN=1, VTIME=0: Read 1 byte immediately");
203
204 // Draw frame
205 for (let row = 13; row < height - 1; row++) {
206 moveTo(row, 1);
207 setColor(34); // Blue
208 writeText("|");
209 moveTo(row, width);
210 writeText("|");
211 }
212
213 // Cursor marker
214 moveTo(y, x);
215 setColor(32); // Green
216 writeText("●");
217
218 // Instructions
219 moveTo(height, 2);
220 setColor(37); // White
221 writeText("Arrow keys: move | q: quit");
222
223 flush();
224 }
225
226 render();
227
228 // Read raw bytes
229 process.stdin.on("data", (data: Buffer) => {
230 const { char, key } = parseKey(data);
231
232 switch (key) {
233 case "CTRL_C":
234 cleanup();
235 process.exit(0);
236 return;
237 case "UP":
238 if (y > 13) y--;
239 break;
240 case "DOWN":
241 if (y < height - 1) y++;
242 break;
243 case "LEFT":
244 if (x > 2) x--;
245 break;
246 case "RIGHT":
247 if (x < width - 1) x++;
248 break;
249 }
250
251 if (char === "q" || char === "Q") {
252 cleanup();
253 process.exit(0);
254 return;
255 }
256
257 render();
258 });
259}
260
261main();With this low-level implementation, you can see what each termios flag specifically controls, how POSIX's tcgetattr/tcsetattr map to stty commands, and what process.stdin.setRawMode(true) is doing for you behind the scenes. For true native access in Node.js without shelling out, you'd use node-ffi-napi to call libc's tcgetattr/tcsetattr directly, or a native addon.
The Full Picture
1 ┌─────────────────────────────────────────────────────
2 │ YOUR SCREEN
3 │
4 │ ┌─ Terminal (iTerm2, Alacritty, etc.) ──────────
5 │ │
6 │ │ $ grep -r "TODO" ./src
7 │ │ src/app.js: // TODO fix this
8 │ │ src/util.js: // TODO refactor
9 │ │ $ _
10 │ │
11 │ └────────────────────────────────────────────────
12 └─────────────────────────────────────────────────────
13 │
14 ┌───────────┴─────────────────────────────
15 │ PTY device Kernel layer
16 │ /dev/pts/1 (the plumbing)
17 │
18 │ Line Discipline
19 │ termios flags
20 └─────────┬─────────
21 │
22 ┌─────────┴─────────
23 │ Shell Interprets commands
24 │ (zsh, bash) Runs programs
25 └─────────┬─────────
26 │
27 ┌─────────┴─────────
28 │ grep process The actual program
29 │ your shell launched
30 └───────────────────
31
32 And if all of this is on a monitor + keyboard
33 plugged directly into the machine?
34
35 Then the terminal is also the CONSOLE.Quick Reference: When Things Break
1 Problem Which layer?
2 ──────────────────────────────── ─────────────────────────
3 Works in bash, not zsh Shell
4 Font rendering is ugly Terminal
5 Ctrl+C not killing process TTY / Line Discipline
6 TUI app not getting keypresses termios (Raw Mode)
7 Screen flickers on redraw Buffering
8 Layout breaks on window resize SIGWINCH handlingSummary
This was a tour of the full stack of terminal knowledge, from vocabulary to implementation:
Terminology and concepts: the relationships between Terminal, Shell, TTY, Console, Line Discipline, termios, PTY, POSIX, and how they all fit together.
Five elements of TUI development: terminal mode settings (Canonical/Non-Canonical/Raw), input processing (parsing escape sequences and control characters), screen control (ANSI escape sequences for cursor, color, clearing, alternate screen buffer), terminal size management (SIGWINCH), and buffering (preventing flicker).
Implementation: a high-level Node.js TUI using process.stdin.setRawMode(), and a low-level version directly manipulating termios flags via stty to show what the high-level API does underneath. Both demonstrate the same five elements.
Understanding these layers means you know where to look when something breaks. And knowing where to look is half of debugging.
References
Specifications and Standards
- POSIX.1-2024 – General Terminal Interface
- termios(3) – Linux manual page
- TTY Line Discipline – Linux Kernel Documentation
Wikipedia
- Computer terminal
- Terminal emulator
- Text-based user interface
- POSIX terminal interface
- Pseudoterminal
- ANSI escape code



