Escape Sequences & Terminal Capabilities

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A terminal emulator is a program that draws characters in a grid and sends keystrokes back to whatever is attached to its pseudo-terminal. Everything that is not "print this character" travels in band: the program writes ordinary bytes, and a few of them, starting with ESC (0x1B), are interpreted by the terminal as commands instead of being drawn. Colours, cursor movement, switching to a full-screen buffer, mouse reporting, hyperlinks and clipboard access are all done this way, and so are the questions a program can ask the terminal. This page explains the vocabulary, the sequences a command-line or full-screen tool really uses, the capability databases (TERM, terminfo) that describe what a terminal understands, how wide a character is on screen, how Windows handles the same sequences, and why untrusted text must never reach a terminal unfiltered.

It was written against ECMA-48 5th edition (identical to ISO/IEC 6429), the XTerm Control Sequences document, the terminfo(5) manual of ncurses 6.6, Unicode Standard Annex #11 (revision 46, Unicode 18.0), the Kitty keyboard and graphics protocol documents, and the Windows Console documentation, on Linux, macOS and Windows 10 and later. The running scenario is the Bookshelf tool, shelf: the one-shot commands (shelf list), the interactive shelf repl and the full-screen shelf tui catalogue browser all use the sequences described here. How the terminal, the TTY driver and the shell fit together is covered on Terminals, TTYs & Pseudo-Terminals; raw mode, which every interactive example below needs, is explained there. The Windows console API that exists beside the sequences is covered on Windows Console & Processes. Deciding whether to colour output (isatty, NO_COLOR, TERM=dumb, --no-color) is covered on TTY, Colour & Interactivity and is not repeated here.

What is standard and what is not

The sequences fall into layers with very different levels of authority, and a portable program has to know which layer it is standing on.

Layer Source Status

Control functions: C0 and C1 sets, CSI, OSC, DCS, ST, cursor movement, erasing, SGR

ECMA-48 (ISO/IEC 6429)

International standard. The core of every terminal. The later sequences below are mostly not in it.

DEC private modes (CSI ? Pm h / CSI ? Pm l), alternate screen, mouse tracking, bracketed paste, focus events, window reports, title strings

XTerm Control Sequences

De facto standard maintained by the xterm author. It documents what xterm does, and most other emulators copy a subset.

OSC 8 hyperlinks, synchronized output (mode 2026), the Kitty keyboard and graphics protocols, COLORTERM=truecolor, NO_COLOR, FORCE_COLOR

Specifications written by terminal authors and published as documents or gists

Community conventions. No standards body publishes them, and there is no detection mechanism guaranteed by a standard: a program probes, or reads environment variables, and falls back.

Terminal descriptions (TERM, terminfo, termcap)

terminfo(5) and the ncurses database

A database of capabilities per terminal name. POSIX does not standardise the terminfo format.

Character width

Unicode UAX #11 and wcwidth (specified by POSIX)

Advisory. Emulators, libraries and the C library can disagree, and the disagreement is visible as misaligned columns.

The official documents do not provide a test that tells a program "this terminal supports hyperlinks" or "this terminal supports the Kitty keyboard protocol"; the practical techniques (environment variables, queries and terminfo extension entries) are described below and each has failure modes that are stated where they apply.

Anatomy of a control sequence

ECMA-48 divides the 8-bit code table into the C0 set (0x00 to 0x1F, among them BEL 0x07, BS 0x08, HT 0x09, LF 0x0A, CR 0x0D and ESC 0x1B), the graphic characters, and the C1 set (0x80 to 0x9F). A C1 function has a 7-bit form, an ESC followed by a byte in 0x40 to 0x5F, and that two-byte form is what programs emit.

Function 7-bit form 8-bit C1 byte Use

CSI, Control Sequence Introducer

ESC [

0x9B

Starts almost every cursor, erase, colour and mode sequence

OSC, Operating System Command

ESC ]

0x9D

Starts a string command (title, hyperlink, clipboard)

DCS, Device Control String

ESC P

0x90

Starts device strings (Sixel, XTVERSION reply)

ST, String Terminator

ESC \

0x9C

Ends OSC, DCS and APC strings

APC, Application Program Command

ESC _

0x9F

Carries the Kitty graphics protocol

