Blog

How terminals work: TTY, PTY, and line discipline

Every terminal on your Mac is software standing in for a machine that printed on paper. The kernel layer that keeps up that illusion still decides, right now, whether Ctrl-C kills your process or just types the letter C.

Quick Answer

A terminal emulator and a shell are connected through a pseudo-terminal, a kernel device pair where the emulator holds the master end and the shell is attached to the slave end as if it were a physical teletype. A kernel layer called the line discipline sits in between, buffering a full line in canonical mode so backspace works, or passing every keystroke straight through in raw mode for full-screen tools. The line discipline, not the shell, turns Ctrl-C into SIGINT, and the kernel sends SIGWINCH when a window is resized. Colors and cursor movement are just bytes the program writes that the emulator interprets instead of printing.

Where the name tty actually comes from

tty is short for teletypewriter, and for the first decade of Unix that is literally what sat between a person and the computer. The earliest Unix systems at Bell Labs were driven from machines like the Teletype Corporation's Model 33, a mechanical printer that sent keystrokes down a wire and typed the computer's replies onto a roll of paper, because there was no screen to put them on.

Screens eventually replaced paper, and software terminal emulators eventually replaced physical screens, but the kernel kept the name. The special file your shell is attached to right now is still called a tty, and the driver code that manages it is still called the tty layer, long after the last Model 33 was unplugged.

What the kernel's line discipline actually does

Between the device and the program reading from it sits a kernel layer called the line discipline, and its default mode is canonical mode. POSIX's termios(3) interface describes canonical input as available line by line: the kernel holds every keystroke you type until you press Enter, which is what lets backspace erase a character before your shell ever sees it.

Switch the same device to noncanonical, commonly called raw, mode and the behavior inverts: input is available immediately, with no line editing and no input processing at all, so every keystroke reaches the program the instant you press it. That is how vim, htop, and a coding agent's full-screen view find out about each arrow key as it happens rather than after you hit return.

You can inspect and change these settings yourself with stty, whose entire job, per its manual, is to print or change terminal characteristics. stty -a prints the current line discipline settings for your terminal, and stty raw is one of the ways a program drops into noncanonical mode without writing a line of C to do it.

What a pseudo-terminal actually is

A terminal emulator is not a terminal in the classic sense; it is a window that draws text, and underneath it the kernel maintains a pseudo-terminal. Linux's pty(7) man page defines one as a pair of virtual character devices that provide a bidirectional communication channel, with one end called the master and the other the slave. Whatever the program holding the master writes is delivered to the slave exactly as though it had been typed at a real terminal, and whatever the process on the slave prints comes back out the master for the emulator to draw.

Your shell, attached to the slave end, has no way to tell the difference between that and a physical teletype, which is the whole point: every program ever written for a real terminal runs unmodified over a connection that is actually just two file descriptors and some kernel buffering.

The classic BSD scheme precreated device pairs with names like /dev/ptyXY and /dev/ttyXY. The portable, modern way opens a single clone device at /dev/ptmx through posix_openpt(3) and then unlocks a matching slave under /dev/pts. Most man pages, including the BSD ones macOS descends from, still call the two ends master and slave, though the Linux kernel's own coding-style guidance now lists leader and follower as one of several suggested replacements for that pair of terms. The vocabulary is shifting faster than the mechanism underneath it is.

What the emulator does and what the shell does

Two different programs are doing two different jobs, and conflating them is the most common confusion about terminals. The emulator, whether that is Terminal.app, iTerm2, or a pane inside an app like Forkbench, holds the pseudo-terminal's master end, draws a window, turns your keypresses into bytes, and interprets what comes back: ordinary characters get drawn as text, and a narrow set of other byte sequences get interpreted as instructions instead of being printed.

The shell, and anything the shell runs, has no idea an emulator exists. It is attached to the slave end and reads and writes exactly as if it were talking to a teletype with a roll of paper. That separation is why the same claude or codex session behaves identically inside a dozen different emulators: whatever changed is upstream of the pseudo-terminal, not downstream of it.

How a program draws colors, moves the cursor, and clears the screen

Most of what a program sends through the pseudo-terminal is just the text you read. A small, standardized set of byte sequences does something else: they are commands to the emulator rather than characters to display, and the formal description of that set is ECMA-48, also published as ISO/IEC 6429, the standard that defines control functions and their coded representations for devices like terminals.

Almost all of them start with the same two bytes, Escape followed by [, known as the Control Sequence Introducer, or CSI. What follows is a set of numbers and a single letter that names the command.

  • CSI 5 A moves the cursor up five lines.
  • CSI 31 m sets the foreground color to red.
  • CSI 12;4H moves the cursor to row 12, column 4.
  • CSI ?1049h switches to a second, separate screen buffer, and CSI ?1049l switches back to your ordinary one.

Why Ctrl-C stops a program, and a resize doesn't crash it

Ctrl-C does not arrive at the program you're running as the letter C. Per termios(3), when the ISIG flag is set, the line discipline watches for a small set of special characters, and on seeing the interrupt character it generates SIGINT directly: the keystroke is recognized by the kernel and never passed through as ordinary input at all. The signal goes to every process in the terminal's foreground process group, which is why Ctrl-C in a shell pipeline can stop several commands at once.

Resizing a window works the same way from the other direction. The kernel tracks a terminal's row and column count, and the moment it changes, it sends SIGWINCH, the window-change signal, to the foreground process group. A well-behaved full-screen program's only job on receiving it is to ask the kernel for the new size and repaint; a program that ignores SIGWINCH is the one you've watched draw into the wrong half of a window you just resized.

Why full-screen agent interfaces redraw and flicker

Put the last two sections together and the flicker you see in a coding agent's live view, or in htop, stops being mysterious. Every repaint is a batch of CSI cursor-movement and color sequences sent over the same byte pipe as everything else, with no frame buffer underneath guaranteeing the screen updates all at once.

A naive redraw clears the whole alternate screen and writes it again from scratch, and on a slow link or a busy terminal that clear-then-fill is visible as flicker. SIGWINCH makes this worse on purpose: because the old cursor positions were calculated for the old width, there is no way to patch around a resize, so a correct program has to throw the screen away and repaint in full. Terminal UI code that avoids flicker does it by tracking what is already drawn and sending only the difference, not through any special protocol the terminal offers.

How this works in Forkbench

Forkbench is a terminal built around coding agents, and a Forkbench pane is exactly the mechanism this piece describes end to end. Forkbench opens a pseudo-terminal and execs your real login shell into the slave end, the same shell, dotfiles, and PATH you'd get from Terminal.app, so tmux, vim, and a coding agent's own full-screen interface redraw, resize, and catch Ctrl-C exactly as they do outside it. See what agents can see inside that pane for the exact claim.

The honest limit: a pane is not sandboxed, simulated, or proxied by default, which is deliberate, since faking any layer described above is how you break the tools that depend on it. A Thread can be locked to a set of folders, enforced by the kernel, if you want an agent's reach narrowed; the pseudo-terminal and line discipline underneath behave exactly as described above either way.

Related: The best terminal for AI coding agents, How to sandbox a coding agent on macOS, What a connected agent can and cannot reach in Forkbench

Frequently asked

Keep reading

Sources