The CSI Catalog

This is the reference you will implement, sequence by sequence. For each one: the raw bytes, the parameters and their defaults, the state transition, a unit test, and a shell command that generates it so you can watch a real terminal do it.

Implement them in the order given. Each block is a milestone-sized chunk with its own tests.

Note on notation: CSI = ESC [ = 0x1b 0x5b. Ps is a single numeric parameter, Pt/Pb are top/bottom, and Pm is a list. A missing parameter means the sequence's default, which is usually 1 but not always. Never treat a missing parameter as 0.


Block 1: Cursor Movement

NameSequenceBytesDefaultEffect
CUU Cursor UpCSI Ps A1b 5b Ps 411Up Ps rows. Clamps at the scroll-region top (or row 0). Does not scroll.
CUD Cursor DownCSI Ps B... 421Down Ps. Clamps at the region bottom. Does not scroll.
CUF Cursor ForwardCSI Ps C... 431Right Ps. Clamps at the last column. Never wraps.
CUB Cursor BackCSI Ps D... 441Left Ps. Clamps at column 0.
CNL Cursor Next LineCSI Ps E... 451Down Ps and to column 0
CPL Cursor Prev LineCSI Ps F... 461Up Ps and to column 0
CHA Cursor Horiz. AbsoluteCSI Ps G... 471To column Ps (1-based)
VPA Vertical Pos. AbsoluteCSI Ps d... 641To row Ps (1-based), column unchanged
CUP Cursor PositionCSI Pr ; Pc H... 481;1To row Pr, column Pc (1-based). Honors origin mode.
HVP Horiz/Vert PositionCSI Pr ; Pc f... 661;1Identical to CUP
CHT Cursor Fwd TabCSI Ps I... 491Forward Ps tab stops
CBT Cursor Back TabCSI Ps Z... 5a1Back Ps tab stops

All of them clear pending_wrap.

# Generate and watch:
printf 'abc\033[2Dxy\n'          # CUB 2: back two, overwrite → "axy"
printf '\033[2;5Hhere\n'         # CUP: row 2, column 5
printf 'abc\033[10Cdef\n'        # CUF 10
printf 'X\033[999Cend\n'         # CUF clamps — "end" lands at the right edge
#![allow(unused)]
fn main() {
#[test]
fn cuf_clamps_at_the_last_column_and_never_wraps() {
    // CSI 999 C — Cursor Forward. ECMA-48 says clamp; it must NOT wrap to the
    // next line, which is the difference between cursor motion and printing.
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[999C");
    assert_eq!(t.cursor().col, 79);
    assert_eq!(t.cursor().row, 0);
}

#[test]
fn cup_is_one_based() {
    // CSI 1;1H is the top-left cell, which is (0,0) internally.
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[1;1H");
    assert_eq!((t.cursor().row, t.cursor().col), (0, 0));
    t.advance(b"\x1b[5;10H");
    assert_eq!((t.cursor().row, t.cursor().col), (4, 9));
}

#[test]
fn missing_parameters_use_the_default_not_zero() {
    // CSI H means CSI 1;1H. Treating the absent params as 0 would underflow.
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[10;20H\x1b[H");
    assert_eq!((t.cursor().row, t.cursor().col), (0, 0));
    // CSI ;5H means row=default(1), col=5.
    t.advance(b"\x1b[;5H");
    assert_eq!((t.cursor().row, t.cursor().col), (0, 4));
}

#[test]
fn cursor_movement_clears_pending_wrap() {
    let mut t = Terminal::new(5, 10);
    t.advance(b"0123456789");
    assert!(t.cursor().pending_wrap);
    t.advance(b"\x1b[1D");                 // CUB 1
    assert!(!t.cursor().pending_wrap);
}
}

Block 2: Erase

NameSequencePsEffect
ED Erase in DisplayCSI Ps J0 (default)Cursor to end of screen
1Start of screen to cursor
2Entire screen (cursor unmoved — a classic mistake)
3Scrollback (xterm extension)
EL Erase in LineCSI Ps K0 (default)Cursor to end of line
1Start of line to cursor
2Entire line
ECH Erase CharacterCSI Ps X1Erase Ps characters from the cursor, without moving it or shifting anything

Erased cells become blanks with the current SGR background color — not with the default. That is why printf '\033[41m\033[2J' gives you a red screen, and it is a real source of "why is my terminal red" bugs.

printf '\033[2J\033[H'              # clear screen, home — what `clear` does
printf 'abcdef\033[3D\033[Kxyz\n'   # EL 0: erase to end of line from the cursor
printf '\033[41m\033[2J\033[0m'     # ED 2 with a red background → a red screen
printf '\033[3J'                    # erase scrollback (xterm extension)
#![allow(unused)]
fn main() {
#[test]
fn ed_2_clears_the_screen_but_does_not_move_the_cursor() {
    // The classic mistake: assuming CSI 2 J homes the cursor. It does not.
    // That is why `clear` sends "\x1b[2J\x1b[H" — two sequences.
    let mut t = Terminal::new(5, 10);
    t.advance(b"\x1b[3;5Habc");
    let before = t.cursor();
    t.advance(b"\x1b[2J");
    assert_eq!(t.screen().row_text(2).trim(), "");
    assert_eq!(t.cursor().row, before.row, "ED must not move the cursor");
    assert_eq!(t.cursor().col, before.col);
}

#[test]
fn erase_uses_the_current_background_color() {
    // Erased cells take the CURRENT SGR background, not the default.
    let mut t = Terminal::new(3, 10);
    t.advance(b"\x1b[41m\x1b[2J");
    assert_eq!(t.screen().row(0).cell(0).style().bg, Color::Indexed(1));
}

#[test]
fn ech_erases_without_shifting() {
    // ECH blanks in place. DCH (Block 3) shifts. Confusing the two is common.
    let mut t = Terminal::new(3, 10);
    t.advance(b"abcdefghij\x1b[1;3H\x1b[2X");
    assert_eq!(t.screen().row_text(0), "ab  efghij");
    assert_eq!(t.cursor().col, 2, "ECH does not move the cursor");
}
}

Block 3: Insert and Delete

NameSequenceDefaultEffect
ICH Insert CharacterCSI Ps @1Insert Ps blanks at the cursor; the rest of the line shifts right; characters pushed off the end are lost
DCH Delete CharacterCSI Ps P1Delete Ps characters at the cursor; the rest shifts left; blanks fill from the right
IL Insert LineCSI Ps L1Insert Ps blank lines at the cursor row; lines below shift down within the scroll region
DL Delete LineCSI Ps M1Delete Ps lines; lines below shift up within the region
SU Scroll UpCSI Ps S1Scroll the region up Ps; cursor unmoved
SD Scroll DownCSI Ps T1Scroll the region down Ps; cursor unmoved
REP RepeatCSI Ps b1Repeat the last printed character Ps more times
printf 'abcdef\033[1;3H\033[2@XY\n'   # ICH: "abXYcdef" (truncated at width)
printf 'abcdef\033[1;3H\033[2P\n'     # DCH: "abefdef"→"abef"
printf 'a\033[10b\n'                   # REP: "aaaaaaaaaaa"

Warning: IL/DL/SU/SD operate within the scroll region only, and IL/DL do nothing if the cursor is outside the region. Also: IL and DL reset the cursor to column 0 on some terminals (xterm does not; the DEC spec says the column is unchanged). Pick xterm's behavior — it is what programs expect — and note the choice.

#![allow(unused)]
fn main() {
#[test]
fn ich_shifts_right_and_truncates_at_the_line_end() {
    let mut t = Terminal::new(3, 8);
    t.advance(b"abcdefgh\x1b[1;3H\x1b[2@");
    assert_eq!(t.screen().row_text(0), "ab  cdef", "fg h pushed off the end");
}

#[test]
fn il_respects_the_scroll_region() {
    let mut t = Terminal::new(10, 10);
    t.advance(b"\x1b[3;6r");             // region rows 2..5
    t.advance(b"\x1b[1;1HTOP");
    t.advance(b"\x1b[3;1HA\x1b[4;1HB");
    t.advance(b"\x1b[3;1H\x1b[1L");      // insert one line at region top
    assert_eq!(t.screen().row_text(0).trim(), "TOP", "outside the region: untouched");
    assert_eq!(t.screen().row_text(2).trim(), "");
    assert_eq!(t.screen().row_text(3).trim(), "A");
}

#[test]
fn rep_repeats_the_last_printed_character() {
    let mut t = Terminal::new(3, 20);
    t.advance(b"x\x1b[5b");
    assert_eq!(t.screen().row_text(0).trim_end(), "xxxxxx", "1 printed + 5 repeats");
}
}

Block 4: Save, Restore, and Scroll Region

NameSequenceEffect
DECSC Save CursorESC 7 (1b 37)Save position, SGR style, origin mode, charset, and pending-wrap
DECRC Restore CursorESC 8 (1b 38)Restore all of it
SCOSC Save Cursor (ANSI)CSI sSave position only. Ambiguous with DECSLRM — see below.
SCORC Restore CursorCSI uRestore position
DECSTBM Set MarginsCSI Pt ; Pb rSet the scroll region (1-based, inclusive). Homes the cursor.
DECSTR Soft ResetCSI ! pReset modes, margins, SGR, saved cursor; keep the screen contents
RIS Hard ResetESC c (1b 63)Full reset: clear both screens, scrollback, modes, title

Warning — the CSI s ambiguity. CSI s means "save cursor" unless left/right margin mode (DECLRMM, ?69) is enabled, in which case CSI Pl ; Pr s sets the left/right margins (DECSLRM). Since you are not implementing left/right margins, always treat CSI s as save-cursor — and put a comment saying so, because the next reader will wonder.

DECSC saves more than the position. Saving only row/column is a real bug: a program that does ESC 7, changes colors, then ESC 8 expects its colors back.

printf '\033[10;20H\0337\033[1;1HTOP\0338HERE\n'   # DECSC/DECRC round trip
printf '\033[5;20r'                                  # scroll region rows 5-20
printf '\033[!p'                                     # soft reset
printf '\033c'                                       # HARD reset — clears everything
#![allow(unused)]
fn main() {
#[test]
fn decsc_saves_style_and_origin_mode_not_just_position() {
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[31m\x1b[?6h\x1b[5;5H");    // red, origin mode, position
    t.advance(b"\x1b7");                         // DECSC
    t.advance(b"\x1b[0m\x1b[?6l\x1b[1;1H");     // change everything
    t.advance(b"\x1b8");                         // DECRC
    assert_eq!(t.cursor().style.fg, Color::Indexed(1), "SGR must be restored");
    assert!(t.modes().contains(Mode::ORIGIN), "origin mode must be restored");
    assert_eq!((t.cursor().row, t.cursor().col), (4, 4));
}

#[test]
fn decstbm_homes_the_cursor() {
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[10;30H\x1b[5;20r");
    assert_eq!((t.cursor().row, t.cursor().col), (0, 0));
}
}

Block 5: Tabs

NameSequenceEffect
HTS Horizontal Tab SetESC H (1b 48)Set a tab stop at the current column
TBC Tab ClearCSI 0 gClear the stop at the cursor
CSI 3 gClear all tab stops
HT0x09Move to the next tab stop; if none, to the last column

Defaults: a stop every 8 columns. HT must not wrap — at the last stop it goes to the last column and stops.

printf 'a\tb\tc\n'
printf '\033[3g'                # clear all stops
printf 'a\tb\n'                 # now tab goes to the end of the line
printf '\033[1;5H\033H'         # set a stop at column 5
#![allow(unused)]
fn main() {
#[test]
fn tab_moves_to_the_next_multiple_of_eight_by_default() {
    let mut t = Terminal::new(3, 40);
    t.advance(b"a\t");
    assert_eq!(t.cursor().col, 8);
    t.advance(b"bc\t");
    assert_eq!(t.cursor().col, 16);
}

#[test]
fn tab_at_the_last_stop_goes_to_the_last_column_and_does_not_wrap() {
    let mut t = Terminal::new(3, 20);
    t.advance(b"\x1b[1;19H\t");
    assert_eq!(t.cursor().col, 19);
    assert_eq!(t.cursor().row, 0, "HT must never wrap to the next row");
}
}

Block 6: Reports and Queries

These make the terminal write bytes back. They are why Terminal has a replies buffer.

NameSequenceReply
DSR Device StatusCSI 5 nCSI 0 n ("OK")
CPR Cursor Position ReportCSI 6 nCSI <row> ; <col> R (1-based)
DA1 Primary Device AttributesCSI c or CSI 0 cCSI ? 6 2 ; 2 2 c (VT220 + color) — choose what you actually support
DA2 Secondary DACSI > cCSI > 0 ; <version> ; 0 c
DECRQM Request ModeCSI ? Ps $ pCSI ? Ps ; Pv $ y where Pv is 0/1/2/3/4
XTVERSIONCSI > 0 qDCS > | <name> ST
Color queryOSC 10 ; ? STOSC 10 ; rgb:RRRR/GGGG/BBBB ST
#![allow(unused)]
fn main() {
#[test]
fn cpr_reports_a_one_based_position() {
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[5;10H\x1b[6n");
    assert_eq!(t.take_replies(), b"\x1b[5;10R");
}

#[test]
fn replies_are_buffered_not_written() {
    // terminal-core does no I/O. The caller drains replies and writes them to
    // the PTY. This is what keeps the core pure and testable.
    let mut t = Terminal::new(24, 80);
    t.advance(b"\x1b[6n\x1b[5n");
    let replies = t.take_replies();
    assert!(replies.starts_with(b"\x1b["));
    assert!(t.take_replies().is_empty(), "take_replies must drain");
}

#[test]
fn replies_are_rate_limited() {
    // A program spamming CSI 6n must not make the terminal generate unbounded
    // output — a real denial-of-service vector.
    let mut t = Terminal::new(24, 80);
    let spam: Vec<u8> = b"\x1b[6n".repeat(100_000);
    t.advance(&spam);
    assert!(t.take_replies().len() < 1_000_000, "replies must be bounded");
}
}
# Watch a query round-trip in your own shell:
printf '\033[6n'; read -r -d R pos; echo "cursor: ${pos#*[}"
printf '\033[c'; read -r -d c da; echo "device attributes: ${da}"

Warning: Never let a reply be re-parsed as input to your own terminal core. The reply goes to the PTY master, i.e. to the program. If you accidentally feed it back into your parser you get an infinite loop, and it will look like a hang.


Block 7: Miscellaneous but Load-Bearing

NameSequenceEffect
IND IndexESC DCursor down; scroll the region if at the bottom
RI Reverse IndexESC MCursor up; scroll the region down if at the top
NEL Next LineESC EDown + column 0 (a CR+LF)
DECALN Alignment TestESC # 8Fill the whole screen with E. The classic terminal test.
DECKPAM/DECKPNMESC = / ESC >Application / normal keypad mode
Charset selectESC ( B, ESC ( 0ASCII / DEC Special Graphics (box drawing) into G0
SI/SO0x0f / 0x0eShift In (G0) / Shift Out (G1)

Note: The DEC Special Graphics charset (ESC ( 0) maps lqkxjmtuvwn to box-drawing characters. Programs that predate Unicode box drawing — and ncurses on some TERM settings — still use it. If your ls-in-a-box or dialog output shows lqqqk instead of ┌───┐, this is the missing feature.

printf '\033#8'          # DECALN: the screen fills with E
printf '\033(0lqqqk\033(B\n'    # box drawing via the DEC graphics charset
printf '\033M'           # RI: reverse index

C0 Controls (Handled by execute, Not CSI)

ByteNameEffect
0x07BELBell — visual flash or audible, or ignore. Also terminates OSC.
0x08BSCursor left 1, no wrap, clears pending wrap. Does not erase.
0x09HTNext tab stop
0x0aLFLine feed (down; scroll at the region bottom)
0x0bVTTreated as LF
0x0cFFTreated as LF
0x0dCRColumn 0
0x0eSOInvoke G1
0x0fSIInvoke G0
0x00NULIgnored

Note: Backspace does not erase. It moves the cursor left. The visual erase you see when you press Backspace in a shell is the line discipline (or readline) sending BS SP BS — back up, write a space, back up again. Implementing BS as "erase" is a real bug that corrupts any program using BS for cursor positioning.

#![allow(unused)]
fn main() {
#[test]
fn backspace_moves_but_does_not_erase() {
    let mut t = Terminal::new(3, 10);
    t.advance(b"abc\x08");
    assert_eq!(t.cursor().col, 2);
    assert_eq!(t.screen().row_text(0).trim_end(), "abc", "BS must not erase");
    // The visual erase is BS SP BS:
    t.advance(b" \x08");
    assert_eq!(t.screen().row_text(0).trim_end(), "ab");
}
}

Implementation Order

 Week 1   Block 1 (cursor movement) + Block 2 (erase) + C0 controls
          → you can run `clear`, `printf` with positioning, and simple TUIs

 Week 2   Block 4 (save/restore, DECSTBM) + Block 3 (insert/delete)
          → `less` and simple ncurses programs start working

 Week 3   Block 5 (tabs) + Block 6 (reports) + Block 7 (misc)
          → `vim` and `top` become usable

For each sequence, before you write code: run the shell command in a real terminal and watch what happens. You are implementing an observed behavior, not a spec.


Validation / Self-check

  1. What does a missing parameter mean, and what is the default for CUF? For ED?
  2. Why does CSI 2 J not move the cursor, and what does clear actually send?
  3. What color do erased cells take, and what surprising visual does that explain?
  4. Distinguish ECH, DCH, and EL 0 — all three "erase" but differently.
  5. What does DECSC save beyond the cursor position? What breaks if you save only position?
  6. What does CSI s mean, and under what mode does it mean something else?
  7. Why must HT not wrap?
  8. Where do terminal replies go, and why must the core not write them itself?
  9. What does Backspace do, and what does a shell actually send to visually erase a character?
  10. Which sequences operate only within the scroll region?
  11. What is DECALN for, and why is it a good first full-screen test?
  12. ls in a box shows lqqqk instead of ┌───┐. What feature is missing?

Next: SGR and Color.