Power-user techniques from the tmux manual, mailing lists, and years of terminal living.
# ~/.tmux.conf
set -g escape-time 0 # no delay on Escape (critical for Vim/Neovim)
set -g base-index 1 # windows start at 1, not 0
setw -g pane-base-index 1 # panes start at 1 too
set -g renumber-windows on # close window 2, window 3 becomes 2 automatically
set -g focus-events on # pass focus events to apps (needed by Neovim autoread)
set -g mouse on # scroll, click panes, resize splits
set -g history-limit 50000 # bigger scrollback
set -g display-time 2000 # status messages stay visible 2s
set -g status-keys vi
setw -g mode-keys vi
Reload without restart:
tmux source-file ~/.tmux.conf
Or bind it:
bind R source-file ~/.tmux.conf \; display "Config reloaded"
display-popup spawns a floating terminal over the current pane. Available since tmux 3.2.
# Toggle a scratch terminal with prefix-g
bind g display-popup -E -w 80% -h 80% -d "#{pane_current_path}" \
"tmux new-session -A -s scratch"
-A means attach if session exists, create if not. -d inherits the current directory.
bind G display-popup -E -w 95% -h 95% -d "#{pane_current_path}" lazygit
No persistent session needed: lazygit exits when you quit, popup disappears.
bind s display-popup -E -w 60% -h 40% \
"tmux list-sessions -F '#{session_name}' \
| fzf --reverse --prompt='session: ' \
| xargs -r tmux switch-client -t"
Replaces the default prefix-s tree view with a fuzzy picker.
bind H display-popup -E -w 90% -h 85% htop
| Flag | Meaning |
|---|---|
-E |
close popup when command exits |
-w |
width (pixels or %) |
-h |
height (pixels or %) |
-x |
x position (default: center) |
-y |
y position (default: center) |
-d |
working directory |
-e |
set environment variable |
-T |
popup border title |
Close a popup from inside: Ctrl-c or just exit the shell.
capture-pane dumps pane content to a buffer. It captures what is visible, or the full scrollback with -S.
tmux capture-pane -p -S -50000 > ~/scrollback.txt
-p prints to stdout. -S -50000 goes 50000 lines back (negative = from start of history).
tmux capture-pane -p -t %3 # by pane id
tmux capture-pane -p -t 2.1 # window 2, pane 1
# Pull URLs from scrollback, pick one, open it
tmux capture-pane -p -S -5000 \
| grep -oE 'https?://[^ ]+' \
| sort -u \
| fzf --reverse \
| xargs -r xdg-open
Bind it:
bind u run-shell \
"tmux capture-pane -p -S -5000 \
| grep -oE 'https?://[^ ]+' \
| sort -u \
| fzf-tmux -p 80%,40% --reverse \
| xargs -r xdg-open"
tmux capture-pane -p -e > with-colors.txt # -e includes escape sequences
tmux capture-pane -p > plain.txt # default strips them
To strip ANSI from captured output after the fact:
sed 's/\x1b\[[0-9;]*m//g' with-colors.txt
pipe-pane redirects a pane's output to a shell command. It stays running until toggled off.
# Start logging
tmux pipe-pane -o 'cat >> ~/logs/tmux-pane-%Y%m%d.log'
# Stop logging (run same command again when -o is set)
tmux pipe-pane
-o means "toggle": if already piping, stop; if not, start.
Bind it:
bind P pipe-pane -o 'cat >> ~/logs/pane-#S-#W-#P-$(date +%F).log' \; \
display "Logging toggled"
#S = session name, #W = window name, #P = pane index.
tmux pipe-pane 'grep --line-buffered -iE "error|warn|fatal" >> ~/logs/errors.log'
--line-buffered is critical: without it grep buffers output and you see nothing.
tmux pipe-pane "sed -u 's/\x1b\[[0-9;]*m//g' >> ~/logs/clean-$(date +%F).log"
-u (unbuffered) on sed keeps output flowing in real time.
tmux pipe-pane 'logger -t tmux-pane'
All pane output lands in syslog under the tag tmux-pane.
tmux has three built-in tables: root (no prefix), prefix (after pressing prefix), and copy-mode-vi. You can create your own named tables for modal interaction.
# Enter resize mode with prefix-r, then use arrow keys to resize
bind r switch-client -T resize
bind -T resize Left resize-pane -L 5
bind -T resize Right resize-pane -R 5
bind -T resize Up resize-pane -U 5
bind -T resize Down resize-pane -D 5
# Stay in resize mode after each keypress until you press Enter or Escape
bind -T resize Enter switch-client -T prefix
bind -T resize Escape switch-client -T prefix
Without repeating back to prefix you stay stuck. Add this to exit cleanly.
bind L switch-client -T layout
bind -T layout e select-layout even-horizontal
bind -T layout E select-layout even-vertical
bind -T layout m select-layout main-horizontal
bind -T layout M select-layout main-vertical
bind -T layout t select-layout tiled
bind -T layout Escape switch-client -T prefix
Use sparingly -- root bindings fire on every keystroke without a prefix.
# Alt+h/j/k/l to navigate panes without prefix
bind -n M-h select-pane -L
bind -n M-j select-pane -D
bind -n M-k select-pane -U
bind -n M-l select-pane -R
# Alt+n / Alt+p for next/prev window
bind -n M-n next-window
bind -n M-p previous-window
-n is shorthand for -T root.
tmux list-keys -T resize
tmux list-keys -T root | grep -v '^bind -n Mouse'
Hooks run tmux commands automatically on events. Set with set-hook (session scope) or set-hook -g (global).
Fires after you switch to a pane. Useful for updating status or running per-pane commands.
set-hook -g after-select-pane "run-shell 'echo #{pane_current_command} > /tmp/tmux-active-cmd'"
Requires focus-events on. These fire when a pane gains or loses focus.
# Dim inactive panes via pane border style
set-hook -g pane-focus-in "set -g pane-active-border-style fg=colour208"
set-hook -g pane-focus-out "set -g pane-border-style fg=colour240"
A more dramatic version changes the pane background:
set-hook -g pane-focus-out "select-pane -P bg=colour235"
set-hook -g pane-focus-in "select-pane -P bg=default"
Runs once when a new session starts. Use it to bootstrap an environment.
set-hook -g session-created "run-shell '~/.local/bin/tmux-session-init.sh #{session_name}'"
# ~/.local/bin/tmux-session-init.sh
#!/bin/sh
SESSION="$1"
case "$SESSION" in
dev) tmux send-keys -t "$SESSION" 'nvim' Enter ;;
logs) tmux send-keys -t "$SESSION" 'journalctl -f' Enter ;;
esac
Fires after every split. Use it to ensure new panes inherit the current directory.
set-hook -g after-split-window "run-shell 'tmux send-keys -t #{pane_id} \"cd #{pane_current_path}\" Enter'"
Or simpler: tmux already respects -c "#{pane_current_path}" if you bind your splits that way:
bind '"' split-window -c "#{pane_current_path}"
bind % split-window -h -c "#{pane_current_path}"
tmux show-hooks -g
tmux show-hooks # session-local hooks
send-keys types text into any pane as if you typed it. This is how you automate tmux from scripts.
tmux send-keys -t mysession:1.1 "ls -la" Enter
tmux send-keys -t mysession:2 "htop" Enter # targets pane 1 of window 2
tmux send-keys -t %3 "cd /tmp" Enter # targets pane by id
# Run a command in every pane of window 2
tmux list-panes -t mysession:2 -F "#{pane_id}" | while read pane; do
tmux send-keys -t "$pane" "git pull" Enter
done
Turn on sync and every keystroke goes to all panes simultaneously:
bind S setw synchronize-panes \; \
display "Sync: #{?pane_synchronized,ON,OFF}"
From the command line:
tmux setw synchronize-panes on
# ... type in any pane, all panes receive it ...
tmux setw synchronize-panes off
Use case: deploy the same command across 8 servers each in its own pane.
tmux send-keys -t %1 "q" # literal q
tmux send-keys -t %1 "C-c" # Ctrl-c
tmux send-keys -t %1 "C-l" # clear screen
tmux send-keys -t %1 "Escape"
tmux send-keys -t %1 "" # BEL
Named keys: Enter, Escape, Tab, BSpace, Up, Down, Left, Right, F1-F12, C-a through C-z.
tmux send-keys -t %2 "git commit -m 'fix'"
# leaves the text in the prompt, user hits Enter manually
command-prompt opens an interactive prompt inside tmux for scripted user input.
bind N command-prompt -p "New session name:" "new-session -s '%%'"
%% is the placeholder for what the user types.
bind , command-prompt -I "#{window_name}" -p "Rename window:" "rename-window '%%'"
-I pre-fills the prompt with the current window name.
bind j command-prompt -p "Join pane from (window.pane):" "join-pane -s '%%'"
Lets you interactively pull any pane into the current window.
bind m command-prompt -p "Move window to session:" "move-window -t '%%'"
bind C command-prompt -p "Session name:","Window name:" \
"new-session -s '%1' -n '%2'"
%1, %2 map to the first and second answers.
tmuxinator is not needed. A 20-line shell script does the same thing with no Ruby dependency and no YAML.
#!/bin/sh
# ~/.local/bin/dev-session.sh
# Usage: dev-session.sh [project-dir]
DIR="${1:-$HOME/dev/myproject}"
SESSION="dev"
tmux has-session -t "$SESSION" 2>/dev/null && tmux attach -t "$SESSION" && exit
tmux new-session -d -s "$SESSION" -c "$DIR" -x 220 -y 50
# Window 1: editor
tmux rename-window -t "$SESSION:1" "editor"
tmux send-keys -t "$SESSION:1" "nvim ." Enter
# Window 2: two-pane terminal
tmux new-window -t "$SESSION:2" -n "shell" -c "$DIR"
tmux split-window -t "$SESSION:2" -h -c "$DIR"
# Window 3: server
tmux new-window -t "$SESSION:3" -n "server" -c "$DIR"
tmux send-keys -t "$SESSION:3" "npm run dev" Enter
# Window 4: git
tmux new-window -t "$SESSION:4" -n "git" -c "$DIR"
tmux send-keys -t "$SESSION:4" "lazygit" Enter
tmux select-window -t "$SESSION:1"
tmux attach -t "$SESSION"
#!/bin/sh
# Usage: t [name] [dir]
NAME="${1:-work}"
DIR="${2:-$(pwd)}"
tmux has-session -t "$NAME" 2>/dev/null \
&& tmux attach -t "$NAME" \
|| tmux new-session -A -s "$NAME" -c "$DIR"
Save as ~/bin/t, chmod +x. Type t anywhere to attach or create.
#!/bin/sh
# Kill all windows but confirm first
SESSION="${1:-dev}"
echo "Kill session '$SESSION'? [y/N]"
read -r ans
[ "$ans" = "y" ] && tmux kill-session -t "$SESSION"
if-shell runs a tmux command if a shell command exits 0, optionally with an else branch.
if-shell "uname | grep -q Darwin" {
bind C-v run "pbpaste | tmux load-buffer - && tmux paste-buffer"
bind C-c run "tmux save-buffer - | pbcopy"
} {
bind C-v run "xclip -o | tmux load-buffer - && tmux paste-buffer"
bind C-c run "tmux save-buffer - | xclip -i"
}
# If inside SSH, change status bar color to signal remote context
if-shell '[ -n "$SSH_CLIENT" ]' {
set -g status-bg colour52
set -g status-fg colour255
}
# If running inside another tmux, use a different prefix
if-shell '[ -n "$TMUX" ]' {
set -g prefix C-a
} {
set -g prefix C-b
}
This lets you nest sessions: outer uses C-b, inner uses C-a.
# At the end of ~/.tmux.conf
if-shell "[ -f ~/.tmux.local.conf ]" "source ~/.tmux.local.conf"
Machine-specific overrides go in ~/.tmux.local.conf, never in the main config.
# display-popup requires tmux >= 3.2
if-shell "tmux -V | awk '{exit ($2 < 3.2)}'" {
bind g display-popup -E -w 80% -h 80% zsh
}
OSC 52 is a terminal escape sequence that instructs the local terminal emulator to set the clipboard. It works through SSH and through tmux, allowing remote processes to write to your local clipboard with no X11 forwarding.
allowWindowOps).set -g set-clipboard on # tmux handles OSC 52 itself (tmux >= 3.2)
set -as terminal-features ",xterm-256color:clipboard"
If tmux is older or set-clipboard causes issues, use pass-through:
set -g set-clipboard off
set -as terminal-overrides ',*:Ms=\E]52;c;%p2%s\007'
#!/bin/sh
# ~/.local/bin/osc52copy
# Reads from stdin, sends OSC 52 sequence
buf=$(cat)
encoded=$(printf '%s' "$buf" | base64 | tr -d '\n')
printf '\033]52;c;%s\a' "$encoded"
Usage on a remote machine over SSH:
cat file.txt | osc52copy
echo "some text" | osc52copy
With set-clipboard on, tmux intercepts the OSC 52 sequence and routes it to the local terminal. This means y in copy-mode-vi can copy to your local clipboard automatically:
set -g set-clipboard on
bind -T copy-mode-vi y send -X copy-selection-and-cancel
No xclip/xsel/pbcopy needed on the remote end.
echo "test" | base64 | xargs -I{} printf '\033]52;c;{}\a'
Open your local clipboard manager -- "test" should appear.
tmux is monolithic. The Plan 9 philosophy: one tool, one job. abduco handles session detach/attach. dvtm handles the multiplexer. They compose.
# Arch
pacman -S abduco dvtm
# Build from source (suckless.org)
git clone https://git.suckless.org/abduco
git clone https://git.suckless.org/dvtm
# Create a named session running dvtm
abduco -c mysession dvtm
# Detach: Ctrl-\
# Reattach
abduco -a mysession
# List sessions
abduco
| Key | Action |
|---|---|
Mod-c |
create new window |
Mod-x |
close window |
Mod-j/k |
focus next/prev |
Mod-Space |
cycle layouts |
Mod-f |
fullscreen toggle |
Mod-[1-9] |
switch to window n |
abduco -c foo bash)| Layout | Description |
|---|---|
tiled |
all panes tiled |
bstack |
one large pane, rest stacked at bottom |
grid |
equal grid |
fullscreen |
one pane, full |
vertical |
two columns |
screen predates tmux by 15 years and has one feature tmux lacks: direct serial port access.
screen /dev/ttyUSB0 115200
screen /dev/ttyS0 9600
screen /dev/ttyACM0 115200 # Arduino, etc.
First argument is the device, second is baud rate. No additional software needed.
| Device Type | Baud Rate |
|---|---|
| Arduino | 9600 or 115200 |
| Raspberry Pi UART | 115200 |
| Cisco console | 9600 |
| Network switches (varies) | 9600 or 115200 |
| Embedded Linux | 115200 |
| Key | Action |
|---|---|
Ctrl-a k |
kill session (disconnect) |
Ctrl-a d |
detach (session keeps running) |
Ctrl-a H |
toggle logging to screenlog.0 |
Ctrl-a [ |
enter copy/scroll mode |
Ctrl-a : |
command prompt |
screen -L /dev/ttyUSB0 115200
# creates screenlog.0 in current directory
Or toggle mid-session: Ctrl-a H.
If you get "Permission denied" on the device:
sudo usermod -aG dialout $USER # Debian/Ubuntu
sudo usermod -aG uucp $USER # Arch
# log out and back in, or:
sudo chmod a+rw /dev/ttyUSB0 # temporary
tmux has no serial device support. screen is the standard tool for this. Keep both installed.
tmux capture-pane -p -S -10000 | grep -c "ERROR"
# Poll until "READY" appears in pane %3
while ! tmux capture-pane -p -t %3 | grep -q "READY"; do
sleep 0.5
done
echo "Pane is ready"
Useful in CI scripts or shell scripts that drive a long-running process in another pane.
tmux capture-pane -p -t %1 > /tmp/pane1-before.txt
# ... wait ...
tmux capture-pane -p -t %1 > /tmp/pane1-after.txt
diff /tmp/pane1-before.txt /tmp/pane1-after.txt
tmux exposes state through format strings. Use them in config, bindings, and scripts.
tmux display-message -p "#{session_name}"
tmux display-message -p "#{pane_current_path}"
tmux display-message -p "#{pane_current_command}"
tmux display-message -p "#{window_index} #{window_name}"
Useful variables:
| Variable | Value |
|---|---|
#{session_name} |
current session name |
#{window_index} |
current window number |
#{pane_id} |
pane unique id (e.g. %3) |
#{pane_current_path} |
cwd of pane |
#{pane_current_command} |
foreground process |
#{pane_width} / #{pane_height} |
dimensions |
#{client_termname} |
terminal type |
#{host} |
hostname |
#{cursor_x} / #{cursor_y} |
cursor position |
Conditionals in formats:
# Show "SSH" in status if inside an SSH session
set -g status-right "#{?SSH_CLIENT,SSH,local} | %H:%M"
#!/bin/sh
# deploy-all.sh -- run a command across multiple servers in parallel panes
SERVERS="web1 web2 web3 db1"
CMD="sudo systemctl restart app"
SESSION="deploy-$(date +%s)"
tmux new-session -d -s "$SESSION" -n "servers"
first=1
for host in $SERVERS; do
if [ "$first" = "1" ]; then
tmux send-keys -t "$SESSION:1" "ssh $host '$CMD'" Enter
first=0
else
tmux split-window -t "$SESSION:1" -h "ssh $host '$CMD'"
tmux select-layout -t "$SESSION:1" tiled
fi
done
tmux setw -t "$SESSION:1" synchronize-panes on
tmux attach -t "$SESSION"
All servers run the command simultaneously. You watch all output at once.
# Always split relative to current directory
bind '"' split-window -v -c "#{pane_current_path}"
bind % split-window -h -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
bind -r < swap-window -d -t -1
bind -r > swap-window -d -t +1
-r makes the binding repeatable: hold prefix and tap < multiple times to move a window left.
bind b break-pane -d
Pulls the current pane out into its own window without switching to it (-d).
bind Tab last-window
prefix-z is built in. Add a visual indicator to status:
set -g window-status-current-format "#{?window_zoomed_flag,[Z] ,}#I:#W"
When SSH-ing into a remote machine that has tmux, you end up with two tmux layers. Handle cleanly:
# Outer tmux: prefix is C-b
# Inner tmux: prefix is C-a (set via if-shell or hardcoded)
# To send prefix to inner tmux: press outer prefix twice
# e.g., press C-b C-b to send C-b to the remote session
# Or bind a passthrough key
bind -n C-o send-prefix # outer: C-o passes next key to inner
The common pattern: outer machine uses C-b, remote (SSH) sessions use C-a. Different prefixes eliminate ambiguity.
| Task | Command |
|---|---|
| Switch session | tmux ls -F '#S' | fzf | xargs tmux switch-client -t |
| Kill session | tmux ls -F '#S' | fzf | xargs tmux kill-session -t |
| Switch window | tmux list-windows -F '#I #W' | fzf | awk '{print $1}' | xargs tmux select-window -t |
| Select pane | tmux list-panes -a -F '#S:#I.#P #{pane_current_command}' | fzf | cut -d' ' -f1 | xargs tmux switch-client -t |
| Open recent dir | tmux capture-pane -p -S -3000 | grep -oE '/[^ ]+' | sort -u | fzf | xargs tmux send-keys -t $TMUX_PANE " cd " Enter |
# Left: session name in bold
set -g status-left "#[bold]#S#[default] "
# Right: date, time, hostname
set -g status-right "%Y-%m-%d %H:%M #H"
# Show CPU load (requires external command)
set -g status-right "#(uptime | awk -F'load average:' '{print $2}' | awk '{print $1}') | %H:%M"
# Window list: show activity flag
set -g window-status-activity-style "fg=colour208,bold"
setw -g monitor-activity on
set -g visual-activity off # no message, just style change
| Platform | Tool | tmux command |
|---|---|---|
| Linux (X11) | xclip | run "tmux save-buffer - | xclip -i -selection clipboard" |
| Linux (Wayland) | wl-copy | run "tmux save-buffer - | wl-copy" |
| macOS | pbcopy | run "tmux save-buffer - | pbcopy" |
| SSH remote | OSC 52 | set -g set-clipboard on |
Bind copy in copy-mode-vi to use the right tool:
if-shell "uname | grep -q Darwin" {
bind -T copy-mode-vi y send -X copy-pipe-and-cancel "pbcopy"
} {
bind -T copy-mode-vi y send -X copy-pipe-and-cancel "xclip -i -selection clipboard"
}