In a UTF-8 terminal a lone 0x9B byte is not a character, and U+009B is encoded as two bytes, so the C1 controls are not reliably usable; always send the 7-bit forms. A control sequence has the shape ESC [, then optional parameter bytes (0x30 to 0x3F: digits, ;, and the private-parameter markers ?, >, <, =), then optional intermediate bytes (0x20 to 0x2F), then one final byte (0x40 to 0x7E) that selects the function. Omitted parameters take a default, usually 0 or 1, which depends on the function. A string sequence (OSC, DCS, APC) runs until ST; most terminals also accept BEL (0x07) as the end of an OSC string, which is how title sequences were traditionally written.

Anatomy of ESC [ 1 ; 31 m, showing the CSI introducer, the parameter bytes, the final byte m and, below, an OSC 8 string ended by ESC backslash

Two properties follow from this design and recur in every section below.

  • A sequence is only bytes. The terminal gives ESC its meaning; a file, a pipe or a log that contains the bytes keeps them, and cat replays them to the terminal. Use cat -v or od -c to look at what a program really emits.

  • Terminals ignore what they do not understand, but not always cleanly. An unrecognised CSI with a valid shape is normally swallowed, yet a terminal that does not know a private mode may misparse the bytes and print part of the sequence as text. This is why probing is preferred over assuming, and why the examples query before they rely on an extension.

Writing sequences in source code

Source code writes ESC as \x1b (C, Rust, Python, Go, Swift), \u001b (Kotlin, Java, C#, JavaScript), \033 (C, shell printf) or \e (bash, GNU C). The sequences in this page’s tables are written with the mnemonics ESC and CSI, and with spaces between the bytes for readability; the spaces are not sent. The Bookshelf shell session below sends bold red text, then underlined text, and shows the real bytes:

$ printf '\033[1;31mOverdue\033[0m: \033[4mDune\033[24m\n' | cat -v
^[[1;31mOverdue^[[0m: ^[[4mDune^[[24m

SGR: colour and text attributes

SGR, Select Graphic Rendition, is CSI Ps ; Ps ... m. Several parameters can share one sequence, and they are applied left to right. CSI m with no parameter is the same as CSI 0 m, reset.

Parameter Meaning Reset by

0

Reset every attribute and colour

-

1, 2

Bold or increased intensity, faint

22

3

Italic

23

4

Underline

24

5

Blink (many emulators ignore or disable it)

25

7

Reverse video, swap foreground and background

27

8

Conceal. Dangerous with untrusted text, see the injection section

28

9

Strike-through

29

30 to 37

Foreground colour 0 to 7 (black, red, green, yellow, blue, magenta, cyan, white)

39

90 to 97

Bright foreground colours (aixterm extension, not in ECMA-48)

39

40 to 47, 100 to 107

Background colours, normal and bright

49

38;5;n

Foreground from the 256-colour palette (0 to 15 are the 16 colours, 16 to 231 a 6x6x6 cube, 232 to 255 a grey ramp)

39

48;5;n

Background from the 256-colour palette

49

38;2;r;g;b

Foreground in 24-bit colour ("truecolor")

39

48;2;r;g;b

Background in 24-bit colour

49

ITU T.416 and ECMA-48 define the extended colours with colons (38:2::r:g:b); real programs overwhelmingly send the semicolon form shown above, and some emulators accept only that form. Extensions such as curly and coloured underlines (4:3, 58;2;r;g;b) exist in several emulators, but they are not part of ECMA-48 and a terminal that does not know them may render a plain underline or misparse the colon.

How a program decides how many colours to use

There are three sources, and all three are conventions rather than guarantees.

  1. The TERM terminfo entry. tput colors prints the colors number: 8 for xterm, 256 for xterm-256color, -1 for dumb. This reports palette support only.

  2. COLORTERM. The termstandard/colors convention is that COLORTERM=truecolor (or 24bit) means 24-bit colour is understood. It is a variable set by the emulator, so it is lost across ssh unless the server accepts it (SendEnv / AcceptEnv) and across sudo unless the environment is kept.

  3. Terminfo extensions. ncurses and tmux describe 24-bit support with the RGB capability (ncurses) or Tc (tmux). Entries on the macOS system database that was queried while this page was written did not carry either, so terminfo alone under-reports truecolor there.

A defensive policy used by many tools: no colour when the output is not a terminal or NO_COLOR is set to a non-empty value; 24-bit colour when COLORTERM is truecolor or 24bit; otherwise 256 colours when TERM ends in -256color; otherwise the 16 base colours. Staying with the 16 base colours has one extra benefit: the user’s palette recolours them, which matters for dark and light themes and for people who rely on a high-contrast scheme. Windows Terminal, macOS Terminal and every Linux emulator honour a user-defined palette for 0 to 15 but not for 24-bit values.

Cursor movement, erasing and scrolling

Coordinates are 1-based, row first. Most movement parameters default to 1.

Sequence Name Effect

CSI n A, B, C, D

CUU, CUD, CUF, CUB

Move up, down, forward (right), back (left) by n, stopping at the screen edge

CSI r ; c H

CUP

Move to row r, column c. CSI H goes to 1;1. CSI r ; c f (HVP) is equivalent

CSI n G

CHA

Move to column n of the current row

CSI 0 J, 1 J, 2 J, 3 J

ED

Erase from the cursor to the end of the screen, from the start to the cursor, the whole screen, the whole screen plus the scrollback (xterm extension)

CSI 0 K, 1 K, 2 K

EL

Erase to the end of the line, to the start, the whole line

CSI n @, P, L, M

ICH, DCH, IL, DL

Insert or delete n characters or lines

CSI n S, T

SU, SD

Scroll the scroll region up or down

CSI t ; b r

DECSTBM

Set the scroll region to rows t to b

ESC 7, ESC 8

DECSC, DECRC

Save and restore cursor position and attributes

CSI ? 25 l, CSI ? 25 h

DECTCEM

Hide and show the cursor

CSI n SP q

DECSCUSR

Cursor shape: 0 or 1 blinking block, 2 steady block, 3 and 4 underline, 5 and 6 bar

CSI 6 n

DSR

Ask for the cursor position; the reply is CSI r ; c R

Two warnings come with this table. A cursor that is hidden must be shown again on every exit path, including a panic, a signal and an error; a program that dies with the cursor hidden leaves the user’s shell without one. And the position CSI s and CSI u (save and restore) pair collides with DECSLRM in terminals that implement left and right margins and with the Kitty keyboard protocol, whose reports also end in u, so prefer ESC 7 and ESC 8.

Writing at the last column is a classic trap. A terminal that has written a character in the last column sets a pending wrap flag instead of moving to the next line; the next printable character then wraps. A program that computes positions assuming the cursor is already on the next line misplaces its output by one row. Emulators agree on this behaviour (it is the xenl capability in terminfo), but not on what a following cursor-movement sequence does to the pending flag, which is why full-screen programs address cells with CUP instead of relying on wrapping.

The alternate screen

A full-screen program should not scribble over the user’s scrollback. DEC private modes 47, 1047 and 1049 switch to an alternate screen buffer that has no scrollback, and switch back on exit. Use 1049: it saves the cursor, switches buffers and clears the alternate screen on entry, and restores the cursor on exit, which is also what the terminfo smcup / rmcup capabilities do for xterm-256color.

Sequence Effect

CSI ? 1049 h

Save the cursor and enter the alternate screen, cleared

CSI ? 1049 l

Leave the alternate screen and restore the cursor

CSI ? 47 h, CSI ? 1047 h

Older variants without the cursor save (1047 clears on exit)

The full-screen entry and exit order that the Bookshelf catalogue browser uses is: enter raw mode, enter the alternate screen, hide the cursor, enable bracketed paste, enable mouse and focus reporting, draw. On exit, undo everything in reverse order. Programs that are interrupted (SIGINT, SIGTERM, SIGHUP, a panic, SIGTSTP on suspend) must run the same restore code; the C and Python examples below show an atexit and try / finally shape. The signal and exit mechanics are covered on POSIX Processes, Signals & I/O.

Pitfalls: output that a program prints while on the alternate screen vanishes when the screen is left, so error messages and the final result must be printed after leaving it. Terminals reached through tmux or screen honour the same mode, and some emulators offer a setting that turns the alternate screen off, so scrolling the mouse wheel inside a full-screen program may scroll the primary buffer unless mouse reporting is enabled.

Input reporting: mouse, bracketed paste and focus

Several private modes ask the terminal to send more information on the program’s input. They are set with CSI ? Pm h and reset with CSI ? Pm l, and each one changes the bytes that arrive on standard input, so a program that enables one must be ready to parse its reports.

Mode Name Effect

1000

Normal tracking

Report button press and release

1002

Button-event tracking

Also report motion while a button is held (drag)

1003

Any-event tracking

Report all motion, with or without a button. Highest traffic

1004

Focus reporting

CSI I when the window gains focus, CSI O when it loses it

1006

SGR mouse encoding

Use the CSI < b ; x ; y M and m format, with no column limit

1005, 1015

Legacy UTF-8 and urxvt encodings

Superseded by 1006; do not use in new code

2004

Bracketed paste

Wrap pasted text in CSI 200 ~ and CSI 201 ~

Mouse tracking (SGR 1006)

The original encoding sent three bytes after CSI M, each offset by 32, which fails past column 223 and cannot report a release for a specific button. Enable a tracking mode and 1006: CSI ? 1002 h then CSI ? 1006 h. Each event then arrives as CSI < b ; x ; y M for a press or motion, or m for a release, with 1-based cell coordinates. The button code b is built from bits:

Bits Meaning

b & 3

Button: 0 left, 1 middle, 2 right, 3 none (motion with no button)

4, 8, 16

Shift, Meta (Alt), Control were held

32

This is a motion event

64

Wheel: 64 is up and 65 is down; 66 and 67 are horizontal

import re

SGR_MOUSE = re.compile(rb"\x1b\[<(\d+);(\d+);(\d+)([Mm])")


def parse_mouse(data: bytes):
    """Yield (event, button, col, row) for each SGR (1006) mouse report in data."""
    for m in SGR_MOUSE.finditer(data):
        code, col, row = int(m[1]), int(m[2]), int(m[3])
        event = "release" if m[4] == b"m" else ("motion" if code & 32 else "press")
        if code & 64:
            button = "wheel-up" if code & 3 == 0 else "wheel-down"
        else:
            button = ("left", "middle", "right", "none")[code & 3]
        yield event, button, col, row


print(list(parse_mouse(b"\x1b[<0;12;5M\x1b[<0;12;5m\x1b[<64;3;4M\x1b[<35;7;7M")))

This parser was run under Python 3.9 and prints [('press', 'left', 12, 5), ('release', 'left', 12, 5), ('press', 'wheel-up', 3, 4), ('motion', 'none', 7, 7)]. A real program reads from a terminal in raw mode and must also handle a report split across two read calls; the terminals page explains why.

While mouse tracking is on, the terminal no longer lets the user select text with the mouse; most emulators allow Shift+drag (or Option+drag on macOS Terminal and iTerm2) to bypass reporting. Mention that in the tool’s help.

Bracketed paste (2004)

Without bracketed paste, text pasted into a raw-mode program is indistinguishable from typing: a pasted newline runs a command, and a pasted tab triggers completion. With CSI ? 2004 h the terminal sends CSI 200 ~, the text, and CSI 201 ~. A prompt such as shelf repl can then insert the text verbatim and never execute it. Disable the mode on exit, because a shell that inherits it will show ^[[200~ on the next paste. Two caveats: a malicious paste may itself contain the end marker CSI 201 ~ to escape the bracket (a program that handles paste input should remove it from the pasted text), .

Focus events (1004)

With CSI ? 1004 h the terminal reports CSI I and CSI O when the window gains or loses focus. A full-screen program can pause polling or redraw when it returns. A shell that did not enable the mode will print the reports as text if the program forgot to reset it.

Synchronized output (mode 2026)

Redrawing a large screen as many small writes can show tearing, with half of the old frame and half of the new. Mode 2026, defined by the community document contour-terminal/vt-extensions, brackets one frame: the terminal buffers everything between the two sequences and presents it at once.

Sequence Meaning

CSI ? 2026 h

Begin synchronized update

CSI ? 2026 l

End synchronized update and present the frame

CSI ? 2026 $ p

DECRQM: is the mode supported? Reply CSI ? 2026 ; Ps $ y: 0 not recognised, 1 set, 2 reset, 3 permanently set, 4 permanently reset

Terminals that do not know the mode ignore the sequences, so sending them unconditionally is common; checking the DECRQM reply (see the query section) avoids relying on that. A frame should stay short: terminals impose a timeout and flush the buffer if the end sequence never arrives, but the length of that timeout is an implementation choice that the specification does not fix. Because the pair costs a few bytes per frame it is sensible for full-screen redraws, not for every shelf list line. This is a community convention; it is not in ECMA-48 or in the XTerm document.

An OSC sequence is ESC ] Ps ; Pt ST, where Ps is a command number and Pt its payload. Use ST (ESC \); BEL works in most emulators but ST is the standard terminator.

OSC Purpose Notes

0, 2

Set the icon and window title, or only the window title

In xterm family terminals. A matching query of the title reflects text back to the program, which is why many terminals disable it

8

Hyperlink

Community specification (egmontkob gist)

52

Read or write the clipboard

Described in the XTerm document; heavily restricted by many emulators

The community specification defines OSC 8 ; params ; URI ST to start a link and OSC 8 ; ; ST (an empty URI) to end it. Text written between them is clickable, with a modifier key in most emulators. The only widely used parameter is id=value, which lets a link that is broken across several lines (or across several cells of a table) be recognised as one link on hover. Terminals that do not understand OSC 8 are expected to ignore the sequence and show the text only, but a tool still should not rely on that: print links only when the output is a terminal, and print the visible text and the URL separately otherwise.

#define ESC "\x1b"
#define OSC ESC "]"
#define ST  ESC "\\"

/* "details" is shown; the URI is only sent as metadata. */
fputs(OSC "8;;https://example.org/shelf/1" ST "details" OSC "8;;" ST "\r\n", stdout);

Source: Hyperlinks in Terminal Emulators (community specification).

Security notes: the visible text and the URI are independent, so a link can read https://bank.example and lead elsewhere. Terminals typically show the real URI on hover, and sensible ones refuse to open schemes other than a short allowlist, but a tool that links text it did not write (a book title from a catalogue, a git commit message) must never build the URI from that text without validating it, and must strip OSC from the text itself (see the injection section). The specification discusses limits on the URI length; check it before emitting long URIs.

OSC 52: clipboard

OSC 52 ; Pc ; Pd ST writes to the clipboard when Pd is base64 text: Pc selects the selection (c clipboard, p primary, s select), and a payload of ? asks the terminal to report the clipboard. Its value is that a program running on a remote host over ssh can copy to the user’s local clipboard, because the sequence travels back through the connection (and through tmux when its set-clipboard option allows it).

$ printf '\033]52;c;%s\033\\' "$(printf '978-0441013593' | base64)"

That line copies the ISBN 978-0441013593 to the clipboard of a terminal that allows OSC 52 writes. Many do not by default, and reading the clipboard is disabled almost everywhere, because any program (or any text it prints) could otherwise exfiltrate or replace what the user copied. Make a tool’s use of OSC 52 explicit, off unless the user opts in, and never write clipboard data that came from untrusted input.

Source: XTerm Control Sequences — OSC 52. The shell line above was not run against a terminal that supports OSC 52 while this page was written.

The Kitty protocols

Kitty defined two extensions that other emulators (among them Ghostty, WezTerm, iTerm2 and foot, at different levels of completeness) have adopted. Both are community specifications, so detect them by querying and degrade quietly.

Keyboard protocol

The legacy keyboard encoding cannot distinguish Tab from Ctrl+I, Enter from Ctrl+M, Escape from the start of a sequence, or report key release. The Kitty keyboard protocol fixes this by sending CSI unicode-key-code ; modifiers u and a set of progressive-enhancement flags that a program pushes onto a stack and pops on exit.

Sequence Meaning

CSI > flags u

Push the enhancement flags onto the stack

CSI < n u

Pop n entries (default 1)

CSI ? u

Query the current flags; the reply is CSI ? flags u. A terminal that does not know the protocol ignores this, so send DA1 right after it (see below)

The flags are bits: 1 disambiguate escape codes, 2 report event types (press, repeat, release), 4 report alternate keys, 8 report all keys as escape codes, 16 report associated text. The modifier value is 1 plus a bitmask (shift 1, alt 2, ctrl 4, super 8 and further). Pop what was pushed: a program that exits with the flags still on leaves the shell receiving u-style reports that the shell does not understand. How libraries expose it (for example the Ratatui / crossterm option that requests enhanced keyboard events) is covered on the TUI pages; on Windows, crossterm and similar libraries read the console input records instead.

Graphics protocol and Sixel

Images are rarer in the Bookshelf tool, but a book cover browser is the obvious use. The Kitty graphics protocol sends an APC sequence, ESC _ G key=value,... ; base64-payload ESC \, with the payload split into chunks of at most 4096 bytes (m=1 on all but the last chunk). a=T transmits and displays, f=100 says the data is PNG, and the terminal places the image at the cursor. Sixel is older, DCS q ... ST, a raster of six-pixel-tall bands that the VT340 manual archived on vt100.net documents; a terminal announces support as attribute 4 in its DA1 reply. Neither protocol is universal, images do not survive piping through less, and tools such as tmux must be configured to pass them through, so a tool should offer a text fallback and a flag to turn images off.

Asking the terminal

A terminal answers some control sequences by writing a reply on its output, which arrives on the program’s input as if the user had typed it. That is how a program discovers what it is talking to without trusting TERM.

Request Name Reply

CSI c

DA1, primary device attributes

CSI ? 62 ; 4 ; ... c: the first number is the terminal class, the following numbers list features (4 is Sixel)

CSI > c

DA2, secondary device attributes

CSI > Pp ; Pv ; Pc c: terminal type, firmware version, ROM cartridge

CSI > 0 q

XTVERSION

A DCS string: the name and version, for example DCS > | name version ST

CSI ? Pd $ p

DECRQM, request private mode

CSI ? Pd ; Ps $ y: Ps 0 unknown, 1 set, 2 reset, 3 and 4 permanent

CSI 6 n

DSR, cursor position

CSI r ; c R

CSI 18 t

Window size in characters

CSI 8 ; rows ; cols t

CSI 14 t

Window size in pixels

CSI 4 ; height ; width t

CSI ? u

Kitty keyboard flags

CSI ? flags u

A robust probe follows three rules. First, put the terminal in raw mode (no echo, no line buffering), or the reply is echoed and shows on the screen. Second, use a timeout, because a pipe, a dumb terminal or a slow link never answers. Third, send DA1 last as a sentinel: every terminal that understands queries at all answers DA1, so when the DA1 reply arrives, any query sent before it that has not been answered is known to be unsupported. Only probe when both standard input and output are a terminal; a program run from a script, an agent or a pipeline must never block waiting for a reply.

import os
import re
import select
import sys
import termios
import tty

MODE_STATE = {b"0": "not recognised", b"1": "set", b"2": "reset",
              b"3": "permanently set", b"4": "permanently reset"}


def query(fd_in, fd_out, request, final, timeout=0.3):
    """Send a query and read until the reply ends with `final` or the timeout expires."""
    os.write(fd_out, request)
    reply = b""
    while select.select([fd_in], [], [], timeout)[0]:
        reply += os.read(fd_in, 256)
        if reply.endswith(final):
            break
    return reply


def main():
    fd_in, fd_out = sys.stdin.fileno(), sys.stdout.fileno()
    if not (os.isatty(fd_in) and os.isatty(fd_out)):
        sys.exit("shelf probe: needs a terminal on stdin and stdout")
    saved = termios.tcgetattr(fd_in)
    try:
        tty.setraw(fd_in)
        da1 = query(fd_in, fd_out, b"\x1b[c", b"c")
        xtversion = query(fd_in, fd_out, b"\x1b[>0q", b"\x1b\\")
        sync = query(fd_in, fd_out, b"\x1b[?2026$p", b"y")
    finally:
        termios.tcsetattr(fd_in, termios.TCSADRAIN, saved)
    print("DA1       :", da1)
    print("XTVERSION :", xtversion)
    m = re.search(rb"\?2026;(\d)\$y", sync)
    print("mode 2026 :", MODE_STATE[m.group(1)] if m else "no answer")


main()

This probe compiles under Python 3.9 and exits with the message above when it is not attached to a terminal. The replies from a live terminal were not exercised while this page was written, so the sequential three-query shape (rather than the sentinel pattern described above) is deliberately simple. A production version sends all queries followed by DA1 and parses the replies as they arrive.

Replies are a security boundary as well as a feature: because they arrive on standard input, text that makes a terminal emit a reply can inject keystrokes into whatever reads the terminal next. This is the reason several terminals refuse to answer title or clipboard queries, and one more reason to filter untrusted output.

TERM, terminfo and termcap

TERM names the terminal’s type, and the terminfo database maps that name to a list of capabilities: booleans (am, xenl), numbers (colors, cols) and strings with parameters (cup, setaf, smcup). The older termcap format is superseded; ncurses implements both lookups from the terminfo files. A program that uses ncurses, notcurses, tput or termwiz reads terminfo; many other libraries do not read it at all and emit xterm-style sequences directly, so TERM only decides colour depth for them.

$ tput -Txterm-256color colors
256
$ tput -Txterm-256color cup 5 10 | cat -v
^[[6;11H
$ tput -Txterm-256color setaf 1 | cat -v
^[[31m
$ tput -Txterm-256color sgr0 | cat -v
^(B^[[m
$ tput -Tdumb colors
-1
$ infocmp -1 xterm-256color | grep -E '^\s(cup|smcup|rmcup|setaf|sgr0)='
	cup=\E[%i%p1%d;%p2%dH,
	rmcup=\E[?1049l,
	setaf=\E[%?%p1%{8}%<%t3%p1%d%e%p1%{16}%<%t9%p1%{8}%-%d%e38;5;%p1%d%;m,
	sgr0=\E(B\E[m,
	smcup=\E[?1049h,

The session was captured on macOS with ncurses 6.0 tools; the capability strings are the same on current ncurses releases for these entries, though the system database is older than the 6.6 sources documented at the source link. Note that cup 5 10 is zero-based on the command line and the terminal sequence is 1-based (6;11), and that sgr0 also selects the US-ASCII character set (ESC ( B), a reset that a plain CSI 0 m does not perform.

Command or variable Purpose

tput capname [params]

Print one capability, expanded with the parameters, or a number or boolean

tput -T name

Use terminal type name instead of $TERM

infocmp [name]

Print a terminfo entry in source form; -1 one capability per line, -x include extended (non-standard) capabilities, -d a b compare two entries

tic, toe

Compile an entry into the database, list the entries that exist

TERMINFO, TERMINFO_DIRS

Directories searched before ~/.terminfo and the system directory

TERM, COLORTERM, COLUMNS, LINES

Terminal type, 24-bit hint, size overrides (COLUMNS and LINES override the ioctl in ncurses)

Common values of TERM: xterm-256color (the safe default, claimed by nearly every emulator), tmux-256color or screen-256color inside a multiplexer, linux for the Linux virtual console, xterm-kitty and xterm-ghostty for those emulators, and dumb for output that must stay plain.

Pitfalls:

  • A TERM that the machine has no entry for (typically a newer emulator reached over ssh) breaks tput, clear and ncurses programs with "unknown terminal type". The fix is to install the entry on the remote host (infocmp -x $TERM | ssh host tic -x -) or to export a common value such as xterm-256color for that session.

  • Multiplexers rewrite TERM, so an application that checks only COLORTERM may lose its truecolor hint inside tmux.

  • Hard-coding sequences ignores terminfo; reading terminfo ignores that real terminals deviate from it. Pick one and test on the targets, including the Linux console and Windows Terminal.

  • Terminfo describes capabilities that the entry’s author believed true; it does not describe the live terminal. An entry cannot say whether a mode such as 2026 is available, which is why query mechanisms exist.

Unicode display width

A terminal is a grid of cells. The program must know how many cells a string takes, or it cannot align a table, truncate a title or place the cursor after a wide character. A code point occupies zero, one or two cells:

  • Combining marks and zero-width characters take 0 cells (wcwidth returns 0).

  • Most scripts, including Latin, take 1.

  • East Asian Wide and Fullwidth characters, and most emoji, take 2. UAX #11 classifies code points as F, W, Na, H, N or A; F and W take 2 cells.

  • Ambiguous (A) characters such as the plus-minus sign are 1 cell in a Western context and 2 in an East Asian one. An emulator setting or the locale decides, and a program can neither detect nor override it.

The POSIX wcwidth and wcswidth functions return the column count for a wide character or string, subject to the current locale (setlocale(LC_ALL, "") is required in C first). They deal in code points, not user-perceived characters: an accented letter written as a base letter plus a combining mark, or an emoji family joined with zero-width joiners, is several code points that form one grapheme cluster.

#define _XOPEN_SOURCE 700
#include <locale.h>
#include <stdio.h>
#include <wchar.h>

int main(void) {
    setlocale(LC_ALL, "");
    const wchar_t *samples[] = {
        L"Dune", L"书架", L"é", L"é", L"\U0001F4DA",
        L"\U0001F468‍\U0001F469‍\U0001F467", L"±",
    };
    for (size_t i = 0; i < sizeof samples / sizeof *samples; i++)
        printf("%2d columns, %2zu code points\n", wcswidth(samples[i], 64), wcslen(samples[i]));
    return 0;
}

Compiled with Apple clang and run with LC_ALL=en_US.UTF-8 on macOS, it prints:

 4 columns,  4 code points
 4 columns,  2 code points
 1 columns,  2 code points
 1 columns,  1 code points
 2 columns,  1 code points
 6 columns,  5 code points
 1 columns,  1 code points

The first line is ASCII, the second two Chinese characters (two cells each), the third and fourth the same letter written with a combining accent and as a single code point, the fifth a books emoji, the sixth a family emoji (three emoji joined with zero-width joiners, 5 code points) and the last the ambiguous plus-minus sign. The sixth line shows the central disagreement: wcswidth sums the three emoji to 6 cells, while an emulator that renders the sequence as one family glyph uses 2. Some terminals implement a community "grapheme clustering" mode (DEC private mode 2027, documented in the same vt-extensions repository as synchronized output), and a DECRQM query (CSI ? 2027 $ p) reveals whether the terminal knows it. Python’s unicodedata.east_asian_width() returns the UAX #11 class (W for the Chinese character and the emoji, A for the plus-minus sign, Na for ASCII), and unicodedata.combining() flags combining marks, but neither produces cell widths on its own.

Pitfalls for the Bookshelf tool: truncate and pad titles by cells using a width-aware library for the language in use (for example unicode-width for Rust, go-runewidth or uniseg for Go, wcwidth for Python), never by bytes or len; avoid putting emoji in table cells that must stay aligned, because the emulator’s width for the same sequence varies between versions; and never assume the width of a character the font lacks, since the terminal may draw a fallback glyph over several cells.

Windows: virtual-terminal sequences

Windows 10 and later support the sequences above, with a smaller set than xterm, through the console virtual-terminal (VT) processing. Microsoft’s guidance is to use VT sequences for all new development instead of the classic console API, and to keep the classic calls (SetConsoleMode, SetConsoleCP, SetConsoleOutputCP, SetConsoleCtrlHandler, ReadConsoleInput for window resize) where there is no sequence equivalent. VT processing is a mode flag: a program has to switch it on for the output handle, and should switch it back on exit.

Mode flag Effect

ENABLE_VIRTUAL_TERMINAL_PROCESSING (0x0004) on the output handle

The console interprets the output sequences

DISABLE_NEWLINE_AUTO_RETURN (0x0008) on the output handle

A line feed no longer implies a carriage return, as on a Unix terminal

ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) on the input handle

Key presses arrive as VT sequences on the input stream; the VT page lists which input sequences are generated

#include <windows.h>
#include <stdio.h>

int main(void) {
    HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
    DWORD mode = 0;
    if (out == INVALID_HANDLE_VALUE || !GetConsoleMode(out, &mode)) {
        /* Not a console (redirected): print plain text. */
        puts("Dune");
        return 0;
    }
    DWORD saved = mode;
    SetConsoleOutputCP(CP_UTF8);
    if (SetConsoleMode(out, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING)) {
        puts("\x1b[1;31mOverdue\x1b[0m: Dune");
    } else {
        puts("Overdue: Dune"); /* an old console: no VT, stay plain */
    }
    SetConsoleMode(out, saved);
    return 0;
}

This example uses windows.h, so it was not compiled on the macOS host used to write this page; check it on a Windows toolchain. GetConsoleMode failing is the Win32 way of asking "is this a console?", the counterpart of isatty; it fails when the handle is a pipe or a file. SetConsoleOutputCP(CP_UTF8) makes the console accept UTF-8 text; a program can instead embed the activeCodePage UTF-8 manifest setting (Windows 10 version 1903 and later).

Differences to plan for:

  • The set of supported sequences is what Microsoft’s VT page lists. Extensions that rely on terminal-side features that the console host does not implement (for example the Kitty protocols or OSC 52 behaviour) depend on the terminal in front of ConPTY, not on Windows itself, so probe instead of assuming.

  • Terminal emulators such as Windows Terminal talk to console programs through the pseudo console (ConPTY, CreatePseudoConsole, Windows 10 version 1809 and later), which translates between the application’s console and a VT stream; see Windows Console & Processes for it.

  • Do not rely on VT processing being on already: set the flag, check that SetConsoleMode succeeded, and keep a plain-text path for consoles that reject it.

  • TERM is normally unset on Windows. Test whether the handle is a console and whether SetConsoleMode accepts the flag instead of looking for TERM.

  • The classic Console API remains the supported way to read window resize events (ReadConsoleInput).

Libraries hide this: crossterm, Bubble Tea, Textual, Terminal.Gui and others enable the flags on startup. A program written directly against sequences, such as the shelf examples above, has to do it itself.

Escape-sequence injection

The sequences are an input language of the terminal, so any program that writes text it did not produce itself (a book title or note read from a shared catalogue, a git commit message, a filename, an error from a remote server, the output of a subprocess, a log line) can control the user’s terminal by embedding ESC bytes. What an attacker can do with a hostile string:

Technique Effect

ESC [ 2 J, ESC [ H, scroll-region and cursor movement

Erase or overwrite lines already printed, so a warning appears to say something else

SGR 8 (conceal) or setting foreground equal to background

Hide text that a copy and paste or a reviewer will still see

OSC 0 ; ... and title queries

Change the window title; a reply may reach the shell as keystrokes

OSC 52

Replace the clipboard with a command, which the user then pastes

OSC 8

Add a link whose label hides its destination

Query sequences (DA1, DECRQM, CPR, title report)

Make the terminal write bytes onto the shell’s input

C1 byte 0x9B and friends

A terminal in 8-bit mode treats them as a CSI that a filter looking for ESC misses

Bidirectional controls such as U+202E

Reorder visible text without any escape byte

Printing a byte stream raw is the problem, and the fix is the same everywhere: sanitise at the output boundary. Drop C0 controls except tab and the line terminator, drop DEL (0x7F) and the C1 range (when decoding UTF-8, U+0080 to U+009F), and consider dropping the bidirectional override characters. Replace a stripped byte with a visible placeholder if it must be noticed. Stripping only ESC leaves single-byte C1 forms and BEL-terminated strings untouched, and stripping only the [ after ESC leaves a half-written string that the terminal keeps waiting on, swallowing the output that follows.

The Bookshelf renderer used on this page does that in the C helper below and prints a hostile title as plain text:

#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <wchar.h>

#define ESC "\x1b"
#define CSI ESC "["
#define OSC ESC "]"
#define ST  ESC "\\"

/* Print untrusted text: drop C0 (except tab), DEL and the C1 range
   when the string is decoded as UTF-8 (U+0080..U+009F). */
static void put_safe(const char *s) {
    mbstate_t st = {0};
    size_t n = strlen(s);
    while (n > 0) {
        wchar_t wc;
        size_t k = mbrtowc(&wc, s, n, &st);
        if (k == (size_t)-1 || k == (size_t)-2) { putchar('?'); s++; n--; memset(&st, 0, sizeof st); continue; }
        if (k == 0) break;
        if (!((wc < 0x20 && wc != L'\t') || wc == 0x7f || (wc >= 0x80 && wc <= 0x9f)))
            fwrite(s, 1, k, stdout);
        s += k; n -= k;
    }
}

int main(void) {
    setlocale(LC_ALL, "");
    int tty = isatty(STDOUT_FILENO);
    const char *no_color = getenv("NO_COLOR");
    int color = tty && !(no_color && *no_color);
    const char *ct = getenv("COLORTERM");
    int truecolor = color && ct && (!strcmp(ct, "truecolor") || !strcmp(ct, "24bit"));

    if (tty) fputs(CSI "?1049h" CSI "?25l" CSI "?2004h" CSI "?2026h" CSI "2J" CSI "H", stdout);

    if (color) fputs(CSI "1m", stdout);
    fputs("Bookshelf", stdout);
    if (color) fputs(CSI "0m", stdout);
    fputs("\r\n", stdout);

    const char *titles[] = { "Dune", "Neuromancer", "Hostile\x1b[2JTitle\x07" };
    for (int i = 0; i < 3; i++) {
        if (color && i == 1) fputs(truecolor ? CSI "48;2;28;75;115m" CSI "38;2;255;255;255m" : CSI "7m", stdout);
        printf("%d. ", i + 1);
        put_safe(titles[i]);
        if (color) fputs(CSI "0m", stdout);
        fputs("\r\n", stdout);
    }
    if (tty) {
        fputs(OSC "8;;https://example.org/shelf/1" ST "details" OSC "8;;" ST "\r\n", stdout);
        fputs(CSI "?2026l" CSI "?2004l" CSI "?25h" CSI "?1049l", stdout);
    }
    return 0;
}

This program was compiled with Apple clang (clang -Wall -Wextra) without warnings. Run into a pipe it prints plain text with the hostile title shown as Hostile[2JTitle, with the ESC and BEL bytes dropped, so the leftover [2J is harmless text. Run inside a pseudo-terminal with COLORTERM=truecolor it emitted exactly this byte stream (shown with the carriage returns the pseudo-terminal adds):

ESC[?1049h ESC[?25l ESC[?2004h ESC[?2026h ESC[2J ESC[H
ESC[1mBookshelf ESC[0m
1. Dune ESC[0m
ESC[48;2;28;75;115m ESC[38;2;255;255;255m 2. Neuromancer ESC[0m
3. Hostile[2JTitle ESC[0m
ESC]8;;https://example.org/shelf/1 ESC\ details ESC]8;;ESC\
ESC[?2026l ESC[?2004l ESC[?25h ESC[?1049l

Spaces and line breaks above are added for readability. An unrelated limitation: the exit code path here is a normal return; a real full-screen program also restores the terminal from signal handlers and atexit, as noted in the alternate screen section. Rendering inside a live terminal emulator was not checked while this page was written.

Related defences apply when the consumer is a program rather than a person. The AI section covers output caps, sanitisation of tool output and the argv discipline on Security for CLIs, Output caps and sanitisation; this page adds the terminal-specific detail. Tools that show someone else’s text, such as less, usually escape control characters unless asked not to (less -R lets colour sequences through); cat -v and ls -b show them safely.

Pitfalls

  • Leaving the terminal in a bad state. The cursor hidden, mouse reporting on, bracketed paste on or the alternate screen entered after a crash makes the shell unusable until the user types reset or tput reset. Restore in every exit path.

  • Emitting sequences to a non-terminal. Redirected output, logs and CI show ^[[1m garbage; check isatty first and honour NO_COLOR (see TTY, Colour & Interactivity).

  • Assuming every emulator supports an extension. Probe (DECRQM, DA1, the Kitty flags query) and degrade.

  • Reading replies without raw mode or a timeout. The reply is echoed or the program hangs.

  • Counting bytes or code points as columns. Use a width library; test with CJK, combining marks and emoji.

  • Trusting TERM. It is set by the parent and may be wrong or missing; a multiplexer or ssh changes it.

  • Forgetting that ESC alone is ambiguous. A bare ESC key and the start of a sequence look alike; programs wait a few tens of milliseconds, and the Kitty keyboard protocol removes the ambiguity.

  • Printing untrusted text raw. Sanitise at the boundary (see the injection section).