─── ✦─☆─✦─☆─✦─☆─✦ ───
Every server you SSH into looks exactly the same: a black rectangle and a blinking cursor. Your shell has no idea whether it’s a throwaway homelab box or the production database, and—three coffees deep at 2 a.m.—neither do you. This is the precondition for an entire genre of incident report. systemctl stop was supposed to land on staging. It didn’t. The terminal watched it happen and said nothing, because the terminal has no memory of where it is.
GUIs solved this years ago. iTerm2 has profiles, Tabby paints a different background per connection, and your lizard brain learns that red means be careful. I used Tabby for exactly this. It’s a genuinely nice piece of software wrapped in 200-something megabytes of Electron, and I live in tmux, so most days I was paying the full GUI tax to use three features. I wanted those three features native:
- A quick SSH picker —
prefix+ a key, fuzzy-find a host, connect. - Per-host port-forwarding that comes up with the session.
- Per-host theming so prod looks like prod.
So I built tmux-ssh-portal. It’s a few hundred lines of shell and zero Electron. The picker and forwarding are the easy parts. The theming is where it got interesting, because the obvious way to do it is wrong.
The obvious approach is wrong
If you want to recolor a terminal, you reach for OSC 11—the escape sequence that tells the emulator “repaint your background to this color.” Print it, the window goes red, done.
Except you’re inside tmux, and tmux is a man-in-the-middle for your terminal. It intercepts OSC 11 and stores it against the pane, not the outer surface. Wrap it in tmux’s passthrough envelope to force it through and you hit the real wall:
Your terminal has exactly one background. tmux has many panes. The moment you split a window, "recolor the terminal per host" becomes a category error—there's one surface and four hosts fighting over it, and passthrough only forwards from the active pane anyway.
The trick is to stop fighting the multiplexer and use it. tmux owns the grid; it draws every cell of every pane itself. So you don’t recolor the terminal—you recolor the pane, with tmux’s own machinery:
# per-pane background + foreground, lives and dies with the pane
tmux set -p window-style 'bg=#7a251e,fg=#d7c9a7'
# and the full 16-colour ANSI palette, per pane
tmux set -p 'pane-colours[1]' '#ff3f00' # ... [0] through [15]window-style and pane-colours[] are per-pane options. Which means a blood-red prod pane and a calm blue staging pane can sit side by side in the same window, each rendered correctly, with no escape-sequence smuggling and nothing to reset on exit—kill the pane and the color dies with it. It’s not the approach the docs nudge you toward. It’s the one that survives a split.
ssh_config is the source of truth
I didn’t want a second host database to keep in sync. You already have one—~/.ssh/config—and it already knows how to reach every box. What it doesn’t have is a field for “this is prod.” So I borrowed the only thing a config file can’t misinterpret: a comment.
Host web-prod-1 #@ env=prod group=aws note=checkout API, do not reboot
HostName 10.0.4.5
User deployThe #@ tag is inert to OpenSSH and load-bearing to the plugin. env drives the theme, group sections the picker, note reminds you why this box exists. One file, one source of truth, and ssh web-prod-1 still works with zero plugin involved.
Classify the machine once; let the environment decide how it looks. Prod is red everywhere, automatically. New prod box tomorrow? Tag it
env=prodand it inherits the warning paint for free. This is DRY for your reflexes.
There’s a sharp edge worth naming. tmux has no native idea which host a pane is talking to—pane_current_command says ssh and then lies to you the second the remote shell takes over, and says nothing useful at all through ProxyJump or mosh. So the picker stamps the host onto the pane as a user option at launch time and themes off that. You can’t sniff your way to correctness here; you have to write it down when you know it.
The footgun I caught on the way out
This is a security blog, so here’s the bug I’m proud of not shipping.
The picker builds tmux menu entries, and each entry’s command is a string that eventually reaches sh -c. Early versions interpolated the host alias straight into that string:
# selecting this menu item runs: sh -c "... spawn.sh window $alias"
run-shell "$DIR/spawn.sh window $alias"Your aliases come from your own ssh_config, so the threat model is “self-inflicted.” But a tool that reads a config file and executes derived commands should not be one cursed hostname away from running arbitrary code. Name a host x$(touch /tmp/pwned) and the obvious implementation will cheerfully touch /tmp/pwned when you pick it from a menu.
The fix is unglamorous and total: quote every untrusted value into a single shell token before it crosses the sh -c boundary.
shq() { local s=${1//\'/\'\\\'\'}; printf "'%s'" "$s"; }
# -> 'x$(touch /tmp/pwned)' is now a literal string, not a commandConfig files are an input. The moment your tooling turns config into executed shell, "it's just my own file" stops being a security boundary and starts being a trust assumption you're making about your future, sleepier self.
While we’re talking about sleepy selves: env=prod hosts get a confirmation prompt before connect. A two-second speed bump is a cheap insurance premium against muscle memory.
Live port-forwards, without the reconnect dance
Static forwards live in the same #@ tag (forward=L:5432:localhost:5432) and come up with the session. The fun one is toggling a forward on a connection that’s already open. That’s ControlMaster territory: the picker opens every session with a shared control socket, and a separate key talks to it over ssh -O forward / ssh -O cancel. Need to tunnel a database port mid-session? prefix+key, type the spec, done—no killing the shell, no reconnecting.
What it doesn’t do (read this part)
I am allergic to READMEs that only describe the happy path, so:
- The pane tint shows at the shell prompt and empty cells. The moment you open
vimorhtop, the app paints its own background over it. The 16-colour palette swap still applies underneath, and—more importantly—the accent pane-border stays visible no matter what. That border, not the background, is the honest “am I on prod?” signal. - The status bar is session-scoped, so with split panes it can only reflect the focused host. One status line, many panes, same compromise as before.
ControlMastermeans sessions to the same host share one TCP connection and a master can linger ~30s after you disconnect. If you already run your own muxing, turn it off—you’ll lose live forward toggling, not the rest.- Theming only fires on picker-launched sessions. A hand-typed
sshis invisible to it unless you opt into a best-effort autodetect, which guesses the host from the process tree and is exactly as fragile as that sounds.
None of these are bugs so much as the physics of doing this inside a multiplexer. I’d rather tell you where the floor is than let you find it.
The point was never the colors. The point is a terminal that carries a little context—enough that red on the screen fires before enter on the keyboard. Tabby taught my reflexes that lesson; this just moved it into tmux and dropped the 200MB. It’s shell scripts. They have tests. They run on bash 3.2. They will not auto-update at the worst possible moment.
Code’s on GitLab. Tag your prod boxes red. Future-you, three coffees deep, will thank you.
─── ✦─☆─✦─☆─✦─☆─✦ ───