tmux Works Around Terminal Bugs: Believe Nothing, Probe Everything

Terminfo lies, terminals have bugs, and the only reliable way to find out what a terminal can do is to ask it and parse the answer. tmux layers terminfo, overrides, named features, runtime probing and a table of per-terminal special cases.

TL;DR: tmux treats terminal capabilities as unverified claims. It layers terminfo, user overrides, named features, runtime probing and a hard-coded table of per-terminal quirks — and when all five layers fail, it keeps a table of lies.

Every terminal multiplexer eventually becomes a museum of terminal bugs. tmux is no exception: CHANGES is 180 KB of release notes and a substantial fraction of it is about terminals doing the wrong thing.

The interesting part isn’t the individual bugs. It’s the layered strategy tmux uses to deal with them, which has five layers:

  1. terminfo — the database, which is frequently wrong or outdated,
  2. overrides — user-supplied corrections, glob-matched on TERM,
  3. named features — a higher-level abstraction over terminfo capabilities,
  4. runtime probing — actually asking the terminal what it supports and parsing the replies,
  5. a per-terminal table — hard-coded knowledge about specific terminals that no amount of probing will reveal.

Layer 1 and 2: terminfo and overrides

tmux reads terminfo through ncurses but immediately takes control of it. terminal-overrides is a per-TERM list of capability corrections:

c
	{ .name = "terminal-overrides",
	  ...
	  .default_str = "linux*:AX@",

That default is itself a workaround. The AX capability means “the terminal does not clear the screen with a background colour”. Removing it for TERM=linux* makes tmux do a full sgr0 reset when switching away from a bright colour, because the Linux console has bugs with bright colours:

“The Linux console has some bugs with bright colours, so add some workarounds for it.”

The override mechanism is tty_term_apply() in tty-term.c, which supports name=value (set), name@ (remove) and name! (do not replace). The ! form exists for a specific case:

c
	if (term->flags & TERM_NOREPLACE)
		...

which is used for the cstyle feature, so that a terminal’s own Ss/Se (cursor style) capabilities survive. Some terminals have extended DECSCUSR and tmux shouldn’t clobber it.

The values are decoded with strunvis():

c
			if (strunvis(value, cp) == -1) {

so an override value can contain escaped bytes.

Layer 3: named features

Terminfo is a list of capabilities; what tmux actually cares about is capabilities in combination. The terminal-features option names features, and tty-features.c maps each to the terminfo capabilities it requires.

The named features are:

text
256, appesc, bpaste, ccolour, clipboard, hyperlinks, cstyle, extkeys,
focus, ignorefkeys, imagescroll, kittygraphics, margins, mouse, osc7,
overline, progressbar, rectfill, RGB, sixel, strikethrough, sync, title,
usstyle, utf8

Bracketed paste, for example:

c
	static const char *const tty_feature_bpaste_capabilities[] = {
		"Enbp=\\E[?2004h",
		"Dsbp=\\E[?2004l",
		NULL
	};

And synchronized output:

c
	static const char *const tty_feature_sync_capabilities[] = {
		"Sync=\\E[?2026%?%p1%{1}%-%tl%eh%;", NULL };

Note that the feature is expressed as a synthetic terminfo capability (Sync), not as “the terminal has capability X”. That keeps the feature system independent of terminfo names, which differ between ncurses versions.

The defaults shipped are a compromise:

c
	{ .name = "terminal-features",
	  ...
	  .default_str = "xterm*:clipboard:ccolour:cstyle:focus:title,"
			 "screen*:title," "rxvt*:ignorefkeys" },

xterm* gets the features that essentially every xterm derivative has. screen* gets only title setting, because screen’s terminfo entry is minimal and tmux-inside-screen is a common (and painful) configuration. rxvt* gets ignorefkeys — rxvt sends function keys in a format that conflicts with tmux’s own key handling.

Layer 4: asking the terminal

The most reliable capability detection is to ask. tmux sends queries to the terminal and parses the replies, in tty-keys.c:

QueryReplyDetects
ESC [ c (primary DA)ESC [ ? 6 2 ; ... csixel, margins, rectfill, clipboard
ESC [ > c (secondary DA)ESC [ > 4 1 ; 2 0 cterminal identity: mintty, tmux, rxvt-unicode
ESC [ > q (XTVERSION)ESC P > 0 ... ESC \XTerm, kitty, WezTerm, foot, ghostty, iTerm2, Rio
ESC [ ? 2026 $ pESC [ ? 2026 ; 1 $ ysynchronized output
ESC _G i = 31 ... ESC \OKkitty graphics
ESC ] 10 ; ? ESC \ESC ] 10 ; rgb:...foreground colour, and theme detection

The secondary DA reply is particularly useful because it identifies the terminal:

c
		case 'M':	/* mintty */
		case 'T':	/* tmux */
		case 'U':	/* rxvt-unicode */

which selects a hard-coded feature set for that terminal.

There’s a five-second timeout on the whole process. If nothing answers, tmux falls back to terminfo plus the defaults:

c
#define TTY_QUERY_TIMEOUT 5
#define TTY_REQUEST_LIMIT 30

and the foreground/background colour query is repeated every 30 seconds, because a terminal that was busy at startup may answer later.

The “is it VT100-like?” hack

Some terminals respond to device attributes queries; some don’t, and sending a query to a terminal that doesn’t understand it can produce garbage on the screen. tmux guesses:

c
	/*
	 * If TERM has XT or clear starts with CSI then it is safe to assume
	 * the terminal is derived from the VT100. This controls whether device
	 * attributes requests are sent to get more information.
	 *
	 * This is a bit of a hack but there aren't that many alternatives.
	 * Worst case tmux will just fall back to using whatever terminfo(5)
	 * says without trying to correct anything that is missing.
	 *
	 * Also add few features that VT100-like terminals should either
	 * support or safely ignore.
	 */
	s = tty_term_string(term, TTYC_CLEAR);
	if (tty_term_flag(term, TTYC_XT) || strncmp(s, "\033[", 2) == 0) {
		term->flags |= TERM_VT100LIKE;
		tty_parse_client_features(c, "bpaste,focus,title", ",");
	}

“Does the clear capability start with ESC [” as a proxy for “is this a VT100 descendant” — and the consequence is that VT100-like terminals are assumed to support bracketed paste, focus reporting and title setting.

This is the kind of heuristic that only works because someone wrote down why it works.

Layer 5: the per-terminal table

Some things cannot be detected. tty_default_features() in tty-features.c is a table of hard-coded feature sets for known terminals — mintty, tmux, rxvt-unicode, iTerm2, foot, WezTerm, ghostty, kitty, Rio, XTerm.

The XTerm entry carries an interesting comment:

c
/*
 * xterm also supports DECSLRM and DECFRA, but they can be
 * disabled so not set it here - they will be added if
 * secondary DA shows VT420.
 */

i.e. “xterm supports these features but they can be turned off, so we only enable them if the terminal tells us it is a VT420”. That’s a level of care that only comes from having been bitten.

Individual workarounds

Beyond the detection layers, there’s a long tail of specific fixes. Some of the most instructive:

The !am terminal

Terminals without the am (auto right margin) capability wrap at $COLUMNS - 1 rather than $COLUMNS, which makes it impossible to write to the bottom-right cell without scrolling the screen. tmux flags this:

c
	/*
	 * Terminals without am (auto right margin) wrap at at $COLUMNS - 1
	 * rather than $COLUMNS (the cursor can never be beyond $COLUMNS - 1).
	 * ...
	 * This is irritating, most notably because it is painful to write to
	 * the very bottom-right of the screen without scrolling.
	 *
	 * Flag the terminal here and apply some workarounds in other places to
	 * do the best possible.
	 */
	if (!tty_term_flag(term, TTYC_AM))
		term->flags |= TERM_NOAM;

and then refuses to write that cell:

c
void
tty_putc(struct tty *tty, u_char ch)
{
	const char	*acs;

	if ((tty->term->flags & TERM_NOAM) &&
	    ch >= 0x20 && ch != 0x7f &&
	    tty->cy == tty->sy - 1 &&
	    tty->cx + 1 >= tty->sx)
		return;

and re-positions the cursor explicitly after a wrap, because on such a terminal the cursor position after wrapping isn’t what tmux thinks:

c
			/*
			 * On !am terminals, force the cursor position to where
			 * we think it should be after a line wrap - this means
			 * it works on sensible terminals as well.
			 */
			if (tty->term->flags & TERM_NOAM)
				tty_putcode_ii(tty, TTYC_CUP, tty->cy, tty->cx);

The PuTTY wrap flag

Some terminals don’t reset their wrap flag when the scroll region is changed, so a subsequent line feed scrolls the screen. tmux works around it by moving the cursor to column 0 first:

c
	/*
	 * Some terminals (such as PuTTY) do not correctly reset the cursor to
	 * 0,0 if it is beyond the last column (they do not reset their wrap
	 * flag so further output causes a line feed). As a workaround, do an
	 * explicit move to 0 first.
	 */
	if (tty->cx >= tty->sx) {
		if (tty->cy == UINT_MAX)
			tty_cursor(tty, 0, 0);
		else
			tty_cursor(tty, 0, tty->cy);
	}

The same class of bug exists in vte, which is why the commit history has both “vte is buggy and doesn’t home the cursor after changing the scroll region” and the general PuTTY fix.

Cursor position after IL and DL

c
/* Cannot rely on cursor position after DL and IL (some terminals move to column 0, some do not). */

Some terminals home the cursor after inserting or deleting lines, some don’t. tmux never assumes either way.

Bright colours on the Linux console

c
	/*
	 * If the current colour is an aixterm bright colour and the new is not,
	 * reset because some terminals do not clear bright correctly.
	 */

Plus the linux*:AX@ default override, which makes tmux use a full sgr0 reset instead of 39m/49m on those terminals.

The Konsole scroll bug

Konsole’s CSI S (scroll up) can’t clear the entire scroll region, and the parameter needs clamping. The CHANGES file records: “Clamp SU (CSI S) parameter to work around a bug in Konsole.”

An earlier attempt at this was reverted:

c
/* The Konsole SU bug means it can't clear the entire scroll region ... remove the workarounds which just cause bugs on other terminals. */

That’s a good reminder: workarounds for one terminal often break another.

Mouse mode reset

c
	/*
	 * If the mouse modes have changed, clear then all and apply
	 * again. There are differences in how terminals track the
	 * various bits.
	 */
	tty_puts(tty, "\033[?1006l\033[?1000l\033[?1002l\033[?1003l");

And a filter for terminals that send spurious wheel-release events:

c
	/* Some terminals (like PuTTY 0.63) mistakenly send button-release events for scroll-wheel button-press event. */

Bogus terminfo capabilities

tty_term_validate() removes terminfo capabilities that don’t work. The Ms capability (OSC 52, set clipboard) is a common casualty: some terminfo entries have an Ms that expands to nothing.

Terminals that break when you set the title

From the CHANGES file:

“Only attempt to set the title where TERM looks like an xterm (contains ‘xterm’, ‘rxvt’ or is ‘screen’). I hate this but I don’t see a better way: setting the title actually kills some other terminals pretty much dead.”

“I hate this but I don’t see a better way” is the most honest sentence in the entire tmux source.

The escape timeout is adaptive too

One more workaround, in the client: the delay before an ambiguous Escape is treated as the Escape key. It’s stretched when tmux is waiting for a terminal reply, so a reply isn’t mistaken for user input, and when a bracketed paste has just ended.

c
	delay = options_get_number(global_options, "escape-time");
	if (delay == 0)
		delay = 1;

with the delay raised to 500 ms in the two special cases.

Wrapping up

The general shape of the solution is: believe nothing, probe everything, and keep a table of lies.

Terminfo is a database maintained by hand, distributed by operating systems that lag behind the terminals they describe, and describing terminals that no longer exist. It’s useful as a starting point and untrustworthy as an answer.

Probing is better, but probing has failure modes of its own: the terminal may not answer, may answer late, may answer with something unexpected, or may be a terminal whose author didn’t read the specification.

And no amount of probing will tell you that PuTTY doesn’t reset its wrap flag, or that the Konsole scroll sequence is broken, or that setting the title on some terminal makes it unusable. That knowledge has to be remembered, written down in a table, and maintained by whoever gets reported.

The terminal-features and terminal-overrides options are the escape hatch, and they exist because the fifth layer inevitably gets something wrong.

TmuxTerminalWorkaroundsInternals