Modes: ANSI Modes and DEC Private Modes
A mode is a boolean the program can set or reset to change how the terminal behaves. Modes are how a program says "send me mouse events," "wrap my paste in markers," "I am taking over the whole screen," and "encode arrow keys differently."
Two families:
CSI Ps h SET an ANSI mode (SM)
CSI Ps l RESET an ANSI mode (RM)
CSI ? Ps h SET a DEC private mode (DECSET)
CSI ? Ps l RESET a DEC private mode (DECRST)
CSI ? Ps $ p QUERY a DEC private mode (DECRQM)
CSI ? Ps s SAVE a DEC private mode (XTSAVE)
CSI ? Ps r RESTORE a DEC private mode (XTRESTORE)
The ? is the distinguishing byte, and it is what the parser reports as private = Some('?'). Mode
4 and mode ?4 are completely unrelated.
ANSI Modes (no ?)
| Ps | Name | Set means | Notes |
|---|---|---|---|
4 | IRM Insert/Replace | Insert: printed characters push the rest of the line right | Rarely used; default is replace |
20 | LNM Line Feed/New Line | LF also does a carriage return | Almost never set; if you see it, something is confused about ONLCR |
That is essentially the whole list you need. The interesting modes are all DEC private.
DEC Private Modes: The Ones That Matter
| Ps | Name | Set (h) means |
|---|---|---|
1 | DECCKM Application Cursor Keys | Arrow keys send SS3 A instead of CSI A |
5 | DECSCNM Screen Mode | Reverse video for the whole screen |
6 | DECOM Origin Mode | Cursor positioning is relative to the scroll region |
7 | DECAWM Autowrap | Text wraps at the right margin (default: set) |
12 | Cursor blink (att610) | The cursor blinks |
25 | DECTCEM Text Cursor Enable | The cursor is visible (default: set) |
47 | Alternate screen (legacy) | Switch to the alternate buffer — no cursor save, no clear |
1000 | Mouse: normal tracking | Report button press and release |
1002 | Mouse: button-event tracking | Also report motion while a button is held (drag) |
1003 | Mouse: any-event tracking | Report all motion. Very chatty. |
1004 | Focus reporting | Send CSI I on focus in, CSI O on focus out |
1005 | Mouse: UTF-8 encoding | Legacy extension; superseded by 1006 |
1006 | Mouse: SGR encoding | CSI < b ; x ; y M/m — the modern encoding. Use this. |
1015 | Mouse: urxvt encoding | Another legacy extension |
1016 | Mouse: SGR-pixel encoding | Pixel coordinates rather than cells |
1047 | Alternate screen | Switch, and clear the alternate on exit |
1048 | Save/restore cursor | Like DECSC/DECRC, driven by mode set/reset |
1049 | Alternate screen + save cursor + clear | The combination everything actually uses |
2004 | Bracketed paste | Pasted text is wrapped in CSI 200~ … CSI 201~ |
2026 | Synchronized output | Buffer updates between set and reset, then present atomically |
2027 | Grapheme clustering | The program and terminal agree on grapheme-cluster widths |
The Alternate Screen (?1049)
This is the mode that makes vim, less, top, and man behave the way they do, and it is the one
users notice immediately when it is wrong.
CSI ? 1049 h (enter)
1. Save the cursor position and style (like DECSC)
2. Switch the active buffer to the ALTERNATE grid
3. CLEAR the alternate grid
→ the program now has a blank full screen with no scrollback
CSI ? 1049 l (leave)
1. Switch back to the PRIMARY grid
2. Restore the saved cursor
→ the user's shell session reappears EXACTLY as it was
Properties of the alternate screen, all of which you must implement:
| Property | Reason |
|---|---|
| No scrollback. Ever. | The program owns the screen; there is no "history" to keep. Scrolling vim is vim redrawing, not the terminal scrolling. |
| Not reflowed on resize | The program redraws on SIGWINCH |
| Cleared on entry (for 1049) | So the program starts from a known state |
| Independent cursor | Saved and restored across the switch |
| Mouse wheel behavior differs | Most terminals translate wheel events into arrow keys on the alternate screen, since there is nothing to scroll |
Note: The variants exist for historical reasons.
?47is the original (no save, no clear),?1047adds the clear,?1048is save/restore only,?1049is the useful combination. Implement?1049properly and map?47/?1047onto it;?1048maps to DECSC/DECRC.
# Watch it happen:
printf '\033[?1049h'; echo "I am on the alternate screen"; sleep 2; printf '\033[?1049l'
# The message appears, then vanishes and your shell is exactly as it was.
# What `less` and `vim` do:
less /etc/hosts # note: your scrollback is untouched when you quit
man ls # same
#![allow(unused)] fn main() { #[test] fn alternate_screen_round_trip_preserves_the_primary_screen() { let mut t = Terminal::new(5, 20); t.advance(b"primary content\n"); t.advance(b"\x1b[?1049h"); t.advance(b"alternate content\n"); assert!(t.screen().row_text(0).starts_with("alternate")); t.advance(b"\x1b[?1049l"); assert!(t.screen().row_text(0).starts_with("primary"), "leaving the alt screen must restore the primary buffer exactly"); } #[test] fn alternate_screen_never_accumulates_scrollback() { let mut t = Terminal::new(5, 20); t.advance(b"\x1b[?1049h"); for _ in 0..100 { t.advance(b"line\n"); } assert_eq!(t.screen().scrollback().len(), 0); t.advance(b"\x1b[?1049l"); assert_eq!(t.screen().scrollback().len(), 0, "alt-screen output must never leak into history"); } #[test] fn entering_1049_clears_the_alternate_buffer() { let mut t = Terminal::new(5, 20); t.advance(b"\x1b[?1049hLEFTOVER\x1b[?1049l"); t.advance(b"\x1b[?1049h"); assert_eq!(t.screen().row_text(0).trim(), "", "?1049h must clear, so the program starts from a known state"); } }
DECAWM (?7) — Autowrap
Default set. When reset, text at the last column overwrites in place rather than wrapping.
printf '\033[?7l' # autowrap OFF
printf '0123456789012345678901234567890123456789...' # piles up in the last column
printf '\n\033[?7h' # back on
Used by programs drawing at the screen edge that must not trigger a scroll. Interacts directly with pending wrap: with DECAWM reset, the pending-wrap flag is never honored.
DECCKM (?1) — Application Cursor Keys
This is why the same arrow key produces different bytes in bash and in vim.
DECCKM reset (normal): Up = CSI A = 1b 5b 41
DECCKM set (application): Up = SS3 A = 1b 4f 41
The terminal does not decide — the program does, by setting the mode, and the terminal's input
encoder must honor it. This is the first place where the terminal's output state feeds back into
input encoding, which is why terminal-input needs access to the mode flags.
printf '\033[?1h' # application cursor keys ON
# Now press an arrow key while running your Lab 1 inspector: 1b 4f 41
printf '\033[?1l' # back to normal: 1b 5b 41
Bracketed Paste (?2004)
Set: pasted text arrives as CSI 200~ <text> CSI 201~
Reset: pasted text is indistinguishable from typed text
The bug it fixes: paste four lines of indented Python into vim in insert mode. Without bracketed
paste, vim sees four Enter keypresses and auto-indents each one, producing a staircase. With it,
vim knows the block is a paste and disables auto-indent.
Your responsibilities as a terminal:
- Only wrap when the mode is set.
- Sanitize the payload. If the pasted text itself contains
\x1b[201~, a malicious clipboard could end the paste early and have the remainder interpreted as typed commands. Strip or escape it. This is a real, exploited attack. - Consider stripping other control bytes — at minimum, warn on a pasted
\nwhen the mode is not set, since that executes a command immediately.
#![allow(unused)] fn main() { #[test] fn bracketed_paste_wraps_only_when_the_mode_is_set() { let mut modes = TerminalModes::default(); assert_eq!(encode_paste("hello", &modes), b"hello".to_vec()); modes.insert(Mode::BRACKETED_PASTE); assert_eq!(encode_paste("hello", &modes), b"\x1b[200~hello\x1b[201~".to_vec()); } #[test] fn paste_payload_cannot_terminate_its_own_bracket() { // A clipboard containing the end marker must not be able to escape the // paste envelope and have the remainder run as typed input. let mut modes = TerminalModes::default(); modes.insert(Mode::BRACKETED_PASTE); let evil = "safe\x1b[201~rm -rf /\n"; let out = encode_paste(evil, &modes); let body = &out[6..out.len() - 6]; assert!(!contains_subslice(body, b"\x1b[201~"), "the end marker must be stripped or escaped inside the payload"); } }
Mouse Reporting (?1000, ?1002, ?1003, ?1006)
Two orthogonal choices: what to report, and how to encode it.
What to report
| Mode | Reports |
|---|---|
?1000 | Button press and release only |
?1002 | Press, release, and motion while a button is held (drag) |
?1003 | Press, release, and all motion — one event per cell crossed. Very chatty. |
How to encode it
Legacy X10 encoding (the default when only ?1000 is set):
CSI M Cb Cx Cy
where each of Cb/Cx/Cy is a single byte = value + 32
Cb bits: 0-1 = button (0=left,1=middle,2=right,3=release)
2 = shift, 3 = meta, 4 = ctrl
5 = motion, 6 = wheel (button 64/65 = wheel up/down)
FATAL FLAW: coordinates are one byte, so column/row > 223 CANNOT be encoded.
Any terminal wider than 223 columns is broken. This is why ?1006 exists.
SGR encoding (?1006) — use this:
Press: CSI < b ; x ; y M (uppercase M)
Release: CSI < b ; x ; y m (lowercase m)
b, x, y are DECIMAL — no 223 limit.
x and y are 1-based cell coordinates.
Example: left button press at column 10, row 5: \x1b[<0;10;5M
release at the same place: \x1b[<0;10;5m
# Turn on mouse reporting and watch the events in your Lab 1 inspector:
printf '\033[?1000h\033[?1006h'
# ...click around; you will see 1b 5b 3c 30 3b 31 30 3b 35 4d
printf '\033[?1000l\033[?1006l' # ALWAYS turn it off again
Warning: Leaving mouse reporting enabled when your program exits makes the user's shell emit garbage on every click. It is one of the rudest bugs you can ship. Reset
?1000,?1002,?1003,?1006,?2004, and?1049on every exit path, including panic.
Synchronized Output (?2026)
CSI ? 2026 h begin: the terminal buffers updates and does not present them
CSI ? 2026 l end: present everything atomically
Fixes tearing. Without it, a program redrawing a full screen can be rendered halfway, and the user
sees a flicker or a torn frame. tmux, neovim, and modern TUI libraries emit it.
Implementation: set a flag; while set, keep mutating the screen but tell the renderer not to present. On reset, present. Add a timeout (~150 ms) so a program that crashes between set and reset does not freeze your display forever.
Mode Storage and Query
#![allow(unused)] fn main() { bitflags::bitflags! { #[derive(Copy, Clone, PartialEq, Eq, Default)] pub struct Mode: u32 { const INSERT = 1 << 0; // ANSI 4 (IRM) const LINE_FEED_NEWLINE = 1 << 1; // ANSI 20 (LNM) const APP_CURSOR_KEYS = 1 << 2; // ?1 DECCKM const REVERSE_VIDEO = 1 << 3; // ?5 DECSCNM const ORIGIN = 1 << 4; // ?6 DECOM const AUTO_WRAP = 1 << 5; // ?7 DECAWM — default SET const CURSOR_VISIBLE = 1 << 6; // ?25 DECTCEM — default SET const MOUSE_NORMAL = 1 << 7; // ?1000 const MOUSE_DRAG = 1 << 8; // ?1002 const MOUSE_ANY = 1 << 9; // ?1003 const FOCUS_REPORTING = 1 << 10; // ?1004 const MOUSE_SGR = 1 << 11; // ?1006 const ALT_SCREEN = 1 << 12; // ?1049 const BRACKETED_PASTE = 1 << 13; // ?2004 const SYNC_OUTPUT = 1 << 14; // ?2026 const GRAPHEME_CLUSTER = 1 << 15; // ?2027 const APP_KEYPAD = 1 << 16; // DECKPAM (ESC =) } } impl Default for TerminalModes { fn default() -> Self { // DECAWM and DECTCEM are SET by default. Getting these two defaults // wrong means no wrapping and an invisible cursor on a fresh terminal. Self(Mode::AUTO_WRAP | Mode::CURSOR_VISIBLE) } } }
DECRQM (CSI ? Ps $ p) lets a program ask. The reply is CSI ? Ps ; Pv $ y:
| Pv | Meaning |
|---|---|
| 0 | Not recognized |
| 1 | Set |
| 2 | Reset |
| 3 | Permanently set |
| 4 | Permanently reset |
Answering 0 for modes you do not implement is more useful than silence — it lets programs
degrade gracefully instead of guessing.
Reset Behavior
| Reset | Effect on modes |
|---|---|
DECSTR soft reset (CSI ! p) | Reset to defaults: DECAWM on, DECOM off, DECCKM off, cursor visible, mouse off, alt screen off. Screen contents kept. |
RIS hard reset (ESC c) | Everything, plus clear both screens, scrollback, title, and the palette |
| Program exit | Nothing automatic. The terminal has no idea a program exited. This is why a crashed vim leaves your terminal in application-cursor-key mode with mouse reporting on — and why reset and stty sane exist. |
Tip: In your GUI, implement a "reset terminal" keybinding that issues a full RIS internally. You will need it, and so will your users.
Experiment
CLAIM. Modes are program-controlled state that persists after the program exits, and that is directly observable.
METHOD.
# 1. Watch vim set and reset modes. Record and inspect:
pty-runner --record vim.cast
# inside: vim, then :q
grep -o '\\u001b\[?[0-9]*[hl]' vim.cast | sort | uniq -c | sort -rn
# You will see ?1049, ?1, ?2004, ?1006, ?25 ...
# 2. Leave a mode set and observe the consequence:
printf '\033[?1h' # application cursor keys
# press Up in bash → readline may misbehave, or show ^[OA
printf '\033[?1l'
printf '\033[?1000h\033[?1006h' # mouse on
# click around: your shell fills with garbage like ^[[<0;10;5M
printf '\033[?1000l\033[?1006l'
printf '\033[?25l' # hide the cursor
# ...it is gone. Now:
printf '\033[?25h'
# 3. Simulate a crash mid-program:
printf '\033[?1049h\033[?1000h\033[?1h' # enter alt screen + mouse + app keys
# Your shell is now in a strange state, exactly as after a crashed TUI.
# Recover:
reset # or: printf '\033c'
PREDICTION. Before step 3: what will your prompt look like? Will reset fix all three modes?
Which one is hardest to notice?
Test
#![allow(unused)] fn main() { #[test] fn default_modes_are_correct() { // DECAWM and DECTCEM set; everything else clear. Getting these wrong means // a fresh terminal that does not wrap and has no visible cursor. let t = Terminal::new(24, 80); assert!(t.modes().contains(Mode::AUTO_WRAP)); assert!(t.modes().contains(Mode::CURSOR_VISIBLE)); assert!(!t.modes().contains(Mode::APP_CURSOR_KEYS)); assert!(!t.modes().contains(Mode::ALT_SCREEN)); assert!(!t.modes().contains(Mode::BRACKETED_PASTE)); } #[test] fn private_and_ansi_modes_are_independent() { // Mode 4 (IRM) and mode ?4 are unrelated. Sharing a namespace is a real bug. let mut t = Terminal::new(24, 80); t.advance(b"\x1b[4h"); // ANSI 4: insert mode assert!(t.modes().contains(Mode::INSERT)); t.advance(b"\x1b[?4h"); // DEC ?4: smooth scroll — unimplemented, ignored assert!(t.modes().contains(Mode::INSERT), "?4 must not have touched IRM"); } #[test] fn decawm_reset_prevents_wrapping() { let mut t = Terminal::new(3, 5); t.advance(b"\x1b[?7l"); t.advance(b"abcdefghij"); assert_eq!(t.cursor().row, 0, "with DECAWM off the cursor must not wrap"); assert_eq!(t.cursor().col, 4); assert_eq!(t.screen().row_text(1).trim(), ""); } #[test] fn decrqm_answers_zero_for_unimplemented_modes() { // Answering "not recognized" lets programs degrade gracefully. let mut t = Terminal::new(24, 80); t.advance(b"\x1b[?9999$p"); assert_eq!(t.take_replies(), b"\x1b[?9999;0$y"); } #[test] fn soft_reset_restores_mode_defaults_but_keeps_the_screen() { let mut t = Terminal::new(5, 20); t.advance(b"content\x1b[?1h\x1b[?7l\x1b[?25l"); t.advance(b"\x1b[!p"); assert!(!t.modes().contains(Mode::APP_CURSOR_KEYS)); assert!(t.modes().contains(Mode::AUTO_WRAP)); assert!(t.modes().contains(Mode::CURSOR_VISIBLE)); assert!(t.screen().row_text(0).starts_with("content"), "DECSTR keeps the screen"); } }
Challenge Extensions
- Implement
?2026with a 150 ms timeout, and measure tearing with and without it by runningneovimand taking rapid screenshots. - Implement XTSAVE/XTRESTORE (
CSI ? Ps s/CSI ? Ps r) so programs can save and restore mode state. - Implement
?1003and measure the event rate while moving the mouse across a full screen. Decide whether to coalesce, and justify it with the number. - Add a mode-change log to your debugger: every set/reset with its DEC number, name, and a
timestamp. Run
vimthrough it and read the startup sequence — it is a compact education in what a TUI actually needs. - Implement
?2027grapheme clustering, and demonstrate the family-emoji difference.
Validation / Self-check
- What distinguishes an ANSI mode from a DEC private mode, at the byte level and in your storage?
- What exactly does
?1049hdo, in order? What does?1049ldo? - Why does the alternate screen have no scrollback?
- What does DECAWM reset change, and how does it interact with pending wrap?
- Why does the same arrow key send different bytes in
bashandvim? Name the mode. - What bug does bracketed paste fix, and what security issue does it introduce?
- Why is
?1006necessary? What breaks with X10 mouse encoding? - Which two modes are set by default, and what breaks if you get that wrong?
- Why should DECRQM answer
0rather than staying silent for unimplemented modes? - Why is your terminal broken after a TUI crashes, and what are the two recovery commands?
- Which modes must you reset on every exit path in your own GUI, and why?
Next: OSC and String Sequences.