// lesson: ansi-basics
ANSI Escape Sequences โ the Wire Protocol
Raw mode gave us a byte pipe with no kernel meddling. Now: what do the
bytes mean? How does htop paint bars in the middle of the screen,
how does vim turn a line yellow, how does anything ever un-print?
The answer is an in-band protocol: special byte sequences, mixed right
into the output stream, that the terminal interprets as commands
instead of printing. There is no second channel โ no ioctl for "move
the cursor", no side API for "make it red". The screen is programmed
entirely through the same fd the text flows through. This design is
why output can be piped, logged, replayed, and sent over ssh without
anyone in the middle understanding it โ and why cat-ing a binary
file can wreck your terminal (you just fed it random commands).
A short history you actually need
Every terminal vendor of the 1970s invented its own control codes,
and portable software drowned in the differences (the termcap
database, and later terminfo, exist to catalogue that chaos โ more
below). ANSI standardized a common language in ANSI X3.64 (1979),
which grew into ECMA-48 / ISO 6429. DEC's VT100 (1978) was the
hit implementation, so the family is called "ANSI escape codes" and
"VT100 sequences" interchangeably. Everything since โ xterm, Linux
console, tmux, kitty, iTerm2, Windows Terminal โ is a VT100 descendant
with extensions. Implement the VT100 core and you speak to fifty years
of software.
The byte grammar
Codes 0x00โ0x1F are the C0 control characters โ the originals, each one a tiny command:
| Byte | Name | Meaning |
|---|---|---|
| 0x07 | BEL | ring the bell |
| 0x08 | BS | cursor left one column (doesn't erase!) |
| 0x09 | HT | tab: cursor to next tab stop (every 8 by default) |
| 0x0A | LF | cursor down one row |
| 0x0D | CR | cursor to column 1 |
| 0x1B | ESC | the escape: the next bytes are a command |
Multi-byte commands start with ESC (0x1B, "\x1b", sometimes
written ^[). ESC followed by most single characters is a simple
command (ESC 7 save cursor, ESC 8 restore, ESC c full reset).
But the workhorse is ESC followed by [ โ the Control Sequence
Introducer (CSI) โ which begins a parameterized command:
CSI = ESC [
full sequence = ESC [ <parameters> <intermediates> <final byte>
- Parameters (bytes 0x30โ0x3F): decimal numbers separated by
;, e.g.12;40. Empty slots are allowed and mean "default":ESC[;5Hhas an empty first parameter. A leading?marks a private parameter space (DEC's extensions live there). - Intermediates (0x20โ0x2F): rare; you can ignore them for years.
- Final byte (0x40โ0x7E): one character that names the command.
The final byte is what dispatches โ
His "cursor position" whether it has zero, one, or two parameters.
This grammar is fixed and machine-readable without understanding the command, which is what makes a clean parser possible (two lessons from now): you always know where a sequence ends.
The sequences that matter
Cursor movement:
ESC [ <r> ; <c> H CUP cursor to row r, column c (1-BASED! both default 1)
ESC [ <n> A CUU up n rows (n defaults to 1)
ESC [ <n> B CUD down n
ESC [ <n> C CUF right n
ESC [ <n> D CUB left n
Row and column are 1-based: ESC[1;1H (or just ESC[H) is the
top-left corner. Fifty years of off-by-one bugs live in that fact โ
your C code counts from 0, the wire counts from 1, and the conversion
belongs in exactly one place in your program.
Erasing (these do not move the cursor):
ESC [ 0 J ED erase cursor -> end of screen (0 = default)
ESC [ 1 J erase start of screen -> cursor
ESC [ 2 J erase entire screen
ESC [ 0 K EL erase cursor -> end of line (0 = default)
ESC [ 1 K erase start of line -> cursor
ESC [ 2 K erase entire line
ESC[K after redrawing a line's content is the cheap way to clear
stale tail characters โ you'll use it in the renderer, it beats
clearing the whole screen and repainting.
DEC private modes โ set with final h, reset with l:
ESC [ ? 25 h / l show / hide the cursor
ESC [ ? 1049 h / l enter / leave the ALTERNATE SCREEN
ESC [ ? 2004 h / l bracketed paste on / off
Hiding the cursor while repainting kills the ghostly flicker of a cursor teleporting through a redraw. The alternate screen is the trick behind vim and less feeling like "apps": a second framebuffer with no scrollback; enter it on startup, leave on exit, and the user's shell history reappears untouched.
Finally, ESC ] ... (note ], not [) starts an OSC โ Operating
System Command โ string, terminated by BEL or ESC \. OSC 0 sets the
window title: ESC ] 0 ; my title BEL. Your parser must at least
skip these correctly, or one title-setting program will desync your
whole stream.
Colors and attributes: SGR
The final byte m โ Select Graphic Rendition โ takes a list of
attribute codes and is by far the most-sent sequence in practice:
| Code | Effect |
|---|---|
| 0 | reset everything to default |
| 1 | bold |
| 4 | underline |
| 7 | reverse video (swap fg/bg โ your status bar) |
| 30โ37 | foreground: black, red, green, yellow, blue, magenta, cyan, white |
| 39 | default foreground |
| 40โ47 / 49 | background versions of the same |
| 90โ97 | bright foreground variants |
Codes chain in one sequence: ESC[1;33;44m = bold yellow on blue.
And SGR is stateful: it sets the current brush, which applies to
everything printed until changed. Forget ESC[0m and your prompt
inherits your last color โ a bug you have certainly already seen in
the wild.
Two modern extensions squeeze bigger palettes through the same door:
ESC [ 38 ; 5 ; <n> m foreground from a 256-color palette
ESC [ 48 ; 5 ; <n> m background, same palette
ESC [ 38 ; 2 ; <r> ; <g> ; <b> m 24-bit "truecolor" foreground
ESC [ 48 ; 2 ; <r> ; <g> ; <b> m truecolor background
Note what 38;5;208 implies for parsers: parameters are no longer
independent โ 5 and 208 are arguments of 38. Keep that in
mind when you write yours.
Where do sequences come from in practice? (terminfo)
Not every terminal supports every sequence, and historically they
disagreed wildly. The terminfo database (query it with infocmp;
the $TERM env var picks the entry) maps capability names to the
bytes each terminal wants, and libraries like ncurses read it so
programs don't hardcode. We will hardcode the VT100/xterm core โ
every terminal you'll meet this decade speaks it โ but you should
know why TERM=dumb make and ncurses exist, and what breaks when
$TERM lies.
Try the protocol by hand right now โ no code needed:
$ printf '\x1b[2J\x1b[10;20HHello\x1b[0m'
$ printf '\x1b[1;31mred and bold\x1b[0m plain\n'
$ printf '\x1b[?25l'; sleep 2; printf '\x1b[?25h' # cursor vanishes
โบ Sequence Builders
15 ptsEscape sequences are just formatted strings, and we want them in
buffers, not written straight to fd 1 โ the renderer will batch
everything into one write. So every builder here takes a
destination buffer and returns how many bytes it wrote:
int n = ansi_cursor_move(buf, sizeof(buf), 5, 10);
/* buf now holds "\x1b[5;10H", n == 7 */
Contract for all builders: write the sequence into dst, return
its length in bytes; if it wouldn't fit in cap (including the NUL
that snprintf writes), write nothing useful and return -1. Let
snprintf's return value do the heavy lifting โ it returns the
length the output would have (excluding the NUL), so
n < 0 || n >= (int)cap is exactly the failure test.
Build: cursor move / up / down, screen clear, line clear, cursor hide/show, and alternate-screen enter/leave.
Log in to submit a solution and earn points.
โบ Colors and Styles โ SGR
20 ptsModel a text style as data, then compile it to the wire format:
enum color_mode { COLOR_DEFAULT, COLOR_BASIC, COLOR_256, COLOR_RGB };
struct color {
enum color_mode mode;
unsigned char idx; /* BASIC: 0-7, C256: 0-255 */
unsigned char r, g, b; /* RGB */
};
struct style {
struct color fg, bg;
unsigned bold : 1, underline : 1, reverse : 1;
};
sgr_encode(dst, cap, &style) must emit one combined sequence
that fully establishes the style from an unknown state. The reliable
recipe (and the required output format):
- Start from reset: begin the parameter list with
0. - Append
1if bold,4if underline,7if reverse โ in that order. - Append the foreground: nothing for
COLOR_DEFAULT(reset already chose it),3<idx>for basic,38;5;<idx>for 256-color,38;2;<r>;<g>;<b>for RGB. - Same for background with
4<idx>/48;5;โฆ/48;2;โฆ. - Final byte
m.
So bold red on default is \x1b[0;1;31m; plain default-on-default is
\x1b[0m. Also provide sgr_reset (emit exactly \x1b[0m) and
style_eq โ the renderer will soon need "is the brush already
right?" to avoid spamming SGR before every character.
Buffer contract: same as the previous challenge (length or -1).
Log in to submit a solution and earn points.