Common ANSI escape-code pitfalls
Specific failure modes you'll hit once and remember forever. Each entry has the symptom, the cause, and the fix.
Last updated
01Output you write in color stays colored on subsequent lines, including the user's prompt.
CauseYou set a color attribute and never emitted SGR 0 (
\x1b[0m) before the final newline / before the program exited.FixAlways end styled output with
\x1b[0m(or a narrower reset like\x1b[39mfor fg-only). Treat color as atry/finallyresource.Reference sequenceSGR 0 — Reset / Normal
02Setting a window title with
\x1b]0;...causes the next program line to silently disappear or never render.CauseOSC requires an explicit terminator. If the BEL (
\x07) or ST (\x1b\\) is missing, the parser keeps eating bytes as title text until one of them shows up — including your following output.FixAlways close OSC sequences:
\x1b]0;title\x07. Prefer BEL (\x07) for xterm compatibility, ST (\x1b\\) for ECMA-48 strictness.Reference sequenceOSC 0 / 2 — Set window/icon title
03Your column-aligned table breaks when you add colored cells: the colored row appears shorter.
CauseEscape sequences contain bytes that are NOT printed but ARE counted by naïve
len(string)orprintf '%-20s'width math.FixMeasure visible width separately: strip ANSI first (see
/strip), then apply padding around the colored output. Many UI libs (rich, blessed, lipgloss, ratatui) handle this for you.Reference sequenceSGR 30–37 — Foreground color (8 basic)
04After leaving alt-screen with
\x1b[?1049l, the cursor is on the wrong row — usually one line too high.CauseDECSET 1049 restores the saved cursor position, but the last visible line your program wrote inside the alt screen often left the cursor without a trailing newline.
FixEmit
\r\n(or platform LF) before\x1b[?1049l, OR explicitly DECRC (\x1b8) right before leaving so the restore is to a clean line.Reference sequenceDECSET 1049 — Alternate screen buffer
05Users complain your tool emits color even when piped or redirected, garbling logs.
CauseThe tool emits color unconditionally and ignores the standard signals that the consumer doesn't want it.
FixRespect the de-facto
NO_COLOR=1env var (https://no-color.org), and checkisatty(stdout)— disable color when output is not a terminal. Also honorTERM=dumbandCLICOLOR=0.Reference sequenceSGR 30–37 — Foreground color (8 basic)
06Truecolor output renders fine on your machine but looks washed-out or wrong on other terminals.
CauseNot all terminals support 24-bit color; those without it quantize each RGB to the nearest 256-palette entry, which can shift hues dramatically.
FixCheck
$COLORTERM(looks fortruecoloror24bit) before emitting38;2;r;g;b. Fall back to a 256-color palette index, or to one of the 16 basic colors, when truecolor is unavailable.Reference sequenceSGR 38;2;R;G;B — 24-bit truecolor foreground
07Color-blind users can't tell the difference between your 'success' and 'error' output.
CauseColor is the only thing distinguishing the two states; once the green/red signal is gone, the text looks identical.
FixPair color with a textual prefix or icon:
✓ okvs✗ failed,[ok]vs[err]. Color should reinforce a signal, never carry it alone.Reference sequenceSGR 30–37 — Foreground color (8 basic)
09After vim crashes (or any TUI exits unexpectedly), the arrow keys stop working in the user's shell — pressing Up no longer recalls the previous command, and instead a stray sequence like
OAshows up at the cursor. Backspace, Home, End may also misbehave.CauseThe crashed app emitted
smkx(\x1b[?1h\x1b=— DECCKM on + DECKPAM) on startup to put the terminal in application-keypad mode, but never got to emit the matchingrmkx(\x1b[?1l\x1b>— DECCKM off + DECKPNM) on exit. With DECCKM stuck on, the terminal now sends\x1bOA(SS3 A) when the user presses Up — but the user's shell's readline / zsh-line-editor is bound only to the *normal-mode*\x1b[A(CSI A). The arrow byte stream arrives but doesn't match any binding, so readline echoes the printable part (OA) instead of moving up history.FixImmediate recovery without logging out: run
tput rmkx(or directlyprintf '\e[?1l\e>') — both halves of the toggle. If that's not enough,resetortput resetdoes a wider hard reset. Prevention in your own TUI: register a SIGINT / SIGTERM / SIGQUIT handler AND anatexit/deferblock that emitsrmkxon every exit path including unhandled panics. In Go:defer fmt.Print("\x1b[?1l\x1b>"). In Python:atexit.register(lambda: sys.stdout.write('\x1b[?1l\x1b>')). In Rust crossterm: use theLeaveAlternateScreen+DisableMouseCapturecleanup pattern in aDropimpl on a wrapper struct.Reference sequenceDECKPAM / DECKPNM — Keypad application / numeric mode (ESC = / ESC >)
10Your tool emits
\x1b[Aexpecting the cursor to move up zero rows (a no-op) and instead the cursor jumps up one row. Or you emit\x1b[mafter styling and the styling clears — but you emit\x1b[Jto clear nothing and the entire screen below the cursor wipes.CauseCSI parameter defaults are NOT uniformly
0. SGR (m) defaults its omitted Ps to0(reset) —\x1b[m≡\x1b[0m. But cursor-movement CSI codes (CUU/CUD/CUF/CUB=A/B/C/D) default to1—\x1b[A≡\x1b[1A(up one row), not\x1b[0A(would be 'up zero'). Erase commands (ED\x1b[J,EL\x1b[K) default to0meaning 'cursor to end-of-display / line' —\x1b[Jerases from cursor down to end of screen, not 'erase zero cells = no-op'. The per-sequence default is part of each command's ECMA-48 specification; relying on 'CSI defaults to 0' as a uniform rule will burn you across the boundary.FixAlways emit the parameter explicitly when the value matters.
\x1b[1Afor 'up one' (don't rely on the default),\x1b[0mfor 'reset SGR' (don't rely on the SGR-specific default — explicit0is the same byte count and reads unambiguously). When you genuinely want 'no-op', emit nothing — don't try to find a parameter value that means 'no movement'. For each CSI sequence you generate, consult its/sequence/<slug>page's parameter table — the default value is listed per-sequence because it's per-sequence in the spec.Reference sequenceCUU / CUD / CUF / CUB — Move cursor
11After a TUI exits, every clipboard paste in the user's shell now arrives wrapped in stray
\x1b[200~…\x1b[201~byte markers — instead of the pasted text running as commands, the user sees200~ls -la 201~on the prompt line, or worse, the markers leak into a file they're editing.CauseThe exited TUI emitted
\x1b[?2004h(DEC private mode 2004 — *bracketed paste*) on startup to ask the terminal to surround future pastes with sentinel markers (so the app could distinguish typed input from pasted input), but never emitted the matching\x1b[?2004lon exit. The terminal is still in bracketed-paste mode, but the user's shell — readline / zsh-line-editor — isn't bound to recognise the markers, so they pass through as literal text.FixImmediate recovery: run
printf '\e[?2004l'to turn bracketed-paste mode off, orresetfor a wider sweep. zsh / bash readline 8.0+ actually *do* understand bracketed paste and will silently consume the markers when bound; if you see them leak, your shell is older or its bracketed-paste binding got stripped — checkbind -p | grep paste. Prevention in your own TUI: pair every\x1b[?2004hyou emit with a\x1b[?2004lin the same cleanup block that handlesrmkx/ cursor-restore / mouse-disable / alt-screen-leave. Even better: use the XTSAVE/XTRESTORE stack (\x1b[?2004sto push,\x1b[?2004rto pop) so the state on exit is whatever the parent had, not unconditionally 'off'.Reference sequenceDECSET ?2004 — Bracketed paste mode
12In your TUI, pressing just Esc (intending to leave a mode, like vim's insert mode) takes a noticeable 100–1000 ms before the app reacts. Or worse: pressing Esc + something fast (like Alt+letter via the Meta-as-Esc convention) sometimes registers as just Esc, sometimes as the combo — race-condition flaky.
CauseAlmost every multi-byte input sequence starts with
\x1b— the same byte the user emits by pressing Esc alone. So your input loop, on seeing\x1b, can't immediately decide: 'lone Esc' or 'first byte of \x1b[A / \x1bOP / \x1b]...'? Naive solution: wait some timeT, and if nothing else arrives withinT, declare 'lone Esc'. ncurses defaultsT(the ESCDELAY env var) to 1000 ms — far too long; the user feels the lag. SetTtoo short (< 20 ms) and a slow tmux / SSH pipeline can split a real\x1b[Ainto two reads, falsely declaring 'Esc' + literal[A.FixModern compromise:
export ESCDELAY=25(25 ms) — fast enough that lone-Esc feels instant on a local terminal, slow enough that even slow SSH won't split a 2-byte sequence. Better fix: opt into the kitty keyboard protocol (CSI u —\x1b[>1uflag 1 'disambiguate escape codes') if your target emulators support it (kitty / foot / WezTerm / ghostty / Konsole 24.02+) — the terminal then sends\x1b[27ufor a lone Esc keypress, eliminating the ambiguity entirely. Detect availability via XTGETTCAP query for theSucap, fall back to ESCDELAY when not supported. Per-language: bashread -t 0.025, Pythonselect.select(..., 0.025), Rust crosstermpoll(Duration::from_millis(25)), Go tcellPollEventwithEventTimefilter.Reference sequenceC1 controls — 8-bit single-byte equivalents of ESC sequences (0x80–0x9F)
13Your TUI emits
\x1b[?3h(DECCOLM set — request 132-column mode) on startup. Running directly under xterm it works; running the same binary inside tmux or GNU screen produces a row (or several) of corrupted output — half-erased rows, ghost characters in columns 80–131, or the entire layout rendered into the wrong column count. Resizing the tmux pane often makes it worse.CauseDECCOLM is a physical column-count change on a real VT510, so the spec mandates
\x1b[?3h/\x1b[?3lclear screen + home cursor + reset margins as side effects (see related/sequence/deccolm). xterm honors all of this — including the actual 80↔132 resize via X11 resourceallowC132. tmux can't honor it: tmux is a multiplexer, its panes have a fixed column count set by the outer terminal, and tmux can't ask the parent to resize. So tmux's emulation of\x1b[?3hdoes the screen-clear + cursor-home + margin-reset half (because the parser dispatches those eagerly) but leaves the actual column count unchanged — your TUI now believes it has 132 columns and writes wide rows that wrap mid-line, while tmux's pane is still 80 wide. The corruption is the mismatch. SIGWINCH compounds the trap: tmux propagates SIGWINCH on its own resize but does NOT re-fire one to undo a DECCOLM mismatch, so the TUI never gets notified its column-belief is wrong. The same trap fires under DECSCPP (CSI Pn$|— parameterized column count).FixDon't emit DECCOLM under a multiplexer. Detect tmux / screen via
$TMUX/$STYenv vars, OR via terminfocolscap (tput cols) — if it reports a fixed 80 / 132 outside ofallowC132-class emulators, you're nested. Modern fix: query the actual terminal size viaTIOCGWINSZ(ioctl) or$COLUMNSand render to that width — never useDECCOLMas a layout-setup primitive in 2026. If you truly need wide mode for a screenshot / printer flow, gate it:if [ -n "$TMUX" ] || [ -n "$STY" ]; then echo 'wide mode disabled inside multiplexer'; fiand bail out gracefully. Resize-correctness: install aSIGWINCHhandler that re-readsTIOCGWINSZand re-lays-out the screen on every signal — never trust your last-known DECCOLM-derived column count after a window event. Pairs with/pitfalls/stuck-app-modefor the broader 'state set by side effect, not undone on exit' class of bug.Reference sequenceDECCOLM — 80 / 132 column mode (CSI ? 3 h / l)
14Your CLI emits an OSC 8 hyperlink with an
id=param (intended to group multi-line text that all link to the same URL:\x1b]8;id=row42;https://x.com\x07Item\x1b]8;;\x07). On Kitty, all rows taggedid=row42highlight together when the user hovers any one — works as designed. On iTerm2, theid=param is silently ignored — each\x1b]8;...\x07 … \x1b]8;;\x07block stands alone, no grouping. On older gnome-terminal (≤ 3.36) or konsole (< 21.04), re-usingid=row42across two *different* URLs makes the second link inherit the first URL — silent data corruption.CauseThe OSC 8 spec (gnome-terminal author Egmont Koblinger's 2017 proposal, adopted by Kitty / WezTerm / Ghostty / VS Code terminal / iTerm2 3.5+ / Konsole 21.04+ / Windows Terminal 1.21+) defines
id=<token>as a hint that adjacent or non-adjacent runs with the same id and same URL are one logical link (so cell-by-cell hover-highlight can span line wraps without each cell being treated as an independent hyperlink). What the spec does NOT mandate: (a) what happens if the same id appears with two different URLs — undefined, terminal-author's choice. (b) whether re-binding an id within the same session is allowed. (c) whether scope is per-screen, per-pane, or session-global. Each emulator picks differently: Kitty enforcesid+urlas the dedup key (same id, different url → two distinct links — correct); pre-21.04 Konsole / pre-3.36 gnome-terminal use id-only dedup (same id, second url silently ignored — corruption); iTerm2 ignores id entirely (every block is its own link); Windows Terminal accepts id but doesn't visually group across line wraps.FixTwo rules: (1) Never re-use the same
id=for two different URLs in the same OSC 8 session. Generate ids from a content hash or a monotonic counter:id=link-$(uuidgen | head -c 8)per logical link, NEVERid=rowreused. The Kitty-correct behaviour (treatid+urlas the key) is the upper bound — assume id-only dedup as the lower bound for compatibility. (2) Don't depend onid=for grouping if your audience includes iTerm2 or older gnome-terminal / konsole. The grouping is a UX hint, not a layout primitive — content that needs *guaranteed* same-URL-across-rows behaviour should emit the full\x1b]8;;URL\x07cell-text\x1b]8;;\x07envelope on every row (more bytes, but works everywhere). Reserveid=for the genuine multi-line-wrapped-link case where Kitty / Ghostty users get a small UX upgrade. Detection: there is no DA-style probe for OSC 8 id semantics — assume the conservative behaviour or test in CI against the target emulators.Reference sequenceOSC 8 — Inline hyperlink
15Your shell script colors output two different ways — sometimes
tput setaf 1(terminfo-driven), sometimesprintf '\033[31m'(hardcoded SGR 31). On most modern terminals the two look identical, but on monochrome TTYs, the Linux console, or underTERM=dumb/TERM=xterm-mono, thetputversion correctly produces no color while theprintfversion leaks raw escape bytes like^[[31minto the output. Worse: piping the script intolesswithout-Ror to a file shows theprintfversion's bytes inline as garbage, whiletputwould have produced clean text on a non-TTY.Cause
tput setaf 1does TWO thingsprintf '\033[31m'does NOT: (1) it consults$TERMand the terminfo database for the right escape sequence for that specific terminal — e.g.xterm-256colorreturns\e[31m, butlinuxreturns\e[31monly on a color console and EMPTY string on a monochrome boot console;dumbreturns empty everywhere;screen-256colorreturns\e[31m. (2) it respectsisatty(stdout): when stdout is NOT a TTY (pipe, file, captured by CI),tputfrom a modern ncurses (≥ 6.2 withNCURSES_NO_UTF8_ACS=1resolved) emits nothing — silent no-op.printf '\033[31m'does neither: it always emits the literal 5 bytes, regardless of terminal capability or output destination. The trap is intent-mismatch: developers think 'I want red text' → both look right on their dev machine → ship → users on a different TERM or piping to a log file see broken output.FixTwo-rule decision: (1) Use
tputfor portable scripts that need to ship to unknown terminals — system install scripts, distro tools,/etc/bashrcsnippets, CI runners. The performance cost (~1 ms pertputinvocation, sincetputforks) is trivial vs the correctness win. Cache values:red=$(tput setaf 1); reset=$(tput sgr0)once at the top of the script. (2) Use hardcodedprintf '\033[31m'ONLY when the target terminal is known and you control it — Docker entrypoints, in-CI build output (you already knowTERM=xterm-256coloror the CI exports a colour-capable TERM), test fixtures, your own dotfile in your own terminal. Even then, gate on[ -t 1 ](isatty stdout) to suppress when piped:[ -t 1 ] && printf '\033[31m%s\033[0m\n' "red" || printf '%s\n' "red". Never mix in the same script — pick one approach so the team has one mental model. HonourNO_COLOR=1regardless of approach. Pairs with/pitfalls/no-colorand thetput colsadvice in/pitfalls/tmux-sigwinch-deccolm.Reference sequenceSGR 30–37 — Foreground color (8 basic)
16Your TUI enters alt-screen with
\x1b[?1049hand writes status lines with plain\nbetween them. Instead of each line starting at column 0 of the next row, the output stair-steps diagonally — line 2 starts where line 1 ended, line 3 starts where line 2 ended, and the right edge of the pane fills with truncated overflow. Direct-write (not through ncurses / a TUI lib) makes this most visible.Cause
\nis LF only (0x0A), not LF+CR. ECMA-48 defines LNM (Line-Feed/New-Line Mode — ANSI mode 20, set/reset via\x1b[20h/\x1b[20l) to control whether a bare LF should also return the cursor to column 0. LNM defaults OFF: LF advances the row but does NOT reset the column. On a regular pty, the kernel termios layer addsONLCR(translate output LF → CR-LF) so applications writing through stdout get column-0-on-newline 'for free' — masking the LNM-off behaviour. The alt-screen buffer itself is unaffected by termios mode (it's a terminal-level buffer, not a tty-line-discipline thing), but the trap is: alt-screen output is often produced by code paths that bypass stdio buffering (directwrite()to fd 1, custom render loops that batch escapes) — those paths bypassONLCRtranslation. The kernel ONLCR only fires onwrite(STDOUT_FILENO, "\n", 1)going through the line discipline; framework write loops that emit raw escape strings often disableONLCRviatermios.c_oflag &= ~ONLCRto gain full control. Result: in alt-screen, your\ns become pure LF, the cursor advances rows but never returns to column 0, output stair-steps.FixAlways emit
\r\nin alt-screen render loops. Treat every line ending as a 2-byte sequence —printf '%s\r\n'in shell,write("row\r\n")in C / Go / Rust,print(line, end='\r\n')in Python. Don't rely on LNM (\x1b[20h) as the fix: it works on xterm + gnome-terminal but Windows Terminal and some macOS terminals ignore it, and Konsole partially honours it. Don't restoreONLCRviatermiosinside the render loop — that fires column-reset on every LF including escape-string internal ones, mangling positioned writes like\x1b[Hrow1\nrow2(which should putrow2at column 1 of line 2, NOT column 0). The portable invariant: in alt-screen, the cursor position is your responsibility — emit explicit\r\nbetween rows, or use\x1b[<row>;1H(CUP to start of next row) for absolute positioning. ncurses / blessed / lipgloss / ratatui handle this automatically; raw-escape code paths must do it themselves. Related:/sequence/alt-screen,/pitfalls/alt-screen-newlinefor the cursor-position-on-exit corner.Reference sequenceDECSET 1049 — Alternate screen buffer
17Your TUI draws a fixed-width table with column borders that align perfectly on plain ASCII rows, but the moment a row contains an emoji (
🎉), a CJK character (字), or a combining mark (é=e+\u0301), the right border of that row shifts left or right by 1–2 cells. Worse: the same binary running on glibc Linux aligns differently from musl Alpine which aligns differently from macOS Terminal, even though all three got the same byte stream.CauseFour independent measurements of 'how many cells wide is this glyph' disagree. (1) Your library (
wcwidth(3)from glibc / musl / a vendored jquast/wcwidth / Rust'sunicode-width) computes one number from the codepoint by table lookup. glibc's table is roughly Unicode 13 (2020); musl is older and treats some ambiguous-width chars differently; jquast/wcwidth tracks Unicode 15+. (2) The terminal emulator independently decides how wide to draw the same glyph — xterm'scjkWidthresource toggles East-Asian-Width-Ambiguous between 1 and 2; wezterm has its own table; Apple Terminal hardcodes width 1 for many emojis that wezterm / iTerm2 render at width 2. (3) The font the emulator picked may not have the glyph at all and substitutes a replacement that takes a different number of cells. (4) Unicode East-Asian-Width itself revises with each release — the codepoint for…(U+2026) was Ambiguous in Unicode 8, then explicitly width 1 in 11, then Ambiguous again on some Asian locales. If yourwcwidthwas compiled against one revision and the terminal against another, you've already lost.FixDon't trust local wcwidth as ground truth. Three tiers of fix, pick by the cost you can pay: (1) Cheap & portable — restrict alignment-critical columns to ASCII (
[ -~]); if a user-supplied string contains anything outside, render it in a column where width doesn't matter (rightmost, or with…truncation that doesn't care about cell count). (2) Better — vendor jquast/wcwidth (Python),unicode-width(Rust), or the Gomattn/go-runewidthand pin to a Unicode revision; ship the same revision as a test fixture so a glibc / musl divergence is caught in CI. East-Asian-Ambiguous chars should be treated as width 2 for CJK-locale users, width 1 elsewhere — gate viaLANG/LC_CTYPE. (3) Ground truth — ask the terminal: emit the glyph, then\x1b[6n(DSR cursor-position query — see/sequence/csi-dsr), parse the\x1b[<row>;<col>Rreply, subtract from the column you wrote at. Cost: one round-trip per uncertain glyph plus the input-leak risk noted in the CSI cookbook (must consume the reply, or it goes to the user's input). Real-world TUIs (textual, ratatui, lipgloss) ship with the tier-2 strategy + a tier-3 fallback when uncertainty crosses a threshold. Pairs with/pitfalls/terminal-width-mathfor the SGR-byte side of the same alignment problem.Reference sequenceDSR — Device Status Report (CSI 5n / CSI 6n)
18Your app emits
\x1b]8;;https://example.com\x07link\x1b]8;;\x07(OSC 8 hyperlinks),\x1b]52;c;...\x07(OSC 52 clipboard),\x1b]1337;File=...\x07(iTerm2 inline images), or\x1b]9;4;1;42\x07(ConEmu progress) and it all works when run directly on a hyperlink-capable terminal — but the moment the same app runs inside tmux, the outer terminal shows raw bytes (]8;;https://example.comas plain text) and the feature is dead. Reattaching tmux to a different outer terminal makes no difference.Causetmux is a terminal multiplexer, not a passthrough: it parses every escape sequence its inner panes emit and decides what to forward to the outer terminal. Its default whitelist covers the basics (SGR, cursor control, alt-screen, mouse) but drops most OSC family extensions by default — OSC 8, OSC 52, OSC 1337, OSC 9;4 are all dropped or transformed unless explicitly allowed. The reasoning is conservative: tmux can't know whether the outer terminal supports those features, and forwarding might break (e.g. a confused outer terminal could leak the bytes back into tmux's input stream). Two mechanisms govern this: (1)
allow-passthrough on— tmux ≥ 3.3 option that whitelists the DCS passthrough envelope\x1bPtmux;<doubled-inner-escape>\x1b\\; outer-bound apps must wrap their raw escape, doubling every embedded\x1bto\x1b\x1b. (2) Per-featureset-option allow-*— newer tmux hasallow-set-clipboard(OSC 52),allow-hyperlinks(OSC 8 — added in 3.4),allow-rename(OSC 0/1/2), each with its own default. The trap is intent-mismatch: inner-pane apps detect the OUTER terminal's capabilities (often by emitting DA queries that *do* pass through tmux) but forget to detect tmux itself. Result: app thinks 'outer terminal supports OSC 8, I'll emit it' → tmux silently drops it.FixTwo-pronged strategy. (1) Server-side (tmux config): add to
~/.tmux.conf—set -g allow-passthrough on(tmux ≥ 3.3),set -g allow-hyperlinks on(tmux ≥ 3.4 for OSC 8),set -g allow-set-clipboard on(OSC 52). Reload viatmux source ~/.tmux.conf. Trade-off: any inner app can now poison the outer terminal — only do this on trusted environments. (2) Client-side (app): detect$TMUXenv var (tmux sets this in every shell it spawns). When set, wrap each outer-bound escape in the DCS passthrough envelope:\x1bPtmux;+ the inner escape with every\x1bdoubled +\x1b\\. Concrete example: to emit OSC 8 inside tmux, instead of sending\x1b]8;;https://example.com\x07TEXT\x1b]8;;\x07, send\x1bPtmux;\x1b\x1b]8;;https://example.com\x07TEXT\x1b\x1b]8;;\x07\x1b\\. Mind the corner: this only forwards the bytes — tmux still doesn't *know* what they mean, so its scrollback shows the literal escape. Library-level: kitty'spyperclipfork,gum's style module, Rust'scrossterm0.27+ all detect$TMUXand do the DCS-wrap automatically. Cross-ref/sequence/osc-hyperlink,/family/osccookbook section on hyperlinks for the id-rebind cousin trap.Reference sequenceOSC 8 — Inline hyperlink
19Your CLI emits a smooth gradient using 24-bit truecolor SGR (
\x1b[38;2;r;g;b m), and on Ghostty, WezTerm, kitty, iTerm2, and Windows Terminal the gradient looks clean — soft transitions across hundreds of shades. On macOS Terminal.app (built-in), Linux console (fbcon), older PuTTY, and some xterm builds without truecolor patches, the same gradient renders as visible bands / stripes — 8 or 16 distinct stripes where there should be a smooth ramp. Even worse: every emulator's banding looks slightly different, so a screenshot from one platform doesn't match another even when the bytes are identical.CauseTerminals that advertise 256-color via
$TERM=xterm-256color(the default on most distros) don't reject 24-bit SGR bytes — they accept them gracefully but downsample to the nearest indexed palette entry. The downsampling strategy is implementation-defined and differs across emulators: macOS Terminal.app maps to the 256-color cube with truncation (banding), Linux console (/dev/consoleon a kernel without DRM/KMS color extensions) maps to the 16 ANSI base colors plus 8 bright (so a 256-step gradient collapses to 16 visible bands), older xterm without--enable-direct-colordoes the same. Even emulators that DO support truecolor sometimes downsample for specific palette ranges — macOS Terminal's 'Pro' theme overrides indexed 16 but passes truecolor through verbatim, so a mixed gradient (some 24-bit, some indexed) gets two different visual treatments side-by-side. The detection gap:$TERM=xterm-256coloris ambiguous about truecolor support — the value advertises 256 indexed but says NOTHING about 24-bit. The de-facto truecolor capability flag is$COLORTERM=truecolor(or24bit) — set by ghostty / wezterm / kitty / iTerm2 / Windows Terminal automatically, NOT set by macOS Terminal.app / Linux console / many SSH sessions through old chains.FixDetect before emitting. Gate truecolor SGR on
$COLORTERM=truecoloror$COLORTERM=24bit. If absent, manually quantize to the 256-color palette (the xterm 6×6×6 cube: index =16 + 36*r + 6*g + bforr,g,b ∈ 0..5, derived by mapping each 0..255 component to one of [0, 95, 135, 175, 215, 255]) — emit\x1b[38;5;<idx>minstead. This puts your gradient under your quantizer, not the emulator's mystery downsampler. Bonus: gradient now looks identical across all 256-color emulators. Library support: RusttermcolorhasColorChoice::Autothat does this; Gofatih/colorv1.16+ checks$COLORTERM; PythonrichhasConsole(color_system='auto')with the same logic. Don't assume$TERM=xterm-directis set — that's an explicit-truecolor TERM but adoption is near zero; rely on$COLORTERM. Don't test on your own machine alone — at least spot-check on macOS Terminal.app (the most common false-positive: it accepts truecolor without complaint, downsamples poorly). Pairs with/sequence/sgr-fg-truecolorfor the byte-level spec and/family/sgrcookbook truecolor section.Reference sequenceSGR 38;2;R;G;B — 24-bit truecolor foreground
20Visually-impaired users running NVDA, JAWS, Orca, or VoiceOver report your tool reads aloud as
escape bracket three one m error escape bracket zero minstead oferror.CauseScreen readers parse the text buffer character-by-character; raw SGR / CSI bytes are read as their literal Unicode codepoints. When stdout is a real TTY (or the screen-reader's accessibility pty), tools that emit color unconditionally hand the assistive software escape bytes it can't render as styling — only as speech.
FixHonor
NO_COLOR=1(see related pitfall) — many screen-reader users set it globally. Detect known accessible-terminal env vars (TERM_PROGRAM=Speakup,SCREEN_READER_RUNNING) and downgrade to plain output. For TUIs, ship a--plain/--no-colorflag and prefer semantic prefixes (error:,ok:) over color-only signals. ARIA equivalents do NOT apply — terminals have no DOM; the only intervention is bytes you choose not to emit.Reference sequenceSGR 30–37 — Foreground color (8 basic)
21
\x1b[0;0H(or\x1b[0H) appears to do nothing — the cursor stays where it was, or lands one row / column too far down-right of where you expected.CauseCUP (
\x1b[<row>;<col>H) is defined by ECMA-48 §8.3.21 with 1-based row and column indexing —\x1b[1;1His the top-left cell, not\x1b[0;0H. Per §5.4 omitted CSI parameters default to 1, so\x1b[H(no params) means\x1b[1;1H, not\x1b[0;0H. Most terminals clamp0to1silently, so the symptom usually appears only when you supply other arithmetic like\x1b[<top-1>;<left-1>Htranslated from a 0-based canvas / framebuffer / DOM coordinate system.FixWhen porting from 0-based coordinates, add 1 before emitting:
printf '\x1b[%d;%dH' (row + 1) (col + 1). Use\x1b[Has the idiomatic top-left shortcut. Also note CUP order: it is row;column (y;x), NOTx;y— anyone coming from canvas / GL coordinate APIs (where the convention is x-then-y) needs to swap arguments. The companion\x1b[H\x1b[2Jis the universal idiom for clear-and-home; emitting\x1b[2Jalone leaves the cursor wherever it was. See/sequence/cursor-positionfor the full CUP byte spec.Reference sequenceCUP — Set cursor position
22Your
clear-style command emits\x1b[2Jand the visible viewport goes blank, but scrolling up still shows everything that was just on screen. Or worse — the user'sclearalias DOES wipe scrollback and your TUI doesn't, leading to bug reports that you 'leak history'.CauseED (
\x1b[<n>J, ECMA-48 §8.3.39) has four selective modes:0(cursor → end of display, the default),1(start of display → cursor),2(entire display — but viewport only), and3(entire display including scrollback buffer, an xterm extension documented in xterm-ctlseqs asPs = 3).\x1b[2Jwas defined before scrollback was a thing — it specifies the visible screen, not the emulator's ring buffer of historical lines. The xterm authors added3in 2002 explicitly because users wanted a way to nuke scrollback, and the standard didn't have one. The Linux console, kitty, alacritty, wezterm, Windows Terminal, iTerm2, gnome-terminal, and macOS Terminal.app all honour3J; older emulators (real DEC VT, some embedded terminals) silently ignore it.FixPick deliberately. If you want the user to still be able to scroll up to see prior output (TUI redraws, full-screen menus), use
\x1b[H\x1b[2J— clears the viewport, leaves scrollback intact. If you want a trueclear-equivalent that the user cannot recover from (welcome screens, security-conscious CLIs), emit\x1b[H\x1b[2J\x1b[3J— the3Jextension nukes the ring buffer. ED also does not move the cursor; pair it with\x1b[H(home) for the idiomatic reset. Note macOS Terminal.app honours3Jonly whenEdit > Clear Buffershortcut policy is set to default; iTerm2 and the others honour it unconditionally. See/sequence/erase-displayfor the four-mode byte table.Reference sequenceED — Erase in display (\x1b[2J clear screen)
23After your program writes exactly enough characters to fill row 1 to column 80 (terminal width), subsequent CSI cursor-position commands appear to land one cell off —
\x1b[1;1Hdoesn't reset cleanly, or\x1b[B(cursor down) skips two rows instead of one. With DECAWM off, the symptom is different: characters beyond the right margin are silently dropped rather than wrapping, so your status-line text vanishes.CauseDECAWM (
\x1b[?7h/\x1b[?7l, DEC private mode 7, default ON in every modern terminal) defines what happens when a printing character reaches the rightmost column. With DECAWM on, the cursor is parked in a 'pending wrap' state at column N (the last column) — it does NOT advance to column 1 of the next row until the NEXT printing character arrives. Any cursor-position query you make while in this state reports the cursor as at row R, column N — not row R+1, column 1 as visual intuition suggests. CSI cursor-movement (\x1b[B,\x1b[C) clears the pending-wrap flag and moves as if from column N. With DECAWM off (\x1b[?7l), overflow characters are dropped silently — the cursor stays parked at column N and each subsequent char overwrites in-place.FixFor interactive TUIs: don't rely on column-position math after writing to the rightmost column — instead emit an explicit
\x1b[<row>;<col>H(CUP) before every status-line redraw to leave no ambiguity. For status lines that must NOT wrap onto the next row (right-edge clocks, scroll indicators): disable DECAWM with\x1b[?7lBEFORE writing, restore with\x1b[?7hAFTER. Pair it with truncation in your code — don't depend on the terminal to drop overflow gracefully across emulators (Linux console differs from xterm on this edge). For DSR\x1b[6ncursor-position queries: be aware the reply during pending-wrap isR;N, notR+1;1— code that parses the reply must tolerate this. See/sequence/dec-line-wrapfor the full DECAWM byte spec and/sequence/csi-dsrfor the cursor-report mechanics.Reference sequenceDECAWM ?7 — Auto-wrap mode
24The same 256-colour SGR code looks meaningfully different across terminals — what you tuned on xterm as
orangereads asbrownon iTerm2,mud-redon macOS Terminal with the default Basic profile, and slightly desaturated on solarized / dracula / one-dark profiles.Causexterm's 256-colour palette has three regions (ECMA-48 §SGR-Indexed-Color and xterm-ctlseqs §256-Color): slots 0–15 are the user-configurable ANSI / aixterm 16 (mapped by the profile / theme), slots 16–231 are the 6×6×6 RGB cube (
16 + 36*r + 6*g + b,r,g,b ∈ 0..5, where 0..5 maps to 0/95/135/175/215/255), and slots 232–255 are the 24-step greyscale ramp. The 6-cube region IS spec-defined, but the cube's anchor values are NOT — terminals only commit to the index → RGB function in their documentation, and several deliberately override it. iTerm2 has anIndexed Colourspreference (Profile → Colours → Color Presets) that overrides slots 16–231 wholesale; the macOS Terminal Basic profile rebalances mids; truecolor-emulating terminals (kitty, alacritty, wezterm) honour the xterm cube exactly. Slot 208 specifically: xterm cube →(5,2,0)→(255,135,0)(orange); iTerm2 default →(225,135,40)(browner); macOS Terminal Basic →(220,120,40)(closer to muddy red on bright backgrounds).FixPick semantics over slot indices. If you need brand-accurate colour (logo orange, status-bar green that must match across machines), emit truecolor
\x1b[38;2;255;135;0m(gated on$COLORTERM=truecolor) — the 6-cube only gets you 'approximately orange'. If you need a stable identifier (CI / log highlighting where the value isn't the visual but the role), use slots 0–15 — they're tied to the user's profile / theme, so red-text is whatever the user calls red, which is the right tradeoff. Don't rely on the 6-cube for anything where users will compare side-by-side across emulators — they will, and the differences are real. For palette debugging: print all 256 slots withfor i in {0..255}; do printf '\x1b[48;5;${i}m %3d \x1b[0m' $i; doneon each target terminal and compare visually. Library support:rich(Python) andcrossterm(Rust) ship a 6-cube-to-RGB quantizer;fatih/color(Go) defers to terminal interpretation. See/sequence/sgr-fg-256for the byte-level spec and/sequence/sgr-fg-truecolorfor the truecolor escape hatch.Reference sequenceSGR 38;5;n — 256-color foreground
25You emit
\x1b[38;5;208m WARNING: disk fullthen a plain\n, and the next program line — sometimes including the user's shell prompt — comes out in the same colour 208. This is the same 'color bleed' family as\x1b[31mwithout reset, but harder to spot because 256-colour slots often look 'almost normal' against the user's background and the bleed only becomes obvious when the prompt is read.CauseSGR state is sticky — the terminal keeps the active foreground / background / attribute until you explicitly change them.
\nis a C0 LF (\x0a) which advances the cursor; it does NOT reset SGR. The narrower reset\x1b[39mclears the foreground back to default (which is the shell's chosen prompt colour, not 'no colour'). The full reset\x1b[0mclears EVERYTHING (fg, bg, bold, dim, italic, underline, …). 256-colour-specific tooling tends to ship example snippets without the trailing reset, so library tutorials that worked when you tested in isolation leak into production CLI output. Aggravating factor:printf/echousers frequently doprintf '\x1b[38;5;208mWARN\n'instead ofprintf '\x1b[38;5;208mWARN\x1b[0m\n'— the difference is one byte sequence away.FixAlways reset before
\nAND on exit. The idiomatic shape is\x1b[<set>m<text>\x1b[0m\n— reset comes BEFORE the newline, not after, because if a downstream tool pipes your output into a paginator / less / a Slack-bot escape stripper, the trailing newline may be the last byte before output is truncated. For long-lived TUIs, emit\x1b[0min your atexit / signal handler — even if the user Ctrl-C's mid-run, the prompt comes back clean. Tighter reset: if you only set fg,\x1b[39mis the precise reset (preserves bg + attrs); same for\x1b[49m(bg) and individual\x1b[22m(bold off),\x1b[24m(underline off), etc. Use the narrow reset when composing with other styling. See/pitfalls#color-bleedfor the SGR-0 family pitfall and/sequence/sgr-resetfor the full reset-code reference.Reference sequenceSGR 38;5;n — 256-color foreground
26You translate from a 0-based canvas (top row 0, footer row 23 on a 24-row terminal) and emit
\x1b[0;23rto reserve top/bottom margins — the scrolling region behaves nothing like you set: scrolling skips the row you meant to keep static, or scrolling spans the full screen as if DECSTBM was ignored. Same bug from the other direction: you emit\x1b[2;24ron a 25-row terminal expecting rows 2..24 to scroll and row 25 to stay as a status bar, and instead the status bar at row 25 disappears on the first scroll.CauseDECSTBM (
\x1b[<top>;<bottom>r, DEC private — xterm-ctlseqs §DECSTBM) takes 1-based, inclusive row indices. On a 24-row terminal, rows are numbered 1..24, so\x1b[1;24ris 'whole screen scrolls' (the default),\x1b[2;24rreserves row 1 as a static header,\x1b[1;23rreserves row 24 as a static footer, and\x1b[2;23rreserves both row 1 (header) and row 24 (footer). DECSTBM withtop=0is undefined per the spec — most terminals clamp totop=1silently, which is why your symptom is 'doesn't behave as expected' rather than 'errors visibly'. Also: DECSTBM moves the cursor to (1,1) inside the scrolling region as a side-effect (per VT100 / xterm-ctlseqs), so a stale CUP after a DECSTBM may not be where you think. And the bottom-must-be-≥-top constraint silently rejects backwards ranges (\x1b[24;2r) — xterm-ctlseqs documents the no-op fall-back to the previous region.FixTranslate at the boundary, not in your head. If your application uses 0-based row indices internally, do the
+1conversion at the moment you call DECSTBM —printf '\x1b[%d;%dr' (top + 1) (bottom + 1). Treat row 1 as 'first visible row' and row N as 'last visible row' on an N-row terminal. Usetput lines(or DSR\x1b[18tif you can't shell out) to discover N; do NOT hardcode 24 or 25. After every DECSTBM emit an explicit CUP (\x1b[<row>;<col>H) — DECSTBM's reset-cursor-to-(1,1)-inside-region side-effect is rarely what you want and is easier to override than to remember. For nested TUI scenarios (tmux, screen): emit DECSTBM only after probing$TERM-aware capabilities — multiplexers may rewrite or clamp the region. See/sequence/decstbmfor the byte spec.Reference sequenceDECSTBM — Set Top/Bottom Margins (CSI r)
27Your TUI sets a scrolling region (
\x1b[2;23rto reserve a header and footer) and then crashes / is killed before resetting. The user returns to the shell and types — output scrolls inside rows 2..23 only; the prompt may stay pinned at the last in-region row while the original header (now shell output) and the original footer (now shell prompt fragment) are static and unreachable.cleardoesn't help. Resizing the terminal sometimes fixes it (because some terminals re-apply DECSTBM on SIGWINCH), sometimes makes it worse.CauseDECSTBM is a persistent terminal-state setting — once you emit
\x1b[<top>;<bottom>r, the scrolling region stays in effect until the terminal receives ANOTHER\x1b[r(or\x1b[1;<N>rwith N = current row count, both of which mean 'whole screen'), a RIS reset (\x1bc), or DECSTR soft-reset (\x1b[!p). It does NOT clear on TUI exit, on shell prompt regeneration, onclear(which is just\x1b[H\x1b[2J— neither touches DECSTBM), or on subprocess fork. Alt-screen exit (\x1b[?1049l) DOES reset DECSTBM on xterm and most modern emulators (it's part of the alt-screen pair's saved-state contract) — but ONLY if the TUI was actually using alt-screen. A TUI that crashed in the main screen with DECSTBM set leaves the user stuck.FixAlways pair DECSTBM with cleanup. Treat scrolling-region setup as a try/finally resource: on normal exit, on every signal handler (SIGINT, SIGTERM, SIGHUP — at minimum), and inside a Drop / defer block emit
\x1b[r(whole-screen scroll) BEFORE relinquishing the terminal. For long-running TUIs, prefer alt-screen + DECSTBM together so the alt-screen exit handler does the DECSTBM cleanup for you. User-side recovery: the unstick incantation isprintf '\x1b[r'(ortput rs1, orresetfrom coreutils — thoughresetre-probes$TERMand is slower); document this in the TUI's troubleshooting / README. Don't rely on the user knowing this. Aggravating combo to also test: DECSTBM + DECCKM + DECKPAM all set, none reset — that's the 'crashed nvim leaves arrow keys, prompt, AND scrolling broken' triple-fault. See/sequence/decstbmfor the byte spec and/pitfalls#stuck-app-modefor the parallel DECCKM persistence pitfall.Reference sequenceDECSTBM — Set Top/Bottom Margins (CSI r)