tmux + SSH: work survives dropped connections
You’re mid-task on an unreliable connection — SSH dies mid-command. Without tmux, you start over. With tmux, you reconnect and pick up exactly where you left off: all windows, panes, scrollback, running processes intact.
The catch: vanilla tmux over SSH feels like a degraded terminal. Paste mangles your code. Some keys don’t work. Colour rendering is off. These are fixable.
Auto-spawn tmux on SSH
Add to ~/.zshrc on the remote machine:
# Auto-attach to tmux on SSH login
if [[ -z "$TMUX" ]] && [[ -n "$SSH_CONNECTION" ]]; then
TMUX_SESSION="ssh-$$"
exec tmux new-session -A -s "$TMUX_SESSION"
fi
SSH_CONNECTION is set for any inbound SSH connection — works for both regular SSH and Tailscale SSH. If you’re not already inside tmux, it creates a new named session (ssh-<pid>) and replaces the current shell. Every new SSH connection gets its own independent session; detaching leaves it running for the next reconnect.
SSH dies mid-flight? ssh macmini and you’re back in seconds.
Terminal behavior gaps
tmux 3.6a on macOS has rough edges that make it feel unlike a real terminal. These are fixable.
1. Mouse + paste mode
Without bracketed paste mode, pasted code gets interpreted by tmux’s command key table. Paste text containing Ctrl+c and you interrupt your session. Paste something starting with : and you drop into the tmux command prompt.
set -g mouse on
In tmux 3.6a, mouse on implicitly enables paste handling. Done.
2. Focus events
vim, less, watch, and TUI dashboards need to know when they lost keyboard focus so they can stop rendering or refresh. Without focus events, they keep stale state after you switch panes or windows.
set -g focus-events on
3. Cursor shape
Shells (zsh vi-mode, fish), editors (vim, nvim), and TUIs that manage their own input cursor emit DECSCUSR escape sequences (\E[<n> q) to switch between block, underline, and bar cursors. tmux only forwards them if its terminfo entry advertises the Ss/Se pair. Without this override, you’ll see stray bytes like E[2 q printed onto the terminal instead of a cursor shape change.
set -ga terminal-overrides ',*:Ss=\E[%p1%d q:Se=\E[2 q'
Two non-obvious things here. Use single quotes, not double — tmux’s config parser eats the backslash inside double quotes and \E collapses to a literal E, which is exactly the failure mode that produces those stray bytes. And always pair Ss with Se, otherwise the capability is half-defined and some clients won’t use it. (Full debug story.)
4. Colour and RGB
Some apps emit 256-colour codes when you have a full 24-bit terminal. This causes subtle rendering differences for truecolour apps that check $COLORTERM as well as $TERM.
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
5. Kitty keyboard protocol over SSH
If you use Kitty as your SSH client, it sends an extended key protocol that tmux doesn’t decode by default. Ctrl+← arrives as plain ←, breaking option-arrow word-jumping in shells and editors.
set -as terminal-overrides ",xterm-kitty:Kitty"
This tells tmux to decode the Kitty protocol so key combos reach the shell correctly.
The full config
# ~/.tmux.conf
set -g default-terminal "tmux-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
set -ga terminal-overrides ',*:Ss=\E[%p1%d q:Se=\E[2 q'
set -g focus-events on
set -g mouse on
# Splits open in the current working directory
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
# Status bar
set -g status-position bottom
set -g status-interval 5
set -g status-style bg=colour235,fg=colour250
set -g status-left "#[bg=colour076,fg=black,bold] #S #[nobold]"
set -g status-left-length 30
set -g status-right " %H:%M %d-%b "
set -g status-right-length 20
# Active pane — green border
set -g pane-border-style "fg=colour240"
set -g pane-active-border-style "fg=colour076"
set -g window-status-current-style "bg=colour076,fg=black,bold"
set -g window-status-style "fg=colour250"
# Messages
set -g message-style "bg=yellow,fg=black,bold"
set -g message-command-style "bg=black,fg=yellow"
# History persists across restarts
set -g history-limit 50000
set -g history-file ~/.tmux_history
# Window titles
set -g set-titles on
set -g set-titles-string "#S:#I.#P #T"
# Windows and panes start at 1, not 0
set -g base-index 1
set -g pane-base-index 1
# Hot reload: Ctrl+b r
bind r source-file ~/.tmux.conf \; display "Config reloaded"
# Send input to all panes at once (good for prod debugging)
bind e set -g synchronize-panes
Session name shows in the status bar, the active pane border turns green, windows start at 1, and Ctrl+b r hot-reloads the config without killing sessions.
Custom status bar: Mac Mini info on every SSH session
Since this is a headless Mac Mini on Tailscale, the status bar shows machine-relevant info refreshed every 30 seconds:
Tarangan │ ssh-hermes │ 1:zsh │ Tarangan │ 100.114.39.97 │ up 4:48 │ 47ms │ 21:26 04-May
Left: hostname │ session-name
Centre: window-index:command
Right: hostname │ tailscale-ip │ uptime │ latency │ date-time
The right side runs a script every 30 seconds (status-interval 30):
# ~/.tmux/macmini-status.sh
TAILSCALE_IP=$(tailscale ip -4 2>/dev/null || echo "disconnected")
HOSTNAME=$(hostname)
UPTIME=$(uptime | sed -E 's/.*up[[:space:]]+//; s/,.*//; s/[[:space:]]+/ /')
LATENCY=$(tailscale netcheck 2>/dev/null | grep -oE '[0-9]+\.?[0-9]*ms' | head -1 | tr -d 'ms')
if [[ -n "$LATENCY" ]]; then LATENCY="${LATENCY}ms"; else LATENCY="?"; fi
echo "$HOSTNAME │ $TAILSCALE_IP │ up $UPTIME │ $LATENCY"
The #{} conditional in the tmux config only shows the script output for sessions matching ssh-* — so any local tmux sessions you create manually won’t try to run the remote-specific script.
What doesn’t fix itself
Copy-mode scrolling with the trackpad feels different from native terminal scrollback, and mouse selection is tmux’s, not the system’s. But for the core SSH workflow — running commands, editing files, keeping state alive across reconnections — this gets you to roughly 95% of sitting at the machine.
The remaining 5% is cosmetic. Accept the medium and optimize for it.