The Full Stack of Terminals Explained: Terminal, Shell, TTY, Console, POSIX, ANSI Escapes & PTYs

A deep dive into the full stack of terminals — what makes a terminal, how shells work, what TTY and PTY mean, POSIX standards, ANSI escape codes, and more.

26 min readFeb 22, 2026

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 └───────────────────────────────────── 1112 │ wire 1314 ┌───────┴──────── 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 45 ┌────┴──── 6 │ server 7 └────┬──── 89 Console access (the real deal): 1011 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) ─────────────── 23 │ Owns: 4 │ - Rendering text on screen 5 │ - Colors and fonts 6 │ - Scrollback history 7 │ - Copy-paste 8 │ - Interpreting ANSI escape sequences 910 │ Does NOT own: 11 │ - Understanding commands 12 │ - Running programs 13 │ - Tab completion 14 │ - Your prompt 1516 └───────────────────────────────────────── 17 18 Examples: 19 macOS: iTerm2, Terminal.app, Alacritty, kitty 20 Linux: GNOME Terminal, Konsole 21 Windows: Windows Terminal, ConEmu 22 Web/Node: xterm

This 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 234 ┌─ TERMINAL ─────────────────────── 5 │ Converts keystrokes to bytes 6 │ Sends text to the shell 7 └──────────────┬─────────────────── 89 ┌─ SHELL ──────┴─────────────────── 10 │ Parses: git status 11 │ Finds: /bin/git 12 │ Spawns the process 13 │ Manages its lifecycle 14 └──────────────┬─────────────────── 15 │ output bytes 1617 ┌─ TERMINAL ─────────────────────── 18 │ Receives output 19 │ Renders it on your screen 2021 │ 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 ┌───────────────────────────────────────────────────── 23 │ CLI (shell) TUI (vim, htop) 45 │ $ 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 └────────┬─────────── 56 ┌────────▼─────────── 7 │ PTY (manager) 8 └────────┬─────────── 910 ┌────────▼──────────────────────────────────────── 11 │ Line Discipline 1213 │ 1. Line editing 14 │ Backspace = delete char 15 │ Ctrl+U = delete entire line 16 │ Ctrl+W = delete word 1718 │ 2. Echo back 19 │ Input chars automatically sent back to screen 20 │ So the user can see what they're typing 2122 │ 3. Special character processing 23 │ Ctrl+C --> SIGINT (kill process) 24 │ Ctrl+Z --> SIGTSTP (suspend process) 25 │ Ctrl+D --> EOF (end of input) 2627 │ 4. Character conversion 28 │ Newline code conversion (CR <--> LF) 2930 │ 5. Input buffering 31 │ Canonical mode: buffer until Enter 32 │ Non-canonical: pass through immediately 3334 └────────┬────────────────────────────────────────── 3536 ┌────────▼─────────── 37 │ PTY (subsidiary) 38 └────────┬─────────── 3940 ┌────────▼─────────── 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 ┌──────────────────────────────────────────────────── 34 │ c_iflag (Input flags) 5 │ Newline conversion, flow control, etc. 67 │ c_oflag (Output flags) 8 │ Output processing settings 910 │ c_cflag (Control flags) 11 │ Baud rate, character size, etc. 1213 │ c_lflag (Local flags) 14 │ Echo, canonical mode, signal generation, etc. 1516 │ c_cc (Special characters) 17 │ Definitions for Ctrl+C, Ctrl+Z, EOF, etc. 1819 │ VMIN / VTIME (Timeout) 20 │ Read control in non-canonical mode 2122 └────────────────────────────────────────────────────

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 / tcsetattr

For 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 └──────┘ └──────────┬──────────── 56 ┌──────────▼──────────── 7 │ PTY (manager side) 8 └──────────┬──────────── 910 ┌──────────▼──────────── 11 │ TTY + Line Discipline 12 │ (ICANON/ECHO/ISIG flags) 13 └──────────┬──────────── 1415 ┌──────────▼──────────── 16 │ TUI App 17 │ (vim, htop) 18 └──────────┬──────────── 1920 ┌───────────────┼─────────────── 21 │ │ │ 22 ▼ ▼ ▼ 23 termios config Output (ANSI Rendering 24 changes escape seqs) on screen 25 (Raw Mode, etc.) back to via Terminal 26 Terminal Emulator

Part 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 ON

Characters you type are automatically echoed to the screen and sent to the shell when Enter is pressed:

1$ ls 2example.txt

But 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 ┌───────────────────────────────────────────── 23 │ 1. Terminal Mode Settings 4 │ Raw Mode, Canonical Mode, etc. 56 │ 2. Input Processing 7 │ Parsing keys, escape sequences, Ctrl+ 89 │ 3. Screen Control 10 │ ANSI escape sequences for rendering 1112 │ 4. Terminal Size Management 13 │ Detecting and responding to resizes 1415 │ 5. Buffering 16 │ Batch writes to prevent flicker 1718 └─────────────────────────────────────────────

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 234 Terminal Emulator 5 │ ioctl(TIOCSWINSZ) 67 Kernel TTY structure (winsize) 8 │ SIGWINCH signal 910 Process (bash, vim, less) 1112 │ Receive signal 13 │ Call ioctl(TIOCGWINSZ) to get new size 14 │ Redraw 1516 Updated screen

Terminal 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.ts

Arrow 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 34 │ ┌─ 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 └───────────────────────────────────────────────────── 1314 ┌───────────┴───────────────────────────── 15 │ PTY device Kernel layer 16 │ /dev/pts/1 (the plumbing) 1718 │ Line Discipline 19 │ termios flags 20 └─────────┬───────── 2122 ┌─────────┴───────── 23 │ Shell Interprets commands 24 │ (zsh, bash) Runs programs 25 └─────────┬───────── 2627 ┌─────────┴───────── 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 handling

Summary

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

Wikipedia

Tutorials and Documentation

" Command Code is continuously learning my coding taste, after a week it stopped making the mistakes I kept fixing with other coding agents.It learns from what you keep and what you delete."

Zeno Rocha
Zeno Rocha
Founder · Resend

//community

Community

What developers and founders are saying about Command Code.

80% fewer corrections

"After a week, I stopped fixing AI code manually. Command learned my conventions. The most important thing is taste. It's the real difference between a junior and senior developer. Command gets it."

Anand Chowdhary
Anand Chowdhary
CTO · FirstQuadrant AI
GitHub Star · Forbes 30U30
Zero repeated instructions

"I never explain my preferences twice anymore. Push once, use everywhere. Creating the taste automatically is a natural addition to the dev experience. It benefits team projects massively."

Elio Struyf
Elio Struyf
GitHub Star · Google Developer Expert
 
Built for developers

"Ahmad is uniquely positioned to dramatically improve the AI developer experience. He has done exactly that with Command, building on his deep expertise creating products for developers"

Logan Kilpatrick
Logan Kilpatrick
Google · OpenAI · Harvard