Ahmad Awais

NAVIGATE


SHARE


The Full Stack of Terminals Explained: Terminal, Shell, TTY, Console, POSIX, ANSI Escapes, PTYs, Raw/Canonical Modes

Ahmad AwaisAhmad Awais

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 change how you think?”

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:

  ┌─────────────────────────────────────
  │  ┌───────────────────────────────
  │  │  $ hello world_               │   <-- "Console" (physical device)
  │  │                               │   <-- "Terminal" (end of a wire)
  │  │                               │   <-- "TTY" (it's a teletypewriter)
  │  └───────────────────────────────
  │  ┌───────────────────────────────
  │  │ ┌───┐┌───┐┌───┐┌───┐┌───┐
  │  │ │ Q ││ W ││ E ││ R ││ T │...
  │  │ └───┘└───┘└───┘└───┘└───┘
  │  └───────────────────────────────
  └─────────────────────────────────────
              │
              │  wire
              │
      ┌───────┴────────
      │   MAINFRAME
      │   COMPUTER
      └────────────────

Three names, one thing. That’s why they blur together. As hardware evolved into software, each word drifted toward its own meaning. But they drifted slowly.


Part 1: The Vocabulary#

Console#

The console is the physical input/output device of a computer. Originally it meant a keyboard and display directly connected to the machine itself. From the OS’s perspective, it’s treated as “the system’s primary standard I/O terminal.”

On a server, it’s what you see when you walk up and plug in a monitor. On your laptop, it’s the text screen you’d land on if the graphical desktop crashed. 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.

  Remote access (not console):

    You --> laptop --> SSH --> internet --> server
                                            │
                                       ┌────┴────
                                       │ server
                                       └────┬────
                                            │
  Console access (the real deal):
                                            │
    You --> keyboard ──────────────────────>
            monitor  <──────────────────────

    ^ This is the console. Direct. Physical.
      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: the direct, local, hardware interface.

Terminal#

A terminal is the thing you see. It handles input and output. Keystrokes go in, text comes out. That’s its entire job.

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.

Here’s the part that surprises people: 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.

  ┌─ TERMINAL (what you see) ───────────────
  │
  │  Owns:
  │    - Rendering text on screen
  │    - Colors and fonts
  │    - Scrollback history
  │    - Copy-paste
  │    - Interpreting ANSI escape sequences
  │
  │  Does NOT own:
  │    - Understanding commands
  │    - Running programs
  │    - Tab completion
  │    - Your prompt
  │
  └─────────────────────────────────────────

Major terminal emulators by platform:

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.

  You type: git status
        │
        ▼
  ┌─ TERMINAL ───────────────────────
  │
  │  Converts keystrokes to bytes
  │  Sends text to the shell
  │
  └──────────────┬───────────────────
                 │  "git status\n"
                 ▼
  ┌─ SHELL (bash, zsh, fish) ────────
  │
  │  Parses the command
  │  Finds /usr/bin/git
  │  Spawns the process
  │  Manages its lifecycle
  │
  └──────────────┬───────────────────
                 │  output bytes
                 ▼
  ┌─ TERMINAL ───────────────────────
  │
  │  Receives output
  │  Renders it on your screen
  │
  │  On branch main
  │  nothing to commit
  │
  └──────────────────────────────────

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.

The crucial thing: your terminal and your shell are independent. You can swap either one without touching the other.

  Any terminal          works with         Any shell
  ┌──────────────┐                    ┌──────────────
  │  iTerm2      │─────────┐    ┌─────│  Bash
  │  Alacritty   │─────────┼────┼─────│  Zsh
  │  Kitty       │─────────┼────┼─────│  Fish
  │  Win Terminal│─────────┘    └─────│  PowerShell
  └──────────────┘                    └──────────────
        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, and the one you can safely ignore the longest.

In modern Unix, a TTY is a device file in the kernel that connects your terminal to your shell. It’s a general term for terminal devices in Unix-like OSes. Originally it was a device for connecting physical teletypewriters, but 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.

  ┌──────────────┐     ┌──────────────┐     ┌──────────────
  │              │     │              │
  │   Terminal   │◄───►│  TTY device  │◄───►│    Shell
  │  (iTerm2)    │     │ /dev/pts/1   │     │   (bash)
  │              │     │              │
  └──────────────┘     └──────────────┘     └──────────────
    user-space            kernel               user-space

  The TTY sits in between. It's the kernel's
  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 systems-level code or debugging something weird with signals.

PTY (Pseudo Terminal)#

A PTY is a virtual terminal device used by terminal emulators. In practice, two device files operate as a pair:

  ┌─────────────────┐         ┌──────────────────
  │  PTY Manager    │         │  PTY Subsidiary
  │  (master side)  │◄───────►│  (slave side)
  │                 │
  │  Operated by    │         │  Where shells &
  │  the terminal   │         │  TUI apps
  │  emulator       │         │  connect
  └─────────────────┘         └──────────────────

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:

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 achieves a rich user experience using cursor movement, colors, borders, menus, and forms.

Think of vim, less, htop, or nmtui. They take over your whole terminal window and redraw it constantly.

  ┌─────────────────────────────────────────────────────
  │
  │  CLI (shell)               TUI (vim, htop)
  │
  │  $ ls -la                  ┌──────────────────────
  │  total 48                  │ htop - 3.2.1
  │  drwxr-xr-x  5 user ...    │ CPU[||||||||  38.2%]
  │  -rw-r--r--  1 user ...    │ Mem[|||||    512/8G]
  │  $ _                       │
  │                            │ PID  USER  CPU%  MEM
  │  Text flows line by line   │ 1234 root  12.3  1.2
  │  downward. That's it.      │ 5678 user   8.1  0.9
  │                            └──────────────────────
  │                            Full screen. Interactive.
  │                            Redraws constantly.
  │
  └─────────────────────────────────────────────────────

Here’s how CLI and TUI compare:

Aspect CLI (Shell) TUI (vim, htop)
Input Line-based (confirmed w/ Enter) Key-based (immediate on press)
Screen Text flows downward Full screen redrawn
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.

  ┌────────────────────
  │  Terminal
  │  Emulator
  └────────┬───────────
           │
  ┌────────▼───────────
  │  PTY (master)
  └────────┬───────────
           │
  ┌────────▼──────────────────────────────────────────
  │  TTY Line Discipline
  │
  │  1. Line editing
  │     Backspace = delete char
  │     Ctrl+U   = delete entire line
  │     Ctrl+W   = delete word
  │
  │  2. Echo back
  │     Input chars automatically sent back to screen
  │     So the user can see what they're typing
  │
  │  3. Special character processing
  │     Ctrl+C  --> SIGINT (kill process)
  │     Ctrl+Z  --> SIGTSTP (suspend process)
  │     Ctrl+D  --> EOF (end of input)
  │
  │  4. Character conversion
  │     Newline code conversion (CR <--> LF)
  │
  │  5. Input buffering
  │     Canonical mode:  buffer until Enter
  │     Non-canonical:   pass through immediately
  │
  └────────┬──────────────────────────────────────────
           │
  ┌────────▼───────────
  │  PTY (subsidiary)
  └────────┬───────────
           │
  ┌────────▼───────────
  │  Shell / TUI App
  └────────────────────

This is important to understand because TUI apps work by disabling most of these line discipline features. When you run vim, it tells the kernel: “stop doing line editing, stop echoing, stop interpreting Ctrl+C as a signal. Just give me the raw bytes.”

termios#

termios is the POSIX standard terminal control structure. It’s how you talk to the line discipline. It manages:

  termios structure
  ┌────────────────────────────────────────────────────
  │
  │  c_iflag  (Input flags)
  │    Newline conversion, flow control, etc.
  │
  │  c_oflag  (Output flags)
  │    Output processing settings
  │
  │  c_cflag  (Control flags)
  │    Baud rate, character size, etc.
  │
  │  c_lflag  (Local flags)
  │    Echo, canonical mode, signal generation, etc.
  │
  │  c_cc     (Special characters)
  │    Definitions for Ctrl+C, Ctrl+Z, EOF, etc.
  │
  │  VMIN / VTIME  (Timeout)
  │    Read control in non-canonical mode
  │
  └────────────────────────────────────────────────────

In TUI development, termios is used to implement Raw Mode.

ioctl (Input/Output Control)#

ioctl is a general-purpose device control system call. Through a file descriptor, it instructs device drivers to perform special operations that can’t be done with normal read/write. Main operations include getting and setting termios, and getting 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:

// High-level: Node.js handles termios internally
process.stdin.setRawMode(true);  // Equivalent to MakeRaw / tcsetattr
process.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:

  ┌──────┐     ┌───────────────────────
  │ User │────>│ Terminal Emulator
  │      │     │ (iTerm2, xterm, etc.)
  └──────┘     └──────────┬────────────
                          │
               ┌──────────▼────────────
               │ PTY
               │ (manager <-> subsid.)
               └──────────┬────────────
                          │
               ┌──────────▼────────────
               │ TTY + Line Discipline
               │ (ICANON/ECHO/ISIG flags)
               └──────────┬────────────
                          │
               ┌──────────▼────────────
               │ TUI App
               │ (vim, htop)
               └──────────┬────────────
                          │
          ┌───────────────┼───────────────
          │
          ▼               ▼               ▼
   termios config    Output (ANSI     Rendering
   changes           escape seqs)     on screen
   (Raw Mode, etc.)  back to          via Terminal
                     Terminal          Emulator

Part 3: Terminal Behavior for TUI Development#

TUI apps don’t interpret commands like shells do. Instead, they directly handle terminal I/O control. They use the termios API to change terminal mode settings and ANSI escape sequences to render and update the screen.

In a normal shell environment, the terminal is set to canonical mode (ICANON), where input is buffered line by line and passed to the program after you press Enter.

$ stty -a | grep icanon
# icanon isig iexten echo echoe echok echoke -echonl echoctl
# ^ Canonical mode is ON

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

$ ls
example.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.

$ vim

# In another terminal, check vim's terminal mode:
$ ps aux | grep vim         # find vim's terminal
$ stty -a < /dev/ttys049 | grep icanon
# -icanon -isig -iexten -echo -echoe echok echoke -echonl echoctl
#  ^ 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. Terminal Mode Settings
  │     Canonical --> Raw Mode
  │
  │  2. Input Processing
  │     Parsing keys, escape sequences, Ctrl+
  │
  │  3. Screen Control
  │     ANSI escape sequences for rendering
  │
  │  4. Terminal Size Management
  │     Detecting and responding to resizes
  │
  │  5. Buffering
  │     Batch writes to prevent flicker
  │
  └─────────────────────────────────────────────

Let’s go through each one.


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)#

