Skip to content
Lyx's blog

How Terminal Keyboard Input Works: TTY, Readline & Keymap Explained

When you bind Ctrl+M to a custom action in Claude Code running inside Ghostty, it works. Open the same Claude Code in GNOME Terminal, and the binding silently breaks — Ctrl+M behaves like Enter instead. The difference isn’t in Claude Code or your configuration. It’s in the bytes. Ghostty transmits \x1b[109;5u for Ctrl+M via the Kitty Keyboard Protocol, while GNOME Terminal sends 0x0D — the exact same byte as Enter. Your shortcut works in one terminal and vanishes in another because the underlying byte sequences are fundamentally different.

Terminal keyboard input flows through three layers before reaching your application: ASCII control character encoding, the kernel’s TTY driver (configured via termios), and application-level processing (GNU Readline, Fish, Claude Code). Understanding this stack explains why Ctrl+M is identical to Enter at the ASCII level, why Ctrl+C can’t be rebound in most tools, and why modern terminals like Ghostty and Kitty can break free from 1960s constraints that GNOME Terminal still enforces. This article traces a single keypress from your keyboard through all three layers — explaining what each layer does, what it doesn’t, and where the gaps between standards and reality live.

The Terminal Input Pipeline: From Keyboard to Application

When you press a key in a terminal emulator like Ghostty, Kitty, or Alacritty, the byte travels through a well-defined pipeline:

Terminal Emulator (Ghostty)  →  captures keyboard event, writes bytes to PTY master

TTY Driver (kernel)          →  PTY slave side, intercepts or passes bytes per termios settings

Application (Fish, etc.)     →  in raw mode, handles all keypresses itself

The terminal emulator is the modern equivalent of a physical teletypewriter. The PTY (Pseudo-TTY) pair is the modern equivalent of the serial cable. The TTY driver is the same kernel module that processed serial data from physical teletypewriters in the 1970s.

1. TTY and Terminal: A History of Names

TTY stands for TeleTYpewriter — a physical electromechanical device with a keyboard and a print head, connected to a computer via serial cable. Terminal is its successor concept. Early terminals were also hardware (like the DEC VT100), replacing paper with a CRT screen.

Hardware Evolution

EraDeviceInterfaceDisplay
1960sPhysical teletypewriter (TTY)Serial portPaper
1970sVideo terminal (VT100)Serial portCRT screen
1980sTerminal emulator (xterm)PTY (Pseudo-TTY)X11 window
2020sGhostty / Kitty / AlacrittyPTYGPU-accelerated window

The kernel’s TTY driver and PTY interface were designed for physical teletypewriters and have been carried forward unchanged. Today, Ghostty is “the emulated teletypewriter,” the PTY is “the serial cable,” and the TTY driver is “the kernel module that processes serial data.”

Name Legacy

NameOriginal meaningCurrent meaning
TTYTeletypewriterKernel terminal driver module
PTYPseudo-teletypewriterSoftware-emulated TTY (master/slave pair)
terminalPhysical terminal deviceTerminal emulator software (Ghostty)
consoleSystem console (directly connected terminal)TUI or system primary terminal
sttySet teletypeConfigure TTY driver parameters
/dev/ttyTTY device fileCurrent terminal’s device file

2. ASCII Control Character Encoding

The encoding rule for Ctrl-modified keys was defined in the 1960s ASCII standard:

Ctrl + key = key & 0x1F  (clear bits 5 and 6, i.e., AND with 0b00011111)

This rule applies to characters in the ASCII range 0x400x5F (uppercase letters AZ and symbols @, [, \, ], ^, _). It does not apply to lowercase letters or characters outside this range. The equivalent subtraction shorthand ASCII − 64 only holds within this range.

KeyCalculationByteASCII nameMeaning
Ctrl+M0x4D & 0x1F = 0x0D0x0DCRIdentical to Enter
Ctrl+J0x4A & 0x1F = 0x0A0x0ALFLine feed
Ctrl+I0x49 & 0x1F = 0x090x09HTIdentical to Tab
Ctrl+[0x5B & 0x1F = 0x1B0x1BESCEscape
Ctrl+C0x43 & 0x1F = 0x030x03ETXEnd of text
Ctrl+D0x44 & 0x1F = 0x040x04EOTEnd of transmission

Ctrl+M = Enter and Ctrl+I = Tab are not tool configurations. At the byte level, they are the same value. This was defined by the ASCII standard (ANSI X3.4-1963) and cannot be changed by any application.

3. The TTY Driver (termios)

The TTY driver is a kernel-level software layer that sits between the terminal and the process. POSIX (IEEE 1003.1 / ISO/IEC 9945) defines its behavior through the termios structure:

struct termios {
    tcflag_t c_iflag;    // Input mode flags
    tcflag_t c_oflag;    // Output mode flags
    tcflag_t c_cflag;    // Control mode flags
    tcflag_t c_lflag;    // Local mode flags (echo, icanon, ...)
    cc_t     c_cc[NCCS]; // Special character array
};

The c_cc Array (Linux, 19 entries)

Indexstty nameDefaultMeaning
VINTRintr^CSend SIGINT
VQUITquit^\Send SIGQUIT
VERASEerase^?Delete previous character
VKILLkill^UDelete entire line
VEOFeof^DEOF marker / Flush buffer
VSTARTstart^QResume output (XON)
VSTOPstop^SSuspend output (XOFF)
VSUSPsusp^ZSend SIGTSTP
VEOLeol<undef>End-of-line character
VMINmin1Minimum characters for read() (non-canonical only)
VTIMEtime0read() timeout (non-canonical only)

In the POSIX termios structure, Canonical and Non-canonical modes are mutually exclusive, so the driver reuses memory slots in the c_cc array. VMIN typically shares the same index as VEOF (^D), and VTIME shares with VEOL. This is why system programmers must be careful when switching modes.

The stty Command

stty (set teletype) is the user-space tool for configuring the termios structure. It calls tcgetattr() and tcsetattr() under the hood:

$ stty -a
intr = ^C     →  SIGINT (interrupt)
quit = ^\     →  SIGQUIT (quit + core dump)
susp = ^Z     →  SIGTSTP (suspend)
erase = ^?    →  Delete previous character
kill = ^U     →  Delete entire line
eof = ^D      →  Flush buffer / EOF marker

Note that Ctrl+D (VEOF) is not a signal. In Canonical mode, it instructs the TTY driver to immediately “flush” its internal buffer and return all collected bytes to the waiting read() system call. If the buffer is empty (i.e., you press ^D on a new line), read() returns 0 bytes. By POSIX convention, an application interprets a 0-byte return from read() as an End-of-File (EOF) condition.

4. Canonical Mode: Line Editing for Teletypewriters

Canonical mode (also called cooked mode) is the TTY driver’s default line-editing mode. The TTY driver maintains an internal input buffer on behalf of the user — the application only receives the complete line after Enter is pressed.

This design was built for physical teletypewriters, where paper cannot be erased. Editing could only happen in the TTY driver’s internal buffer. What appeared on paper was a separate, irreversible record.

On early 1960s teletypewriters, the erase character was ^H (BS, 0x08), and the convention for marking a deletion on paper was to print #. The ^? (DEL, 0x7F) became the standard erase character later, established by DEC VT-series terminals in the 1970s.

The origin of ^? (DEL) is physical: in the era of punched paper tape, a character was recorded by punching holes into a tape. Once a hole was punched, it couldn’t be “un-punched.” The binary representation of 0x7F is 1111111 (seven holes punched). If an operator made a mistake, they would back up the tape and punch all seven holes (the “Rubout” character), which the computer was then programmed to ignore. This physical constraint is why DEL became the standard for erasing.

Modern Linux systems default to ^? for VERASE, while some older BSD-derived systems still default to ^H.

KeyTTY buffer operationEffect on paper (1960s TTY era)
^H (erase, early TTYs)Delete last characterBack up one position, print # to mark deletion
^W (werase)Delete last wordOverwrite characters one by one
^U (kill)Clear entire linePrint @ to mark line as void, newline to retype
^R (reprint)Re-output buffer contentsReprint the clean current buffer

Consider this input sequence: fix the bu + ^W + ^W + the bug

What appears on paper (with strikeouts):

fix the bu####the bug
         ^^^^
         Two ^W strikeouts covering "bu" and "the"

TTY buffer (invisible to the operator, evolving step by step):

fix the bu          ← typed up to here
fix the             ← ^W deleted "bu"
fix                 ← ^W deleted "the"
fix the bug         ← typed "the bug"

There’s a critical distinction here:

The paper and the buffer are not synchronized. The paper shows the full history with strikeouts. The buffer holds only the clean current state. This is why ^R (reprint) exists — the operator forgets what’s actually in the buffer and needs a clean reprint.

When video terminals (VT100) arrived with cursor movement and screen erase capabilities, the TTY driver could send BS SPACE BS sequences to actually erase characters on screen. Paper and buffer finally became visually synchronized. Modern terminal emulators (Ghostty) inherit this mechanism.

5. TTY Interception vs Application Handling

The TTY driver intercepts bytes it recognizes and handles them itself (sending signals, editing the line). Bytes it doesn’t recognize are passed through to the application:

Keyboard Ctrl+C → 0x03 → TTY intercepts → sends SIGINT → process receives signal
Keyboard Ctrl+A → 0x01 → TTY passes through → reaches readline/fish → application handles it

This means:

6. Readline and POSIX: What’s Standardized and What Isn’t

Readline is not a standard. It’s GNU Readline — a line-editing library originally written by Brian Fox for the GNU Project (publicly released with Bash around 1988–1989). Its keybindings come from Emacs conventions, not from any specification.

Historical Timeline

YearMilestoneEditing capability
1970sUnix shtermios cooked mode only (^U kill line, ^W kill word, ^H/^? backspace). No cursor movement.
1978BSD cshBill Joy introduces command history substitution (!!, !$), but still no interactive line editing
~1981tcshKen Greer adds Emacs-style interactive line editing (Ctrl+A home, Ctrl+E end) to csh
~1983ksh (AT&T)David Korn independently adds emacs-mode and vi-mode line editing; developed separately from tcsh
1988–89GNU ReadlineBrian Fox creates reusable line-editing library for Bash. Adopts Emacs key conventions established by tcsh/ksh. Chet Ramey becomes primary maintainer ~1990.
1990sPOSIX (IEEE 1003.1)Standardizes termios interface and sh command language syntax. Does not standardize line-editing keybindings.

The Standardization Gap

POSIX termios           — TTY driver behavior (standardized)
    ↑ not covered by any standard ↑
BSD tcsh / GNU Readline — Line-editing keybindings (de facto standard, not a formal specification)
    ↑ each tool implements its own ↑
Fish / Claude Code      — Don't use GNU Readline; implement line editing independently

Key points:

The keybindings you see in terminal tools are the product of 40 years of convention propagated from Emacs → tcsh → GNU Readline. They are not the product of any ISO or POSIX specification.

7. Raw Mode: When Applications Take Over

Cooked Mode vs Raw Mode

Raw Mode is a termios configuration that disables all TTY driver processing, letting the application receive raw bytes directly. Cooked Mode (canonical mode) is the default where the TTY driver handles everything:

BehaviorCooked Mode (default)Raw Mode
Line bufferingInput delivered to app after EnterEach byte delivered immediately
^C → SIGINTTTY intercepts, sends signalByte 0x03 passed to app directly
^U / ^WTTY performs line/word deletionBytes passed to app directly
^D EOFTTY triggers EOF conditionByte 0x04 passed to app directly
^S / ^QTTY suspends/resumes outputBytes passed to app directly
EchoTTY auto-echoes inputApplication decides whether to echo
^VTTY escapes next characterByte passed to app directly

Linux and BSD systems provide cfmakeraw() as a convenience function to switch to Raw Mode in one call. Under the hood, it disables icanon, echo, ISIG, IXON, and other flag bits in the termios structure. Note that cfmakeraw() is not part of the POSIX standard — it originated as a BSD extension and was later adopted by Linux glibc. On strictly POSIX-only environments, you must set the flags manually via tcsetattr().

The Three Roles

Terminal Emulator (Ghostty)        —  Captures keyboard events, converts to bytes, writes to PTY master

TTY Driver (kernel)                —  PTY slave side, intercepts or passes bytes per termios settings

Application (Fish, Claude Code)    —  Calls tcsetattr() to switch raw mode, handles all keypresses itself

The terminal emulator doesn’t participate in Raw Mode switching. It only converts keyboard events to bytes and sends them through the PTY. It’s the applications — Fish, Claude Code — that call tcsetattr() to modify termios flags and take over keypress handling.

Overlap with c_cc Definitions

TTY c_ccTTY behaviorApplication behaviorConflict?
^CSIGINTFish: cancel, Claude Code: cancelNo — tools preserve TTY handling
^DVEOF (exit)Fish: exitNo — same semantics
^UVKILL (delete entire line)Delete to line startNo — semantic refinement
^WVWERASE (delete previous word)Delete previous wordNo — same semantics, app handles in Raw Mode
^SVSTOP (suspend output)Claude Code: stashNo — IXON disabled in Raw Mode
^RVREPRINTClaude Code: search historyNo — app handles in Raw Mode

No actual conflicts. In Raw Mode, the application takes over all keypress handling. The c_cc definitions stop applying. The keybindings are semantically consistent with TTY defaults by deliberate design.

8. Ctrl+J vs Ctrl+M: A Deep Dive

The difference between these two keys reveals how termios flags affect what applications see:

KeyByteTerminal defaultCan be bound separately?
Ctrl+M0x0D (CR)Identical to EnterNo in Cooked Mode; Only with modern protocols in Raw Mode
Ctrl+J0x0A (LF)Different from EnterYes — different byte

In Cooked Mode

The TTY driver enables the icrnl (Input CR to NL) flag by default. This translates incoming 0x0D (CR) to 0x0A (LF). To the application, Ctrl+M and Ctrl+J both produce byte 0x0A — they are indistinguishable.

Why does this translation exist? On physical teletypewriters, CR (0x0D, Carriage Return) and LF (0x0A, Line Feed) were two separate mechanical actions: CR moved the print head back to the left edge, LF rolled the paper up one line. Starting a new line required both. Unix adopted \n (LF) as its line ending, while Windows uses \r\n (CR+LF). The TTY driver’s icrnl flag bridges this gap — it converts the Enter key’s 0x0D (CR) into 0x0A (LF) so that Unix applications only see \n.

In Raw Mode

When an application switches to Raw Mode, icrnl is disabled. This allows the application to distinguish between Ctrl+J (0x0A) and Enter/Ctrl+M (0x0D).

However, there is a common misconception that Raw Mode alone allows you to distinguish Ctrl+M from Enter. In standard terminal configurations, both keys send the exact same byte (0x0D). If the bytes are identical, the application cannot distinguish them even in Raw Mode. To truly separate Enter from Ctrl+M, you must use a terminal emulator that supports modern keyboard protocols.

9. Modern Solutions: Kitty Keyboard Protocol & CSI u

The reason we struggle with Shift+Enter or distinguishing Tab from Ctrl+I is the rigid 1963 ASCII encoding. To solve this without breaking backward compatibility, modern terminal emulators like Ghostty, Kitty, and Alacritty support enhanced keyboard protocols (like the Kitty Keyboard Protocol or the CSI u standard).

Instead of sending a single legacy byte like 0x0D (CR) for Enter, these protocols can send a rich escape sequence:

The application (if it supports these protocols) can then parse these escape sequences to provide fully independent keybindings for every possible key combination, finally moving beyond the constraints of teletypewriter-era hardware.

10. Debugging Terminal Input: Seeing the Bytes Yourself

You can observe terminal input processing at each layer using standard Unix tools.

View Current TTY Settings

stty -a

This shows all termios flags and c_cc character mappings. Look for icanon (canonical mode on/off), echo (echo on/off), and the special character assignments.

Observe Raw Keypress Bytes

# Press keys, then Ctrl+C to exit
cat | xxd

Each keypress shows its raw hex value. Try Ctrl+M (you’ll see 0d) vs Ctrl+J (you’ll see 0a). In cooked mode, both may appear as 0a due to icrnl.

Switch to Raw Mode and Observe

import sys, tty, termios

fd = sys.stdin.fileno()
old = termios.tcgetattr(fd)
try:
    tty.setraw(fd)
    while True:
        ch = sys.stdin.read(1)
        print('byte: %02x (%r)' % (ord(ch), ch), file=sys.stderr)
finally:
    termios.tcsetattr(fd, termios.TCSADRAIN, old)

In Raw Mode, you’ll see Ctrl+M as 0d and Ctrl+J as 0a — confirming they’re distinct bytes when icrnl is off.

Answering the Opening Question

Run the script above in Ghostty and press Ctrl+M. You won’t see 0x0D — you’ll see:

byte: 1b ('\x1b')  byte: 5b ('[')  byte: 31 ('1')  byte: 30 ('0')  byte: 39 ('9')  byte: 3b (';')  byte: 35 ('5')  byte: 75 ('u')

This is \x1b[109;5u — the Kitty Keyboard Protocol encoding for “key m + Ctrl modifier.” Decoded:

\x1b  [  1  0  9  ;  5  u
ESC  CSI ├─────┘  ├──┘  └─ CSI u format terminator
│        │        │
│        │        └─ modifier 5 = Ctrl
│        └─ Unicode codepoint 109 = 'm'
└─ ASCII code 0x1b, Same as Ctrl+[

Ghostty doesn’t compress Ctrl+M into the legacy 0x0D. So Claude Code can tell Ctrl+M apart from Enter.

Press Enter and Ctrl+J for comparison:

KeyLegacy byteGhostty sends
Enter0x0D0x0D — legacy byte
Ctrl+M0x0D (same)\x1b[109;5u — completely different
Ctrl+J0x0A0x0A — legacy byte

Which terminals can distinguish them depends on protocol support:

TerminalEnterCtrl+MDistinguishable?
Ghostty0x0D\x1b[109;5uYes
Kitty0x0D\x1b[109;5uYes (protocol creator)
WezTerm0x0D\x1b[109;5uYes
AlacrittyPartialDepends on versionPartial
GNOME Terminal (VTE)0x0D0x0DNo
xterm0x0D0x0DNo

This is why your Ctrl+M binding works in Ghostty but silently breaks in GNOME Terminal. The three-layer stack — ASCII, TTY driver, application — hasn’t changed. But the terminal emulator has inserted a fourth layer: the keyboard protocol. And that layer determines whether Ctrl+M and Enter are the same byte or not.

Frequently Asked Questions

Why can’t I rebind Ctrl+M independently from Enter?

In cooked mode, you can’t: Ctrl+M produces 0x0D (CR), and the TTY driver’s icrnl flag translates it to 0x0A (LF) — same as Ctrl+J — before it reaches the application. The bytes are identical by the time any application sees them.

In Raw Mode (used by Fish, Claude Code), icrnl is disabled, but Ctrl+M and Enter still typically send the same byte (0x0D). To distinguish them, you need a terminal emulator and an application that both support modern protocols like Kitty Keyboard Protocol or CSI u, which send unique escape sequences for different key combinations.

What’s the difference between Ctrl+J and Ctrl+M?

Ctrl+J produces 0x0A (Line Feed) and Ctrl+M produces 0x0D (Carriage Return) — they are different bytes. However, in cooked mode, the TTY driver’s icrnl flag translates 0x0D to 0x0A, making them indistinguishable to applications. In Raw Mode (used by Fish, Claude Code), icrnl is disabled and the bytes arrive separately, allowing independent keybindings.

Do Fish and Claude Code use GNU Readline?

No. Each implements its own line editing and input processing. However, they both follow the same Emacs keybinding conventions (Ctrl+A for beginning of line, Ctrl+E for end, etc.) because these are 40-year muscle-memory standards, not Readline-specific features.

What does stty raw actually do?

It disables all TTY driver processing: turns off icanon (canonical mode line editing), echo (automatic character display), ISIG (signal generation for ^C, ^Z, ^\), IXON (XON/XOFF flow control), and the icrnl flag (CR-to-NL translation). The application then receives every byte exactly as typed. On Linux and BSD systems, the cfmakeraw() function does this in one call — note that cfmakeraw() is a BSD/Linux extension, not part of the POSIX standard.

Why does Ctrl+U delete to line start instead of the whole line?

In POSIX termios, VKILL (Ctrl+U) is defined as “delete entire line.” But when an application like Bash (via GNU Readline) or Fish switches to Raw Mode, the TTY’s c_cc handling is bypassed. The application redefines Ctrl+U as “kill to beginning of line” (keeping characters after the cursor), which is more useful in practice. This is a semantic refinement, not a conflict.

Conclusion

Terminal keyboard input processing is a three-layer system: ASCII defines the byte-level encoding (unchanged since 1963), the TTY driver (configured via termios) provides line editing and signal handling inherited from teletypewriter design, and the application layer (Readline or custom) handles everything else.

The keybindings in modern terminal tools — Ctrl+A for beginning of line, Ctrl+E for end, Ctrl+K to kill forward — come from 40 years of Emacs convention, not from POSIX or any formal standard. Applications in Raw Mode bypass the TTY driver’s c_cc definitions entirely, but preserve their semantics by design.

Understanding this stack demystifies why some shortcuts can’t be rebound (they’re the same byte at the ASCII level), why Shift+Enter requires Raw Mode to work, and why every terminal tool independently implements the same Emacs keybindings.

Further Reading


Share this post on:

Next Post
macOS Has a Shadow Toolchain That Breaks AI Coding Agents