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 ?)

PsNameSet meansNotes
4IRM Insert/ReplaceInsert: printed characters push the rest of the line rightRarely used; default is replace
20LNM Line Feed/New LineLF also does a carriage returnAlmost 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

PsNameSet (h) means
1DECCKM Application Cursor KeysArrow keys send SS3 A instead of CSI A
5DECSCNM Screen ModeReverse video for the whole screen
6DECOM Origin ModeCursor positioning is relative to the scroll region
7DECAWM AutowrapText wraps at the right margin (default: set)
12Cursor blink (att610)The cursor blinks
25DECTCEM Text Cursor EnableThe cursor is visible (default: set)
47Alternate screen (legacy)Switch to the alternate buffer — no cursor save, no clear
1000Mouse: normal trackingReport button press and release
1002Mouse: button-event trackingAlso report motion while a button is held (drag)
1003Mouse: any-event trackingReport all motion. Very chatty.
1004Focus reportingSend CSI I on focus in, CSI O on focus out
1005Mouse: UTF-8 encodingLegacy extension; superseded by 1006
1006Mouse: SGR encodingCSI < b ; x ; y M/m — the modern encoding. Use this.
1015Mouse: urxvt encodingAnother legacy extension
1016Mouse: SGR-pixel encodingPixel coordinates rather than cells
1047Alternate screenSwitch, and clear the alternate on exit
1048Save/restore cursorLike DECSC/DECRC, driven by mode set/reset
1049Alternate screen + save cursor + clearThe combination everything actually uses
2004Bracketed pastePasted text is wrapped in CSI 200~ … CSI 201~
2026Synchronized outputBuffer updates between set and reset, then present atomically
2027Grapheme clusteringThe 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:

PropertyReason
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 resizeThe program redraws on SIGWINCH
Cleared on entry (for 1049)So the program starts from a known state
Independent cursorSaved and restored across the switch
Mouse wheel behavior differsMost terminals translate wheel events into arrow keys on the alternate screen, since there is nothing to scroll

Note: The variants exist for historical reasons. ?47 is the original (no save, no clear), ?1047 adds the clear, ?1048 is save/restore only, ?1049 is the useful combination. Implement ?1049 properly and map ?47/?1047 onto it; ?1048 maps 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:

  1. Only wrap when the mode is set.
  2. 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.
  3. Consider stripping other control bytes — at minimum, warn on a pasted \n when 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

ModeReports
?1000Button press and release only
?1002Press, release, and motion while a button is held (drag)
?1003Press, 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 ?1049 on 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:

PvMeaning
0Not recognized
1Set
2Reset
3Permanently set
4Permanently reset

Answering 0 for modes you do not implement is more useful than silence — it lets programs degrade gracefully instead of guessing.


Reset Behavior

ResetEffect 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 exitNothing 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

  1. Implement ?2026 with a 150 ms timeout, and measure tearing with and without it by running neovim and taking rapid screenshots.
  2. Implement XTSAVE/XTRESTORE (CSI ? Ps s / CSI ? Ps r) so programs can save and restore mode state.
  3. Implement ?1003 and measure the event rate while moving the mouse across a full screen. Decide whether to coalesce, and justify it with the number.
  4. Add a mode-change log to your debugger: every set/reset with its DEC number, name, and a timestamp. Run vim through it and read the startup sequence — it is a compact education in what a TUI actually needs.
  5. Implement ?2027 grapheme clustering, and demonstrate the family-emoji difference.

Validation / Self-check

  1. What distinguishes an ANSI mode from a DEC private mode, at the byte level and in your storage?
  2. What exactly does ?1049h do, in order? What does ?1049l do?
  3. Why does the alternate screen have no scrollback?
  4. What does DECAWM reset change, and how does it interact with pending wrap?
  5. Why does the same arrow key send different bytes in bash and vim? Name the mode.
  6. What bug does bracketed paste fix, and what security issue does it introduce?
  7. Why is ?1006 necessary? What breaks with X10 mouse encoding?
  8. Which two modes are set by default, and what breaks if you get that wrong?
  9. Why should DECRQM answer 0 rather than staying silent for unimplemented modes?
  10. Why is your terminal broken after a TUI crashes, and what are the two recovery commands?
  11. Which modes must you reset on every exit path in your own GUI, and why?

Next: OSC and String Sequences.