Hacker News

The full stack of terminals explained

The full stack of terminals explained

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.

The Vocabulary Problem

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_
β”‚
laptop --> SSH --> internet --> server
β”‚
β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€
β”‚ server
└────┬────
β”‚
Console access (the real deal):
You --> keyboard ──────────────────────> monitor

The Line Discipline

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 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    Output (ANSI   Rendering
         changes    escape seqs)   on screen
         (Raw       back to        via Terminal
          Mode,     Terminal       Emulator
          etc.)

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

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

Comments

No comments yet. Start the discussion.