The Hidden Conversation Between You and Your Terminal
I open a terminal, type git status, and the right bytes show up in the right program.
It feels obvious. It is not.
There is no direct wire from my keyboard to the shell. There is no direct wire from the shell to the screen either. In between sits a small piece of kernel machinery that almost nobody draws when they explain this.
This post is about that piece. We will go slowly and answer five questions in order:
- Who creates the terminal?
- Which side does the terminal emulator hold?
- Which side does the shell hold?
- When the shell launches a program like
cat, how does that program end up reading my keystrokes instead of the shell? - How does a single byte travel from a key press to the program?
Quick Refresher
Before we get into the flow, letâs name the pieces.
Terminal Emulator
A terminal emulator is the GUI application you open. Terminal.app, iTerm2, GNOME Terminal, Alacritty, and so on.
Its job is small and well defined. It draws characters on a window and it reads your keyboard events. That is it. It does not parse commands. It does not know what ls is.
Shell
A shell is a program like bash, zsh or fish.
It reads command lines, parses them, launches other programs, and manages jobs. The prompt you see on the screen belongs to the shell, not to the emulator.
Pseudo-Terminal (PTY)
A pseudo-terminal, usually written as PTY, is a kernel object that behaves like a terminal device for programs that expect one.
In simpler words, it is a fake terminal device that the kernel hands out. Programs read and write through it as if a real serial terminal was on the other end.
A PTY always comes as a pair:
- the master side
- the slave side
Both sides are just file descriptors. Whatever is written into one side becomes readable on the other side, after the kernel has had a chance to process it.
Terminal Line Discipline
The terminal line discipline is the bit of kernel code that sits between the master and the slave.
It is not a user-space program. It is not part of the emulator. It is not part of the shell. It is a layer inside the kernel that decides what to do with the bytes flowing between the two sides of the PTY.
It is small but it does real work. We will come back to it.
Step 1: The PTY Pair Gets Created
Letâs say you double-click on iTerm.
The very first thing the emulator does, before any shell exists, is ask the kernel for a fresh PTY pair.
The kernel does the work and returns two file descriptors:
- one for the master side
- one for the slave side
The emulator keeps the master file descriptor for itself. The slave side is sitting there, owned by the kernel, with nobody attached to it yet.
+-------------------+
| terminal emulator |
+---------+---------+
|
master fd
|
====== kernel ======
|
line discipline
|
slave fd <-- nobody attached yet
====================
This is the empty state. The pipe exists. The line discipline exists. Nothing useful is happening on it yet.
Step 2: The Shell Gets Attached To The Slave
Now the emulator wants a shell on the other end.
It does the standard Unix dance:
- It calls
fork(), which creates a child process that is a copy of the emulator. - In that child, it takes the slave file descriptor and uses
dup2()to point file descriptors 0, 1 and 2 at it. Those numbers arestdin,stdoutandstderr. - In that child, it calls
exec()on the shell binary, for example/bin/bash.
After exec(), the child process is no longer running the emulator code. It is running the shell. But the file descriptors carry over. The shell starts up with its stdin, stdout and stderr already wired to the slave side of the PTY.
So from the shellâs very first instruction, it is already reading and writing through the slave. It does not know there is a PTY. It does not know there is an emulator. It only knows it has three open file descriptors.
+-------------------+
| terminal emulator |
+---------+---------+
|
master fd
|
====== kernel ======
|
line discipline
|
slave fd
|
+---------+---------+
| shell | stdin / stdout / stderr -> slave
+-------------------+
This is the part that is easy to miss. The shell is not reading the keyboard. The shell is reading a file descriptor. It happens to be one end of a kernel pipe whose other end is owned by the emulator.
Step 3: A Byte Travels From Key To Shell
Now we can walk a single byte through this setup.
You press the a key.
- The window system delivers a key event to the terminal emulator.
- The emulator translates that event into one or more bytes. For
a, that is just the byte0x61. - The emulator calls
write()on the master file descriptor with that byte. - The kernel takes the byte and runs it through the line discipline. In normal mode, the discipline echoes the byte back, buffers it as part of the current line, and waits to see if more characters are coming.
- When the line is complete, the bytes become readable on the slave side.
- The shell, which was sitting in a
read()onstdin, now gets the line back.
The reverse direction is the mirror of this:
- The shell calls
write()onstdoutwith some bytes. - Those bytes go into the slave side.
- The line discipline passes them through to the master side, possibly translating things like
\nalong the way. - The emulatorâs
read()on the master returns those bytes. - The emulator paints them on the screen.
The emulator and the shell never talk to each other. They both talk to the kernel pipe in the middle. The kernel decides what each side sees and when.
That sentence is the whole model. Most things about terminals fall out of it.
Step 4: The Shell Launches A Program & The Slave Gets Inherited
This is the part the original question was really about.
You type cat and press enter.
What does the shell do?
- It calls
fork(). A new child process appears, which is a copy of the shell. - The child inherits all of the shellâs open file descriptors. That includes
stdin,stdoutandstderr, which are still pointing at the slave side of the PTY. - The child calls
exec()on/bin/cat.
After exec(), the child process is now running cat. But the file descriptors carry over. So cat starts up with its stdin, stdout and stderr already wired to the same slave side that the shell was using a moment ago.
The shell does not need to do anything special to âhand overâ the terminal. It does not copy bytes. It does not proxy anything. It just forks, the child inherits the slave, and now there is a new reader on that file descriptor.
+-------------------+
| terminal emulator |
+---------+---------+
|
master fd
|
====== kernel ======
|
line discipline
|
slave fd
|
+---------+---------+ +-------------------+
| cat | | shell | (parked, waiting)
+-------------------+ +-------------------+
The emulator is still on the master side. It has no idea that the program on the other end changed from bash to cat. As far as the emulator is concerned, it is still writing bytes into the master, the same way it was before.
That is why your keystrokes âgo to catâ now. Nothing magical happened. The same slave is still wired to fd 0. There is just a different program holding it.
One important detail. Only one program should be the active reader on that slave at a time. If two processes both tried to read it, neither of them would get a clean stream. The kernel and the shell coordinate this by tagging one process group as the foreground group of the terminal. That is the group that gets the input. The shell itself steps out of the foreground while cat runs, and steps back into it when cat exits.
Why The Line Discipline Matters
The line discipline is easy to ignore in diagrams, but it is doing a lot of work.
In its default mode, called canonical mode, the line discipline:
- echoes each typed character back so you can see what you typed
- collects characters into a line and only releases them to the program when you press Enter
- handles Backspace by editing the buffered line in place
- translates certain control characters into signals instead of forwarding them as bytes
This is why typing into a shell feels editable. The shell did not implement Backspace. The kernel did, on the way in.
The other mode is raw mode. In raw mode, the discipline mostly steps out of the way. Each key press is delivered to the program as soon as it arrives. No echoing. No line buffering. No Backspace handling.
So canonical mode is âthe kernel helps a lotâ. Raw mode is âthe program wants the bytes itselfâ.
Ctrl+C Is Not A Byte
Now the canonical-mode behavior pays off.
When you press Ctrl+C, you might imagine the byte for that key travels through the pipe and the program checks for it. That is not what happens.
The line discipline sees the control character and, instead of forwarding it as input, generates a SIGINT signal. The kernel then delivers that signal to the foreground process group on this terminal.
That is why Ctrl+C interrupts sleep 100 even though sleep is not reading anything. The bytes never reach sleep. A signal does.
The same idea applies to Ctrl+Z, which the discipline turns into SIGTSTP and uses to stop the foreground job.
Raw Mode
Editors and full-screen TUIs need every key press, immediately, without echoing.
So they ask the kernel to switch the line discipline into raw mode for the duration of their run. After that, every byte you type lands directly in the program. The arrow keys become escape sequences that the program parses itself. Backspace becomes the programâs job.
When you quit, the program restores the previous mode and the shell sees a normal canonical terminal again.
What isatty() Really Tells You
Many programs behave differently when their output is going to a terminal versus when it is going to a file or a pipe. Progress bars, colors, prompts, interactive menus.
The check they use is isatty(fd).
What isatty(1) really asks is not âis there a monitor connectedâ and not âis there a shellâ. It asks:
Does this file descriptor refer to a terminal-type device?
In a normal terminal session, fd 1 is the PTY slave, and the answer is yes. If you run the same program with > out.txt, fd 1 is a plain file, and the answer is no. So the program drops the fancy formatting and prints plain text.
That is why this:
my-command
and this:
my-command > out.txt
can produce very differently shaped output, even though the program is the same.
Closing Takeaway
The thing to keep in your head is this:
Your shell, and every program it runs, never talks to the keyboard or the screen. It talks to one side of a kernel pipe. The emulator talks to the other side. The line discipline in the middle decides what each side actually sees.
Once that picture is fixed, the rest of terminal behavior stops feeling mysterious. cat reading your input, Ctrl+C interrupting a sleeping process, vim reacting to arrow keys, ls printing colors only sometimes, all of it is the same pipe, viewed from different angles.