This used to be a single ~/.tmux.conf. It is now five small files, split by
reason for change. Keys live in bindings.conf, looks in statusline.conf,
behavior in options.conf. When I want to change how a key behaves I know the
file before I open an editor. The edit loop is find the file, change a line,
press C-a r.
This is not a big config. The five files add up to about seventy lines, one
screen you could read top to bottom. Splitting it is not about escaping an
unmanageable file. It is a structure I carry through ~/env, where the shell
config and the scripts follow the same rule: small files, one concern each,
shipped with stow.
This runs on macOS with tmux 3.7c from Homebrew. Where something is
version-dependent or machine-specific, I say so. There is no plugin manager
involved and no plugin at all.
TL;DR
There isn’t much to my tmux configuration. A five-line tmux.conf sources four
files (options, terminal, bindings, status line) and ~/.config/tmux/tmux.conf
is a Stow symlink to it, so tmux finds it through its normal config discovery.
I don’t use a plugin manager, and there are no plugins. Two scripts in
~/env/shared/scripts/bin/ round out the workflow: a wrapper that builds a
three-pane workspace, and a shared agent-popup helper that two keys use to raise
a persistent agent session without leaving the current pane.
It’s not meant to be a universal tmux setup. It’s the multiplexer shaped to make
this particular machine comfortable to work in, and the files are split so that
shape stays easy to edit.
Configuration Layout
The source of truth lives inside the ~/env repo, in the shared tree:
~/env/shared/.config/tmux/
├── tmux.conf # thin orchestrator
├── options.conf # session and input behavior
├── terminal.conf # terminal capabilities
├── bindings.conf # keys
└── statusline.conf # status line
~/.config/tmux/ holds Stow symlinks, so tmux’s own config discovery lands on
tmux.conf. There is no ~/.tmux.conf, and XDG_CONFIG_HOME is not set.
~/.config/tmux/tmux.conf is one of the configuration files tmux loads, and on
this machine it is the only one of those locations that exists, so the symlink
is the config tmux reads.
The orchestrator is the whole config set:
# Load shared portable config files
source-file ~/env/shared/.config/tmux/options.conf
source-file ~/env/shared/.config/tmux/terminal.conf
source-file ~/env/shared/.config/tmux/bindings.conf
source-file ~/env/shared/.config/tmux/statusline.conf
The orchestrator does nothing but list the files in order. I source the real
paths in ~/env, not the symlinked copies. That keeps one source of truth, so
C-a r always reloads the versioned files.
Configuring Options
options.conf sets session and input behavior:
# Configure general session and window behavior
set -g base-index 1
set -g renumber-windows on
set -g extended-keys on
set -g extended-keys-format csi-u
# Configure key and input behavior
set -g mode-keys vi
set -s escape-time 10
# Configure mouse and focus handling
set -g mouse off
set -g focus-events on
Windows start at 1 with base-index, not the default 0. renumber-windows
closes the gap when you kill a window, so your C-a <number> habit stays
accurate.
extended-keys needs a recent tmux (3.2 or newer), and extended-keys-format
is a later addition. With the csi-u format, terminal apps get distinct key
events instead of losing modifiers. The same keystroke that means Enter in one
app and C-j in another stays tellable. That matters since C-j, C-h, and
friends do real work in the bindings below, and is the reason the config
requires a recent tmux.
escape-time is the milliseconds tmux waits to tell an Escape from the start of
an escape sequence. Ten is the value I want, and it is also the default in tmux
3.7c, so the line makes that explicit rather than lowering anything. It’s the
number Neovim users tend to land on. I keep it for the editor, not for tmux.
mouse off and mode-keys vi set the tone. Everything in this config is
keyboard-driven. Panes are resized, selected, and copied entirely by key, so the
mouse is dead weight once it is off. Mouse is off by default in tmux. I keep the
line because it is a statement of intent.
focus-events forwards focus in and out to the pane. Terminal apps like Neovim
can then react to the terminal losing focus instead of assuming the window is
gone.
Configuring the Terminal
terminal.conf is about what the terminal underneath can actually do:
# Enable undercurl and terminal color support
set -su terminal-overrides
set -as terminal-overrides ',*:Smulx=\E[4::%p1%dm'
set -as terminal-overrides ',*:Setulc=\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m'
set -g default-terminal "tmux-256color"
set -as terminal-overrides ',xterm-256color:RGB'
The Smulx and Setulc entries are the undercurl. Smulx is the styled
underline capability and Setulc supplies its color. Neovim renders diagnostics
with curly underlines today, and without these overrides tmux can mangle or drop
them.
set -su resets the override list to its default and set -as appends, so this
file builds the effective list in order, and order matters within it. This is
the one place in the whole config where ordering is a real dependency.
default-terminal "tmux-256color" is the TERM every app inside tmux sees. The
last override adds the RGB capability to xterm-256color so tmux knows
truecolor can pass through. Without it, colors silently degrade to the 256-color
palette.
This file exists so the editor renders correctly, not so tmux looks good. The
undercurls and truecolor are for the Neovim config that EDITOR points at.
Configuring Bindings
bindings.conf is where the muscle memory lives:
# Set prefix key
set -g prefix C-a
unbind C-b
bind-key C-a send-prefix
# Create window splits
unbind %
bind | split-window -h
unbind '"'
bind - split-window -v
# Reload configuration
unbind r
bind r source-file ~/env/shared/.config/tmux/tmux.conf
bind -r y run-shell -b $HOME/env/shared/scripts/bin/opencode
bind -r u run-shell -b $HOME/env/shared/scripts/bin/pi
# Resize panes with prefix
bind j resize-pane -D 5
bind k resize-pane -U 5
bind l resize-pane -R 5
bind h resize-pane -L 5
bind m resize-pane -Z
unbind M-c
# Prefix-based pane switching (use with prefix key, e.g. C-a C-h)
bind C-h select-pane -L
bind C-j select-pane -D
bind C-k select-pane -U
bind C-l select-pane -R
bind C-\\ select-pane -l
# Configure copy mode bindings
bind -T copy-mode-vi C-h select-pane -L
bind -T copy-mode-vi C-j select-pane -D
bind -T copy-mode-vi C-k select-pane -U
bind -T copy-mode-vi C-l select-pane -R
bind -T copy-mode-vi C-\\ select-pane -l
The prefix moves from C-b to C-a. C-b is a stretch. C-a sits under the
left hand. The cost is that a literal Ctrl-A in an app needs two presses, so
C-a C-a reaches readline’s move-to-line-start. I paid that cost years ago and
never went back.
Splits read like their shape. | goes vertical side-by-side and - stacks
horizontally, matching the character you press. These bindings split the pane
you are currently in, so the flow is split, work, split. The wrapper below is
where the main pane is targeted: it names the original main pane explicitly
when it builds the initial layout.
C-a r re-sources the orchestrator, which pulls all four files again. Editing
bindings.conf then reloading once is the entire edit loop. No server restart,
no plugin manager, no rehash.
The -r flags on y and u make them repeatable, so one prefix press lets you
re-trigger either popup. run-shell -b runs the script in the background
instead of pausing the pane while it works. Both point at scripts I’ll get to
shortly.
Resize keys mirror Vim. j/k/l/h nudge panes in the obvious directions by
five cells, and m toggles zoom. unbind M-c pins Alt-c as unbound so nothing
can land on it later. Pane jumping uses Ctrl-modified h/j/k/l, and C-\
flips back to the last pane. Both the prefix table and the copy-mode-vi table
get the same pane-switch set, so navigation works whether you are at a prompt or
busy selecting text.
Configuring the Status Line
statusline.conf keeps the chrome honest:
# Configure status line and window styling
set -g status-position top
set -g status-justify absolute-centre
set -g status-style "bg=default"
set -g window-status-current-style "fg=yellow bold"
set -g status-right ""
set -g status-left ""
The status line sits at the top, not the bottom. In the wrapper layout below,
the bottom edge of the main pane is given over to a scratch strip, so the top is
where the status line is allowed to live.
bg=default makes it transparent, which means it blends with the terminal
background instead of drawing a bar. The current window is bold yellow, and the
left and right sides carry no clock, no hostname, and no session name. The only
thing on screen is which window you are in.
The Wrapper Session
The tmux-s alias points at ~/env/shared/scripts/bin/tmux:
#!/usr/bin/env bash
set -euo pipefail
SESSION_NAME="${SESSION_NAME:-dev}"
WINDOW_NAME="${WINDOW_NAME:-main}"
command -v tmux &>/dev/null || {
printf '%s\n' 'Not found: tmux' >&2
exit 1
}
if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
tmux new-session -d -s "$SESSION_NAME" -n "$WINDOW_NAME"
main_pane_id="$(tmux display-message -t "$SESSION_NAME":"$WINDOW_NAME" -p '#{pane_id}')"
tmux split-window -h -p 10 -t "$main_pane_id"
tmux split-window -v -p 10 -t "$main_pane_id"
tmux select-pane -t "$main_pane_id"
fi
if [[ -n "${TMUX:-}" ]]; then
tmux switch-client -t "$SESSION_NAME"
else
tmux attach-session -t "$SESSION_NAME" || {
printf 'tmux attach failed for session %s\n' "$SESSION_NAME" >&2
exit 1
}
fi
It is a bash script, not a tmux config hook, because it is multi-step and has to
run before tmux attaches. It lives in ~/env/shared/scripts/bin so it is
versioned and portable, and it runs under bash regardless of the interactive
shell.
On first run it creates a dev session with a main window, then splits off
two thin panes from the main pane: a 10% strip on the right and a 10% strip
along the bottom. The main pane stays selected.
The layout is the point of the script. One big pane owns the work, the right
rail is a good place for something like a test runner or log stream, and the
bottom strip is a scratch shell. The script only builds the geometry. What you
put in the rails is your business.
If you are already inside tmux it switches to dev. Otherwise it attaches.
Session and window names are env-overridable, but I have never needed to change
them.
This lives behind the alias and nowhere else. Plain tmux keeps its default
behavior, so the opinionated layout only shows up when I ask for it.
y and u from the bindings point at two thin wrappers, opencode and pi.
Both resolve their own directory and exec the same helper with an agent name:
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "$script_dir/../libexec/agent-popup.sh" opencode
The helper, libexec/agent-popup.sh, does the actual work:
#!/usr/bin/env bash
set -euo pipefail
agent_name="${1:-}"
if [[ -z "$agent_name" ]]; then
printf '%s\n' "Usage: $0 <agent-name>" >&2
exit 1
fi
pane_path="$(tmux display-message -p -F '#{pane_current_path}' 2>/dev/null)" || exit 0
if command -v md5sum &>/dev/null; then
path_hash="$(printf '%s' "$pane_path" | md5sum 2>/dev/null | cut -d' ' -f1 | cut -c1-8)"
else
path_hash="$(printf '%s' "$pane_path" | md5 -q 2>/dev/null | cut -c1-8)"
fi
session="$agent_name-$path_hash"
if ! tmux has-session -t "$session" 2>/dev/null; then
tmux new-session -d -s "$session" -c "$pane_path" "$agent_name" &>/dev/null || exit 0
fi
tmux display-popup -w 80% -h 80% -E "tmux attach-session -t \"$session\"" 2>/dev/null || exit 0
The trick is the session name. It hashes the current directory, so the session
is opencode-<hash> or pi-<hash> keyed to where the pane is. First press in a
directory creates a detached session that starts the agent there. Every later
press in the same directory attaches to the same session. The agent keeps its
state, the popup reappears over your panes, and nothing is spawned into the
working pane.
The hash is computed portably. The script prefers md5sum when it is available,
otherwise it uses macOS’s md5 -q, and takes the first eight hex characters in
either case. Only eight are used, which is plenty for directory uniqueness in
practice.
display-popup opens 80% wide and 80% tall, centered over everything. When I
close the popup the agent session stays alive in the background, detached. That
is a feature and a cost. State survives, but so does the session. They sit
around until killed by hand, one per directory that has ever spawned one.
Tradeoffs and Caveats
The prefix is C-a, and that costs you an app key. A literal Ctrl-A in a
shell or in readline takes two presses (C-a then C-a). The escape hatch is
firmly in place, but it is a thing you must get used to.
Mouse is entirely off. No drag-selecting, no mouse wheel scrollback inside
panes, no click-to-focus. Keyboard only. That is a deliberate choice, and some
people will rightly hate it.
The wrapper is opinionated about layout. tmux-s always gives you the main
pane plus two thin rails. It is a workspace shape, not a terminal. When you want
a plain pane, plain tmux is still there, untouched.
The status line tells you almost nothing. No clock, no hostname, no session
name. The only glanceable data is the highlighted current window. Useful signals
were traded away for a quiet screen.
Agent popups leave sessions behind. Every directory with a y or u press
owns a detached session forever. They are cheap and invisible, but they
accumulate, and the cleanup is manual.
This config assumes a recent tmux. extended-keys and
extended-keys-format are recent additions, and focus-events is older but not
universal. I do not guard any of them. On an old distro tmux this config would
error at startup, which is fine for my machines and worth knowing before you
copy it.
Where I Landed
The load order is:
1. options
2. terminal
3. bindings
4. statusline
Nothing here reads a value that an earlier file wrote, except terminal.conf,
where the overrides have to build up in order. The same is mostly true of load
order in the shell post. Only a few lines are real dependencies, and everything
else would survive reordering.
I won’t pretend the split is required. tmux would load a single ~/.tmux.conf
with the same result, and at seventy lines you could read it all in one screen.
One file is the reasonable default, and I have no argument against it for a
config that small.
I keep five files anyway, for three reasons that hold even at this size.
The first is diff scoping. A change to keys touches bindings.conf and nothing
else, so every commit and every git log entry names the concern that moved. That
stays useful no matter how small the config is.
The second is consistency with the rest of ~/env. The shell config and the
scripts follow the same shape, small files, one concern each, shipped through
stow. When the tmux files follow the same rule, the structure of the whole
environment is learnable once.
The third is that the split is nearly free. Reloading re-parses all five files
in a blink, and the orchestrator is five obvious lines, so the cost is the tiny
mental jump from one file to another. I would merge the files back if the
equation ever flips. If the config shrank further or I stopped editing keys, the
extra structure might no longer be worth it. So far, the seams keep paying.
This is small enough to understand, structured enough that I know where to edit
it, and opinionated enough to make my workflow comfortable.
That’s really the point of the configuration. The five files aren’t necessary,
but each concern has a predictable home, and that makes the whole thing easier
for me to change without having to think about where something lives.
Related Posts