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[39m` for fg-only). Treat color as a `try/finally` resource.
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)` or `printf '%-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=1` env var (https://no-color.org), and check `isatty(stdout)` — disable color when output is not a terminal. Also honor `TERM=dumb` and `CLICOLOR=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 for `truecolor` or `24bit`) before emitting `38;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: `✓ ok` vs `✗ 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 `OA` shows 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 matching `rmkx` (`\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.
Fix**Immediate recovery** without logging out: run `tput rmkx` (or directly `printf '\e[?1l\e>'`) — both halves of the toggle. If that's not enough, `reset` or `tput reset` does a wider hard reset. **Prevention** in your own TUI: register a SIGINT / SIGTERM / SIGQUIT handler AND an `atexit` / `defer` block that emits `rmkx` on **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 the `LeaveAlternateScreen` + `DisableMouseCapture` cleanup pattern in a `Drop` impl on a wrapper struct.
Reference sequenceDECKPAM / DECKPNM — Keypad application / numeric mode (ESC = / ESC >)
10Your tool emits `\x1b[A` expecting the cursor to move up zero rows (a no-op) and instead the cursor jumps up one row. Or you emit `\x1b[m` after styling and the styling clears — but you emit `\x1b[J` to clear nothing and the entire screen below the cursor wipes.
Cause**CSI parameter defaults are NOT uniformly `0`.** SGR (`m`) defaults its omitted Ps to `0` (reset) — `\x1b[m` ≡ `\x1b[0m`. But cursor-movement CSI codes (`CUU` / `CUD` / `CUF` / `CUB` = `A` / `B` / `C` / `D`) default to **`1`** — `\x1b[A` ≡ `\x1b[1A` (up one row), not `\x1b[0A` (would be 'up zero'). Erase commands (`ED` `\x1b[J`, `EL` `\x1b[K`) default to `0` meaning 'cursor to end-of-display / line' — `\x1b[J` erases 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.
Fix**Always emit the parameter explicitly** when the value matters. `\x1b[1A` for 'up one' (don't rely on the default), `\x1b[0m` for 'reset SGR' (don't rely on the SGR-specific default — explicit `0` is 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 sees `200~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[?2004l` on 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.
Fix**Immediate recovery**: run `printf '\e[?2004l'` to turn bracketed-paste mode off, or `reset` for 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 — check `bind -p | grep paste`. **Prevention** in your own TUI: pair every `\x1b[?2004h` you emit with a `\x1b[?2004l` in the same cleanup block that handles `rmkx` / cursor-restore / mouse-disable / alt-screen-leave. Even better: use the **XTSAVE/XTRESTORE stack** (`\x1b[?2004s` to push, `\x1b[?2004r` to 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 time `T`, and if nothing else arrives within `T`, declare 'lone Esc'. ncurses defaults `T` (the **ESCDELAY** env var) to **1000 ms** — far too long; the user feels the lag. Set `T` too short (< 20 ms) and a slow tmux / SSH pipeline can split a real `\x1b[A` into two reads, falsely declaring 'Esc' + literal `[A`.
Fix**Modern 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[>1u` flag 1 'disambiguate escape codes') if your target emulators support it (kitty / foot / WezTerm / ghostty / Konsole 24.02+) — the terminal then sends `\x1b[27u` for a lone Esc keypress, eliminating the ambiguity entirely. Detect availability via XTGETTCAP query for the `Su` cap, fall back to ESCDELAY when not supported. **Per-language**: bash `read -t 0.025`, Python `select.select(..., 0.025)`, Rust crossterm `poll(Duration::from_millis(25))`, Go tcell `PollEvent` with `EventTime` filter.
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[?3l` clear 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 resource `allowC132`. **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[?3h` does 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).
Fix**Don't emit DECCOLM under a multiplexer.** Detect tmux / screen via `$TMUX` / `$STY` env vars, OR via terminfo `cols` cap (`tput cols`) — if it reports a fixed 80 / 132 outside of `allowC132`-class emulators, you're nested. **Modern fix**: query the actual terminal size via `TIOCGWINSZ` (`ioctl`) or `$COLUMNS` and render to that width — never use `DECCOLM` as 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'; fi` and bail out gracefully. **Resize-correctness**: install a `SIGWINCH` handler that re-reads `TIOCGWINSZ` and 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-mode` for 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 tagged `id=row42` highlight together when the user hovers any one — works as designed. On **iTerm2**, the `id=` param is silently ignored — each `\x1b]8;...\x07 … \x1b]8;;\x07` block stands alone, no grouping. On older **gnome-terminal** (≤ 3.36) or **konsole** (< 21.04), re-using `id=row42` across 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 enforces `id+url` as 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.
Fix**Two 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, NEVER `id=row` reused. The Kitty-correct behaviour (treat `id+url` as the key) is the upper bound — assume id-only dedup as the lower bound for compatibility. (2) **Don't depend on `id=` 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;;\x07` envelope on every row (more bytes, but works everywhere). Reserve `id=` 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), sometimes `printf '\033[31m'` (hardcoded SGR 31). On most modern terminals the two look identical, but on **monochrome TTYs**, the **Linux console**, or under `TERM=dumb` / `TERM=xterm-mono`, the `tput` version correctly produces no color while the `printf` version leaks raw escape bytes like `^[[31m` into the output. Worse: piping the script into `less` without `-R` or to a file shows the `printf` version's bytes inline as garbage, while `tput` would have produced clean text on a non-TTY.
Cause`tput setaf 1` does TWO things `printf '\033[31m'` does NOT: (1) it **consults `$TERM` and the terminfo database** for the right escape sequence for that specific terminal — e.g. `xterm-256color` returns `\e[31m`, but `linux` returns `\e[31m` only on a color console and EMPTY string on a monochrome boot console; `dumb` returns empty everywhere; `screen-256color` returns `\e[31m`. (2) it **respects `isatty(stdout)`**: when stdout is NOT a TTY (pipe, file, captured by CI), `tput` from a modern ncurses (≥ 6.2 with `NCURSES_NO_UTF8_ACS=1` resolved) 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.
Fix**Two-rule decision**: (1) Use `tput` for **portable** scripts that need to ship to unknown terminals — system install scripts, distro tools, `/etc/bashrc` snippets, CI runners. The performance cost (~1 ms per `tput` invocation, since `tput` forks) is trivial vs the correctness win. Cache values: `red=$(tput setaf 1); reset=$(tput sgr0)` once at the top of the script. (2) Use hardcoded `printf '\033[31m'` ONLY when the target terminal is **known and you control it** — Docker entrypoints, in-CI build output (you already know `TERM=xterm-256color` or 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. Honour `NO_COLOR=1` regardless of approach. Pairs with `/pitfalls/no-color` and the `tput cols` advice in `/pitfalls/tmux-sigwinch-deccolm`.
Reference sequenceSGR 30–37 — Foreground color (8 basic)
16Your TUI enters alt-screen with `\x1b[?1049h` and writes status lines with plain `\n` between 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`\n` is **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 adds `ONLCR` (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 (direct `write()` to fd 1, custom render loops that batch escapes) — those paths bypass `ONLCR` translation. The kernel ONLCR only fires on `write(STDOUT_FILENO, "\n", 1)` going through the line discipline; framework write loops that emit raw escape strings often disable `ONLCR` via `termios.c_oflag &= ~ONLCR` to gain full control. Result: in alt-screen, your `\n`s become pure LF, the cursor advances rows but never returns to column 0, output stair-steps.
Fix**Always emit `\r\n` in 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** restore `ONLCR` via `termios` inside the render loop — that fires column-reset on every LF including escape-string internal ones, mangling positioned writes like `\x1b[Hrow1\nrow2` (which should put `row2` at column 1 of line 2, NOT column 0). **The portable invariant**: in alt-screen, the cursor position is your responsibility — emit explicit `\r\n` between 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-newline` for 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's `unicode-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's `cjkWidth` resource 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 your `wcwidth` was compiled against one revision and the terminal against another, you've already lost.
Fix**Don'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 Go `mattn/go-runewidth` and 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 via `LANG` / `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>R` reply, 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-math` for 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.com` as 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 `\x1b` to `\x1b\x1b`. (2) **Per-feature `set-option allow-*`** — newer tmux has `allow-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.
Fix**Two-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 via `tmux 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 `$TMUX` env 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 `\x1b` doubled + `\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's `pyperclip` fork, `gum`'s style module, Rust's `crossterm` 0.27+ all detect `$TMUX` and do the DCS-wrap automatically. Cross-ref `/sequence/osc-hyperlink`, `/family/osc` cookbook 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/console` on 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-color` does 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-256color` is **ambiguous about truecolor support** — the value advertises 256 indexed but says NOTHING about 24-bit. The de-facto truecolor capability flag is `$COLORTERM=truecolor` (or `24bit`) — set by ghostty / wezterm / kitty / iTerm2 / Windows Terminal automatically, NOT set by macOS Terminal.app / Linux console / many SSH sessions through old chains.
Fix**Detect before emitting.** Gate truecolor SGR on `$COLORTERM=truecolor` or `$COLORTERM=24bit`. If absent, **manually quantize** to the 256-color palette (the xterm 6×6×6 cube: index = `16 + 36*r + 6*g + b` for `r,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>m` instead. 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: Rust `termcolor` has `ColorChoice::Auto` that does this; Go `fatih/color` v1.16+ checks `$COLORTERM`; Python `rich` has `Console(color_system='auto')` with the same logic. **Don't** assume `$TERM=xterm-direct` is 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-truecolor` for the byte-level spec and `/family/sgr` cookbook 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 m` instead of `error`.
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-color` flag 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)