This is the default. It’s what your shell uses. 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 processing can optionally stay enabled.

Raw Mode#

Almost everything disabled. No echo, no signals, no newline conversion. Input is passed to the application as a raw byte stream. This is 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#

  ┌──────────────┬──────────────┬──────────────────
  │  Key         │  Sequence    │  Byte Sequence
  ├──────────────┼──────────────┼──────────────────
  │  Up    (↑)   │  ESC[A       │  \x1b[A
  │  Down  (↓)   │  ESC[B       │  \x1b[B
  │  Right (→)   │  ESC[C       │  \x1b[C
  │  Left  (←)   │  ESC[D       │  \x1b[D
  │  Home        │  ESC[H       │  \x1b[H
  │  End         │  ESC[F       │  \x1b[F
  │  Page Up     │  ESC[5~      │  \x1b[5~
  │  Page Down   │  ESC[6~      │  \x1b[6~
  │  F1-F4       │  ESC[OP-OS   │  \x1b[OP, etc.
  └──────────────┴──────────────┴──────────────────

Control Characters#

  ┌──────────────┬──────────┬─────────────────
  │  Character   │  ASCII   │  Description
  ├──────────────┼──────────┼─────────────────
  │  Ctrl+C      │  3       │  SIGINT
  │  Ctrl+D      │  4       │  EOF
  │  Ctrl+Z      │  26      │  SIGTSTP
  │  Enter       │  13 / 10 │  CR / LF
  │  Tab         │  9       │  Tab
  │  Backspace   │  127 / 8 │  DEL / BS
  │  ESC         │  27      │  Escape
  └──────────────┴──────────┴─────────────────

3. Screen Control (ANSI Escape Sequences)#

Screen control means telling the terminal “what to display where.” ANSI escape sequences are special string commands that begin with the ESC character (\x1b or \033). They were standardized as ANSI X3.64 and implemented in VT100 terminals, which is why they’re everywhere.

Cursor Control#

  ┌──────────────────────┬───────────────────────────────
  │  Sequence            │  Description
  ├──────────────────────┼───────────────────────────────
  │  ESC[H               │  Move to home position (1,1)
  │  ESC[{row};{col}H    │  Move to position (1-indexed)
  │  ESC[{n}A            │  Move n rows up
  │  ESC[{n}B            │  Move n rows down
  │  ESC[{n}C            │  Move n columns right
  │  ESC[{n}D            │  Move n columns left
  │  ESC[s               │  Save cursor position
  │  ESC[u               │  Restore cursor position
  │  ESC[?25l            │  Hide cursor
  │  ESC[?25h            │  Show cursor
  └──────────────────────┴───────────────────────────────

Screen Clearing#

  ┌──────────────────────┬───────────────────────────────────
  │  Sequence            │  Description
  ├──────────────────────┼───────────────────────────────────
  │  ESC[2J              │  Clear entire screen
  │  ESC[H               │  Move cursor to home
  │  ESC[K               │  Clear from cursor to end of line
  │  ESC[1K              │  Clear from start of line to cur.
  │  ESC[2K              │  Clear entire line
  └──────────────────────┴───────────────────────────────────

Basic Styles#

  ┌──────────────────────┬──────────────
  │  Sequence            │  Description
  ├──────────────────────┼──────────────
  │  ESC[0m              │  Reset all
  │  ESC[1m              │  Bold
  │  ESC[4m              │  Underline
  │  ESC[7m              │  Reverse
  └──────────────────────┴──────────────

Foreground Colors (Text)#

  ┌──────────────────────┬──────────────
  │  Sequence            │  Color
  ├──────────────────────┼──────────────
  │  ESC[30m             │  Black
  │  ESC[31m             │  Red
  │  ESC[32m             │  Green
  │  ESC[33m             │  Yellow
  │  ESC[34m             │  Blue
  │  ESC[35m             │  Magenta
  │  ESC[36m             │  Cyan
  │  ESC[37m             │  White
  └──────────────────────┴──────────────

Background Colors#

  ┌──────────────────────┬──────────────
  │  Sequence            │  Color
  ├──────────────────────┼──────────────
  │  ESC[40m             │  Black
  │  ESC[41m             │  Red
  │  ESC[42m             │  Green
  │  ESC[43m             │  Yellow
  │  ESC[44m             │  Blue
  │  ESC[45m             │  Magenta
  │  ESC[46m             │  Cyan
  │  ESC[47m             │  White
  └──────────────────────┴──────────────

Extended Color Modes#

  ┌──────────────────────────────┬────────────────────────────────
  │  Sequence                    │  Description
  ├──────────────────────────────┼────────────────────────────────
  │  ESC[38;5;{n}m               │  Foreground (n: 0-255)
  │  ESC[48;5;{n}m               │  Background (n: 0-255)
  │  ESC[38;2;{r};{g};{b}m       │  Foreground (RGB True Color)
  │  ESC[48;2;{r};{g};{b}m       │  Background (RGB True Color)
  └──────────────────────────────┴────────────────────────────────

Alternate Screen Buffer#

  ┌──────────────────────┬─────────────────────────────────────────
  │  Sequence            │  Description
  ├──────────────────────┼─────────────────────────────────────────
  │  ESC[?1049h          │  Switch to alternate screen buffer
  │                      │  (used by vim, less, etc.)
  │  ESC[?1049l          │  Return to normal screen buffer
  └──────────────────────┴─────────────────────────────────────────

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 render according to terminal size, and they need to redraw when users resize the window. Here’s how that works:

  User resizes window
        │
        ▼
  Terminal Emulator
        │
        │  ioctl(TIOCSWINSZ) -- notify new rows/cols
        ▼
  PTY (master --> subsidiary)
        │
        │  Update winsize in kernel
        ▼
  Kernel TTY Layer
        │
        │  Send SIGWINCH signal
        ▼
  Process (bash, vim, less)
        │
        │  Receive signal
        │  Call ioctl(TIOCGWINSZ) to get new size
        │  Redraw
        ▼
  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.

// Structure to manage terminal state
interface Terminal {
  width: number;
  height: number;
  buffer: string;
}

// ─────────────────────────────────────────────
// 1. Terminal Mode Settings
// ─────────────────────────────────────────────

function initTerminal(): Terminal {
  // Set to Raw Mode (equivalent to term.MakeRaw in Go)
  process.stdin.setRawMode(true);
  process.stdin.resume();
  process.stdin.setEncoding("utf8");

  const { columns, rows } = process.stdout;

  return {
    width: columns || 80,
    height: rows || 24,
    buffer: "",
  };
}

function restoreTerminal(): void {
  write("\x1b[?25h"); // Show cursor
  write("\x1b[0m");   // Reset color
  flush();
  process.stdin.setRawMode(false);
}

// ─────────────────────────────────────────────
// 4. Terminal Size Management
// ─────────────────────────────────────────────

function getTerminalSize(): { width: number; height: number } {
  return {
    width: process.stdout.columns || 80,
    height: process.stdout.rows || 24,
  };
}

// ─────────────────────────────────────────────
// 2. Input Processing
// ─────────────────────────────────────────────

interface KeyEvent {
  char: string | null;
  key: string | null;
}

function parseKey(data: string): KeyEvent {
  // Ctrl+C
  if (data === "\x03") {
    return { char: null, key: "CTRL_C" };
  }

  // Escape sequences (arrow keys, etc.)
  if (data === "\x1b[A") return { char: null, key: "UP" };
  if (data === "\x1b[B") return { char: null, key: "DOWN" };
  if (data === "\x1b[C") return { char: null, key: "RIGHT" };
  if (data === "\x1b[D") return { char: null, key: "LEFT" };
  if (data === "\x1b[H") return { char: null, key: "HOME" };
  if (data === "\x1b[F") return { char: null, key: "END" };
  if (data === "\x1b[5~") return { char: null, key: "PAGE_UP" };
  if (data === "\x1b[6~") return { char: null, key: "PAGE_DOWN" };

  // Bare ESC
  if (data === "\x1b") return { char: null, key: "ESC" };

  // Normal character
  return { char: data, key: null };
}

// ─────────────────────────────────────────────
// 3. Screen Control (ANSI Escape Sequences)
// ─────────────────────────────────────────────

let outputBuffer = "";

function bufferWrite(s: string): void {
  outputBuffer += s;
}

function clear(): void {
  bufferWrite("\x1b[2J\x1b[H");
}

function moveTo(row: number, col: number): void {
  bufferWrite(`\x1b[${row};${col}H`);
}

function setColor(fg: number): void {
  bufferWrite(`\x1b[${fg}m`);
}

function writeText(s: string): void {
  bufferWrite(s);
}

// ─────────────────────────────────────────────
// 5. Buffering
// ─────────────────────────────────────────────

function flush(): void {
  process.stdout.write(outputBuffer);
  outputBuffer = "";
}

function write(s: string): void {
  process.stdout.write(s);
}

// ─────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────

function main(): void {
  // Check if running in a terminal
  if (!process.stdin.isTTY) {
    process.stderr.write("Error: Must be run in an interactive terminal\n");
    process.exit(1);
  }

  // Initialize
  const term = initTerminal();

  // Always restore on exit
  process.on("exit", restoreTerminal);
  process.on("SIGINT", () => {
    restoreTerminal();
    process.exit(0);
  });

  // 4. Detect window size changes (SIGWINCH)
  process.on("SIGWINCH", () => {
    const size = getTerminalSize();
    term.width = size.width;
    term.height = size.height;
  });

  // Cursor position
  let x = Math.floor(term.width / 2);
  let y = Math.floor(term.height / 2);

  function render(): void {
    // Clear screen
    clear();

    // Title
    moveTo(1, Math.floor(term.width / 2) - 10);
    setColor(36); // Cyan
    writeText("TUI Demo (press 'q' to quit)");

    // Display information
    moveTo(3, 2);
    setColor(33); // Yellow
    writeText(`Terminal Size: ${term.width}x${term.height}`);

    moveTo(4, 2);
    writeText(`Cursor Position: (${x}, ${y})`);

    // Draw frame
    for (let row = 5; row < term.height - 1; row++) { moveTo(row, 1); setColor(34); // Blue writeText("|"); moveTo(row, term.width); writeText("|"); } // Display cursor marker moveTo(y, x); setColor(32); // Green writeText("●"); // Instructions moveTo(term.height, 2); setColor(37); // White writeText("Arrow keys: move | q: quit"); // Flush buffer (reflect to screen all at once) flush(); } // Initial render render(); // 2. Listen for key input process.stdin.on("data", (data: string) => {
    const { char, key } = parseKey(data);

    // Process key
    switch (key) {
      case "CTRL_C":
        restoreTerminal();
        process.exit(0);
        return;
      case "UP":
        if (y > 5) y--;
        break;
      case "DOWN":
        if (y < term.height - 1) y++; break; case "LEFT": if (x > 2) x--;
        break;
      case "RIGHT":
        if (x < term.width - 1) x++;
        break;
    }

    if (char === "q" || char === "Q") {
      restoreTerminal();
      process.exit(0);
      return;
    }

    render();
  });
}

main();

Save this as program.ts and run it:

npx tsx program.ts

Operation: 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 FFI to directly manipulate termios flags.

import { execSync } from "child_process";
import process from "process";

// ─────────────────────────────────────────────
// Low-level termios manipulation via stty
// ─────────────────────────────────────────────

// termios flag reference (what each flag controls):
//
//  Input flags (c_iflag):
//    IGNBRK   - Ignore break condition
//    BRKINT   - Signal interrupt on break
//    PARMRK   - Mark parity errors
//    ISTRIP   - Strip 8th bit
//    INLCR    - Translate NL to CR on input
//    IGNCR    - Ignore CR on input
//    ICRNL    - Translate CR to NL on input
//    IXON     - Enable XON/XOFF flow control
//
//  Output flags (c_oflag):
//    OPOST    - Post-process output
//
//  Local flags (c_lflag):
//    ECHO     - Echo input characters
//    ECHONL   - Echo NL even if ECHO is off
//    ICANON   - Canonical mode (line buffering)
//    ISIG     - Enable signals (Ctrl+C, Ctrl+Z)
//    IEXTEN   - Extended input processing
//
//  Control flags (c_cflag):
//    CSIZE    - Character size mask
//    PARENB   - Parity enable
//    CS8      - 8 bits per character

function saveTerminalState(): string {
  // Save all current termios settings
  return execSync("stty -g", { stdio: ["inherit", "pipe", "pipe"], encoding: "utf8" }).trim();
}

function restoreTerminalState(savedState: string): void {
  // Restore saved termios settings
  execSync(`stty ${savedState}`, { stdio: "inherit" });
}

function enableRawMode(): void {
  execSync("stty raw -echo -isig -icanon -iexten -opost min 1 time 0", {
    stdio: "inherit",
  });
}

function getTerminalSize(): { width: number; height: number } {
  // Get window size
  try {
    const output = execSync("stty size", { stdio: ["inherit", "pipe", "pipe"], encoding: "utf8" }).trim();
    const [rows, cols] = output.split(" ").map(Number);
    return { width: cols || 80, height: rows || 24 };
  } catch {
    return { width: 80, height: 24 }; // Default values
  }
}

// ─────────────────────────────────────────────
// Screen control and buffering (same as high-level)
// ─────────────────────────────────────────────

let outputBuffer = "";

function bufferWrite(s: string): void {
  outputBuffer += s;
}

function flush(): void {
  process.stdout.write(outputBuffer);
  outputBuffer = "";
}

function clear(): void {
  bufferWrite("\x1b[2J\x1b[H");
}

function moveTo(row: number, col: number): void {
  bufferWrite(`\x1b[${row};${col}H`);
}

function setColor(fg: number): void {
  bufferWrite(`\x1b[${fg}m`);
}

function writeText(s: string): void {
  bufferWrite(s);
}

// ─────────────────────────────────────────────
// Input processing (same parsing logic)
// ─────────────────────────────────────────────

interface KeyEvent {
  char: string | null;
  key: string | null;
}

function parseKey(buf: Buffer): KeyEvent {
  // Ctrl+C (in raw mode with -isig, we get byte 3 directly)
  if (buf[0] === 3) {
    return { char: null, key: "CTRL_C" };
  }

  // Escape sequences
  if (buf[0] === 27 && buf[1] === 91) {
    // ESC[ = 0x1b 0x5b
    switch (buf[2]) {
      case 65: return { char: null, key: "UP" };    // A
      case 66: return { char: null, key: "DOWN" };  // B
      case 67: return { char: null, key: "RIGHT" }; // C
      case 68: return { char: null, key: "LEFT" };  // D
    }
  }

  if (buf[0] === 27) return { char: null, key: "ESC" };

  // Normal character
  return { char: String.fromCharCode(buf[0]), key: null };
}

// ─────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────

function main(): void {
  // Save original termios state
  const savedState = saveTerminalState();

  // Enable Raw Mode (direct termios flag manipulation)
  enableRawMode();

  // Read stdin as raw bytes (not utf8 strings)
  process.stdin.resume();

  // Always restore on exit
  const cleanup = (): void => {
    bufferWrite("\x1b[?25h"); // Show cursor
    bufferWrite("\x1b[0m");   // Reset color
    flush();
    restoreTerminalState(savedState);
  };

  process.on("exit", cleanup);

  // Detect window size changes (SIGWINCH)
  let { width, height } = getTerminalSize();
  process.on("SIGWINCH", () => {
    const size = getTerminalSize();
    width = size.width;
    height = size.height;
  });

  // Cursor position
  let x = Math.floor(width / 2);
  let y = Math.floor(height / 2);

  function render(): void {
    clear();

    // Title
    moveTo(1, Math.floor(width / 2) - 15);
    setColor(36); // Cyan
    writeText("TUI Demo (Low-level termios API)");

    // termios info
    moveTo(3, 2);
    setColor(33); // Yellow
    writeText("Using stty (tcgetattr/tcsetattr under the hood)");

    moveTo(4, 2);
    writeText(`Terminal Size: ${width}x${height}`);

    moveTo(5, 2);
    writeText(`Cursor Position: (${x}, ${y})`);

    // Explanation of configured flags
    moveTo(7, 2);
    setColor(37); // White
    writeText("Raw Mode flags:");
    moveTo(8, 4);
    writeText("- ICANON off: Line buffering disabled");
    moveTo(9, 4);
    writeText("- ECHO off: Echo back disabled");
    moveTo(10, 4);
    writeText("- ISIG off: Signal generation disabled");
    moveTo(11, 4);
    writeText("- VMIN=1, VTIME=0: Read 1 byte immediately");

    // Draw frame
    for (let row = 13; row < height - 1; row++) { moveTo(row, 1); setColor(34); // Blue writeText("|"); moveTo(row, width); writeText("|"); } // Cursor marker moveTo(y, x); setColor(32); // Green writeText("●"); // Instructions moveTo(height, 2); setColor(37); // White writeText("Arrow keys: move | q: quit"); flush(); } render(); // Read raw bytes process.stdin.on("data", (data: Buffer) => {
    const { char, key } = parseKey(data);

    switch (key) {
      case "CTRL_C":
        cleanup();
        process.exit(0);
        return;
      case "UP":
        if (y > 13) y--;
        break;
      case "DOWN":
        if (y < height - 1) y++; break; case "LEFT": if (x > 2) x--;
        break;
      case "RIGHT":
        if (x < width - 1) x++;
        break;
    }

    if (char === "q" || char === "Q") {
      cleanup();
      process.exit(0);
      return;
    }

    render();
  });
}

main();

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#

  ┌─────────────────────────────────────────────────────
  │                     YOUR SCREEN
  │
  │  ┌─ Terminal (iTerm2, Alacritty, etc.) ──────────
  │  │
  │  │  $ grep -r "TODO" ./src
  │  │  src/app.js:  // TODO fix this
  │  │  src/util.js: // TODO refactor
  │  │  $  _
  │  │
  │  └──────────────────┬────────────────────────────
  │
  └─────────────────────┼───────────────────────────────
                        │
              ┌─────────┴─────────
              │    TTY device     Kernel layer
              │   /dev/pts/1      (the plumbing)
              │
              │   Line Discipline
              │   termios flags
              └─────────┬─────────
                        │
              ┌─────────┴─────────
              │      Shell       Interprets commands
              │    (zsh, bash)   Runs programs
              └─────────┬─────────
                        │
              ┌─────────┴─────────
              │   grep process   The actual program
              │                  your shell launched
              └───────────────────

  And if all of this is on a monitor + keyboard
  plugged directly into the machine?

  Then the terminal is also the CONSOLE.

Quick Reference: When Things Break#

  Problem                          Which layer?
  ──────────────────────────────   ─────────────
  Colors look wrong                Terminal
  Tab completion broke             Shell
  SSH session acting strange       TTY
  GUI is dead, need to log in      Console
  Script works in bash not zsh     Shell
  Font rendering is ugly           Terminal
  Ctrl+C not killing process       TTY / Line Discipline
  TUI app not getting keypresses   termios (Raw Mode)
  Screen flickers on redraw        Buffering
  Layout breaks on window resize   SIGWINCH handling

Summary: What We Covered#

This was a summarized view 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#

Courses#

I also happen to have a course on building Node.js based CLIs with 20+ projects. You can check it out at NodeCLI.com, the first 12 videos are also available for free in the NodejsBeginner.com course. The entire course exercises are open source in this Node.js CLI Tips and Tricks repo. I recorded it in 2020, but most of the info should hold true.

What’s next?#

Check out hundreds of open source CLIs I have published on my GitHub account and we’re about to launch Command Code, the first coding agent that can learn your coding taste as you use it. And yes, it’s a CLI as well.

Use your code for good!
Peace! ✌️

Founder & CEO of ⌘ Command Code coding agent with taste. Founded Langbase.com, AI cloud to build, deploy, and scale AI agents with tools & memory · Creator of Command.new.

"Awais is an awesome example for developers" — Satya Nadella, CEO of Microsoft.

NASA Mars Ingenuity Helicopter mission code contributor 8th GitHub Stars Award recipient with 5x GitHub Stars Award (Listed as GitHub's #1 JavaScript trending developer). Google Developers Expert Web DevRel.

Ex VP Eng (DevTools & DevRel) Rapid · Google Developers Advisory Board (gDAB) founding member · AI/ML/DevTools Angel Investor (Replit, Resend, Daytona, Gumroad and you?) ❯ AI/ML Advisory Board San Francisco, DevNetwork.

Award-winning Open Source Engineering leader authored hundreds of open-source dev-tools and software libraries used by millions of developers, including Shades of Purple code theme and corona-cli.

Linux Foundation (Node.js Committee Lead), OpenAPI Business Governing Board. Taught 108K+ developers via NodeCLI.com and VSCode.pro course. 274 million views, blogging for 24 yrs.

❯ Read more about Ahmad Awais or come say hi on 𝕏 @MrAhmadAwais.

📨

Developers Takeaway

Stay ahead in the web dev community with Ahmad's expert insights on open-source, developer relations, dev-tools, and side-hustles. Insider-email-only-content. Don't miss out - subscirbe for a dose of professional advice and a dash of humor. No spam, pinky-promise!

✨ 172,438 Developers Already Subscribed
Comments 0
There are currently no comments.