Why tmux Needs Its Own Terminal Emulator
tmux uses ncurses for the terminfo database and nothing else. It implements a complete VT parser and screen model, because a multiplexer has to keep four screens, one per pane, and re-render them into one terminal.
TL;DR: ncurses assumes one application owns one terminal. A multiplexer owns N screens and has to render them into one terminal without the terminal knowing. That one mismatch breaks every assumption ncurses makes, so tmux keeps ncurses for terminfo and writes its own emulator.
tmux links against ncurses. It uses ncurses for exactly one thing: reading the terminfo database.
Everything else — parsing escape sequences, maintaining the screen, tracking
the cursor, handling the alternate screen, mouse reporting, bracketed paste
— is tmux’s own code, about 8000 lines of it across input.c, screen.c,
screen-write.c and grid.c.
This article is about why.
The reason in one sentence
A terminal program owns one screen. A multiplexer owns N screens and has to render them into one terminal — and it has to do that without the terminal knowing that there were N.
That single requirement breaks every assumption ncurses makes.
What ncurses gives you
ncurses is a screen management library. You call initscr(), move(),
addstr(), refresh(), and it maintains a virtual screen (curscr) and
an optimisation structure (newscr) that lets it compute the minimal set
of cursor movements and updates to bring the physical terminal up to date.
Its model is:
- one terminal,
- one screen buffer,
- one cursor,
- the application writes to the terminal through the library.
That model is right for vi, lynx and top. It’s wrong for tmux.
The four requirements ncurses cannot meet
1. Several independent screens
A tmux window with four panes has four independent screens, each with its own cursor position, its own scroll region, its own modes, its own alternate screen buffer, its own title.
ncurses has exactly one curscr. There’s no way to have four and render
them into one terminal.
2. The terminal is not the target
A multiplexer’s output goes to a terminal that the multiplexer doesn’t control and can’t query. The bytes the multiplexer writes to the outer terminal are its decision, made by comparing what it wants on screen with what it last sent.
ncurses’ optimisation is based on knowing the physical screen’s contents and being the only writer to it. tmux has to build that model itself, because it needs to know what each pane’s screen looks like before it decides how to composite them.
3. The application and the multiplexer both think they own the screen
A program running in a pane believes it has the whole terminal. It uses the alternate screen, sets scroll regions, moves the cursor to absolute positions, and enables mouse reporting.
tmux has to model all of that per pane so that when it renders the pane into a region of the outer terminal, it can translate “move to 10,5” into “move to 10+offset, 5+offset”.
4. The multiplexer is itself a terminal user
tmux’s own client is a terminal program as far as the outer terminal is concerned. It has to deal with terminfo, feature detection and the limitations of whatever terminal the user happens to have. Using ncurses for screen management and implementing a VT parser internally would mean two terminal emulators in one process, fighting over the cursor.
So tmux uses ncurses for terminfo only. The CHANGES file records the decision:
“Stop using ncurses variables and instead build a table of the codes we want into an array for each terminal type. This makes the code a little untidy in places but gets rid of the awful global variables and calling setterm all the time, and shoves all the ncurses-dependent mess into a single file, tty-term.c. It also allows overriding single terminal codes, this is used to fix rxvt on some platforms (where it is missing dch) and in future may allow user customisation a la vim.”
That was in 2009. Before that, tmux used ncurses’ setterm() and its
global variables.
What tmux implements instead
The parser
input.c implements a state machine that handles the VT escape sequence
families:
- CSI — cursor movement, erasing, inserting and deleting, scrolling
regions, SGR attributes, device status reports, window manipulation,
repeat (
CSI b), and the private modes. - ESC —
DECSC/DECRC(save/restore cursor),DECALN,IND,NEL,RI,RIS, character set selection. - OSC — window title, palette queries, clipboard, hyperlinks, shell integration marks, notifications.
- DCS — Sixel images,
DECRQSSqueries. - APC — kitty graphics, and passthrough.
The mode handling is a good illustration of the scale of it:
case 1000:
screen_write_mode_clear(sctx, ALL_MOUSE_MODES);
screen_write_mode_set(sctx, MODE_MOUSE_STANDARD);
break;
case 1002:
screen_write_mode_clear(sctx, ALL_MOUSE_MODES);
screen_write_mode_set(sctx, MODE_MOUSE_BUTTON);
break;
case 1003:
screen_write_mode_clear(sctx, ALL_MOUSE_MODES);
screen_write_mode_set(sctx, MODE_MOUSE_ALL);
break;
case 1004:
screen_write_mode_set(sctx, MODE_FOCUSON);
break;
case 1005:
screen_write_mode_set(sctx, MODE_MOUSE_UTF8);
break;
case 1006:
screen_write_mode_set(sctx, MODE_MOUSE_SGR);
break;
case 47:
case 1047:
screen_write_alternateon(sctx, gc, 0);
break;
case 1049:
screen_write_alternateon(sctx, gc, 1);
break;
case 2004:
screen_write_mode_set(sctx, MODE_BRACKETPASTE);
break;Mouse reporting has four encodings (normal, button-event, any-event, and SGR), each with an optional UTF-8 variant, plus focus reporting and bracketed paste. A multiplexer has to track all of that per pane, because each pane’s program has its own idea of what mouse mode it wants.
The screen model
struct screen (screen.c) holds a grid, a cursor, a set of mode bits, a
scroll region (rupper/rlower), a title stack, a selection, and — this
is the part ncurses has no concept of — an alternate screen buffer:
/*
* Enter alternative screen mode. A copy of the visible screen is saved and
* the history is not updated.
*/
int
screen_alternate_on(struct screen *s, struct grid_cell *gc, int cursor)
{
u_int sx, sy;
if (SCREEN_IS_ALTERNATE(s))
return 0;
sx = screen_size_x(s);
sy = screen_size_y(s);
s->saved_grid = grid_create(sx, sy, 0);
grid_duplicate_lines(s->saved_grid, 0, s->grid, screen_hsize(s), sy);
...
s->saved_flags = s->grid->flags;
s->grid->flags &= ~GRID_HISTORY;
return 1;
}tmux keeps its own copy of the alternate screen, with its own history
suppressed. The terminal’s smcup/rmcup sequences are a visual
convenience; the truth lives in tmux.
That’s essential: when you resize a window with vim in the alternate
screen, tmux has to know what was on the alternate screen before the
resize, and it has to be able to reconstruct it.
The write path
screen-write.c is the half of the emulator that turns “the application
wrote these bytes” into “the grid now looks like this”. It’s about 3300
lines.
The interesting part is the collect buffer: consecutive plain characters are batched into one item, and the batch is trimmed when a later write overlaps it:
/* Write cell data, collecting if necessary. */
void
screen_write_collect_add(struct screen_write_ctx *ctx,
const struct grid_cell *gc)
{
...
collect = 1;
if (gc->data.width != 1 || gc->data.size != 1 || *gc->data.data >= 0x7f)
collect = 0;
else if (gc->flags & GRID_FLAG_TAB)
collect = 0;
else if (gc->attr & GRID_ATTR_CHARSET)
collect = 0;
else if ((~s->mode & MODE_WRAP) &&
s->cx + ctx->item->used >= sx - 1)
collect = 0;
else if (s->mode & MODE_INSERT)
collect = 0;
else if (s->sel != NULL)The collect buffer exists for a specific reason: to decide whether a cell needs to be drawn at all. The alternative — writing every cell one at a time — would make it impossible to know what the terminal currently shows, and therefore impossible to skip unchanged cells. It’s the mechanism behind the diffing described in How tmux Redraws Only What Changed.
The scrollback and reflow
ncurses has no scrollback at all. tmux keeps a history of lines above the
visible area, trims it to history-limit, and — this is the hard part —
reflows it when the window is resized, so that wrapped paragraphs are
rewrapped rather than left ragged.
grid_reflow(), grid_reflow_join() and grid_reflow_split() in
grid.c implement text reflow over a grid of fixed-width cells. It has to
recognise which lines are continuations of a wrapped line and which are
genuinely new lines, and it has to handle wide characters at the wrap
boundary.
The performance angle
Doing it yourself is also faster, in a specific way. ncurses’ refresh()
is a general-purpose screen update: it diffs two full-screen buffers. tmux’s
renderer diffs a pane’s grid against what it last sent to that client,
which is a much smaller problem, and it can do it at a finer granularity
(spans, damage rectangles, per-cell).
The ncurses model would also force tmux to give ncurses a full-screen buffer of the composited image, which it doesn’t have — the composite is assembled at draw time from four independent pane grids.
The cost
Writing your own terminal emulator isn’t free:
- Every terminal bug becomes your bug. If a program does something tmux’s parser doesn’t understand, the pane’s screen diverges from what the program thinks it drew. tmux has to keep up with a moving target of escape sequences, and it has to decide what to do with the ones it doesn’t implement (usually: ignore them, but sometimes: emulate them).
- The parser is a security boundary. A hostile program inside a pane
is feeding untrusted bytes to the parser. Hence the bounded buffers, the
fuzzing harnesses (
fuzz/input-fuzzer.c), and the careful handling of the OSC/DCS payload length. - Unicode is a swamp. Character widths, combining marks, variation selectors, emoji modifiers — see How tmux Parses Escape Sequences Fast.
- Terminal capability detection is a swamp. See How tmux Works Around Terminal Bugs.
Wrapping up
The general lesson is that a library which embeds an assumption about who owns the resource can only be reused when that assumption holds.
ncurses assumes “one application, one terminal”. That’s the right assumption for a text editor and the wrong one for a multiplexer, a pager with a status bar, a terminal-based debugger, or anything that wants to render two independent screens into one device.
tmux’s decision to reimplement the screen model — while keeping ncurses for the part ncurses is genuinely good at (the terminfo database) — is the kind of separation of concerns that only pays off once you understand exactly which assumption is blocking you.