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.Psis a single numeric parameter,Pt/Pbare top/bottom, andPmis 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
| Name | Sequence | Bytes | Default | Effect |
|---|---|---|---|---|
| CUU Cursor Up | CSI Ps A | 1b 5b Ps 41 | 1 | Up Ps rows. Clamps at the scroll-region top (or row 0). Does not scroll. |
| CUD Cursor Down | CSI Ps B | ... 42 | 1 | Down Ps. Clamps at the region bottom. Does not scroll. |
| CUF Cursor Forward | CSI Ps C | ... 43 | 1 | Right Ps. Clamps at the last column. Never wraps. |
| CUB Cursor Back | CSI Ps D | ... 44 | 1 | Left Ps. Clamps at column 0. |
| CNL Cursor Next Line | CSI Ps E | ... 45 | 1 | Down Ps and to column 0 |
| CPL Cursor Prev Line | CSI Ps F | ... 46 | 1 | Up Ps and to column 0 |
| CHA Cursor Horiz. Absolute | CSI Ps G | ... 47 | 1 | To column Ps (1-based) |
| VPA Vertical Pos. Absolute | CSI Ps d | ... 64 | 1 | To row Ps (1-based), column unchanged |
| CUP Cursor Position | CSI Pr ; Pc H | ... 48 | 1;1 | To row Pr, column Pc (1-based). Honors origin mode. |
| HVP Horiz/Vert Position | CSI Pr ; Pc f | ... 66 | 1;1 | Identical to CUP |
| CHT Cursor Fwd Tab | CSI Ps I | ... 49 | 1 | Forward Ps tab stops |
| CBT Cursor Back Tab | CSI Ps Z | ... 5a | 1 | Back 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
| Name | Sequence | Ps | Effect |
|---|---|---|---|
| ED Erase in Display | CSI Ps J | 0 (default) | Cursor to end of screen |
| 1 | Start of screen to cursor | ||
| 2 | Entire screen (cursor unmoved — a classic mistake) | ||
| 3 | Scrollback (xterm extension) | ||
| EL Erase in Line | CSI Ps K | 0 (default) | Cursor to end of line |
| 1 | Start of line to cursor | ||
| 2 | Entire line | ||
| ECH Erase Character | CSI Ps X | 1 | Erase 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
| Name | Sequence | Default | Effect |
|---|---|---|---|
| ICH Insert Character | CSI Ps @ | 1 | Insert Ps blanks at the cursor; the rest of the line shifts right; characters pushed off the end are lost |
| DCH Delete Character | CSI Ps P | 1 | Delete Ps characters at the cursor; the rest shifts left; blanks fill from the right |
| IL Insert Line | CSI Ps L | 1 | Insert Ps blank lines at the cursor row; lines below shift down within the scroll region |
| DL Delete Line | CSI Ps M | 1 | Delete Ps lines; lines below shift up within the region |
| SU Scroll Up | CSI Ps S | 1 | Scroll the region up Ps; cursor unmoved |
| SD Scroll Down | CSI Ps T | 1 | Scroll the region down Ps; cursor unmoved |
| REP Repeat | CSI Ps b | 1 | Repeat 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/SDoperate within the scroll region only, andIL/DLdo nothing if the cursor is outside the region. Also:ILandDLreset 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
| Name | Sequence | Effect |
|---|---|---|
| DECSC Save Cursor | ESC 7 (1b 37) | Save position, SGR style, origin mode, charset, and pending-wrap |
| DECRC Restore Cursor | ESC 8 (1b 38) | Restore all of it |
| SCOSC Save Cursor (ANSI) | CSI s | Save position only. Ambiguous with DECSLRM — see below. |
| SCORC Restore Cursor | CSI u | Restore position |
| DECSTBM Set Margins | CSI Pt ; Pb r | Set the scroll region (1-based, inclusive). Homes the cursor. |
| DECSTR Soft Reset | CSI ! p | Reset modes, margins, SGR, saved cursor; keep the screen contents |
| RIS Hard Reset | ESC c (1b 63) | Full reset: clear both screens, scrollback, modes, title |
Warning — the
CSI sambiguity.CSI smeans "save cursor" unless left/right margin mode (DECLRMM,?69) is enabled, in which caseCSI Pl ; Pr ssets the left/right margins (DECSLRM). Since you are not implementing left/right margins, always treatCSI sas 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
| Name | Sequence | Effect |
|---|---|---|
| HTS Horizontal Tab Set | ESC H (1b 48) | Set a tab stop at the current column |
| TBC Tab Clear | CSI 0 g | Clear the stop at the cursor |
CSI 3 g | Clear all tab stops | |
| HT | 0x09 | Move 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.
| Name | Sequence | Reply |
|---|---|---|
| DSR Device Status | CSI 5 n | CSI 0 n ("OK") |
| CPR Cursor Position Report | CSI 6 n | CSI <row> ; <col> R (1-based) |
| DA1 Primary Device Attributes | CSI c or CSI 0 c | CSI ? 6 2 ; 2 2 c (VT220 + color) — choose what you actually support |
| DA2 Secondary DA | CSI > c | CSI > 0 ; <version> ; 0 c |
| DECRQM Request Mode | CSI ? Ps $ p | CSI ? Ps ; Pv $ y where Pv is 0/1/2/3/4 |
| XTVERSION | CSI > 0 q | DCS > | <name> ST |
| Color query | OSC 10 ; ? ST | OSC 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
| Name | Sequence | Effect |
|---|---|---|
| IND Index | ESC D | Cursor down; scroll the region if at the bottom |
| RI Reverse Index | ESC M | Cursor up; scroll the region down if at the top |
| NEL Next Line | ESC E | Down + column 0 (a CR+LF) |
| DECALN Alignment Test | ESC # 8 | Fill the whole screen with E. The classic terminal test. |
| DECKPAM/DECKPNM | ESC = / ESC > | Application / normal keypad mode |
| Charset select | ESC ( B, ESC ( 0 | ASCII / DEC Special Graphics (box drawing) into G0 |
| SI/SO | 0x0f / 0x0e | Shift In (G0) / Shift Out (G1) |
Note: The DEC Special Graphics charset (
ESC ( 0) mapslqkxjmtuvwnto box-drawing characters. Programs that predate Unicode box drawing — andncurseson someTERMsettings — still use it. If yourls-in-a-box ordialogoutput showslqqqkinstead 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)
| Byte | Name | Effect |
|---|---|---|
0x07 | BEL | Bell — visual flash or audible, or ignore. Also terminates OSC. |
0x08 | BS | Cursor left 1, no wrap, clears pending wrap. Does not erase. |
0x09 | HT | Next tab stop |
0x0a | LF | Line feed (down; scroll at the region bottom) |
0x0b | VT | Treated as LF |
0x0c | FF | Treated as LF |
0x0d | CR | Column 0 |
0x0e | SO | Invoke G1 |
0x0f | SI | Invoke G0 |
0x00 | NUL | Ignored |
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
- What does a missing parameter mean, and what is the default for
CUF? ForED? - Why does
CSI 2 Jnot move the cursor, and what doesclearactually send? - What color do erased cells take, and what surprising visual does that explain?
- Distinguish
ECH,DCH, andEL 0— all three "erase" but differently. - What does DECSC save beyond the cursor position? What breaks if you save only position?
- What does
CSI smean, and under what mode does it mean something else? - Why must
HTnot wrap? - Where do terminal replies go, and why must the core not write them itself?
- What does Backspace do, and what does a shell actually send to visually erase a character?
- Which sequences operate only within the scroll region?
- What is DECALN for, and why is it a good first full-screen test?
lsin a box showslqqqkinstead of┌───┐. What feature is missing?
Next: SGR and Color.