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, the kind of thing you never think about. But it is not obvious at all.
The thing is, 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 the two sits a small piece of kernel machinery that almost nobody draws when they explain this, and once you see it the rest of terminal behavior stops feeling like magic.
So letβs go slowly and follow that machinery from one end to the other. Along the way we will answer the questions that usually stay fuzzy: who actually creates the terminal, which side the emulator holds, which side the shell holds, how a program like cat ends up reading my keystrokes instead of the shell, and finally how a single typed byte travels all the way from a key press to the program.
I will use one running example the whole time. I open iTerm, a shell starts, and at some point I run cat inside it. That is the entire story, told in slow motion.
Quick Refresher
There are four pieces in this story, and the whole post is really about how they fit together. So before we start moving bytes around, letβs name them and say what each one is responsible for.
Terminal Emulator
The piece you actually click on is the terminal emulator. Terminal.app, iTerm2, GNOME Terminal, Alacritty, and so on.
Its job is smaller than people think. It draws characters in a window, and it reads your keyboard events. That is basically it. It does not parse commands, and it does not know what ls is. When you type ls, the emulator has no idea that anything interesting just happened.
Shell
The thing that does understand ls is the shell, a program like bash, zsh or fish.
The shell reads command lines, parses them, launches other programs, and manages jobs. One detail worth fixing early: the prompt you see on the screen belongs to the shell, not to the emulator. The emulator just painted the characters the shell sent it.
Pseudo-Terminal (PTY)
Now the piece most people have never heard of. 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 an old serial terminal was sitting on the other end, even though there is no such hardware anywhere.
A PTY always comes as a pair, a master side and a slave side. You can think of it as a pipe with two ends that the kernel sits in the middle of. Both ends are just file descriptors, and whatever is written into one side becomes readable on the other side, after the kernel has had a chance to process it. That little βafter the kernel has had a chance to process itβ is doing a lot of work, and it brings us to the last piece.
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, and 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 ends of the PTY. It is small, but as we will see, it is where a surprising amount of βterminal behaviorβ actually lives. We will come back to it once the pipe is set up.
Step 1: The PTY Pair Gets Created
Back to our example. You double-click on iTerm.
Before there is any shell, before there is any prompt, the very first thing the emulator does is ask the kernel for a fresh PTY pair. The kernel does the work and hands back two file descriptors, one for the master side and one for the slave side.
The emulator keeps the master file descriptor for itself. The slave side just sits 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 and the line discipline exists, but nothing useful is happening on it yet. We have a fake terminal with nobody talking through it.
Step 2: The Shell Gets Attached To The Slave
Now the emulator wants a shell sitting on the other end of that pipe, so 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 three numbers arestdin,stdoutandstderr. - Still 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 across the exec(). So the shell starts up with its stdin, stdout and stderr already wired to the slave side of the PTY.
What this means is that from the shellβs very first instruction, it is already reading and writing through the slave. It does not know there is a PTY in front of it. It does not know there is an emulator further up. All it knows is that it has three open file descriptors, and it reads and writes them like any other file.
βββββββββββββββββββββ
β terminal emulator β
βββββββββββ¬ββββββββββ
β master fd
βββββββββββͺββββββββββ kernel
line discipline
βββββββββββͺββββββββββ
β slave fd
βββββββββββ΄ββββββββββ
β shell β stdin / stdout / stderr β slave
βββββββββββββββββββββ
This is the part that is easy to miss, so it is worth saying plainly. The shell is not reading the keyboard. The shell is reading a file descriptor. It just so happens that this file descriptor is one end of a kernel pipe, and the other end is owned by the emulator. In other words, the shell thinks it is talking to a terminal, but it is really talking to the kernel.
Step 3: A Byte Travels From Key To Shell
With the pipe set up and the shell attached, we can finally walk a single byte through the whole thing.
Suppose the shell is showing its prompt and you press the a key. Here is what happens, step by step:
- 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 single 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 so you can see it, buffers it as part of the current line, and waits to see if more characters are coming.
- When the line is complete, that is, when you press Enter, the bytes become readable on the slave side.
- The shell, which was parked in a
read()onstdin, now gets the whole line back.
Drawn out, the byte takes this path. Notice that the echo goes back up to the screen, while the finished line goes down to the shell:
you press 'a'
β
βΌ
βββββββββββββββββββββ
β terminal emulator β ββββ echo: you see 'a' on screen
βββββββββββ¬ββββββββββ
write β β²
(0x61) βΌ β master fd (echo back up)
ββββββββββββββββββββββ kernel
line discipline
β’ echoes 'a' back up
β’ buffers it into the line
β’ waits for Enter β΅
ββββββββββββββββββββββ
β slave fd
βΌ (after Enter, the whole line)
βββββββββββββββββββββ
β shell β read(stdin) returns the line
βββββββββββββββββββββ
The reverse direction, the shell printing something to the screen, is just 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 one sentence is really the whole model. Almost everything else about terminals falls out of it, including the part we are about to get to.
Step 4: The Shell Launches A Program & The Slave Gets Inherited
This is the part our running example was building toward. The prompt is up, and now you type cat and press Enter. What does the shell actually do?
- It calls
fork(), and a new child process appears, which is a copy of the shell. - That 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 then calls
exec()on/bin/cat.
After exec(), the child process is running cat instead of the shell. But again, 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.
This is the important part. The shell does not do anything special to βhand overβ the terminal. It does not copy bytes, and it does not proxy anything between cat and the screen. It just forks, the child inherits the slave, and now there is simply a different program reading and writing that same file descriptor.
βββββββββββββββββββββ
β terminal emulator β
βββββββββββ¬ββββββββββ
β master fd
βββββββββββͺββββββββββ kernel
line discipline
βββββββββββͺββββββββββ
β slave fd
βββββββββββ΄ββββββββββ βββββββββββββββββββββ
β cat β β shell β
β (foreground) β β (parked, waiting) β
βββββββββββββββββββββ βββββββββββββββββββββ
The emulator, meanwhile, is still sitting on the master side and 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, exactly the way it was a second ago.
So 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 the other end of it.
One detail is worth calling out here. 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 of input. The kernel and the shell coordinate this by tagging one process group as the foreground group of the terminal, and that is the group that receives the input. When cat runs, the shell steps out of the foreground and lets cat be the foreground group, and when cat exits, the shell steps back in.
Why The Line Discipline Matters
We kept saying we would come back to the line discipline, so here we are. It is easy to ignore in diagrams, but it is quietly doing a lot of the work you give the terminal credit for.
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 in the first place. Think about our example: when you mistype something at the prompt and hit Backspace, the shell did not implement that. The kernel did, on the way in, before the shell ever saw the line.
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, with no echoing, no line buffering, and no Backspace handling.
So you can think of canonical mode as βthe kernel helps a lotβ, and raw mode as βthe program wants the raw bytes and will handle everything itselfβ.
Ctrl+C Is Not A Byte
This is where that canonical-mode behavior really pays off.
When you press Ctrl+C, you might imagine the byte for that key travels through the pipe and the program reads it and decides to quit. That is not what happens at all.
What actually happens is that 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.
The byte never makes it to the program. It branches off into a signal instead:
you press Ctrl+C (byte 0x03)
β
βΌ
βββββββββββββββββββββ
β terminal emulator β
βββββββββββ¬ββββββββββ
β master fd
βββββββββββͺββββββββββββββββββ kernel
line discipline
sees 0x03, does not
forward it as input ββββββββΊ SIGINT
βββββββββββͺββββββββββββββββββ β
β³ slave fd β
nothing arrives here βΌ
βββββββββββββββββββββββββ
β foreground group β
β (cat, sleep ...) β
βββββββββββββββββββββββββ
That is why Ctrl+C can interrupt 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 a SIGTSTP signal and uses to stop the foreground job.
Raw Mode
Now think about an editor like vim or a full-screen TUI. It needs every key press, immediately, without echoing, because it wants to react to arrow keys and shortcuts the moment you hit them.
So when it starts, it asks the kernel to switch the line discipline into raw mode for the duration of its run. After that, every byte you type lands directly in the program. The arrow keys arrive as escape sequences that the program parses itself, and Backspace becomes the programβs job instead of the kernelβs.
When you quit, the program restores the previous mode, and the shell sees a normal canonical terminal again, exactly the way it was before.
What isatty() Really Tells You
There is one more piece that makes sense now that we have the full picture. Many programs behave differently when their output is going to a terminal versus when it is going to a file or a pipe. Things like progress bars, colors, prompts, and interactive menus tend to appear in one case and disappear in the other.
The check they use for this is isatty(fd).
What isatty(1) really asks is not βis there a monitor connectedβ, and not βis there a shellβ. It asks something much narrower:
Does this file descriptor refer to a terminal-type device?
In our normal terminal session, fd 1 is the PTY slave, and the answer is yes. But if you run the same program with > out.txt, fd 1 is now a plain file, and the answer is no. So the program quietly drops the fancy formatting and prints plain text instead.
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
If you keep just one picture in your head, make it this one:
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, it is all the same pipe from our example, just viewed from a different angle each time.