// 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[;5H has 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 โ€” H is "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 pts

Escape 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 pts

Model 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):

  1. Start from reset: begin the parameter list with 0.
  2. Append 1 if bold, 4 if underline, 7 if reverse โ€” in that order.
  3. 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.
  4. Same for background with 4<idx> / 48;5;โ€ฆ / 48;2;โ€ฆ.
  5. 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.