`E[2 q`: when tmux quoting silently eats your ESC byte

#tmux #terminal #macos #debugging #til

Logging into a tmux session and starting an interactive tool, I kept seeing stray characters appear on the terminal: E[2 q. The session was otherwise normal. Nothing was broken. Just three letters of garbage after every prompt redraw.

The fix turned out to be eight characters of tmux config. But the bug taught me something I’d like to remember.

What those characters actually are

ESC [ 2 SP q is the DECSCUSR sequence — “set cursor shape to steady block.” Modern shells (zsh’s vi-mode, fish), editors (vim, nvim), and any TUI that manages its own input cursor emit it routinely. If the terminal understands the sequence, the cursor changes shape and nothing prints. If the ESC byte is missing, the rest of the sequence — [2 q — lands on screen as visible text.

The leading E is the giveaway. The original byte sequence is written \E[2 q in terminfo notation, where \E is the ASCII escape character (0x1b). When ESC is stripped but the literal E from the textual notation somehow survives, you see exactly E[2 q. That asymmetry — losing one byte while keeping the others — narrows the suspect list a lot. Something is treating \E as a two-character string rather than as one escape byte.

Why tmux was eating ESC

The relevant line in my tmux config was:

set -ag terminal-overrides ",xterm*:Ss=\E[%p1%d q"

Three things wrong with it.

The double quotes ate the backslash. Inside "...", tmux’s config parser processes a small list of backslash escapes: \\, \", \$. \E is not in that list. The backslash gets consumed; the E remains as a literal letter. So the override was stored as Ss=E[%p1%d q — no ESC byte, ever. tmux dutifully emitted those literal bytes whenever something used the cursor-shape capability.

tmux show -gv terminal-overrides confirmed it: the stored value showed up as Ss=E[…, not Ss=\E[… and not an unprintable ESC. The byte was gone before it ever reached terminfo’s interpreter.

Only Ss was set, no Se. Ss starts a cursor style; Se resets it. Without the pair, the capability is half-defined. Some clients refuse to use a half-defined cap; others use it but never restore the cursor when they exit. Either way, you don’t want it.

The glob was too narrow. xterm* only matches when the outer terminal advertises a TERM starting with xterm. Anything else — alacritty, screen-derived terms, various embedded terminals — never gets the override at all.

The fix

set -ga terminal-overrides ',*:Ss=\E[%p1%d q:Se=\E[2 q'

Three changes:

  • Single quotes. Backslash escapes pass through untouched. tmux’s terminfo-cap layer then turns \E into a real ESC byte at terminal output time. (If you must use double quotes, double the backslash: "...Ss=\\E[%p1%d q...".)
  • Both Ss and Se for a complete capability.
  • *: glob so the override applies regardless of outer TERM.

After editing, reload the config:

tmux source-file ~/.tmux.conf

Reload alone isn’t enough, though. terminal-overrides is negotiated at client attach time, so existing panes still hold the old (broken) caps. Detach with prefix-d and reattach with tmux a to pick up the fix in a running session.

A side gotcha worth knowing

Each invocation of bind r source-file ~/.tmux.conf re-runs set -ag terminal-overrides …, which appends without dedup. After enough config reloads, tmux show -gv terminal-overrides will show the same line dozens of times. Functionally harmless — the last (or most-specific) match wins — but it’s startling the first time you see it, and it makes diffing tmux state confusing.

There’s no “reset overrides” command. The only way to clear accumulated entries is to restart the server with tmux kill-server from outside tmux, then start a fresh session.

The lesson

When you see a printable string in a terminal that looks almost-but-not-quite like an escape sequence — [?25h, [2 q, ]0;something, [6n — assume it’s a real escape sequence that lost its leading ESC byte somewhere upstream. Trace the byte, not the symbol. The symbol is misleading; the missing byte is the bug.

For tmux config specifically: prefer single quotes when the value contains backslash escapes. What you write is what gets stored. Double-quoted strings are silently rewritten by tmux’s config parser if any substring happens to match one of its supported escapes — and silently passed through otherwise, often missing exactly the byte you cared about. Single quotes don’t have this failure mode.

For terminfo capabilities: define them in pairs. Half a pair is worse than none — it makes the capability appear defined while breaking the round-trip behavior the calling code expects.

For terminal-overrides globs: prefer * over narrow xterm* patterns unless you specifically need terminal-specific behavior. Cursor shape, RGB color, focus events — these are universal expectations now. The override should be too.