Tmux — Terminal Multiplexer Mastery
Prerequisites: Comfortable with the terminal/command line, basic shell scripting (bash), familiarity with a text editor (vim/neovim preferred but not required). No prior tmux experience needed — but this is NOT a gentle intro; it moves fast toward productivity.
Tmux Architecture: Server, Sessions, Windows, and Panes
Tmux is not a shell — it is a client-server system. Understanding the architecture explains why sessions survive disconnects, how multiple terminals share a session, and how scripting tmux actually works under the hood.
The Client-Server Model
When you run tmux for the first time, two things happen: a server process starts in the background, and a client attaches to it. The server owns all the state — sessions, windows, panes, buffers, and the pty for every shell process. The client is just a rendering layer that draws the server state into your terminal emulator.
Communication happens over a Unix domain socket. By default, it lives at /tmp/tmux-UID/default where UID is your numeric user ID. Every tmux command — whether typed at a prefix key or in a script — is a message sent to the server through this socket.
# Find the tmux server process and its socket
$ pgrep -a tmux
48291 tmux new -s dev
$ ls -la /tmp/tmux-501/
total 0
drwx------ 3 ash wheel 96 Jun 10 09:14 .
srwxrwx--- 1 ash wheel 0 Jun 10 09:14 default
# The 's' in srwxrwx--- means it is a Unix socket
# Every tmux command communicates through this file
This is why tmux kill-server is nuclear — it terminates the server process, taking every session, window, and pane with it. The clients are disposable; the server is the brain.
The Hierarchy: Server → Sessions → Windows → Panes
Tmux organizes everything in a strict four-level hierarchy. One server manages multiple sessions. Each session contains one or more windows (think tabs). Each window contains one or more panes (splits within a tab). Every level is addressable by index or name.
flowchart TD
subgraph clients["Clients - Terminal Emulators"]
C1["Client 1 iTerm2"]
C2["Client 2 Alacritty"]
end
SOCK(["Unix Socket /tmp/tmux-501/default"])
C1 -- attach --> SOCK
C2 -- attach --> SOCK
SOCK --> SERVER
subgraph SERVER["tmux server PID 48291"]
direction TB
subgraph S1["Session: dev"]
direction LR
subgraph W1["Win 0: editor"]
P1["Pane 0 nvim"]
P2["Pane 1 zsh"]
end
subgraph W2["Win 1: server"]
P3["Pane 0 npm run dev"]
end
end
subgraph S2["Session: ops"]
direction LR
subgraph W3["Win 0: logs"]
P4["Pane 0 tail -f"]
P5["Pane 1 htop"]
end
end
end
Inspecting the Hierarchy
Three commands give you a complete view of what the server is managing. These are the foundation for any tmux automation script.
$ tmux list-sessions
dev: 2 windows (created Tue Jun 10 09:14:22 2025) (attached)
ops: 1 windows (created Tue Jun 10 09:30:05 2025)
# Format output for scripting
$ tmux list-sessions -F '#{session_name}: #{session_windows} windows, attached=#{session_attached}'
dev: 2 windows, attached=1
ops: 1 windows, attached=0
# List windows for a specific session
$ tmux list-windows -t dev
0: editor* (2 panes) [200x50] [layout e8b4,200x50,0,0{100x50,0,0,0,99x50,101,0,1}]
1: server- (1 panes) [200x50] [layout 2a26,200x50,0,0,2]
# The * marks the current window, - marks the last window
# Use -a to list all windows across every session
$ tmux list-windows -a -F '#{session_name}:#{window_index} #{window_name} #{window_active}'
dev:0 editor 1
dev:1 server 0
ops:0 logs 1
# List panes in the current window
$ tmux list-panes
0: [100x50] [history 1203/50000] (active)
1: [99x50] [history 47/50000]
# Target a specific session:window
$ tmux list-panes -t dev:0 -F '#{pane_index}: #{pane_current_command} #{pane_pid} active=#{pane_active}'
0: nvim 48305 active=1
1: zsh 48310 active=0
# List EVERY pane across the entire server
$ tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}'
dev:0.0 nvim
dev:0.1 zsh
dev:1.0 node
ops:0.0 tail
ops:0.1 htop
Tmux uses a consistent session:window.pane target format everywhere. dev:0.1 means session “dev”, window 0, pane 1. You can also use names: dev:editor.1. This syntax works in -t flags across all commands — send-keys, split-window, select-pane, and more.
Why Sessions Survive SSH Disconnects
The server process is a daemon — it has no controlling terminal. When your SSH connection drops, the kernel sends SIGHUP to your login shell, which terminates the tmux client. But the server is a separate process tree. It keeps running, and every shell inside its panes keeps running, because those shells are children of the server, not of your SSH session.
# The process tree shows why this works:
$ pstree -p $(pgrep -x tmux | head -1)
tmux(48291)-+-zsh(48305)---nvim(48320)
|-zsh(48310)
|-zsh(48340)---node(48355)
|-zsh(48380)---tail(48395)
`-zsh(48385)---htop(48400)
# Your SSH session tree is completely separate:
# sshd---bash---tmux(client) <-- only THIS dies on disconnect
# Reconnect from anywhere:
$ ssh myserver
$ tmux attach -t dev
# Everything is exactly where you left it
Detach, Attach, and Multi-Client
Detaching (Ctrl-b d) disconnects your client from the server. The session continues to exist. Any running commands keep running. You can attach again from the same terminal, a different terminal, or even a different machine via SSH.
# Detach from current session
$ tmux detach # or press: Ctrl-b d
# List what is running without attaching
$ tmux ls
dev: 2 windows (created Tue Jun 10 09:14:22 2025)
ops: 1 windows (created Tue Jun 10 09:30:05 2025)
# Attach to a named session
$ tmux attach -t dev
# Attach from a SECOND terminal to the same session
# Terminal 2:
$ tmux attach -t dev
# Both clients now mirror the same session in real-time.
# Typing in one terminal shows up in the other.
# Great for pair programming or multi-monitor setups.
When multiple clients are attached to the same session, the window size is constrained to the smallest client. If one terminal is 200x50 and another is 80x24, everything renders at 80x24. Use aggressive-resize or grouped sessions (below) to work around this.
Named Sessions vs. Grouped Sessions
This is a commonly confused distinction. tmux new -s name creates an independent session. tmux new -t existing creates a grouped session — a new session that shares the same window set with an existing session, but maintains its own independent “current window” pointer.
# Independent session — completely separate window set
$ tmux new -s dev
$ tmux new -s ops # ops has its own windows, no connection to dev
# Grouped session — shares windows with "dev" but independent view
$ tmux new -s dev-monitor -t dev
# Now "dev" and "dev-monitor" share the SAME windows.
# But each can view a different window independently:
# dev -> viewing window 0 (editor)
# dev-monitor -> viewing window 1 (server)
#
# This solves the multi-monitor resize problem:
# each grouped session sizes to its own client.
| Behavior | tmux new -s name |
tmux new -t existing |
|---|---|---|
| Window set | New, independent | Shared with target session |
| Current window pointer | Own pointer | Own pointer (can view different windows) |
| Window sizing | Constrained to smallest attached client | Sized to own client independently |
| Destroying behavior | Removes session and all its windows | Removes group link; windows survive in original |
| Use case | Separate projects / workspaces | Multi-monitor, pair programming |
Current, Last, and Focus Tracking
Tmux tracks which window and pane are “active” at every level. This matters for scripting — many commands default to the current target when you omit -t.
- Current window — the one you are looking at, marked with
*in the status bar - Last window — the previously active window, marked with
-. Toggle with Ctrl-b l - Current pane — the one receiving keystrokes, highlighted with a green border by default
- Last pane — toggle back with Ctrl-b ;
# Query the current and last window/pane in scripts
$ tmux display-message -p \
'window: #{window_index}(#{window_name}) last: #{session_last_window}'
window: 0(editor) last: 1
$ tmux display-message -p 'pane: #{pane_index} last-pane: #{window_last_pane}'
pane: 0 last-pane: 1
# Use special tokens in targets:
$ tmux select-window -t ! # jump to last window (same as prefix l)
$ tmux last-pane # jump to last pane (same as prefix ;)
# Send a command to a specific pane without switching focus
$ tmux send-keys -t dev:1.0 'npm test' Enter
The socket directory at /tmp/tmux-UID/ is owner-only (drwx------). Another user cannot attach to your sessions even if they know the path. If you need shared sessions across users, use tmux -S /tmp/shared-socket new and set permissions with chmod 770. Both users must be in the same group.
Using a Custom Socket
You can run multiple independent tmux servers on the same machine by specifying different sockets with -L (named) or -S (path). Each socket is a completely separate server with its own sessions.
# Named socket — creates /tmp/tmux-501/work
$ tmux -L work new -s project-a
$ tmux -L work attach -t project-a
# Full path socket — useful for shared pair sessions
$ tmux -S /tmp/pair-session new -s collab
$ chmod 770 /tmp/pair-session
# Colleague attaches with the same socket:
$ tmux -S /tmp/pair-session attach -t collab
# List sessions on a specific socket
$ tmux -L work list-sessions
Run tmux info | head -5 to see the server PID, socket path, and protocol version. Run tmux list-clients to see every terminal attached to the current server. These two commands answer “what server am I talking to?” and “who else is connected?” instantly.
Essential Commands and Navigation Cheatsheet
Every tmux command has two forms: a prefix-key shortcut you press inside tmux, and a CLI command you run from any shell. Knowing both lets you work interactively and script your workflows.
The default prefix key is Ctrl+b, written as C-b in tmux notation. You press the prefix, release it, then press the action key. All CLI commands follow the pattern tmux <command> [flags] — you can run them from inside or outside a tmux session.
Session Commands
Sessions are the top-level container. Each session has its own set of windows and persists until you explicitly destroy it or the server shuts down.
| Action | Prefix Key | CLI Command |
|---|---|---|
| New session | — | tmux new-session -s work |
| New session (detached) | — | tmux new-session -ds background |
| Detach from session | C-b d | tmux detach-client |
| List sessions | C-b s | tmux list-sessions |
| Attach to session | — | tmux attach-session -t work |
| Switch to another session | C-b s (then select) | tmux switch-client -t other |
| Rename current session | C-b $ | tmux rename-session -t old new |
| End a session | — | tmux kill-session -t work |
| End all other sessions | — | tmux kill-session -a |
| Move to previous session | C-b ( | tmux switch-client -p |
| Move to next session | C-b ) | tmux switch-client -n |
| Choose tree (visual picker) | C-b s | tmux choose-tree -s |
# Create a named session and immediately split the layout
tmux new-session -s project -n editor -d
tmux send-keys -t project:editor 'vim .' Enter
# Attach to the most recently used session (or create one)
tmux attach-session || tmux new-session -s default
# List sessions with their dimensions and creation time
tmux list-sessions -F '#{session_name}: #{session_windows} windows (#{session_width}x#{session_height})'
Window Commands
Windows live inside sessions. Think of them as tabs — only one is visible per client at a time, but you can switch between them instantly.
| Action | Prefix Key | CLI Command |
|---|---|---|
| New window | C-b c | tmux new-window -n logs |
| Next window | C-b n | tmux select-window -t :+ |
| Previous window | C-b p | tmux select-window -t :- |
| Select window by number | C-b 0–9 | tmux select-window -t :3 |
| Last active window | C-b l | tmux last-window |
| Rename window | C-b , | tmux rename-window -t :1 api |
| Find window (by name) | C-b f | tmux find-window -N pattern |
| Close window | C-b & | tmux kill-window -t :2 |
| Move window to index 5 | — | tmux move-window -t :5 |
| Swap window 1 and 3 | — | tmux swap-window -s :1 -t :3 |
| Visual window picker | C-b w | tmux choose-tree -w |
| Rotate layout | C-b Space | tmux next-layout |
# Renumber windows so there are no gaps (1, 2, 5 → 1, 2, 3)
tmux move-window -r
# Create a window that runs a specific command
tmux new-window -n logs 'tail -f /var/log/app.log'
# Swap current window with the next one (move window right)
tmux swap-window -t +1
Pane Commands
Panes divide a single window into multiple terminal regions. This is where you’ll spend most of your navigation time — splitting, resizing, and jumping between panes.
| Action | Prefix Key | CLI Command |
|---|---|---|
| Split horizontally (top/bottom) | C-b " | tmux split-window -v |
| Split vertically (left/right) | C-b % | tmux split-window -h |
| Navigate to pane (direction) | C-b ↑↓←→ | tmux select-pane -U/-D/-L/-R |
| Cycle to next pane | C-b o | tmux select-pane -t :.+ |
| Toggle last active pane | C-b ; | tmux last-pane |
| Zoom pane (fullscreen toggle) | C-b z | tmux resize-pane -Z |
| Resize pane down 5 rows | C-b C-↓ (repeat) | tmux resize-pane -D 5 |
| Resize pane right 10 cols | C-b C-→ (repeat) | tmux resize-pane -R 10 |
| Display pane numbers | C-b q | tmux display-panes |
| Swap pane with next | C-b } | tmux swap-pane -D |
| Swap pane with previous | C-b { | tmux swap-pane -U |
| Break pane to own window | C-b ! | tmux break-pane |
| Join pane from another window | — | tmux join-pane -s :2 -t :1 |
| Close current pane | C-b x | tmux kill-pane |
| Rotate panes in window | C-b C-o | tmux rotate-window |
# Split and run a command in the new pane
tmux split-window -h 'htop'
# Join pane 1 from window 3 into current window as a horizontal split
tmux join-pane -h -s :3.1
# Resize: make the current pane 80 columns wide
tmux resize-pane -x 80
# Resize: make the current pane 24 rows tall
tmux resize-pane -y 24
# Show pane numbers — press the number to jump to that pane
# (increase display time in config: set -g display-panes-time 3000)
tmux display-panes
Built-in Layouts with select-layout
Tmux ships five layout presets you can apply to any window. Cycle through them with C-b Space, or jump to a specific one with the CLI.
| Layout | CLI Command | Description |
|---|---|---|
| even-horizontal | tmux select-layout even-horizontal | All panes side by side, equal widths |
| even-vertical | tmux select-layout even-vertical | All panes stacked, equal heights |
| main-horizontal | tmux select-layout main-horizontal | One large pane on top, rest below |
| main-vertical | tmux select-layout main-vertical | One large pane on left, rest stacked right |
| tiled | tmux select-layout tiled | All panes arranged in a grid |
# Apply main-vertical: big editor pane on left, small terminals on right
tmux select-layout main-vertical
# Set the main pane size for main-* layouts (percentage of window)
tmux set-window-option main-pane-width 65%
# Save the current layout string (for scripting / restoring later)
tmux display-message -p '#{window_layout}'
# Output example: "a]b0,208x54,0,0{148x54,0,0,0,59x54,149,0[59x27,...]}"
Client Commands
Client commands control how your terminal client connects to the tmux server. Useful when multiple people or terminal windows share the same session.
| Action | CLI Command |
|---|---|
| List all clients | tmux list-clients |
| Detach a specific client | tmux detach-client -t /dev/ttys003 |
| Detach all other clients | tmux detach-client -a |
| Switch this client to session | tmux switch-client -t other-session |
| Lock client | tmux lock-client |
| Refresh client display | tmux refresh-client |
| Suspend client (like C-z) | tmux suspend-client |
Commands I Actually Use Daily
Out of 100+ tmux commands, these are the ones my muscle memory fires without thinking:
C-b c— new windowC-b ,— rename window (do this immediately)C-b 1–9— jump to window by numberC-b %— vertical splitC-b "— horizontal splitC-b ↑↓←→— move between panesC-b z— zoom pane (absolute lifesaver for reading logs)C-b d— detach (go home, come back, reattach)C-b s— visual session/window tree pickerC-b w— visual window picker (with preview!)C-b x— close paneC-b !— break pane to its own windowC-b [— enter copy mode (scroll up)C-b l— toggle to last windowC-b ;— toggle to last pane
If you’re new to tmux, learn these first. Ignore everything else until these are second nature.
Key Tables: How Tmux Organizes Bindings
Tmux doesn’t have one flat list of keybindings. It organizes them into key tables — separate namespaces that activate in different contexts. Understanding this makes custom bindings much less confusing.
| Key Table | When Active | Example Keys |
|---|---|---|
prefix | After pressing C-b | c, n, %, ", z |
root | Always (no prefix needed) | MouseDown1Pane, custom binds with -n |
copy-mode | In emacs-style copy mode | C-Space, M-w |
copy-mode-vi | In vi-style copy mode | v, y, /, n |
The prefix table holds the bindings you use after pressing C-b. The root table handles keys that work without any prefix — mouse events live here, and you can add your own (like M-h/M-l for window switching). The copy-mode-vi table contains bindings active only in scroll/copy mode.
Inspecting Key Tables with list-keys
# List ALL keybindings across all tables
tmux list-keys
# List only prefix table bindings (the ones after C-b)
tmux list-keys -T prefix
# List only root table bindings (no prefix required)
tmux list-keys -T root
# List copy-mode-vi bindings (active in scroll/copy mode)
tmux list-keys -T copy-mode-vi
# Search for a specific binding — what does C-b s do?
tmux list-keys -T prefix | grep ' s '
# Show all bindings interactively inside tmux
# Press C-b ? to open the binding list
$ tmux list-keys -T prefix | head -15
bind-key -T prefix C-b send-prefix
bind-key -T prefix C-o rotate-window
bind-key -T prefix C-z suspend-client
bind-key -T prefix Space next-layout
bind-key -T prefix ! break-pane
bind-key -T prefix " split-window
bind-key -T prefix # list-buffers
bind-key -T prefix $ command-prompt -I "#S" { rename-session -- "%%" }
bind-key -T prefix % split-window -h
bind-key -T prefix & confirm-before -p "kill-window #W? (y/n)" kill-window
bind-key -T prefix ' command-prompt -T window-target { select-window -t "%%" }
bind-key -T prefix ( switch-client -p
bind-key -T prefix ) switch-client -n
bind-key -T prefix , command-prompt -I "#W" { rename-window -- "%%" }
bind-key -T prefix - delete-buffer
Running tmux list-keys without -T dumps every binding across all tables — often 150+ lines. Always filter with -T prefix, -T root, or -T copy-mode-vi to see only what you need. Pipe to grep to find specific bindings fast.
The Command Prompt
Pressing C-b : opens tmux’s built-in command prompt at the bottom of the screen. You can type any tmux command here without the tmux prefix — just type split-window -h directly. This is invaluable for running one-off commands or testing configurations before committing them to your config file.
# Inside tmux command prompt (C-b :), drop the "tmux" prefix:
# Instead of: tmux split-window -h
# You type: split-window -h
# Useful commands to try from the prompt:
# display-message "#{pane_current_path}"
# show-options -g
# source-file ~/.tmux.conf
# list-commands
Quick Reference: Target Syntax
Many commands accept a -t (target) flag. Understanding the target syntax is key to scripting tmux effectively.
# Target format: session:window.pane
tmux send-keys -t work:1.0 'echo hello' Enter # session "work", window 1, pane 0
tmux select-window -t :2 # window 2 in current session
tmux select-pane -t :.1 # pane 1 in current window
tmux kill-session -t myproject # session named "myproject"
# Relative targets
tmux select-window -t :+1 # next window
tmux select-window -t :-1 # previous window
tmux select-pane -t :.+ # next pane
# Special tokens
tmux select-window -t :! # last (most recently active) window
tmux select-pane -t :.! # last active pane
Run tmux list-commands to see every command tmux supports with full usage signatures. There are over 100 of them. Pipe through grep pane or grep window to discover commands you didn’t know existed.
Copy Mode, Scrollback Buffer, and System Clipboard
Copy mode is how you scroll, search, select, and copy text inside tmux. By default, tmux keeps a scrollback buffer per pane, and copy mode gives you full keyboard-driven access to it. Once you master it, you'll never reach for the mouse to select terminal output again.
Entering and Exiting Copy Mode
Press prefix + [ to enter copy mode. You'll see a yellow position indicator in the top-right corner. Press q or Escape to exit. While in copy mode, your pane is frozen — new output accumulates but doesn't scroll the view.
# Enter copy mode
# prefix + [
# Exit copy mode
# q or Escape
# Scroll up/down (emacs-style defaults)
# C-Up / C-Down or PgUp / PgDown
# You can also enter copy mode by scrolling with the mouse (if mouse is enabled)
set -g mouse on
Vi vs Emacs Key Bindings
Tmux supports two key binding modes for copy mode: emacs (default) and vi. If you use Vim, switch to vi mode immediately — the navigation maps directly to what your fingers already know.
# Switch copy mode to vi key bindings
set-window-option -g mode-keys vi
Here's a quick reference for navigation in vi copy mode:
| Key | Action |
|---|---|
h / j / k / l | Move left / down / up / right |
w / b | Next word / previous word |
0 / $ | Start / end of line |
g / G | Top / bottom of scrollback |
C-u / C-d | Half-page up / half-page down |
C-b / C-f | Full page up / full page down |
/ | Search forward |
? | Search backward |
n / N | Next / previous search match |
Selecting and Yanking Text (Vi-Style)
The default vi copy-mode bindings use Space to begin selection and Enter to copy. That works, but binding v and y makes it feel exactly like Vim visual mode.
# Vi-style selection and yank
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
# Paste with prefix + P (uppercase to avoid conflict with prev-window)
bind P paste-buffer
The workflow is: prefix + [ → navigate to start → v → move to end → y. The yanked text lands in tmux's internal paste buffer. Press prefix + P to paste it.
Incremental Search and Regex Search
Tmux supports both incremental and regex search within copy mode. Incremental search highlights matches as you type, just like Vim's incsearch.
# Bind / and ? to incremental search (already default in vi mode)
bind-key -T copy-mode-vi / command-prompt -i -p "(search down)" "send -X search-forward-incremental \"%%%\""
bind-key -T copy-mode-vi ? command-prompt -i -p "(search up)" "send -X search-backward-incremental \"%%%\""
For regex search, tmux uses POSIX extended regular expressions by default when you press / in copy mode. You can match patterns like ERROR|WARN or [0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3} to find IP addresses in your scrollback.
# In copy mode, press / then type a regex pattern:
# /ERROR|FATAL — find error lines
# /[0-9]+\.[0-9]+s — find timing values like "3.42s"
# ?def [a-z_]+\( — search backward for Python function defs
# Press n to jump to next match, N for previous
The Paste Buffer System
Every time you yank text, tmux pushes it onto a stack of paste buffers. Tmux maintains multiple buffers — think of it as a clipboard history that persists across sessions.
# List all paste buffers (most recent first)
tmux list-buffers
# Show contents of the most recent buffer
tmux show-buffer
# Show a specific buffer by name
tmux show-buffer -b buffer0001
# Save the most recent buffer to a file
tmux save-buffer ~/output.txt
# Save a specific buffer to a file
tmux save-buffer -b buffer0003 ~/snippet.txt
# Load a file into a new paste buffer
tmux load-buffer ~/input.txt
# Delete a specific buffer
tmux delete-buffer -b buffer0001
# Choose interactively from buffers (shows a selection UI)
# prefix + =
Press prefix + = to open an interactive buffer picker. You can scroll through your clipboard history and press Enter to paste any entry. This is one of the most underused tmux features.
Scrollback Buffer Size
By default, tmux keeps 2000 lines of scrollback per pane. For log-heavy workflows, bump this up. Be aware that large scrollback buffers consume memory — each pane maintains its own buffer.
# Increase scrollback to 50,000 lines (default is 2000)
set -g history-limit 50000
# To clear a pane's scrollback history:
# prefix + : then type clear-history
# Or bind it:
bind C-l send-keys C-l \; clear-history
System Clipboard Integration
By default, yanked text lives only in tmux's internal buffers — it doesn't reach your system clipboard. Fixing this is the single most important copy-mode configuration you'll do. The approach differs by platform.
macOS
Modern tmux (3.2+) has built-in clipboard support via the copy-command option. If you're on an older version, you'll need reattach-to-user-namespace.
# Modern macOS (tmux 3.2+) — use copy-command
set -s copy-command 'pbcopy'
# 'y' in copy mode will now yank to both tmux buffer AND system clipboard
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel
The copy-command option pipes all copy operations to the specified command automatically. No need to specify pbcopy in every binding.
# Legacy macOS — install reattach-to-user-namespace
# brew install reattach-to-user-namespace
set -g default-command "reattach-to-user-namespace -l ${SHELL}"
# Pipe yanked text to pbcopy explicitly
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
# Also copy on mouse drag release
bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel "pbcopy"
The reattach-to-user-namespace wrapper fixes the macOS limitation where background processes (like tmux) can't access the per-user pasteboard namespace. This is unnecessary on tmux 3.2+ which handles it natively.
Linux — X11 and Wayland
# Install: sudo apt install xclip
set -s copy-command 'xclip -selection clipboard'
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel
# Install: sudo apt install xsel
set -s copy-command 'xsel --clipboard --input'
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel
# Install: sudo apt install wl-clipboard
set -s copy-command 'wl-copy'
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel
WSL (Windows Subsystem for Linux)
# WSL — clip.exe is available by default
set -s copy-command 'clip.exe'
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel
# For pasting FROM Windows clipboard into tmux:
bind C-v run "powershell.exe Get-Clipboard | tmux load-buffer - && tmux paste-buffer"
Cross-Platform Auto-Detection
If you use the same tmux.conf on multiple machines, detect the platform and set the clipboard command conditionally.
# Auto-detect platform and set copy command
if-shell 'command -v pbcopy' { set -s copy-command 'pbcopy' }
if-shell 'command -v wl-copy' { set -s copy-command 'wl-copy' }
if-shell 'command -v xclip' { set -s copy-command 'xclip -selection clipboard' }
if-shell 'command -v xsel' { set -s copy-command 'xsel --clipboard --input' }
if-shell '[ -n "$WSL_DISTRO_NAME" ]' { set -s copy-command 'clip.exe' }
Tmux evaluates if-shell blocks in order and all matching conditions execute. The last match wins for set commands. Place your preferred fallback last. For WSL, the check uses the $WSL_DISTRO_NAME environment variable which is always set inside WSL.
Complete Copy Mode Configuration
Here's a production-ready copy mode config that combines everything above. Drop this into your ~/.tmux.conf and reload with prefix + r (if you have reload bound) or tmux source ~/.tmux.conf.
# ── Copy Mode Configuration ──────────────────────────────────────
# Vi mode for copy-mode key bindings
set-window-option -g mode-keys vi
# Generous scrollback
set -g history-limit 50000
# ── Selection and Yank ───────────────────────────────────────────
# Enter copy mode
bind-key -T prefix [ copy-mode
# v = start selection, C-v = toggle block selection, y = yank
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel
# Copy on mouse drag release
bind-key -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe-and-cancel
# Double-click selects word, triple-click selects line
bind-key -T copy-mode-vi DoubleClick1Pane select-pane \; send-keys -X select-word
bind-key -T copy-mode-vi TripleClick1Pane select-pane \; send-keys -X select-line
# ── Paste ────────────────────────────────────────────────────────
# prefix + P to paste (uppercase P avoids conflict with previous-window)
bind P paste-buffer
# prefix + = to choose from buffer list
bind = choose-buffer
# ── Incremental Search ───────────────────────────────────────────
bind-key -T copy-mode-vi / command-prompt -i -p "(search down)" "send -X search-forward-incremental \"%%%\""
bind-key -T copy-mode-vi ? command-prompt -i -p "(search up)" "send -X search-backward-incremental \"%%%\""
# ── System Clipboard (auto-detect platform) ──────────────────────
# The last matching if-shell wins
if-shell 'command -v xclip' { set -s copy-command 'xclip -selection clipboard' }
if-shell 'command -v xsel' { set -s copy-command 'xsel --clipboard --input' }
if-shell 'command -v wl-copy' { set -s copy-command 'wl-copy' }
if-shell 'command -v pbcopy' { set -s copy-command 'pbcopy' }
if-shell '[ -n "$WSL_DISTRO_NAME" ]' { set -s copy-command 'clip.exe' }
# ── Convenience ──────────────────────────────────────────────────
# Clear scrollback buffer with prefix + C-l
bind C-l send-keys C-l \; clear-history
If you prefer not to configure clipboard integration manually, the tmux-yank plugin handles all of this automatically. Install it via TPM with set -g @plugin 'tmux-plugins/tmux-yank'. It auto-detects your OS and clipboard tool. The manual approach above gives you full control and zero dependencies.
Quick Workflow Cheat Sheet
| Step | Keys | What Happens |
|---|---|---|
| 1. Enter copy mode | prefix + [ | Pane freezes, cursor appears |
| 2. Navigate | h/j/k/l, /pattern | Move to target text or search |
| 3. Start selection | v | Visual highlight begins |
| 4. Adjust selection | h/j/k/l, e, $ | Extend highlight to desired end |
| 5. Yank | y | Copied to tmux buffer + system clipboard |
| 6. Paste (tmux) | prefix + P | Inserts text into active pane |
| 6. Paste (system) | Cmd+V / Ctrl+Shift+V | Paste anywhere outside tmux too |
Building Your .tmux.conf from Scratch
Tmux ships with sane defaults, but virtually every experienced user overwrites them. The configuration file at ~/.tmux.conf is read once when the tmux server first starts. Every directive in it is just a tmux command — the same commands you can run from the tmux command prompt with prefix + :.
We'll build the file piece by piece, explaining every line. By the end you'll have a complete starter config you can copy directly into your home directory.
Changing the Prefix Key
The default prefix is Ctrl-b. It works, but it's awkward — your left hand has to stretch across the keyboard. Most veteran users rebind to Ctrl-a (borrowed from GNU Screen) or Ctrl-Space (easy to hit on any layout). You need two lines: one to set the new prefix, one to unbind the old one.
# Option A: Ctrl-a (Screen-style, most popular)
set -g prefix C-a
unbind C-b
bind C-a send-prefix
# Option B: Ctrl-Space (ergonomic, no conflict with readline)
# set -g prefix C-Space
# unbind C-b
# bind C-Space send-prefix
The bind C-a send-prefix line is easy to forget. It ensures that pressing Ctrl-a twice sends a literal Ctrl-a to the program running inside the pane — important if you use readline or Emacs-style shortcuts where Ctrl-a jumps to the beginning of a line.
Understanding Option Scopes
Tmux has three distinct option scopes. Mixing them up is one of the most common sources of "why doesn't my config work?" confusion. Here's the breakdown:
| Command | Scope | What It Controls | Example |
|---|---|---|---|
set -s / set -sg |
Server | Options for the tmux server process itself | escape-time, default-terminal |
set / set -g |
Session | Options that apply per-session (or globally with -g) |
prefix, base-index, mouse |
setw / setw -g |
Window | Options that apply per-window (or globally with -g) |
pane-base-index, mode-keys |
The -g flag means "global default." Without it, the option only applies to the current session or window. In your config file, you almost always want -g so the setting applies everywhere.
Since tmux 3.0, set -g can set window options too — tmux will figure out the correct scope. So set -g pane-base-index 1 works the same as setw -g pane-base-index 1. Many modern configs use set -g for everything. We use the explicit setw form here for clarity about which scope each option actually belongs to.
Essential Server Options
Server options affect the tmux server process. These are the two you should always set:
# Tell tmux the terminal supports 256 colors (or truecolor — see your terminal docs)
set -s default-terminal "tmux-256color"
# Remove the delay after pressing Escape — critical for Vim/Neovim users.
# Default is 500ms. At 0, Escape is instant.
set -sg escape-time 0
The escape-time setting deserves special attention. Tmux waits after receiving an Escape byte to see if it's the start of a longer escape sequence (like an arrow key). The default 500ms delay makes Vim's mode switching feel sluggish. Setting it to 0 makes Escape immediate. If you experience issues with function keys over slow SSH connections, try 10 instead.
Essential Session Options
Session options control behavior you interact with every day — indexing, scrollback, and input.
# Start window numbering at 1 instead of 0 (matches keyboard layout)
set -g base-index 1
# Automatically renumber windows when one is closed
# Without this, closing window 2 leaves a gap: 1, 3, 4
set -g renumber-windows on
# Increase scrollback buffer from the default 2000 lines
set -g history-limit 50000
# Enable mouse support — scroll, click to select panes, resize by dragging
set -g mouse on
Setting base-index to 1 is almost universal in custom configs. Your keyboard's number row starts with 1 on the left, so prefix + 1 should go to the first window — not the second. Without renumber-windows on, closing the middle window in a set of three gives you windows 1 and 3 with no 2, which breaks the muscle memory.
Essential Window Options
# Start pane numbering at 1 to match window numbering
setw -g pane-base-index 1
# Use vi-style keys in copy mode (prefix + [)
setw -g mode-keys vi
Reloading Your Config
After editing ~/.tmux.conf, you need to tell the running tmux server to re-read it. You can do this from a shell:
tmux source-file ~/.tmux.conf
But you'll be doing this constantly while iterating on your config, so bind it to a key. The display-message at the end gives you visual confirmation:
# Reload config with prefix + r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!"
The \; syntax chains multiple tmux commands in a single binding. The backslash escapes the semicolon so the shell doesn't interpret it. You can chain as many commands as you want: bind x command1 \; command2 \; command3.
Smarter Pane Splitting
The default split bindings (prefix + % for vertical, prefix + " for horizontal) are hard to remember. Worse, new panes open in the directory where the tmux server was started, not the directory your current pane is in. Fix both problems at once:
# Split panes using | and - (intuitive: | is vertical line, - is horizontal)
# -c "#{pane_current_path}" opens the new pane in the same directory
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
# Also fix the default new-window binding to keep the current path
bind c new-window -c "#{pane_current_path}"
# Unbind the old defaults (optional, keeps things clean)
unbind '"'
unbind %
The -h flag means "horizontal split" — which, confusingly, creates a vertical divider line. Think of -h as "the new pane goes to the side" and -v as "the new pane goes below." The format string #{pane_current_path} is a tmux variable that resolves to the working directory of the active pane at the moment you press the key.
The Complete Starter Config
Here's everything assembled into one file. Copy this to ~/.tmux.conf and reload to apply.
# ~/.tmux.conf — Starter config
# ─────────────────────────────────────────────
# ── Prefix ──
set -g prefix C-a
unbind C-b
bind C-a send-prefix
# ── Server Options ──
set -s default-terminal "tmux-256color"
set -sg escape-time 0
# ── Session Options ──
set -g base-index 1
set -g renumber-windows on
set -g history-limit 50000
set -g mouse on
set -g display-time 3000
# ── Window Options ──
setw -g pane-base-index 1
setw -g mode-keys vi
# ── Config Reload ──
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!"
# ── Pane Splitting ──
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
unbind '"'
unbind %
# ── Pane Navigation (Vim-style) ──
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# ── Pane Resizing ──
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
Tmux reads ~/.tmux.conf only when the server starts — not when you open a new session. If tmux is already running, you must reload with tmux source-file ~/.tmux.conf from a shell, or press prefix + r if you already have the reload binding active. For a completely fresh start, stop all tmux processes and launch again.
The resize bindings use bind -r, which makes the key repeatable. After pressing prefix + H once, you can keep pressing H to continue resizing without hitting the prefix again — as long as you press within the repeat-time window (default 500ms). This makes incremental resizing fast and fluid.
Start with this config as-is. Once you're comfortable, build on top of it — add status bar theming, copy-mode refinements, and plugin manager integration in the sections that follow.
Custom Key Bindings That Actually Stick
The default tmux bindings are functional but awkward. Reaching for Prefix + % to split a pane or Prefix + arrow to navigate gets old fast. This section builds a set of bindings you'll actually want to keep — prefix-free navigation, repeatable resizing, pane zoom toggles, and a session switcher — all backed by an understanding of how bind-key actually works.
The bind-key Syntax
Every custom binding uses bind-key (aliased to bind). The flags you pass fundamentally change how the binding behaves:
# Standard binding — requires prefix first
bind key command
# -n : No prefix required — key alone triggers it
bind -n key command
# -r : Repeatable — after prefix, press key multiple times
# within repeat-time (default 500ms) without re-pressing prefix
bind -r key command
# -T table : Bind key in a specific key table
bind -T copy-mode-vi key command
The -n flag is shorthand for -T root — it puts the binding in the root key table, which is always active. Standard bindings (without -n) go into the prefix table, which only activates after you press the prefix key.
Prefix-Free Pane Navigation with Alt+hjkl
This is the single most impactful set of bindings you can add. Navigate between panes instantly with Alt+h/j/k/l — no prefix key, no delay:
# Pane navigation — Alt+hjkl, no prefix needed
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
M- stands for Meta (Alt on most keyboards). These four lines let you fly between panes as naturally as moving your cursor in Vim. You press Alt+h — you're in the left pane. Done. No prefix, no waiting.
M-l conflicts with the Bash built-in clear-screen (equivalent to running clear). If this bothers you, remap Bash's clear to something else in ~/.inputrc, or just use Ctrl+l in shells that support it. The trade-off is worth it — you'll navigate panes hundreds of times a day.
Repeatable Resize Bindings
The -r flag is tailor-made for resizing. Press prefix once, then tap the resize key repeatedly to fine-tune pane dimensions without re-pressing the prefix each time:
# Pane resizing — prefix + H/J/K/L (uppercase), repeatable
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
# Increase repeat-time if 500ms feels too short
set -g repeat-time 1000
With these bindings, press Prefix, then tap H H H to shrink the pane left by 15 cells. The repeat-time setting controls how long the window stays open for repeat presses — bump it to 1000ms if you like a more relaxed pace.
Maximize Pane Toggle
Tmux has a built-in zoom feature (resize-pane -Z) that expands the current pane to fill the entire window. The default binding is Prefix + z, but you can remap it or add a more convenient shortcut:
# Toggle zoom on current pane with prefix + m
bind m resize-pane -Z
# Or go prefix-free with Alt+z
bind -n M-z resize-pane -Z
This is a toggle — press it once to zoom in, press it again to restore the original layout. The zoomed state is visible in the status bar as a Z flag on the window name. Extremely useful when you need to temporarily focus on a single pane's output.
Quick Split and Run a Command
One of the most useful binding patterns: split a pane and immediately run a program in it. Perfect for tools you open frequently like lazygit, htop, or a project-specific REPL:
# Prefix + g — open lazygit in a new pane (closes pane on exit)
bind g split-window -h -c "#{pane_current_path}" lazygit
# Prefix + T — open htop in a full-width bottom pane
bind T split-window -v -l 15 htop
# Prefix + P — open a Python REPL in the current directory
bind P split-window -h -c "#{pane_current_path}" python3
When the command exits, the pane automatically closes. The -c "#{pane_current_path}" ensures the new pane starts in the same directory as your current one.
Popup Floating Windows (tmux 3.2+)
If you're on tmux 3.2 or later, display-popup gives you floating terminal windows that overlay your panes — no layout disruption:
# Prefix + G — lazygit in a floating popup (80% width, 80% height)
bind G display-popup -w 80% -h 80% -d "#{pane_current_path}" -E lazygit
# Prefix + F — fzf file finder in a floating popup
bind F display-popup -w 60% -h 60% -d "#{pane_current_path}" -E \
"fzf --preview 'cat {}'"
# Alt+t — quick floating terminal (prefix-free)
bind -n M-t display-popup -w 70% -h 70% -d "#{pane_current_path}" -E
The -E flag closes the popup automatically when the command finishes. Without -E, the popup stays open even after the command exits. Popups are ideal for quick lookups and throwaway terminals that shouldn't disturb your layout.
Session Switcher Binding
Quickly jumping between sessions is critical once you run multiple projects. The built-in choose-tree is solid, but combining it with fzf is faster:
# Built-in: prefix + s — visual tree picker
bind s choose-tree -sZ
# Power move: prefix + S — fzf-powered session switcher
bind S display-popup -E "tmux list-sessions -F '#{session_name}' \
| fzf --reverse --header='Switch Session' \
| xargs tmux switch-client -t"
# Prefix + ( and ) — cycle through sessions (already default, but good to know)
# Previous session
bind -r ( switch-client -p
# Next session
bind -r ) switch-client -n
Key Tables and Custom Modes
Tmux organizes all bindings into key tables. There are three built-in tables:
prefix— active after you press the prefix key. This is where most default bindings live.root— always active. Bindings here fire without any prefix (bind -nis shorthand forbind -T root).copy-mode-vi(orcopy-mode) — active when you're in copy mode.
You can also create your own key tables for modal-style workflows. A "resize mode" is the classic example — enter the mode once, then use plain h/j/k/l to resize without holding prefix:
# Enter resize mode with prefix + R
bind R switch-client -T resize-mode
# Inside resize-mode: h/j/k/l resize, Escape exits
bind -T resize-mode h resize-pane -L 5 \; switch-client -T resize-mode
bind -T resize-mode j resize-pane -D 5 \; switch-client -T resize-mode
bind -T resize-mode k resize-pane -U 5 \; switch-client -T resize-mode
bind -T resize-mode l resize-pane -R 5 \; switch-client -T resize-mode
# Fine-grained: Shift+h/j/k/l for 1-cell adjustments
bind -T resize-mode H resize-pane -L 1 \; switch-client -T resize-mode
bind -T resize-mode J resize-pane -D 1 \; switch-client -T resize-mode
bind -T resize-mode K resize-pane -U 1 \; switch-client -T resize-mode
bind -T resize-mode L resize-pane -R 1 \; switch-client -T resize-mode
# Press Escape or Enter to leave resize mode
bind -T resize-mode Escape switch-client -Tprefix
bind -T resize-mode Enter switch-client -Tprefix
The trick is the \; switch-client -T resize-mode at the end of each binding — it re-enters resize mode after each keypress so you stay in the mode until you explicitly exit. Without this, tmux would drop back to the root table after a single keypress.
Copy-Mode Specific Bindings
You can bind keys that only activate inside copy mode. This is how you set up Vim-style visual selection and yanking:
# First, ensure vi mode is enabled
set-window-option -g mode-keys vi
# v starts selection (like Vim visual mode)
bind -T copy-mode-vi v send-keys -X begin-selection
# y yanks selection to tmux buffer AND system clipboard (macOS)
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
# For Linux (X11), replace pbcopy:
# bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "xclip -sel clipboard"
# V selects the entire line
bind -T copy-mode-vi V send-keys -X select-line
# Ctrl+v toggles rectangle (block) selection
bind -T copy-mode-vi C-v send-keys -X rectangle-toggle \; \
send-keys -X begin-selection
These bindings only fire when you're in copy mode (Prefix + [). They won't interfere with normal typing because they live in the copy-mode-vi key table, which is only active during copy mode.
Useful Pane Management Bindings
A few more bindings that earn their place in any .tmux.conf — breaking panes out to windows and pulling them back in:
# Prefix + B — break current pane out into a new window
bind B break-pane
# Prefix + j — join a pane from another window into this one
# (opens an interactive picker)
bind j choose-window "join-pane -h -s '%%'"
# Prefix + ! is the default break-pane, but this adds:
# Prefix + @ — send pane to a new window and switch to it
bind @ break-pane -d \; last-window
Binding Conflicts — Troubleshooting
When a binding doesn't work, it's almost always because something else is already bound to that key. Tmux ships with dozens of default bindings, and plugins add more. Here's how to find conflicts:
# List ALL current key bindings
tmux list-keys
# Find what's bound to a specific key (e.g., "g")
tmux list-keys | grep -i " g "
# List bindings in a specific key table
tmux list-keys -T copy-mode-vi
# Check bindings in the root table (prefix-free bindings)
tmux list-keys -T root
# See what a key is currently doing — useful for debugging
tmux list-keys | grep "M-h"
If you find a conflict, you have two options: unbind the conflicting key before rebinding it, or choose a different key:
# Unbind a key in the prefix table
unbind C-z
# Unbind a key in a specific table
unbind -T root M-l
# Unbind all default bindings (nuclear option — start fresh)
unbind -a
# Safer: unbind a specific key, then rebind it
unbind l
bind l select-pane -R
After editing ~/.tmux.conf, reload it without restarting tmux: tmux source-file ~/.tmux.conf. Then immediately run tmux list-keys | grep <your-key> to verify your binding took effect. If you see the old binding, check for a typo or a plugin overriding your config.
The Complete Binding Block
Here's every binding from this section in one copy-paste-ready block for your ~/.tmux.conf:
# ── Pane Navigation (prefix-free) ──
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
# ── Pane Resizing (repeatable) ──
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
set -g repeat-time 1000
# ── Zoom / Maximize Toggle ──
bind m resize-pane -Z
# ── Quick Tools ──
bind g split-window -h -c "#{pane_current_path}" lazygit
bind T split-window -v -l 15 htop
bind G display-popup -w 80% -h 80% -d "#{pane_current_path}" -E lazygit
bind -n M-t display-popup -w 70% -h 70% -d "#{pane_current_path}" -E
# ── Session Switching ──
bind s choose-tree -sZ
bind S display-popup -E "tmux list-sessions -F '#{session_name}' \
| fzf --reverse --header='Switch Session' \
| xargs tmux switch-client -t"
# ── Pane Management ──
bind B break-pane
bind j choose-window "join-pane -h -s '%%'"
# ── Resize Mode (custom key table) ──
bind R switch-client -T resize-mode
bind -T resize-mode h resize-pane -L 5 \; switch-client -T resize-mode
bind -T resize-mode j resize-pane -D 5 \; switch-client -T resize-mode
bind -T resize-mode k resize-pane -U 5 \; switch-client -T resize-mode
bind -T resize-mode l resize-pane -R 5 \; switch-client -T resize-mode
bind -T resize-mode Escape switch-client -Tprefix
bind -T resize-mode Enter switch-client -Tprefix
# ── Copy Mode (vi-style) ──
set-window-option -g mode-keys vi
bind -T copy-mode-vi v send-keys -X begin-selection
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
bind -T copy-mode-vi V send-keys -X select-line
bind -T copy-mode-vi C-v send-keys -X rectangle-toggle \; \
send-keys -X begin-selection
Status Bar Design and Customization
The tmux status bar is a single line at the bottom (or top) of your terminal that displays session info, window list, and anything else you want — from git branches to Kubernetes contexts. Mastering it means you always know where you are without running extra commands.
This section walks through the anatomy of the status bar, the format string language, styling options, and provides three complete configurations you can drop into your ~/.tmux.conf.
Anatomy of the Status Bar
The status bar has three zones, each controlled by separate options:
# ┌──────────────┬──────────────────────────┬──────────────┐
# │ status-left │ window-status-format(s) │ status-right │
# └──────────────┴──────────────────────────┴──────────────┘
# Left zone — session info, prefix indicator, etc.
set -g status-left "#[bold] #S "
# Center zone — individual window tabs (repeated per window)
set -g window-status-format " #I:#W "
set -g window-status-current-format " #I:#W "
# Right zone — hostname, time, custom data
set -g status-right " %H:%M #H "
- status-left — appears on the far left. Typically shows the session name.
- window-status-format — template for each inactive window tab in the center.
- window-status-current-format — template for the active window tab.
- status-right — appears on the far right. Good for clock, hostname, and dynamic data.
Format String Reference
Tmux uses special tokens in format strings that get replaced with live values. Here are the ones you’ll use most:
| Token | Expands To | Example Output |
|---|---|---|
#S | Session name | work |
#W | Window name | nvim |
#I | Window index | 0 |
#P | Pane index | 1 |
#T | Pane title | ~/projects |
#H | Hostname (short) | devbox |
#h | Hostname (FQDN) | devbox.local |
#{pane_current_path} | Full path of active pane | /home/user/app |
#{b:pane_current_path} | Basename of pane path | app |
#(command) | Shell command output | (anything) |
You can list every available format variable by checking the FORMATS section of man tmux. There are over 100 variables covering sessions, windows, panes, clients, and servers.
Colors and Styling
Style attributes are applied with #[...] blocks inside format strings. You can set foreground, background, and text decorations.
# Basic named colors: black, red, green, yellow, blue, magenta, cyan, white
#[fg=green,bg=black,bold]
# 256-color palette (colour0 through colour255)
#[fg=colour39,bg=colour234]
# Hex colors (tmux 3.2+ required)
#[fg=#e06c75,bg=#282c34]
# Available decorations: bold, dim, italics, underscore, strikethrough
#[fg=yellow,bold,italics]
# Reset all styling back to default
#[default]
There are also global style options that set the baseline for each zone:
# Base status bar style
set -g status-style "fg=#abb2bf,bg=#282c34"
# Styling for inactive window tabs
set -g window-status-style "fg=#5c6370,bg=default"
# Styling for the active window tab
set -g window-status-current-style "fg=#61afef,bg=#2c323c,bold"
# Message / command line style
set -g message-style "fg=#282c34,bg=#e5c07b"
Shell Commands with #()
The #(shell-command) syntax lets you embed any shell command’s stdout directly into your status bar. Tmux runs these commands at each refresh interval and caches the output.
# Git branch of the active pane's working directory
#(cd #{pane_current_path} && git branch --show-current 2>/dev/null || echo '-')
# Current kubectl context
#(kubectl config current-context 2>/dev/null || echo 'none')
# AWS profile
#(echo $AWS_PROFILE)
# Battery percentage (macOS)
#(pmset -g batt | grep -Eo '[0-9]+%')
# CPU load average (1-min)
#(uptime | awk -F'load averages?: ' '{print $2}' | cut -d, -f1)
Shell commands run on every status bar refresh. The default status-interval is 15 seconds. If your commands are slow (like kubectl or API calls), either increase the interval or cache results to a file with a cron job. A slow #() command will freeze your entire status bar update.
# Refresh every 5 seconds (good for dev work, git branch updates)
set -g status-interval 5
# Refresh every 30 seconds (safer when using slow commands)
set -g status-interval 30
Controlling Width and Position
A few options give you control over layout before you start building designs:
# Put the status bar on top instead of bottom
set -g status-position top
# Max character width of left/right zones (defaults: 10 and 40)
set -g status-left-length 30
set -g status-right-length 80
# Center the window list between left and right
set -g status-justify centre
Design 1 — Minimalist
Clean and distraction-free. Just the session name, a simple window list, and the time. No shell commands, no Nerd Fonts required.
# ── Minimalist Status Bar ───────────────────────────────────────
# Looks like: ❐ work │ 0:zsh 1:nvim 2:logs │ 14:32
set -g status-position bottom
set -g status-style "fg=#a0a0a0,bg=#1a1b26"
set -g status-left-length 20
set -g status-right-length 20
# Left: session name
set -g status-left "#[fg=#7aa2f7,bold] ❐ #S #[fg=#3b3f51]│"
# Window tabs
set -g window-status-format "#[fg=#565f89] #I:#W "
set -g window-status-current-format "#[fg=#c0caf5,bold] #I:#W "
set -g window-status-separator ""
# Right: just the time
set -g status-right "#[fg=#3b3f51]│#[fg=#565f89] %H:%M "
Design 2 — Developer
Optimized for coding. Shows the git branch, the current directory basename, hostname, and time. Uses Nerd Font symbols — make sure your terminal uses a patched font like JetBrainsMono Nerd Font.
# ── Developer Status Bar ──────────────────────────────────────
# Looks like: work 0:zsh 1:nvim 2:test main app devbox 14:32
set -g status-position bottom
set -g status-style "fg=#abb2bf,bg=#282c34"
set -g status-left-length 30
set -g status-right-length 80
set -g status-interval 5
# Left: session name with icon
set -g status-left "#[fg=#282c34,bg=#61afef,bold] #S #[fg=#61afef,bg=#282c34]"
# Inactive window
set -g window-status-format "#[fg=#5c6370] #I:#W "
# Active window (highlighted)
set -g window-status-current-format "#[fg=#282c34,bg=#2c323c]#[fg=#61afef,bg=#2c323c,bold] #I:#W #[fg=#2c323c,bg=#282c34]"
set -g window-status-separator ""
# Right: git branch | current dir | hostname | time
set -g status-right "#[fg=#e5c07b] #(cd #{pane_current_path} && git branch --show-current 2>/dev/null || echo '-') #[fg=#3e4452]| #[fg=#98c379] #{b:pane_current_path} #[fg=#3e4452]| #[fg=#c678dd] #H #[fg=#3e4452]| #[fg=#56b6c2] %H:%M "
# Message styling
set -g message-style "fg=#282c34,bg=#e5c07b,bold"
Design 3 — DevOps
Built for infrastructure work. Displays the Kubernetes context, AWS profile, and Docker container count alongside the standard window list. Uses a longer status-interval since these commands can be slower.
# ── DevOps Status Bar ─────────────────────────────────────────
# Looks like: ⎈ prod-us-east | staging | 🐳 3 0:zsh 1:k9s devbox 14:32
set -g status-position top
set -g status-style "fg=#c0c0c0,bg=#1e1e2e"
set -g status-left-length 60
set -g status-right-length 40
set -g status-interval 10
# Left: k8s context | AWS profile | Docker container count
set -g status-left "#[fg=#1e1e2e,bg=#89b4fa,bold] ⎈ #(kubectl config current-context 2>/dev/null || echo 'none') #[fg=#89b4fa,bg=#1e1e2e]#[fg=#45475a] | #[fg=#fab387] #(echo ${AWS_PROFILE:-default}) #[fg=#45475a] | #[fg=#94e2d5] 🐳 #(docker ps -q 2>/dev/null | wc -l | tr -d ' ') #[fg=#45475a] | "
# Inactive window
set -g window-status-format "#[fg=#6c7086] #I:#W "
# Active window
set -g window-status-current-format "#[fg=#cdd6f4,bg=#313244,bold] #I:#W #[bg=#1e1e2e]"
set -g window-status-separator ""
# Right: hostname | time
set -g status-right "#[fg=#a6adc8] #H #[fg=#45475a]| #[fg=#f5c2e7] %H:%M "
set -g message-style "fg=#1e1e2e,bg=#f38ba8,bold"
For commands like kubectl or docker ps that can take hundreds of milliseconds, write a small script that caches the result to a temp file and refreshes on a timer. Then read the file from your status bar instead:
# In crontab (every 30s):
* * * * * kubectl config current-context > /tmp/tmux-k8s-ctx 2>&1
* * * * * sleep 30 && kubectl config current-context > /tmp/tmux-k8s-ctx 2>&1
# In tmux.conf:
#(cat /tmp/tmux-k8s-ctx 2>/dev/null || echo 'none')
Powerline-Style Separators
The angled arrow look comes from special Unicode characters in Powerline/Nerd Fonts. The trick is layering foreground and background colors so the separator character “connects” two differently colored blocks.
# Powerline separator characters (require Nerd Font):
# right arrow solid (U+E0B0)
# right arrow thin (U+E0B1)
# left arrow solid (U+E0B2)
# left arrow thin (U+E0B3)
# The pattern: set fg to the PREVIOUS block's bg color,
# then set bg to the NEXT block's bg color, then print the separator.
# Example — session name with powerline arrow into status bar:
set -g status-left "\
#[fg=#282c34,bg=#98c379,bold] #S \
#[fg=#98c379,bg=#282c34]"
# ↑
# fg matches the block we're leaving (#98c379)
# bg matches the block we're entering (#282c34)
# the glyph creates the angled transition
Full Powerline Example for Window Tabs
# Active window: bright background with powerline arrows on both sides
set -g window-status-current-format "\
#[fg=#282c34,bg=#61afef]\
#[fg=#282c34,bg=#61afef,bold] #I #W \
#[fg=#61afef,bg=#282c34]"
# Inactive window: no arrows, subdued text
set -g window-status-format "#[fg=#5c6370,bg=#282c34] #I #W "
Useful Tweaks and Extras
# Show a visual prefix indicator — changes color when prefix is active
# (great for knowing if your Ctrl-b actually registered)
set -g status-left "\
#{?client_prefix,#[fg=#282c34 bg=#e06c75 bold],#[fg=#282c34 bg=#61afef bold]}\
#S \
#[bg=#282c34]\
#{?client_prefix,#[fg=#e06c75],#[fg=#61afef]}"
# Show SYNC label when panes are synchronized (sending keys to all panes)
set -ga status-right "#{?pane_synchronized,#[fg=#e06c75 bold] SYNC ,}"
# Show zoom indicator when a pane is zoomed
set -g window-status-current-format "\
#{?window_zoomed_flag,#[fg=#e5c07b] Z ,}\
#[fg=#61afef,bold] #I:#W "
# Rename windows automatically based on running command
set -g automatic-rename on
set -g automatic-rename-format "#{b:pane_current_command}"
After editing your ~/.tmux.conf, reload it live with tmux source-file ~/.tmux.conf or bind a key for it: bind r source-file ~/.tmux.conf \; display "Reloaded!". You don’t need to close your session to see changes.
Quick Reference — All Status Bar Options
# ── Every status option you might need ─────────────────────
set -g status on # on | off | 2 (for double-line)
set -g status-position bottom # top | bottom
set -g status-justify left # left | centre | right
set -g status-interval 5 # seconds between refresh
set -g status-left-length 40 # max chars for left zone
set -g status-right-length 80 # max chars for right zone
set -g status-style "fg=white,bg=black"
set -g status-left-style "bold"
set -g status-right-style "none"
set -g window-status-style "fg=grey"
set -g window-status-current-style "fg=white,bold"
set -g window-status-activity-style "fg=yellow"
set -g window-status-bell-style "fg=red,bold"
set -g window-status-separator " " # between window tabs
True Color, Undercurl, and Terminal Compatibility
Broken colors inside tmux is the single most common complaint from new users. Your terminal looks beautiful until you launch tmux and everything turns into a washed-out 256-color mess. The fix is straightforward once you understand the capability chain — and it takes exactly three lines in your tmux.conf.
The Terminal Capability Chain
Color information flows through a chain of handoffs. Every link must agree on what the terminal can do, or colors break silently:
Terminal Emulator → $TERM (outside) → tmux → $TERM (inside) → Application
(iTerm2, Kitty...) (xterm-256color) server (tmux-256color) (Neovim, bat...)
Your terminal emulator sets the outer $TERM (usually xterm-256color). Tmux reads that to know what your emulator supports, then presents its own $TERM (tmux-256color) to applications running inside it. If any link reports the wrong capabilities, colors degrade.
The Correct tmux.conf Settings
Add these lines to your ~/.tmux.conf. They cover true color (24-bit RGB), undercurl support, and the escape-time fix for Vim/Neovim responsiveness.
Tmux 3.2+ (recommended)
# Tell tmux to advertise tmux-256color to inner apps
set -g default-terminal "tmux-256color"
# Tell tmux that the OUTER terminal (xterm-256color) supports true color
set -as terminal-features ",xterm-256color:RGB"
# Undercurl support (colored wavy underlines for LSP diagnostics)
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'
# Near-zero escape delay — critical for Vim/Neovim mode switching
set -sg escape-time 0
# Enable focus events (lets Neovim detect window focus)
set -g focus-events on
Tmux 3.1 and older
Older tmux versions don't support terminal-features. Use terminal-overrides with the Tc flag instead:
set -g default-terminal "tmux-256color"
# The older Tc (True Color) flag approach
set -ga terminal-overrides ",xterm-256color:Tc"
set -sg escape-time 0
set -g focus-events on
Run tmux -V to check. If you're on 3.2 or newer, use terminal-features — it's the modern, cleaner API. The -as flag means "append to string," which avoids overwriting existing values.
Verify True Color Support
Save this script and run it both outside and inside tmux. You should see a smooth color gradient — not discrete color bands.
#!/usr/bin/env bash
# truecolor-test.sh — prints a smooth RGB gradient
# If you see banding or discrete color blocks, true color is NOT working.
echo "TERM=$TERM"
echo ""
awk 'BEGIN{
for (i = 0; i <= 76; i++) {
r = 255 - (i * 255 / 76);
g = (i * 510 / 76);
b = (i * 255 / 76);
if (g > 255) g = 510 - g;
printf "\033[48;2;%d;%d;%dm ", r, g, b;
}
printf "\033[0m\n";
}'
echo ""
echo "If the bar above is a smooth gradient: true color works."
echo "If you see distinct bands/blocks: only 256 colors are active."
Run the test inside tmux:
chmod +x truecolor-test.sh
./truecolor-test.sh
You can also do a quick one-liner check of what tmux thinks the outer terminal supports:
# Inside tmux — check the active terminal capabilities
tmux info | grep -i "rgb\|256col\|Tc"
# Check what TERM is set to inside vs outside
echo "Inside tmux: TERM=$TERM"
# Detach, then run:
echo "Outside tmux: TERM=$TERM"
Undercurl for Neovim LSP Diagnostics
Undercurl is the colored wavy underline that modern editors use to show LSP diagnostics (errors, warnings, hints). Without the right terminal settings, you get a plain underline — or nothing at all.
The two terminal-overrides lines in the config above handle the tmux side. On the Neovim side, add this to your init.lua:
-- Enable undercurl in Neovim inside tmux
vim.cmd([[
let &t_Cs = "\e[4:3m"
let &t_Ce = "\e[4:0m"
]])
-- Diagnostic underline styles (uses undercurl when supported)
vim.diagnostic.config({
underline = true,
virtual_text = { spacing = 4, prefix = "●" },
})
Test undercurl with this quick verification command inside Neovim:
" Run inside Neovim to test undercurl rendering
:hi TestUndercurl gui=undercurl guisp=Red
:match TestUndercurl /./
If your entire buffer shows red wavy underlines, undercurl is working. Type :match none to clear it.
Terminal Emulator-Specific Setup
Each terminal emulator needs its own $TERM configuration to feed the chain correctly. Here's what each one requires:
iTerm2 (macOS)
Preferences → Profiles → Terminal → Terminal Emulation
• Report Terminal Type: xterm-256color
• Enable "Use Unicode version 9+ widths" (optional, helps alignment)
iTerm2 supports true color and undercurl out of the box. No extra configuration needed beyond setting $TERM to xterm-256color.
Alacritty
[env]
TERM = "xterm-256color"
Alacritty ships its own alacritty terminfo, but tmux doesn't recognize it. Set TERM to xterm-256color explicitly to avoid issues. Alacritty supports true color natively but does not support undercurl — wavy lines render as straight underlines.
Kitty
# Kitty's default TERM is xterm-kitty, which tmux doesn't know about.
# Override it for tmux compatibility:
term xterm-256color
Kitty supports true color and undercurl natively. The only gotcha is its custom xterm-kitty terminfo — override it to xterm-256color so tmux recognizes it. If you SSH to remote machines from Kitty, also run kitty +kitten ssh to propagate the terminfo.
WezTerm
local wezterm = require("wezterm")
return {
term = "xterm-256color",
}
WezTerm has excellent terminal capability support — true color, undercurl, and even sixel graphics. Setting term to xterm-256color ensures compatibility with tmux.
Windows Terminal
// In your Windows Terminal settings.json profile:
{
"name": "Ubuntu",
"source": "Windows.Terminal.Wsl",
"colorScheme": "One Half Dark",
"cursorShape": "filledBox"
}
Windows Terminal supports true color natively. Inside WSL, $TERM is usually already xterm-256color. If colors still break, add export TERM=xterm-256color to your ~/.bashrc or ~/.zshrc. Undercurl is not supported as of Windows Terminal 1.19.
Full Diagnostic Checklist
Run through these commands to pinpoint exactly where the chain breaks:
-
Check TERM outside tmuxbash
# Outside tmux — should be xterm-256color echo $TERMIf this shows
xterm-kitty,alacritty, or something unexpected, fix it in your terminal emulator settings first. -
Check TERM inside tmuxbash
# Inside tmux — should be tmux-256color (or screen-256color) echo $TERMIf it shows
screen(without-256color), yourdefault-terminalsetting isn't applied. Reload withtmux source ~/.tmux.confand open a new pane. -
Verify the tmux-256color terminfo existsbash
infocmp tmux-256color > /dev/null 2>&1 && echo "OK" || echo "MISSING"If
MISSING, the terminfo entry isn't installed. On macOS, abrew install ncursesusually fixes it. On older Linux distros, installncurses-termorncurses-base. As a fallback, setdefault-terminalto"screen-256color"instead. -
Confirm RGB capability in tmuxbash
# Inside tmux — look for RGB or Tc flags tmux info | grep -E "Tc|RGB" # Expected: 203: Tc: (flag) true # or: RGB: (flag) trueIf neither flag shows
true, yourterminal-featuresorterminal-overridessetting isn't applied. You need to fully stop the tmux server and start a fresh session. -
Run the color gradient testbash
# Quick inline true color test — should show a smooth gradient awk 'BEGIN{ for(i=0;i<=76;i++){ r=255-(i*255/76); g=(i*510/76); b=(i*255/76); if(g>255)g=510-g; printf "\033[48;2;%d;%d;%dm ",r,g,b; } printf "\033[0m\n"; }'Smooth gradient = true color is working. Visible color bands = still falling back to 256-color palette.
Troubleshooting Table
When something goes wrong, match your symptom below:
| Symptom | Diagnosis | Fix |
|---|---|---|
| Colors look washed out / banded inside tmux | True color not enabled — tmux is falling back to 256-color palette | Add set -as terminal-features ",xterm-256color:RGB" and restart the tmux server entirely |
tmux-256color unknown terminal type |
Missing terminfo entry on your system | Install ncurses-term (Linux) or brew install ncurses (macOS), or use "screen-256color" as fallback |
| Neovim shows plain underlines instead of wavy undercurl | Missing Smulx / Setulc overrides in tmux.conf |
Add the two terminal-overrides lines for undercurl from the config above |
| Vim/Neovim has a noticeable delay when pressing Esc | escape-time defaults to 500ms — tmux waits to see if Esc is part of a key sequence |
set -sg escape-time 0 (or 10 if you experience ghost input) |
| Colors work in tmux but break over SSH | Remote machine doesn't have the tmux-256color terminfo |
Copy the terminfo: infocmp tmux-256color | ssh remote 'tic -x /dev/stdin' |
| Italics render as reverse video | The active terminfo doesn't define italics correctly | Ensure default-terminal is "tmux-256color" (not "screen-256color", which lacks sitm/ritm) |
Reloading tmux.conf doesn't fix colors |
terminal-features and terminal-overrides are read at server start, not on config reload |
Stop the server entirely, then start a fresh session |
A common mistake is adding export TERM=xterm-256color to .bashrc or .zshrc. This overrides tmux's carefully set inner $TERM, breaking the capability chain. Let your terminal emulator set the outer $TERM, and let tmux set the inner one via default-terminal. Never force it in your shell config.
TPM: Tmux Plugin Manager Setup and Workflow
TPM (Tmux Plugin Manager) is the standard way to install and manage tmux plugins. It clones plugin repositories into ~/.tmux/plugins/, sources their .tmux files automatically, and gives you key bindings to install, update, and remove plugins — all without leaving your tmux session.
Install TPM
TPM itself is just a Git repository that lives inside your plugins directory. One command gets it set up:
git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
Required Lines in .tmux.conf
TPM needs exactly two things in your config: a list of plugins declared with set -g @plugin, and a run command at the very bottom that bootstraps TPM. The run line must be the last line in the file — TPM reads everything above it to discover which plugins to load.
# ── Plugins ──────────────────────────────────────
# TPM itself (required)
set -g @plugin 'tmux-plugins/tpm'
# Sensible defaults everyone agrees on
set -g @plugin 'tmux-plugins/tmux-sensible'
# Save and restore tmux sessions across reboots
set -g @plugin 'tmux-plugins/tmux-resurrect'
# Automatic session saving (depends on resurrect)
set -g @plugin 'tmux-plugins/tmux-continuum'
# ── Plugin options ───────────────────────────────
set -g @resurrect-capture-pane-contents 'on'
set -g @continuum-restore 'on'
set -g @continuum-save-interval '15'
# ── Your other tmux settings go here ─────────────
set -g mouse on
set -g base-index 1
# ── Initialize TPM (MUST be the last line) ───────
run '~/.tmux/plugins/tpm/tpm'
After saving, reload your config with tmux source-file ~/.tmux.conf, then press prefix + I (capital I) to install the declared plugins.
The Three Key Bindings
TPM gives you three key bindings that handle the entire plugin lifecycle. All use your tmux prefix key (default: Ctrl-b).
| Key Binding | Action | What It Does |
|---|---|---|
prefix + I | Install | Clones any plugins listed in .tmux.conf that aren't yet in ~/.tmux/plugins/ |
prefix + U | Update | Pulls the latest commits for all installed plugins |
prefix + alt + u | Uninstall | Removes plugins whose @plugin line has been deleted from .tmux.conf |
The install binding is prefix + Shift + i (capital I). If you press lowercase i, nothing happens. This trips up almost everyone the first time.
Plugin Specification Formats
You can declare plugins in three ways depending on where they're hosted:
# 1. GitHub shorthand — most common
set -g @plugin 'tmux-plugins/tmux-resurrect'
# 2. Full Git URL — for non-GitHub repos or SSH cloning
set -g @plugin 'git@github.com:user/plugin'
set -g @plugin 'https://gitlab.com/user/tmux-plugin.git'
# 3. Pin to a specific tag or branch
set -g @plugin 'tmux-plugins/tmux-resurrect#v4.0.0'
set -g @plugin 'tmux-plugins/tmux-continuum#main'
Configuring Plugin Options
Plugins read their configuration from tmux user options (the @ prefix). Set these before the run line — TPM sources plugins in order, and each plugin reads its options at load time.
# Declare the plugin
set -g @plugin 'tmux-plugins/tmux-resurrect'
# Configure it (before the run line)
set -g @resurrect-strategy-vim 'session'
set -g @resurrect-capture-pane-contents 'on'
set -g @resurrect-save-shell-history 'on'
# Dracula theme options
set -g @plugin 'dracula/tmux'
set -g @dracula-show-powerline true
set -g @dracula-plugins "cpu-usage ram-usage weather"
set -g @dracula-show-left-icon session
# ... other config ...
# TPM bootstrap — ALWAYS last
run '~/.tmux/plugins/tpm/tpm'
Removing a Plugin
Uninstalling is a two-step process:
- Delete (or comment out) the
set -g @plugin '...'line from~/.tmux.conf - Press
prefix + alt + uinside tmux to clean up the plugin directory
TPM compares what's declared in your config to what's on disk and removes any directories that no longer have a matching @plugin line.
CLI Installation (No Key Bindings)
TPM ships helper scripts in its bin/ directory. These are essential for CI pipelines, Docker images, and headless servers where you can't press key bindings interactively.
# Install all plugins declared in .tmux.conf
~/.tmux/plugins/tpm/bin/install_plugins
# Update all installed plugins
~/.tmux/plugins/tpm/bin/update_plugins all
# Update a specific plugin
~/.tmux/plugins/tpm/bin/update_plugins tmux-resurrect
# Clean up removed plugins
~/.tmux/plugins/tpm/bin/clean_plugins
Automating TPM in Dotfiles Bootstrap Scripts
If you version your dotfiles with Git, you want TPM and all plugins to install automatically on a fresh machine. Here's a bootstrap script that handles the full setup:
#!/usr/bin/env bash
set -euo pipefail
TPM_DIR="$HOME/.tmux/plugins/tpm"
# Install TPM if it's not already present
if [ ! -d "$TPM_DIR" ]; then
echo "Installing TPM..."
git clone https://github.com/tmux-plugins/tpm "$TPM_DIR"
else
echo "TPM already installed, pulling latest..."
git -C "$TPM_DIR" pull --quiet
fi
# Symlink your tmux.conf from your dotfiles repo
DOTFILES_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ln -sf "$DOTFILES_DIR/tmux.conf" "$HOME/.tmux.conf"
# Install all plugins (works even without a running tmux server)
"$TPM_DIR/bin/install_plugins"
echo "✓ tmux setup complete"
You can also add an auto-install guard directly in .tmux.conf so that TPM bootstraps itself the first time tmux starts:
# Auto-install TPM if missing
if "test ! -d ~/.tmux/plugins/tpm" \
"run 'git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm && ~/.tmux/plugins/tpm/bin/install_plugins'"
# Initialize TPM (last line)
run '~/.tmux/plugins/tpm/tpm'
if command worksThe if-shell (or if) command runs its first argument as a shell command. If the exit code is 0 (true), it executes the second argument as a tmux command. This is the idiomatic way to do conditional logic in .tmux.conf.
Troubleshooting: When a Plugin Doesn't Load
If you install a plugin but its features don't appear, work through this checklist:
1. Verify the run line is last
Any set -g @plugin line that appears after the run '~/.tmux/plugins/tpm/tpm' line will be invisible to TPM. Move the run line to the absolute bottom of your config.
2. Check that the plugin was actually cloned
# List installed plugins
ls ~/.tmux/plugins/
# Expected output for the config above:
# tmux-continuum tmux-resurrect tmux-sensible tpm
If the plugin directory is missing, run prefix + I again or use the CLI: ~/.tmux/plugins/tpm/bin/install_plugins.
3. Check the plugin's .tmux file
TPM loads each plugin by sourcing a file named *.tmux in the plugin's root directory. If this file doesn't exist or isn't executable, the plugin silently fails to load.
# Find the .tmux entry point for a plugin
ls -la ~/.tmux/plugins/tmux-resurrect/*.tmux
# Verify it's executable (must have the x flag)
# -rwxr-xr-x 1 user staff 1234 Jan 1 00:00 resurrect.tmux
# Fix permissions if needed
chmod +x ~/.tmux/plugins/tmux-resurrect/*.tmux
4. Source the config and check for errors
# Reload the config and watch for error messages
tmux source-file ~/.tmux.conf
# Manually run a plugin's entry point to see errors
~/.tmux/plugins/tmux-resurrect/resurrect.tmux
# Check if the plugin registered its key bindings
tmux list-keys | grep resurrect
5. Confirm tmux version compatibility
Some plugins require a minimum tmux version. Check yours with tmux -V and compare against the plugin's README. If you're stuck on an older version, pin the plugin to a compatible tag.
Running tmux with a server-destroying command to "reset" things will wipe out all your sessions and windows. Use tmux source-file ~/.tmux.conf instead — it reloads the config without losing any state. Bind it to a key for convenience: bind r source-file ~/.tmux.conf \; display "Config reloaded".
Essential Plugins: The Curated Power User Toolkit
The tmux plugin ecosystem is mature and focused. Unlike editors with thousands of plugins, tmux has a small set of genuinely essential ones that nearly every power user installs. This section covers each one with the exact config you need to drop into your .tmux.conf.
Every plugin below assumes you have TPM (Tmux Plugin Manager) installed. Add the set -g @plugin line to your config, then press prefix + I to install.
1. tmux-resurrect — Save & Restore Sessions Across Reboots
This is the single most impactful plugin you can install. Without it, closing your terminal or rebooting means manually recreating every session, window, pane, and layout. Resurrect serializes your entire tmux environment to a text file and restores it exactly — including pane layouts, working directories, and even running programs.
# Install
set -g @plugin 'tmux-plugins/tmux-resurrect'
# Restore neovim sessions (requires vim-obsession or similar)
set -g @resurrect-strategy-nvim 'session'
# Also capture and restore pane contents (scrollback)
set -g @resurrect-capture-pane-contents 'on'
# Restore additional programs beyond the defaults
# Default: vi vim nvim emacs man less more tail top htop irssi weechat mutt
set -g @resurrect-processes 'ssh psql mysql sqlite3 "~yarn watch"'
Daily usage
prefix + Ctrl-s— Save the current tmux environmentprefix + Ctrl-r— Restore the last saved environment
Save files live in ~/.tmux/resurrect/. Each save creates a new timestamped file, so you can manually restore older snapshots by symlinking last to a previous save file.
The @resurrect-strategy-nvim 'session' option requires your Neovim to actually save sessions. Use tpope/vim-obsession or Neovim’s built-in :mksession via an autocmd. Without this, resurrect will reopen nvim but lose your buffers and splits.
2. tmux-continuum — Automatic Saving & Auto-Restore
Continuum builds on top of resurrect. It automatically saves your environment every 15 minutes (configurable) and can auto-restore the last session when tmux starts. You never have to think about saving again.
# Install (requires tmux-resurrect)
set -g @plugin 'tmux-plugins/tmux-continuum'
# Auto-restore when tmux server starts
set -g @continuum-restore 'on'
# Save interval in minutes (default: 15)
set -g @continuum-save-interval '15'
# Optional: auto-start tmux when your terminal opens (macOS)
# set -g @continuum-boot 'on'
# set -g @continuum-boot-options 'iterm'
Daily usage
There is none — that is the point. Continuum runs silently in the background. Reboot your machine, open tmux, and everything is back exactly as you left it. Verify it is working by checking the timestamps in ~/.tmux/resurrect/.
3. tmux-sensible — Universal Defaults Everyone Should Use
This plugin sets a handful of options that are universally considered better than tmux’s defaults. It is non-controversial and safe to include before any of your own customizations.
# Install — no config needed
set -g @plugin 'tmux-plugins/tmux-sensible'
What it sets
- Escape time → 0 — Eliminates the delay after pressing Escape (critical for vim users)
- History limit → 50000 — Much more scrollback than the default 2000 lines
- Display time → 4000ms — Messages stay visible longer
- Status interval → 5s — More frequent status bar refreshes
- Emacs keys in status line — Even vim users want this for the command prompt
- Focus events on — Needed for autoread in vim/neovim
prefix + R— Reloads your.tmux.conf(hugely convenient)
If you already set any of these in your own config, your values take precedence. Sensible only applies options that are not already set.
4. tmux-yank — Cross-Platform Clipboard Integration
Tmux’s built-in copy mode puts text into tmux’s internal buffer, not your system clipboard. tmux-yank bridges this gap on Linux (xclip/xsel), macOS (pbcopy), WSL, and Cygwin — without any platform-specific hacks in your config.
# Install
set -g @plugin 'tmux-plugins/tmux-yank'
# Stay in copy mode after yanking (don't jump back to prompt)
set -g @yank_action 'copy-pipe-no-clear'
# Use 'clipboard' selection instead of 'primary' on Linux
set -g @yank_selection_mouse 'clipboard'
set -g @yank_selection 'clipboard'
Daily usage
- In copy mode:
y— Yank selection to system clipboard - In copy mode:
Y— Yank and paste selection to the command line - Normal mode:
prefix + y— Copy current command line to clipboard - Normal mode:
prefix + Y— Copy current pane’s working directory
5. tmux-open — Open Files & URLs from Copy Mode
When you highlight text in copy mode, tmux-open lets you open it directly — URLs launch in a browser, file paths open in $EDITOR. It eliminates the tedious copy-paste-switch-app cycle.
# Install
set -g @plugin 'tmux-plugins/tmux-open'
# Override the editor-open key (default: Ctrl-o)
set -g @open-editor 'C-o'
# Customize the search engine for 'S' key
set -g @open-S 'https://www.google.com/search?q='
Daily usage
- In copy mode, highlight a URL:
o— Opens in the default browser - In copy mode, highlight a file path:
Ctrl-o— Opens in$EDITOR - In copy mode, highlight any text:
S— Searches for it on Google
6. tmux-fzf — Fuzzy Finder for Everything tmux
This plugin wraps fzf around every tmux entity — sessions, windows, panes, key bindings, commands, and clipboard buffers. Instead of remembering :list-sessions or :choose-tree, you get a single fuzzy-searchable interface for all tmux operations.
# Install (requires fzf to be installed on your system)
set -g @plugin 'sainnhe/tmux-fzf'
# Change the trigger key (default: prefix + F)
TMUX_FZF_LAUNCH_KEY="f"
# Show in a popup window instead of a pane (tmux 3.2+)
TMUX_FZF_POPUP=1
TMUX_FZF_POPUP_HEIGHT="80%"
TMUX_FZF_POPUP_WIDTH="70%"
# Order of menu items
TMUX_FZF_ORDER="session|window|pane|command|keybinding|clipboard|process"
Daily usage
Press prefix + f (or your configured key). A menu appears with categories:
- session — Switch, rename, or create sessions
- window — Switch, swap, rename windows across all sessions
- pane — Switch, swap, join, or break panes
- command — Search and execute any tmux command
- keybinding — Browse all active key bindings
- clipboard — Search through tmux paste buffers
7. tmux-thumbs — Vimium-Style Hints for Copying Text
tmux-thumbs scans the visible pane for interesting patterns (paths, hashes, IPs, URLs, numbers) and overlays single-key hints on each match. Press the hint character and that text is yanked to your clipboard. Zero mouse usage, zero scrolling.
# Install (binary is compiled via cargo or downloaded automatically)
set -g @plugin 'fcsonline/tmux-thumbs'
# Run thumbs setup
run-shell ~/.tmux/plugins/tmux-thumbs/tmux-thumbs.tmux
# Trigger key (default: prefix + Space)
set -g @thumbs-key Space
# Copy to system clipboard
set -g @thumbs-command 'echo -n {} | pbcopy' # macOS
# set -g @thumbs-command 'echo -n {} | xclip -sel clip' # Linux
# What to do on uppercase hint (paste into current pane)
set -g @thumbs-upcase-command 'tmux set-buffer -- {} && tmux paste-buffer'
# Add custom regex patterns (e.g., MAC addresses)
set -g @thumbs-regexp-1 '[a-f0-9]{2}:[a-f0-9]{2}:[a-f0-9]{2}:[a-f0-9]{2}:[a-f0-9]{2}:[a-f0-9]{2}'
Daily usage
prefix + Space(or your key) — Activate hint mode- Lowercase hint — Copies the matched text to clipboard
- Uppercase hint — Executes the upcase command (e.g., paste into pane)
- Works out of the box with file paths, git SHAs, IPs, UUIDs, URLs, and hex colors
8. tmux-fingers — Hint Mode Alternative & Comparison
tmux-fingers does the same thing as tmux-thumbs: overlay hints on recognized patterns for quick copying. It is the original plugin in this space and has a slightly different feel.
# Install
set -g @plugin 'Morantron/tmux-fingers'
# Trigger key (default: prefix + F)
set -g @fingers-key F
# Custom patterns
set -g @fingers-pattern-0 'git rebase --(abort|continue)'
set -g @fingers-pattern-1 'https?://\S+'
# Hint position: left or right of the match
set -g @fingers-hint-position 'right'
# Customize the hint highlight style
set -g @fingers-highlight-format '#[fg=green,bold]%s'
Thumbs vs. Fingers — Which One?
| Feature | tmux-thumbs | tmux-fingers |
|---|---|---|
| Written in | Rust (compiled binary) | Shell + awk (v1) / Crystal (v2) |
| Speed on large panes | Very fast | Fast (v2 improved significantly) |
| Installation | Auto-compiles or downloads binary | No compilation (v1), Crystal needed (v2) |
| Custom regex | Yes | Yes |
| Multi-action hints | Lowercase = copy, Uppercase = custom | Configurable per-action |
| Hint display | Compact, overlays source text | Highlights with positioned labels |
| Maturity | Newer, very actively maintained | Older, established user base |
Both are solid. tmux-thumbs tends to feel snappier on large panes and has a simpler install. tmux-fingers has been around longer and has a larger community. Pick one — do not install both, as they will fight over the same key binding.
tmux-thumbs, tmux-fingers, and tmux-fzf can all default to prefix + F or similar keys. If you install more than one, explicitly set different trigger keys for each to avoid silent conflicts.
Recommended Plugin Stack
Here is a minimal, battle-tested plugin block you can drop straight into your .tmux.conf. It covers session persistence, clipboard integration, and fast text picking — the highest-value plugins for the least complexity.
# -- Plugins -----------------------------------------------
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @plugin 'tmux-plugins/tmux-continuum'
set -g @plugin 'tmux-plugins/tmux-yank'
set -g @plugin 'tmux-plugins/tmux-open'
set -g @plugin 'sainnhe/tmux-fzf'
set -g @plugin 'fcsonline/tmux-thumbs'
# -- Plugin Config -----------------------------------------
# Resurrect
set -g @resurrect-strategy-nvim 'session'
set -g @resurrect-capture-pane-contents 'on'
# Continuum
set -g @continuum-restore 'on'
set -g @continuum-save-interval '15'
# Yank
set -g @yank_action 'copy-pipe-no-clear'
set -g @yank_selection 'clipboard'
# Thumbs -- set explicit key to avoid fzf conflict
set -g @thumbs-key Space
# Fzf
TMUX_FZF_LAUNCH_KEY="f"
# -- TPM bootstrap (keep at the very bottom) ---------------
run '~/.tmux/plugins/tpm/tpm'
After adding all plugins to your config, press prefix + I (capital I) once. TPM clones and initializes every plugin in parallel. Then press prefix + R (from tmux-sensible) to reload. You are done.
Writing Your Own Tmux Plugin
Tmux plugins aren't magic — they're just shell scripts that tmux sources at startup. Once you understand the contract between TPM and your code, you can build custom plugins that fit your exact workflow. In this section, you'll build a real plugin from scratch: tmux-project-switcher, which uses fzf to jump between project sessions.
The TPM Plugin Contract
TPM's plugin loading mechanism is dead simple. When you press prefix + I to install (or when tmux starts), TPM clones each plugin's git repo into ~/.tmux/plugins/. Then it finds the file matching *.tmux at the repo root, makes it executable, and runs it.
That's the entire contract:
- Your repo must contain a
.tmuxfile at the root (e.g.,project-switcher.tmux) - That file must be executable (
chmod +x) - TPM runs it as a shell script — whatever
tmuxcommands it issues take effect
Directory Layout
Here's the structure you'll build for tmux-project-switcher:
tmux-project-switcher/
├── project-switcher.tmux # Entry point — TPM runs this
├── scripts/
│ └── switch.sh # The actual fzf picker logic
├── README.md
└── LICENSE
The .tmux file handles setup (key bindings, reading options). Heavy logic lives in scripts/ — this keeps the entry point clean and makes individual scripts easier to test.
Step 1 — The Entry Point
Create project-switcher.tmux at the repo root. This file reads user-configurable options and binds a key to the switcher script.
#!/usr/bin/env bash
CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Read user options with defaults
default_key="f"
default_base_path="$HOME/projects"
default_depth="1"
# tmux show-option -gqv reads a global user option; returns "" if unset
key=$(tmux show-option -gqv @project-switcher-key)
key="${key:-$default_key}"
base_path=$(tmux show-option -gqv @project-switcher-path)
base_path="${base_path:-$default_base_path}"
depth=$(tmux show-option -gqv @project-switcher-depth)
depth="${depth:-$default_depth}"
# Bind the key to run the switcher script in a display-popup
tmux bind-key "$key" display-popup -E -w 60% -h 60% \
"$CURRENT_DIR/scripts/switch.sh '$base_path' '$depth'"
Key details in this file:
CURRENT_DIR— resolves the plugin's install path so you can reference scripts with absolute paths. This is essential because tmux runs commands from an unpredictable working directory.tmux show-option -gqv @option-name— the-gflag reads global options,-qsuppresses errors if the option doesn't exist, and-vreturns just the value. The@prefix marks it as a user option.display-popup -E— opens a tmux popup that closes automatically when the command exits. The-wand-hflags set popup dimensions.
Step 2 — The Switcher Script
This is the core logic. It lists directories under the configured base path, pipes them to fzf, then creates or switches to a tmux session named after the selected project.
#!/usr/bin/env bash
set -euo pipefail
BASE_PATH="$1"
DEPTH="${2:-1}"
# Bail out if fzf isn't installed
if ! command -v fzf &> /dev/null; then
echo "fzf is required but not installed." >&2
exit 1
fi
# List directories and let the user pick one
selected=$(find "$BASE_PATH" -mindepth 1 -maxdepth "$DEPTH" \
-type d | sort | fzf --prompt="project > " --reverse)
# Exit silently if the user pressed Escape
[ -z "$selected" ] && exit 0
# Derive a session name from the directory name (replace dots with underscores)
session_name=$(basename "$selected" | tr '.' '_')
# Create session if it doesn't exist (detached)
if ! tmux has-session -t "=$session_name" 2>/dev/null; then
tmux new-session -d -s "$session_name" -c "$selected"
fi
# Switch to the session
tmux switch-client -t "$session_name"
has-session -t "=$session_name"?The = prefix forces an exact match. Without it, tmux has-session -t "api" would match a session named api-gateway due to tmux's prefix matching behavior. This is a common source of bugs in tmux scripts.
Step 3 — Make It Executable
Both the entry point and the helper script must be executable. TPM handles the entry point, but your scripts need it too:
chmod +x project-switcher.tmux
chmod +x scripts/switch.sh
Step 4 — Reading User Options
Users configure your plugin by setting tmux options in their .tmux.conf. The convention is to use the @ prefix with your plugin name as a namespace:
# Plugin options — set BEFORE the plugin manager runs
set -g @project-switcher-key "f"
set -g @project-switcher-path "$HOME/code"
set -g @project-switcher-depth "2"
# Load the plugin via TPM
set -g @plugin 'your-github-user/tmux-project-switcher'
run '~/.tmux/plugins/tpm/tpm'
The pattern for reading these in your plugin is always the same:
# Pattern: read option with a fallback default
value=$(tmux show-option -gqv @your-plugin-option)
value="${value:-default_value}"
Step 5 — Test Locally Before Publishing
You don't need to push to GitHub to test. Symlink your plugin into the TPM plugins directory, then reload:
# Symlink your dev directory into TPM's plugin path
ln -sf ~/code/tmux-project-switcher ~/.tmux/plugins/tmux-project-switcher
# Source the plugin directly to test (no restart needed)
~/.tmux/plugins/tmux-project-switcher/project-switcher.tmux
# Or reload the entire tmux config
tmux source-file ~/.tmux.conf
For a faster feedback loop, run the entry point script directly after any change. You'll see errors immediately in your terminal. Once you're satisfied, press your bound key (prefix + f by default) to test the full flow.
Add set -x at the top of your scripts during development to trace every command. You can view the output with tmux show-messages or by redirecting to a log file: exec >> /tmp/tmux-plugin-debug.log 2>&1.
Step 6 — Structure the Git Repo for Publishing
When your plugin is ready, push it to a GitHub repo. TPM expects a specific structure:
cd ~/code/tmux-project-switcher
git init
git add -A
git commit -m "Initial release of tmux-project-switcher"
git remote add origin git@github.com:youruser/tmux-project-switcher.git
git push -u origin main
The repo name must match what users put in their .tmux.conf. When a user writes set -g @plugin 'youruser/tmux-project-switcher', TPM clones https://github.com/youruser/tmux-project-switcher and runs the *.tmux file it finds at the root.
A good plugin README should include:
- Requirements — external dependencies (
fzf, tmux version, etc.) - Installation — the exact
set -g @pluginline to copy - Configuration — every
@optionwith its default value - Key bindings — what keys are bound and what they do
Putting It All Together
Here's a quick-reference of the complete minimal plugin — just two files that give you a fully functional project switcher:
#!/usr/bin/env bash
CURRENT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
key=$(tmux show-option -gqv @project-switcher-key)
base_path=$(tmux show-option -gqv @project-switcher-path)
depth=$(tmux show-option -gqv @project-switcher-depth)
tmux bind-key "${key:-f}" display-popup -E -w 60% -h 60% \
"$CURRENT_DIR/scripts/switch.sh '${base_path:-$HOME/projects}' '${depth:-1}'"
#!/usr/bin/env bash
set -euo pipefail
BASE_PATH="$1"
DEPTH="${2:-1}"
command -v fzf &> /dev/null || { echo "fzf required" >&2; exit 1; }
selected=$(find "$BASE_PATH" -mindepth 1 -maxdepth "$DEPTH" \
-type d | sort | fzf --prompt="project > " --reverse)
[ -z "$selected" ] && exit 0
session_name=$(basename "$selected" | tr '.' '_')
tmux has-session -t "=$session_name" 2>/dev/null || \
tmux new-session -d -s "$session_name" -c "$selected"
tmux switch-client -t "$session_name"
This is a starting point. Real-world enhancements include: previewing the git status of each project in the fzf window (--preview flag), killing sessions from the picker, or adding tmux run-shell to execute async initialization commands when a new session is created.
Tmux Scripting Fundamentals: The Command Interface
Every action you perform in tmux — splitting a pane, creating a window, resizing a layout — is a command. There is no distinction between "interactive tmux" and "scriptable tmux." The same command that runs when you press Ctrl-b % can be invoked from a shell script, from .tmux.conf, or from the tmux command prompt.
This means tmux is effectively a terminal API. Once you internalize the command interface, you can automate entire development environments with a single script.
Three Ways to Run a Command
Every tmux command can be executed from three contexts. The syntax is nearly identical across all three:
# 1. From the shell (outside or inside tmux)
tmux split-window -h
# 2. From .tmux.conf (no "tmux" prefix needed)
split-window -h
# 3. From the tmux command prompt (prefix + :)
# Just type: split-window -h
In .tmux.conf and the command prompt, you drop the tmux prefix because you're already talking to the server. From the shell, you always prefix with tmux.
The Core Scriptable Commands
These are the commands you'll use in 90% of your automation scripts:
# Create a detached session (won't attach your terminal)
tmux new-session -d -s myproject
# Create a new window inside that session
tmux new-window -t myproject -n logs
# Split the current pane horizontally (side by side)
tmux split-window -h -t myproject
# Split the current pane vertically (top and bottom)
tmux split-window -v -t myproject
# Send keystrokes to a pane (simulates typing)
tmux send-keys -t myproject 'npm run dev' Enter
# Select a specific pane
tmux select-pane -t myproject:0.1
# Apply a preset layout
tmux select-layout -t myproject main-horizontal
# Resize the active pane
tmux resize-pane -t myproject -D 10 # down 10 rows
tmux resize-pane -t myproject -R 20 # right 20 columns
Always create sessions with new-session -d in scripts. Without -d, tmux will try to attach immediately, which halts your script. Build the entire layout detached, then attach at the very end.
Target Syntax: session:window.pane
The -t (target) flag uses a consistent addressing scheme across all commands. The full form is session:window.pane, but you can use shorthand when the context is unambiguous:
| Target | Meaning | Example |
|---|---|---|
dev |
Session named "dev" (current window & pane) | tmux send-keys -t dev 'ls' Enter |
dev:1 |
Window index 1 in session "dev" | tmux select-window -t dev:1 |
dev:editor |
Window named "editor" in session "dev" | tmux send-keys -t dev:editor 'vim .' Enter |
dev:1.0 |
Pane 0 of window 1 in session "dev" | tmux resize-pane -t dev:1.0 -R 10 |
dev:editor.1 |
Pane 1 of window "editor" in "dev" | tmux send-keys -t dev:editor.1 'tail -f log' Enter |
{last} |
The last (previously active) pane | tmux select-pane -t {last} |
{top-right} |
Positional pane reference | tmux send-keys -t {top-right} 'htop' Enter |
Pane indices start at 0. Window indices start at your base-index setting (default 0, many people set it to 1). You can check pane numbers with Ctrl-b q.
Command Chaining with \;
You can run multiple tmux commands in a single shell invocation by chaining them with \;. The backslash escapes the semicolon so your shell doesn't interpret it as a shell command separator:
# Create session, split, and send commands — one line
tmux new-session -d -s work \; \
split-window -h \; \
send-keys -t work:0.0 'vim' Enter \; \
send-keys -t work:0.1 'htop' Enter
After the first tmux keyword, chained commands don't repeat the tmux prefix — they're all dispatched to the same server call. This is both faster and more atomic than separate invocations.
Use \; (backslash-semicolon) in shell scripts and on the command line. Inside .tmux.conf, use bare ; instead — there's no shell to escape from. Getting this wrong is the #1 cause of "unknown command" errors in tmux scripts.
Debugging Scripts with display-message
When building tmux scripts, you need visibility into what tmux thinks the current state is. The display-message -p command prints tmux format strings to stdout, letting you capture state into shell variables:
# Print the current session name
tmux display-message -p '#{session_name}'
# Capture it into a shell variable
SESSION=$(tmux display-message -p '#{session_name}')
echo "Currently in: $SESSION"
# Get the current pane's working directory
PANE_DIR=$(tmux display-message -p '#{pane_current_path}')
# Show window and pane index
tmux display-message -p 'Window: #{window_index}, Pane: #{pane_index}'
# Useful for debugging: list all panes with details
tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_path}'
The -p flag sends output to stdout instead of displaying it in the tmux status line. This is what makes it usable in scripts.
Conditional Logic with has-session
A well-written session script should be idempotent — running it twice shouldn't create duplicate sessions. Use has-session to check before creating:
SESSION="myproject"
# has-session exits 0 if it exists, 1 if not
if tmux has-session -t "$SESSION" 2>/dev/null; then
echo "Session '$SESSION' already exists. Attaching..."
tmux attach-session -t "$SESSION"
exit 0
fi
# Session doesn't exist — create it
tmux new-session -d -s "$SESSION"
# ... build your layout here ...
The 2>/dev/null suppresses the error message tmux prints when the session doesn't exist. Without it, you'll get "can't find session" noise on stderr.
Synchronizing with wait-for
When your script sends commands to panes with send-keys, those commands run asynchronously. Sometimes you need pane A to finish before starting pane B. The wait-for command provides a signaling mechanism for this:
# In pane 0: run a build, then signal when done
tmux send-keys -t dev:0.0 \
'npm run build && tmux wait-for -S build-done' Enter
# In your script: block until the signal arrives
tmux wait-for build-done
# Now it's safe to start the dependent process
tmux send-keys -t dev:0.1 'npm run test' Enter
The -S flag sends (signals) the named channel. A bare wait-for without -S blocks until that channel is signaled. The channel name (build-done) is arbitrary — just make it unique within your script.
Real Example: Development Environment Bootstrap Script
Here's a complete, reusable script that creates a three-pane development layout. It checks for an existing session, builds the layout if needed, and attaches:
#!/usr/bin/env bash
set -euo pipefail
SESSION="webapp"
PROJECT_DIR="$HOME/projects/webapp"
# ── Idempotent: attach if session already exists ──
if tmux has-session -t "$SESSION" 2>/dev/null; then
tmux attach-session -t "$SESSION"
exit 0
fi
# ── Create detached session with first window named "editor" ──
tmux new-session -d -s "$SESSION" -n editor -c "$PROJECT_DIR"
# ── Window 1: Editor (left=vim, right=git) ──
tmux send-keys -t "$SESSION:editor" 'vim .' Enter
tmux split-window -h -t "$SESSION:editor" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:editor.1" 'git status' Enter
tmux resize-pane -t "$SESSION:editor.0" -R 15
# ── Window 2: Server + Logs ──
tmux new-window -t "$SESSION" -n server -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:server" 'npm run dev' Enter
tmux split-window -v -t "$SESSION:server" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:server.1" 'tail -f logs/app.log' Enter
# ── Window 3: Free terminal ──
tmux new-window -t "$SESSION" -n shell -c "$PROJECT_DIR"
# ── Focus on editor window, left pane ──
tmux select-window -t "$SESSION:editor"
tmux select-pane -t "$SESSION:editor.0"
# ── Attach ──
tmux attach-session -t "$SESSION"
Save this as ~/bin/dev-webapp, make it executable with chmod +x ~/bin/dev-webapp, and you now have a single command that boots your entire workspace. The layout it produces:
- Window "editor" — Vim taking ~65% width on the left, a git/terminal pane on the right
- Window "server" — Dev server on top, log tail on the bottom
- Window "shell" — Clean terminal for ad-hoc commands
Use the -c flag on new-session, new-window, and split-window to set the working directory. This is more reliable than sending cd /path via send-keys — it sets the directory before the shell even starts.
Quick Reference: Script-Friendly Commands
| Command | Purpose | Key Flags |
|---|---|---|
new-session |
Create a session | -d detached, -s name, -n first window name, -c start dir |
new-window |
Add a window | -t target session, -n name, -c start dir |
split-window |
Split a pane | -h horizontal, -v vertical, -t target, -c start dir, -p percentage |
send-keys |
Simulate keystrokes | -t target pane, literal Enter/C-c for special keys |
select-window |
Focus a window | -t target |
select-pane |
Focus a pane | -t target, -L/-R/-U/-D directional |
select-layout |
Apply preset layout | even-horizontal, even-vertical, main-horizontal, main-vertical, tiled |
resize-pane |
Resize a pane | -U/-D/-L/-R N by N cells, -x/-y absolute size |
has-session |
Check if session exists | -t name, exits 0/1 |
wait-for |
Block/signal on a channel | -S to signal, -L to lock, -U to unlock |
display-message |
Print tmux state | -p to stdout, format strings like #{session_name} |
Project Session Bootstrap Scripts
A bootstrap script creates your entire tmux workspace in one shot — windows, panes, splits, working directories, and running commands. Instead of manually setting up your environment every morning, you run a single command and get dropped into a fully configured session.
Every script in this section follows the same proven pattern: check if the session already exists, create it detached if it doesn’t, configure all windows and panes, send commands to each pane, then attach. Running the script twice won’t create duplicates — it just reattaches.
The Core Pattern
Before diving into full scripts, here’s the skeleton every bootstrap script is built from. Memorize this — you’ll use it for every project.
#!/usr/bin/env bash
SESSION="my-project"
PROJECT_DIR="$HOME/projects/my-project"
# Attach if session already exists
tmux has-session -t "$SESSION" 2>/dev/null && {
tmux attach-session -t "$SESSION"
exit 0
}
# Create session detached (-d) with a named first window
tmux new-session -d -s "$SESSION" -n "editor" -c "$PROJECT_DIR"
# Split panes, create windows, send commands...
tmux split-window -v -t "$SESSION:editor" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:editor.1" "npm run dev" C-m
# Select the pane you want active on attach
tmux select-pane -t "$SESSION:editor.0"
# Attach to the session
tmux attach-session -t "$SESSION"
Creating the session with -d (detached) lets you configure everything before you see it. If you attach immediately, you’d watch windows and panes flash around as they’re created. Detached creation gives you a clean, finished workspace when you finally attach.
Script 1: Web Development Session
This script creates a three-pane layout optimized for frontend/fullstack work: your editor takes up the top two-thirds, the dev server runs below-left, and a git log pane sits below-right. Additional windows provide a clean shell and test runner.
#!/usr/bin/env bash
# =============================================================================
# tmux-webdev.sh — Web Development Session Bootstrap
# Usage: ./tmux-webdev.sh [project-dir]
# ./tmux-webdev.sh --teardown
# Layout:
# Window 1 "code":
# ┌─────────────────────────┐
# │ editor (nvim) │
# ├──────────────┬──────────┤
# │ dev server │ git log │
# └──────────────┴──────────┘
# Window 2 "shell" | Window 3 "tests"
# =============================================================================
set -euo pipefail
PROJECT_DIR="${1:-$(pwd)}"
SESSION_NAME="$(basename "$PROJECT_DIR" | tr '.' '-')"
# --teardown flag: destroy the session and exit
if [[ "${1:-}" == "--teardown" ]]; then
tmux kill-session -t "$SESSION_NAME" 2>/dev/null && \
echo "Removed session: $SESSION_NAME" || \
echo "No session named: $SESSION_NAME"
exit 0
fi
# Validate project directory
if [[ ! -d "$PROJECT_DIR" ]]; then
echo "Error: Directory '$PROJECT_DIR' does not exist." >&2
exit 1
fi
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
# Attach if session already exists
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
echo "Session '$SESSION_NAME' exists. Attaching..."
exec tmux attach-session -t "$SESSION_NAME"
fi
# ── Create session with first window: "code" ────────────────────────────
tmux new-session -d -s "$SESSION_NAME" -n "code" -c "$PROJECT_DIR"
# Split bottom pane for dev server (30% height)
tmux split-window -v -l 30% -t "$SESSION_NAME:code" -c "$PROJECT_DIR"
# Split bottom-right pane for git log
tmux split-window -h -l 40% -t "$SESSION_NAME:code.1" -c "$PROJECT_DIR"
# Send commands to each pane
tmux send-keys -t "$SESSION_NAME:code.0" "nvim ." C-m
tmux send-keys -t "$SESSION_NAME:code.1" "npm run dev" C-m
tmux send-keys -t "$SESSION_NAME:code.2" \
"git log --oneline --graph --decorate --all -30" C-m
# Focus the editor pane
tmux select-pane -t "$SESSION_NAME:code.0"
# ── Window 2: clean shell ───────────────────────────────────────────
tmux new-window -t "$SESSION_NAME" -n "shell" -c "$PROJECT_DIR"
# ── Window 3: test runner ───────────────────────────────────────────
tmux new-window -t "$SESSION_NAME" -n "tests" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION_NAME:tests" "npm test -- --watch" C-m
# ── Select first window and attach ──────────────────────────────────
tmux select-window -t "$SESSION_NAME:code"
exec tmux attach-session -t "$SESSION_NAME"
Run it with ./tmux-webdev.sh ~/projects/my-react-app. The session name is derived from the directory name, so each project gets its own session. Run it again and it simply reattaches.
Script 2: DevOps / Infrastructure Session
This layout is built for managing Kubernetes clusters and infrastructure. The first window is a monitoring dashboard with kubectl in a 2×2 grid, the second is for Terraform, and there’s a clean shell for SSH connections.
#!/usr/bin/env bash
# =============================================================================
# tmux-devops.sh — DevOps / Infrastructure Session
# Usage: ./tmux-devops.sh [infra-repo-dir]
# ./tmux-devops.sh --teardown
# =============================================================================
set -euo pipefail
INFRA_DIR="${1:-$(pwd)}"
SESSION_NAME="devops"
K8S_NAMESPACE="${K8S_NAMESPACE:-default}"
if [[ "${1:-}" == "--teardown" ]]; then
tmux kill-session -t "$SESSION_NAME" 2>/dev/null && \
echo "Removed session: $SESSION_NAME" || \
echo "No session named: $SESSION_NAME"
exit 0
fi
[[ ! -d "$INFRA_DIR" ]] && { echo "Error: '$INFRA_DIR' not found" >&2; exit 1; }
INFRA_DIR="$(cd "$INFRA_DIR" && pwd)"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
exec tmux attach-session -t "$SESSION_NAME"
fi
# ── Window 1: Kubernetes cluster overview (2x2 grid) ────────────────
tmux new-session -d -s "$SESSION_NAME" -n "cluster" -c "$INFRA_DIR"
tmux split-window -h -t "$SESSION_NAME:cluster" -c "$INFRA_DIR"
tmux split-window -v -t "$SESSION_NAME:cluster.0" -c "$INFRA_DIR"
tmux split-window -v -t "$SESSION_NAME:cluster.1" -c "$INFRA_DIR"
# Top-left: watch pods
tmux send-keys -t "$SESSION_NAME:cluster.0" \
"watch -n5 kubectl get pods -n $K8S_NAMESPACE -o wide" C-m
# Bottom-left: watch services
tmux send-keys -t "$SESSION_NAME:cluster.2" \
"watch -n10 kubectl get svc,ing -n $K8S_NAMESPACE" C-m
# Top-right: stream logs (edit the deployment name)
tmux send-keys -t "$SESSION_NAME:cluster.1" \
"# kubectl logs -f deploy/your-app -n $K8S_NAMESPACE" Enter
# Bottom-right: watch events
tmux send-keys -t "$SESSION_NAME:cluster.3" \
"kubectl get events -n $K8S_NAMESPACE -w --sort-by=.lastTimestamp" C-m
# ── Window 2: Terraform ─────────────────────────────────────────────
TF_DIR="$INFRA_DIR/terraform"
[[ ! -d "$TF_DIR" ]] && TF_DIR="$INFRA_DIR"
tmux new-window -t "$SESSION_NAME" -n "terraform" -c "$TF_DIR"
tmux send-keys -t "$SESSION_NAME:terraform" \
"terraform init 2>/dev/null; terraform workspace list" C-m
tmux split-window -h -l 40% -t "$SESSION_NAME:terraform" -c "$TF_DIR"
tmux send-keys -t "$SESSION_NAME:terraform.1" \
"# terraform plan -out=tfplan" Enter
tmux select-pane -t "$SESSION_NAME:terraform.0"
# ── Window 3: Monitoring ────────────────────────────────────────────
tmux new-window -t "$SESSION_NAME" -n "monitoring" -c "$INFRA_DIR"
tmux send-keys -t "$SESSION_NAME:monitoring" \
"# k9s OR kubectl top pods -n $K8S_NAMESPACE" Enter
# ── Window 4: SSH ────────────────────────────────────────────────────────
tmux new-window -t "$SESSION_NAME" -n "ssh" -c "$HOME"
# ── Attach ─────────────────────────────────────────────────────────
tmux select-window -t "$SESSION_NAME:cluster"
exec tmux attach-session -t "$SESSION_NAME"
Set K8S_NAMESPACE before running to target a specific namespace: K8S_NAMESPACE=production ./tmux-devops.sh ~/infra. The Terraform window auto-detects a terraform/ subdirectory, falling back to the repo root.
Script 3: Data Science Session
Data science work needs a Jupyter server running in the background, a Python REPL for quick experiments, and easy access to your data files. This script sets all of that up with a single command.
#!/usr/bin/env bash
# =============================================================================
# tmux-datasci.sh — Data Science Session
# Usage: ./tmux-datasci.sh [project-dir]
# ./tmux-datasci.sh --teardown
# =============================================================================
set -euo pipefail
PROJECT_DIR="${1:-$(pwd)}"
SESSION_NAME="datasci-$(basename "$PROJECT_DIR" | tr '.' '-')"
VENV_DIR="$PROJECT_DIR/.venv"
DATA_DIR="$PROJECT_DIR/data"
if [[ "${1:-}" == "--teardown" ]]; then
tmux kill-session -t "$SESSION_NAME" 2>/dev/null && \
echo "Removed session: $SESSION_NAME" || \
echo "No session named: $SESSION_NAME"
exit 0
fi
[[ ! -d "$PROJECT_DIR" ]] && { echo "Error: not found" >&2; exit 1; }
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
exec tmux attach-session -t "$SESSION_NAME"
fi
# Activate virtualenv if present
ACTIVATE=""
[[ -f "$VENV_DIR/bin/activate" ]] && ACTIVATE="source $VENV_DIR/bin/activate"
# ── Window 1: Jupyter + REPL ──────────────────────────────────────────
tmux new-session -d -s "$SESSION_NAME" -n "notebook" -c "$PROJECT_DIR"
# Top pane: Jupyter server
[[ -n "$ACTIVATE" ]] && \
tmux send-keys -t "$SESSION_NAME:notebook.0" "$ACTIVATE" C-m
tmux send-keys -t "$SESSION_NAME:notebook.0" \
"jupyter lab --no-browser --port=8888" C-m
# Bottom pane: Python REPL
tmux split-window -v -l 50% -t "$SESSION_NAME:notebook" -c "$PROJECT_DIR"
[[ -n "$ACTIVATE" ]] && \
tmux send-keys -t "$SESSION_NAME:notebook.1" "$ACTIVATE" C-m
tmux send-keys -t "$SESSION_NAME:notebook.1" \
"command -v ipython >/dev/null && ipython || python3" C-m
# ── Window 2: Editor + Data Explorer ────────────────────────────────
tmux new-window -t "$SESSION_NAME" -n "explore" -c "$PROJECT_DIR"
[[ -n "$ACTIVATE" ]] && \
tmux send-keys -t "$SESSION_NAME:explore.0" "$ACTIVATE" C-m
tmux send-keys -t "$SESSION_NAME:explore.0" "nvim ." C-m
tmux split-window -h -l 35% -t "$SESSION_NAME:explore" \
-c "${DATA_DIR:-$PROJECT_DIR}"
if [[ -d "$DATA_DIR" ]]; then
tmux send-keys -t "$SESSION_NAME:explore.1" "ls -lhS" C-m
else
tmux send-keys -t "$SESSION_NAME:explore.1" \
"echo 'No data/ dir — create it or symlink.'" C-m
fi
# ── Window 3: Shell ──────────────────────────────────────────────────────
tmux new-window -t "$SESSION_NAME" -n "shell" -c "$PROJECT_DIR"
[[ -n "$ACTIVATE" ]] && \
tmux send-keys -t "$SESSION_NAME:shell" "$ACTIVATE" C-m
# ── Attach ─────────────────────────────────────────────────────────
tmux select-window -t "$SESSION_NAME:notebook"
tmux select-pane -t "$SESSION_NAME:notebook.1"
exec tmux attach-session -t "$SESSION_NAME"
The script auto-detects a .venv directory and activates it in every pane. It also checks for a data/ subdirectory and shows file sizes sorted largest-first — useful for spotting which datasets you’re working with.
Script 4: Microservices Session
When you work on multiple services simultaneously, you need one window per service. Each window opens an editor in the top pane and runs the service in the bottom pane. This script scans a monorepo’s services/ directory and creates windows dynamically.
#!/usr/bin/env bash
# =============================================================================
# tmux-microservices.sh — Microservices Session
# Usage: ./tmux-microservices.sh <mono-repo-root>
# ./tmux-microservices.sh --teardown
# Expects: repo/services/api-gateway/, repo/services/user-service/, ...
# =============================================================================
set -euo pipefail
REPO_DIR="${1:-$(pwd)}"
SESSION_NAME="microservices"
SERVICES_DIR="$REPO_DIR/services"
if [[ "${1:-}" == "--teardown" ]]; then
tmux kill-session -t "$SESSION_NAME" 2>/dev/null && \
echo "Removed session: $SESSION_NAME" || \
echo "No session named: $SESSION_NAME"
exit 0
fi
[[ ! -d "$REPO_DIR" ]] && { echo "Error: '$REPO_DIR' not found" >&2; exit 1; }
REPO_DIR="$(cd "$REPO_DIR" && pwd)"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
exec tmux attach-session -t "$SESSION_NAME"
fi
# ── Auto-detect run command per service ─────────────────────────────
detect_run_cmd() {
local dir="$1"
if [[ -f "$dir/package.json" ]]; then echo "npm run dev"
elif [[ -f "$dir/Makefile" ]]; then echo "make run"
elif [[ -f "$dir/main.go" ]]; then echo "go run ."
elif [[ -f "$dir/Cargo.toml" ]]; then echo "cargo run"
elif [[ -f "$dir/manage.py" ]]; then echo "python manage.py runserver"
else echo "echo 'No project file found — start manually.'"
fi
}
# ── Window 1: Docker Compose ─────────────────────────────────────────
tmux new-session -d -s "$SESSION_NAME" -n "docker" -c "$REPO_DIR"
tmux send-keys -t "$SESSION_NAME:docker" \
"docker compose ps 2>/dev/null || echo 'compose not running'" C-m
tmux split-window -v -l 40% -t "$SESSION_NAME:docker" -c "$REPO_DIR"
tmux send-keys -t "$SESSION_NAME:docker.1" \
"# docker compose up -d && docker compose logs -f" Enter
# ── One window per service ───────────────────────────────────────────
if [[ -d "$SERVICES_DIR" ]]; then
for svc_dir in "$SERVICES_DIR"/*/; do
[[ ! -d "$svc_dir" ]] && continue
svc_name="$(basename "$svc_dir")"
run_cmd="$(detect_run_cmd "$svc_dir")"
tmux new-window -t "$SESSION_NAME" -n "$svc_name" -c "$svc_dir"
tmux send-keys -t "$SESSION_NAME:$svc_name.0" "nvim ." C-m
tmux split-window -v -l 35% \
-t "$SESSION_NAME:$svc_name" -c "$svc_dir"
tmux send-keys -t "$SESSION_NAME:$svc_name.1" "$run_cmd" C-m
tmux select-pane -t "$SESSION_NAME:$svc_name.0"
done
else
echo "Warning: $SERVICES_DIR not found" >&2
tmux new-window -t "$SESSION_NAME" -n "app" -c "$REPO_DIR"
fi
tmux select-window -t "$SESSION_NAME:docker"
exec tmux attach-session -t "$SESSION_NAME"
The detect_run_cmd function inspects each service directory for project files — package.json, Makefile, main.go, Cargo.toml, or manage.py — and picks the right start command. Extend it with your own conventions.
Script 5: Smart Session from .tmux-project
This is the most flexible approach. Instead of hardcoding layouts in a script, you drop a .tmux-project file in any project directory. A single “smart session” script reads that file and builds the session from it. Every project can define its own tmux layout without writing a new bash script.
The .tmux-project file format
# .tmux-project — Drop this in any project root
# Format: window_name:split_type:pane0_cmd:pane1_cmd[:pane2_cmd...]
# split_type: "horizontal" (side-by-side) or "vertical" (top/bottom)
# Use "none" for a single-pane window
# Use "-" for an empty pane (no command sent)
code:vertical:nvim .:npm run dev
tests:none:npm test -- --watch
shell:none:-
docs:horizontal:nvim README.md:grip --browser false
The smart session script
#!/usr/bin/env bash
# =============================================================================
# tmux-smart.sh — Reads .tmux-project files to build sessions
# Usage: tmux-smart.sh [directory]
# tmux-smart.sh [directory] --teardown
# =============================================================================
set -euo pipefail
PROJECT_DIR="${1:-$(pwd)}"
SESSION_NAME="$(basename "$PROJECT_DIR" | tr './ ' '---')"
if [[ "${2:-}" == "--teardown" || "${1:-}" == "--teardown" ]]; then
tmux kill-session -t "$SESSION_NAME" 2>/dev/null && \
echo "Removed session: $SESSION_NAME" || \
echo "No session named: $SESSION_NAME"
exit 0
fi
[[ ! -d "$PROJECT_DIR" ]] && { echo "Error: not found" >&2; exit 1; }
PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)"
CONFIG_FILE="$PROJECT_DIR/.tmux-project"
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
exec tmux attach-session -t "$SESSION_NAME"
fi
# ── Window builder function ──────────────────────────────────────────
# Args: $1=window_name $2=split_type $3+=pane commands
build_window() {
local win_name="$1" split_type="$2"
shift 2
local pane_cmds=("$@")
if [[ "$FIRST_WINDOW" == "true" ]]; then
tmux rename-window -t "$SESSION_NAME:0" "$win_name"
FIRST_WINDOW="false"
else
tmux new-window -t "$SESSION_NAME" -n "$win_name" -c "$PROJECT_DIR"
fi
# First pane command
local first_cmd="${pane_cmds[0]}"
if [[ "$first_cmd" != "-" && -n "$first_cmd" ]]; then
tmux send-keys -t "$SESSION_NAME:$win_name.0" "$first_cmd" C-m
fi
# Additional panes
local pane_idx=1
for cmd in "${pane_cmds[@]:1}"; do
if [[ "$split_type" == "horizontal" ]]; then
tmux split-window -h -t "$SESSION_NAME:$win_name" -c "$PROJECT_DIR"
else
tmux split-window -v -t "$SESSION_NAME:$win_name" -c "$PROJECT_DIR"
fi
if [[ "$cmd" != "-" && -n "$cmd" ]]; then
tmux send-keys -t "$SESSION_NAME:$win_name.$pane_idx" "$cmd" C-m
fi
((pane_idx++))
done
# Balance panes
if [[ "$split_type" == "horizontal" ]]; then
tmux select-layout -t "$SESSION_NAME:$win_name" even-horizontal
elif [[ "${#pane_cmds[@]}" -gt 1 ]]; then
tmux select-layout -t "$SESSION_NAME:$win_name" even-vertical
fi
tmux select-pane -t "$SESSION_NAME:$win_name.0"
}
# ── Create session and parse config ─────────────────────────────────
tmux new-session -d -s "$SESSION_NAME" -c "$PROJECT_DIR"
FIRST_WINDOW="true"
if [[ -f "$CONFIG_FILE" ]]; then
echo "Reading: $CONFIG_FILE"
while IFS= read -r line || [[ -n "$line" ]]; do
[[ "$line" =~ ^[[:space:]]*# ]] && continue
[[ -z "${line// /}" ]] && continue
IFS=':' read -r win_name split_type rest <<< "$line"
IFS=':' read -ra cmds <<< "$rest"
if [[ "$split_type" == "none" ]]; then
build_window "$win_name" "vertical" "${cmds[@]}"
else
build_window "$win_name" "$split_type" "${cmds[@]}"
fi
done < "$CONFIG_FILE"
else
echo "No .tmux-project found — creating default session."
tmux rename-window -t "$SESSION_NAME:0" "shell"
fi
tmux select-window -t "$SESSION_NAME:{start}"
exec tmux attach-session -t "$SESSION_NAME"
Here’s a .tmux-project for a Go microservice with different needs:
# .tmux-project — Go API service
code:vertical:nvim .:go run ./cmd/server
tests:none:go test ./... -v -count=1
db:horizontal:psql $DATABASE_URL:redis-cli
logs:none:tail -f /var/log/myapp/app.log
Making Scripts Globally Available
Drop your scripts into a directory on your PATH so you can invoke them from anywhere. Here’s the recommended setup:
# 1. Create a bin directory for your tmux scripts
mkdir -p ~/.local/bin/tmux-scripts
# 2. Move scripts there and make them executable
cp tmux-webdev.sh tmux-devops.sh tmux-datasci.sh \
tmux-microservices.sh tmux-smart.sh ~/.local/bin/tmux-scripts/
chmod +x ~/.local/bin/tmux-scripts/*
# 3. Add to PATH (in .bashrc or .zshrc)
echo 'export PATH="$HOME/.local/bin/tmux-scripts:$PATH"' >> ~/.zshrc
# 4. Create short aliases
cat >> ~/.zshrc << 'EOF'
alias tw='tmux-webdev.sh'
alias td='tmux-devops.sh'
alias tds='tmux-datasci.sh'
alias tms='tmux-microservices.sh'
alias ts='tmux-smart.sh'
# Universal: cd into a project and auto-start its session
tp() { cd "$1" && tmux-smart.sh "$(pwd)"; }
EOF
Pair the smart script with fzf to pick projects interactively: ts "$(find ~/projects -maxdepth 1 -type d | fzf)". This gives you a fuzzy-searchable project launcher in one line.
Key Tmux Flags Reference
These are the flags you’ll use repeatedly when writing bootstrap scripts. Keep this table handy.
| Command + Flag | What it does |
|---|---|
new-session -d -s NAME |
Create a detached session with a given name |
new-session -n WINDOW |
Name the first window during session creation |
new-window -t SESSION -n NAME -c DIR |
Add a named window with a starting directory |
split-window -v -l 30% |
Vertical split (top/bottom), new pane gets 30% height |
split-window -h -l 40% |
Horizontal split (side-by-side), new pane gets 40% width |
send-keys -t TARGET "cmd" C-m |
Type a command and press Enter in the target pane |
select-pane -t SESSION:WIN.PANE |
Set which pane is focused when you view that window |
select-window -t SESSION:WIN |
Set which window is visible when you attach |
select-layout even-vertical |
Redistribute panes to equal heights |
has-session -t NAME |
Exit 0 if session exists, exit 1 if not (for conditionals) |
When you split pane 0 vertically, the new pane becomes pane 1. But if you then split pane 0 horizontally, existing panes get renumbered. Always target panes right after creating them, or use select-layout after all splits are done. When in doubt, run tmux list-panes -t SESSION:WINDOW to see the current numbering.
Tmuxinator and tmuxp: Declarative Session Layouts
Stop rebuilding your tmux workspace by hand every morning. Declarative session managers let you define windows, panes, and startup commands in a YAML file, then launch the entire layout with a single command.
Two tools dominate this space: tmuxinator (Ruby) and tmuxp (Python). Both solve the same problem — reproducing tmux sessions from config files — but differ in ecosystem, syntax, and a few key features. This section walks through both, side by side.
Tmuxinator: The Ruby-Based Classic
Installation
# macOS (Homebrew)
brew install tmuxinator
# Any system with Ruby / RubyGems
gem install tmuxinator
# Verify
tmuxinator version
Tmuxinator stores project configs in ~/.config/tmuxinator/ (or ~/.tmuxinator/ on older setups). Create a new project with:
# Creates and opens ~/.config/tmuxinator/myapp.yml in $EDITOR
tmuxinator new myapp
YAML Schema Walkthrough
Here is every key field you will use in a tmuxinator config:
name: myapp # Session name in tmux
root: ~/projects/myapp # Every pane cds here first
# Run in every pane before pane-specific commands
pre_window: nvm use 18
# tmux options (optional)
startup_window: editor # Focus this window on launch
startup_pane: 0 # Focus this pane within the window
windows:
- editor: # Window name
layout: main-vertical # tmux layout preset
panes:
- vim . # Pane 1 command
- guard # Pane 2 command
- server: rails server # Single-pane window (shorthand)
- logs:
layout: even-horizontal
panes:
- tail -f log/development.log
- tail -f log/sidekiq.log
The layout field accepts any of tmux's five presets: even-horizontal, even-vertical, main-horizontal, main-vertical, and tiled. You can also paste a custom layout string from tmux list-windows.
Real Project Configs
Config 1 — Full-Stack Web App (Rails + React)
name: webapp
root: ~/code/webapp
pre_window: export RAILS_ENV=development
startup_window: code
windows:
- code:
layout: main-vertical
panes:
- vim .
- # empty shell for git / file ops
- servers:
layout: even-horizontal
panes:
- cd backend && rails server -p 3000
- cd frontend && npm run dev
- console:
panes:
- rails console
- logs:
layout: even-vertical
panes:
- tail -f log/development.log
- cd frontend && npm run lint -- --watch
Config 2 — Microservices Dev Environment
name: platform
root: ~/code/platform
windows:
- gateway:
root: ~/code/platform/api-gateway
panes:
- go run ./cmd/server
- go test ./... -v --count=1
- auth-service:
root: ~/code/platform/auth-svc
panes:
- cargo watch -x run
- cargo watch -x test
- infra:
layout: tiled
panes:
- docker compose up
- docker compose logs -f postgres
- redis-cli monitor
- k9s
Config 3 — Data Science / Jupyter Workflow
name: datascience
root: ~/research/nlp-project
pre_window: conda activate nlp-env
windows:
- notebook:
panes:
- jupyter lab --no-browser --port 8888
- code:
layout: main-vertical
panes:
- vim src/
- python -m pytest tests/ -x --tb=short
- gpu:
panes:
- watch -n 2 nvidia-smi
Essential Commands
# Launch a project
tmuxinator start webapp
# Launch with a different root directory
tmuxinator start webapp --root=~/code/webapp-v2
# Stop a project (kills the tmux session)
tmuxinator stop webapp
# List all configured projects
tmuxinator list
# Edit an existing project config
tmuxinator edit webapp
# Delete a project config
tmuxinator delete webapp
tmuxp: The Python-Native Alternative
Installation
# Via pip (Python 3.8+)
pip install tmuxp
# Via pipx (isolated install — recommended)
pipx install tmuxp
# macOS
brew install tmuxp
# Verify
tmuxp --version
tmuxp configs live in ~/.config/tmuxp/ by default, but you can load any YAML or JSON file from any path. There is no tmuxp new command — you create a .yaml file directly.
YAML Schema Walkthrough
session_name: myapp
start_directory: ~/projects/myapp
# Environment variables (optional)
environment:
NODE_ENV: development
windows:
- window_name: editor
layout: main-vertical
panes:
- shell_command:
- vim .
- shell_command:
- git status
- window_name: server
panes:
- shell_command:
- npm run dev
- window_name: logs
layout: even-horizontal
panes:
- shell_command:
- tail -f /var/log/app.log
- shell_command:
- tail -f /var/log/worker.log
Key differences from tmuxinator: the root directory is start_directory (not root), pane commands use shell_command (a list of strings, each executed sequentially), and there is an explicit environment block instead of pre_window. Each pane can also override start_directory individually.
Equivalent Configs in tmuxp
Here are the same three project configs from above, translated to tmuxp syntax.
Config 1 — Full-Stack Web App
session_name: webapp
start_directory: ~/code/webapp
environment:
RAILS_ENV: development
windows:
- window_name: code
layout: main-vertical
focus: true
panes:
- shell_command:
- vim .
- # empty shell
- window_name: servers
layout: even-horizontal
panes:
- shell_command:
- cd backend && rails server -p 3000
- shell_command:
- cd frontend && npm run dev
- window_name: console
panes:
- shell_command:
- rails console
- window_name: logs
layout: even-vertical
panes:
- shell_command:
- tail -f log/development.log
- shell_command:
- cd frontend && npm run lint -- --watch
Config 2 — Microservices Dev Environment
session_name: platform
start_directory: ~/code/platform
windows:
- window_name: gateway
start_directory: ~/code/platform/api-gateway
panes:
- shell_command:
- go run ./cmd/server
- shell_command:
- go test ./... -v --count=1
- window_name: auth-service
start_directory: ~/code/platform/auth-svc
panes:
- shell_command:
- cargo watch -x run
- shell_command:
- cargo watch -x test
- window_name: infra
layout: tiled
panes:
- shell_command:
- docker compose up
- shell_command:
- docker compose logs -f postgres
- shell_command:
- redis-cli monitor
- shell_command:
- k9s
Config 3 — Data Science / Jupyter Workflow
session_name: datascience
start_directory: ~/research/nlp-project
windows:
- window_name: notebook
panes:
- shell_command:
- conda activate nlp-env
- jupyter lab --no-browser --port 8888
- window_name: code
layout: main-vertical
panes:
- shell_command:
- conda activate nlp-env
- vim src/
- shell_command:
- conda activate nlp-env
- python -m pytest tests/ -x --tb=short
- window_name: gpu
panes:
- shell_command:
- watch -n 2 nvidia-smi
tmuxp has no pre_window equivalent. If you need a command like conda activate in every pane, you must add it to each pane's shell_command list. Tmuxinator's pre_window handles this more cleanly for projects where every pane shares the same setup step.
tmuxp's Feature: Freezing Sessions
tmuxp can snapshot your current tmux session and export it as a config file. This is the fastest way to get started — arrange your layout manually, then freeze it:
# Freeze the current session to a YAML file
tmuxp freeze my-session -o ~/frozen-session.yaml
# Review the generated config, then reload it
tmuxp load ~/frozen-session.yaml
The frozen output captures window names, pane layouts, working directories, and active commands. You will likely want to clean up the file (remove blank panes, simplify paths), but it gives you 80% of the config instantly.
Essential tmuxp Commands
# Load a config (from ~/.config/tmuxp/ or any path)
tmuxp load webapp.yaml
# Load and attach immediately (skip confirmation prompt)
tmuxp load webapp.yaml -y
# Load from current directory (.tmuxp.yaml)
tmuxp load .
# Freeze a running session
tmuxp freeze my-session
# List available configs in default directory
ls ~/.config/tmuxp/
Head-to-Head: tmuxinator vs tmuxp
| Feature | tmuxinator | tmuxp |
|---|---|---|
| Language / Runtime | Ruby (gem) | Python (pip / pipx) |
| Config format | YAML only | YAML or JSON |
| Config location | ~/.config/tmuxinator/ |
~/.config/tmuxp/ or any path |
| Scaffold new config | tmuxinator new |
Manual file creation |
| Pre-pane hooks | pre_window (runs in every pane) |
No equivalent — repeat per pane |
| Session freezing | ❌ Not supported | ✅ tmuxp freeze |
| Environment variables | Via pre_window export |
Dedicated environment block |
| Per-pane directory | Per-window root: override |
Per-window or per-pane start_directory |
| Multi-command per pane | Single string (use &&) |
List of commands (sequential) |
| Startup window focus | startup_window |
focus: true on a window |
| Stop / end session | tmuxinator stop |
tmux kill-session -t name |
| Community / age | Older, larger community | Active, Python-ecosystem users |
Use tmuxinator if you already have Ruby on your system (macOS ships with it), want the pre_window convenience, or prefer the polished CLI with new, edit, stop, and list subcommands.
Use tmuxp if Python is your primary ecosystem, you want to freeze existing sessions into configs, or you need per-pane directory control and JSON support.
Shell Aliases and Auto-Loading
Once your configs are stable, wire them into your shell for one-keystroke launches.
Quick Aliases
# Tmuxinator aliases
alias mux="tmuxinator"
alias muxs="tmuxinator start"
alias muxe="tmuxinator edit"
alias muxl="tmuxinator list"
# tmuxp aliases
alias tp="tmuxp load"
alias tpf="tmuxp freeze"
# Quick project launchers
alias work="tmuxinator start webapp"
alias data="tmuxp load datascience.yaml -y"
Directory-Based Auto-Loading
The real power move: automatically load a session config when you cd into a project directory. Place a .tmuxinator.yml or .tmuxp.yaml at the project root, then add this function to your shell config:
# Auto-start tmuxinator when entering a project dir
tmux_auto() {
if [ -f .tmuxinator.yml ] && [ -z "$TMUX" ]; then
local project_name
project_name=$(basename "$PWD")
# Check if session already exists
if ! tmux has-session -t "$project_name" 2>/dev/null; then
tmuxinator start "$project_name" \
--project-config=.tmuxinator.yml
else
tmux attach-session -t "$project_name"
fi
fi
}
# Hook into cd
cd() { builtin cd "$@" && tmux_auto; }
# Auto-start tmuxp when entering a project dir
tmux_auto() {
if [ -f .tmuxp.yaml ] && [ -z "$TMUX" ]; then
local session_name
session_name=$(grep 'session_name:' .tmuxp.yaml \
| head -1 | awk '{print $2}')
if ! tmux has-session -t "$session_name" 2>/dev/null; then
tmuxp load .tmuxp.yaml -y
else
tmux attach-session -t "$session_name"
fi
fi
}
# Hook into cd
cd() { builtin cd "$@" && tmux_auto; }
With this setup, you drop a config file in any project root. When you cd into that directory outside of tmux, the session boots automatically. If the session already exists, it reattaches instead of creating a duplicate.
The [ -z "$TMUX" ] check is critical. Without it, running cd inside a tmux pane would attempt to start a session inside a session, which either fails or creates confusing nested tmux instances. Always include this guard.
Project-Local Config Files
For team-shared configs, commit the file to your repo. Both tools support loading from a local path:
# Load tmuxinator from a project-local file
tmuxinator start --project-config=./tmux/dev.yml
# Load tmuxp from a project-local file
tmuxp load ./tmux/dev.yaml -y
# Typical project structure:
# myproject/
# ├── .tmuxinator.yml <- tmuxinator auto-detects this
# ├── .tmuxp.yaml <- tmuxp loads with `tmuxp load .`
# ├── src/
# └── README.md
FZF-Powered Session Manager: Build It Yourself
The single biggest tmux productivity upgrade isn't a plugin — it's a 60-line bash script that lets you fuzzy-find, create, and destroy sessions instantly. You're going to build it from scratch, one feature at a time, so you understand every line and can customize it to your workflow.
By the end you'll have a session manager that lists existing sessions with live previews, offers project directories as new sessions, integrates with zoxide for frecency sorting, and supports multi-select session removal. All bound to a single key.
Prerequisites
You need fzf installed and on your $PATH. If you haven't already:
# macOS
brew install fzf
# Ubuntu/Debian
sudo apt install fzf
# From source (any Linux)
git clone --depth 1 https://github.com/junegunn/fzf.git ~/.fzf
~/.fzf/install
Iteration 1: The Minimal Session Switcher
Start with the simplest useful version — list all tmux sessions, pick one, switch to it. Save this as ~/.local/bin/tmux-sessionizer:
#!/usr/bin/env bash
# tmux-sessionizer v1: basic session switcher
session=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | \
fzf --height=40% --reverse --prompt="Switch session: ")
# Exit if nothing was selected (user pressed Esc)
[[ -z "$session" ]] && exit 0
# If we're inside tmux, switch client. Otherwise, attach.
if [[ -n "$TMUX" ]]; then
tmux switch-client -t "$session"
else
tmux attach-session -t "$session"
fi
chmod +x ~/.local/bin/tmux-sessionizer
That's the skeleton. The key insight is the -F "#{session_name}" format — it gives fzf clean names to filter on instead of the verbose default list-sessions output. The if/else handles the two contexts you'll invoke this from: inside tmux (switch) and from a bare terminal (attach).
Iteration 2: Add Preview Windows
Fuzzy-picking a name is fine, but seeing what's inside the session before you switch is better. fzf's --preview flag lets you run any command and display its output in a side pane. We'll use tmux list-windows to show the window tree.
#!/usr/bin/env bash
# tmux-sessionizer v2: with preview pane
session=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | \
fzf --height=60% --reverse \
--prompt="Switch session: " \
--preview="tmux list-windows -t {} -F ' #{window_index}: #{window_name} [#{window_layout}] #{?window_active,(active),}'" \
--preview-window=right:50%)
[[ -z "$session" ]] && exit 0
if [[ -n "$TMUX" ]]; then
tmux switch-client -t "$session"
else
tmux attach-session -t "$session"
fi
The {} in the preview command is replaced by fzf with the currently highlighted session name. As you arrow through sessions, the preview updates in real time showing every window, its layout type, and which one is active. You could also use capture-pane for a richer preview — but the window list is faster and less noisy.
Iteration 3: Create Sessions from Project Directories
Switching sessions is only half the story. The real power is creating a session for any project with one keystroke. We'll scan your project directories, combine them with existing sessions, and auto-create sessions for new picks.
#!/usr/bin/env bash
# tmux-sessionizer v3: sessions + project directories
# ── Configuration ──
PROJECT_DIRS=(~/projects ~/work ~/personal)
# ── Gather candidates ──
# Existing tmux sessions
existing=$(tmux list-sessions -F "#{session_name}" 2>/dev/null)
# Project directories (one level deep)
projects=""
for dir in "${PROJECT_DIRS[@]}"; do
[[ -d "$dir" ]] && projects+=$(find "$dir" -mindepth 1 -maxdepth 1 -type d)$'\n'
done
# ── Present unified picker ──
selected=$(printf "%s\n%s" "$existing" "$projects" | \
sed '/^$/d' | \
fzf --height=60% --reverse \
--prompt="Session/Project: " \
--preview='if tmux has-session -t {##*/} 2>/dev/null; then
tmux list-windows -t {##*/} -F " #{window_index}: #{window_name} [#{window_layout}]"
else
ls -la {} 2>/dev/null || echo "tmux session: {}"
fi' \
--preview-window=right:50%)
[[ -z "$selected" ]] && exit 0
# ── Derive session name (directory basename, dots replaced) ──
session_name=$(basename "$selected" | tr '.' '_')
# ── Create session if it doesn't exist ──
if ! tmux has-session -t="$session_name" 2>/dev/null; then
if [[ -d "$selected" ]]; then
tmux new-session -d -s "$session_name" -c "$selected"
else
tmux new-session -d -s "$session_name"
fi
fi
# ── Switch or attach ──
if [[ -n "$TMUX" ]]; then
tmux switch-client -t "$session_name"
else
tmux attach-session -t "$session_name"
fi
The PROJECT_DIRS array is the only thing you customize — point it at wherever you keep code. The script merges existing sessions and directory paths into one fzf list. When you pick a directory, it creates a new session named after the folder and cd'd into it. When you pick an existing session name, it just switches.
Note the tr '.' '_' — tmux session names can't contain dots. If your project is my.cool.app, the session becomes my_cool_app.
Iteration 4: Destroy Sessions with Multi-Select
After a week of work, you will accumulate a dozen orphan sessions. Rather than removing them one-by-one with tmux kill-session -t name, add a destroy mode with fzf's multi-select (Tab to select, Enter to confirm).
We will use fzf's --bind and --header to add a key that toggles into this mode:
#!/usr/bin/env bash
# tmux-sessionizer v4: with session destroy mode (ctrl-x)
PROJECT_DIRS=(~/projects ~/work ~/personal)
DESTROY_MODE=false
# Parse flags
while [[ "${1:-}" == "--destroy" ]]; do DESTROY_MODE=true; shift; done
if $DESTROY_MODE; then
# ── Destroy mode: multi-select existing sessions ──
sessions=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | \
fzf --multi --height=40% --reverse \
--prompt="Destroy sessions (Tab=select, Enter=confirm): " \
--header="Select sessions to destroy" \
--preview="tmux list-windows -t {} -F ' #{window_index}: #{window_name}'")
[[ -z "$sessions" ]] && exit 0
while IFS= read -r s; do
tmux kill-session -t "$s" && echo "Destroyed: $s"
done <<< "$sessions"
exit 0
fi
# ── Normal mode (same as v3) ──
existing=$(tmux list-sessions -F "#{session_name}" 2>/dev/null)
projects=""
for dir in "${PROJECT_DIRS[@]}"; do
[[ -d "$dir" ]] && projects+=$(find "$dir" -mindepth 1 -maxdepth 1 -type d)$'\n'
done
selected=$(printf "%s\n%s" "$existing" "$projects" | \
sed '/^$/d' | \
fzf --height=60% --reverse \
--prompt="Session/Project: " \
--header="ctrl-x: destroy sessions" \
--bind="ctrl-x:execute(tmux-sessionizer --destroy)+abort" \
--preview='if tmux has-session -t {##*/} 2>/dev/null; then
tmux list-windows -t {##*/} -F " #{window_index}: #{window_name} [#{window_layout}]"
else
ls -la {} 2>/dev/null || echo "tmux session: {}"
fi' \
--preview-window=right:50%)
[[ -z "$selected" ]] && exit 0
session_name=$(basename "$selected" | tr '.' '_')
if ! tmux has-session -t="$session_name" 2>/dev/null; then
if [[ -d "$selected" ]]; then
tmux new-session -d -s "$session_name" -c "$selected"
else
tmux new-session -d -s "$session_name"
fi
fi
if [[ -n "$TMUX" ]]; then
tmux switch-client -t "$session_name"
else
tmux attach-session -t "$session_name"
fi
The --bind="ctrl-x:execute(...)+abort" is the magic — pressing Ctrl-x while in the picker launches the same script in --destroy mode, then closes the original picker. In destroy mode, --multi enables Tab-selection so you can nuke five stale sessions in one go.
Iteration 5: Zoxide Integration for Smart Sorting
Zoxide knows which directories you actually use. It tracks "frecency" (frequency x recency) so your most-used projects float to the top. This is the final upgrade that makes the script feel almost telepathic.
#!/usr/bin/env bash
# tmux-sessionizer v5 — the complete session manager
# Dependencies: fzf, zoxide (optional)
set -euo pipefail
PROJECT_DIRS=(~/projects ~/work ~/personal)
# ── Destroy mode ──
if [[ "${1:-}" == "--destroy" ]]; then
sessions=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | \
fzf --multi --height=40% --reverse \
--prompt="Destroy (Tab=select): " \
--preview="tmux list-windows -t {} -F ' #{window_index}: #{window_name}'")
[[ -z "$sessions" ]] && exit 0
while IFS= read -r s; do
tmux kill-session -t "$s" && echo "Destroyed: $s"
done <<< "$sessions"
exit 0
fi
# ── Gather existing sessions ──
existing=$(tmux list-sessions -F "⚡ #{session_name}" 2>/dev/null || true)
# ── Gather project directories ──
projects=""
if command -v zoxide &>/dev/null; then
# Zoxide: frecency-sorted directories (filter to project dirs)
for dir in "${PROJECT_DIRS[@]}"; do
[[ -d "$dir" ]] && projects+=$(zoxide query -l | grep "^${dir}" | head -20)$'\n'
done
else
# Fallback: flat directory scan
for dir in "${PROJECT_DIRS[@]}"; do
[[ -d "$dir" ]] && projects+=$(find "$dir" -mindepth 1 -maxdepth 1 -type d | sort)$'\n'
done
fi
# Prefix project dirs for visual distinction
projects=$(echo "$projects" | sed '/^$/d' | while read -r p; do echo "📁 $p"; done)
# ── Unified picker ──
selected=$(printf "%s\n%s" "$existing" "$projects" | sed '/^$/d' | \
fzf --height=70% --reverse --ansi \
--prompt=" " \
--header="Enter=switch/create | ctrl-x=destroy | ctrl-r=refresh" \
--bind="ctrl-x:execute(tmux-sessionizer --destroy)+abort" \
--bind="ctrl-r:reload(tmux list-sessions -F '⚡ #{session_name}' 2>/dev/null)" \
--preview='
name=$(echo {} | sed "s/^[^ ]* //")
if tmux has-session -t "${name##*/}" 2>/dev/null; then
echo "── Session: ${name##*/} ──"
tmux list-windows -t "${name##*/}" \
-F " #{window_index}:#{window_name} #{window_panes} panes [#{window_layout}] #{?window_active,<- active,}"
echo ""
echo "── Pane Tree ──"
tmux list-panes -t "${name##*/}" \
-F " #{window_index}.#{pane_index}: #{pane_current_command} (#{pane_width}x#{pane_height})"
elif [[ -d "$name" ]]; then
echo "── Project: $name ──"
ls -1 "$name" | head -30
fi
' \
--preview-window=right:50%:wrap)
[[ -z "$selected" ]] && exit 0
# ── Strip the emoji prefix, extract path or name ──
selected=$(echo "$selected" | sed 's/^[^ ]* //')
session_name=$(basename "$selected" | tr './ ' '___')
# ── Create if needed ──
if ! tmux has-session -t="$session_name" 2>/dev/null; then
if [[ -d "$selected" ]]; then
tmux new-session -d -s "$session_name" -c "$selected"
else
tmux new-session -d -s "$session_name"
fi
fi
# ── Switch or attach ──
if [[ -n "${TMUX:-}" ]]; then
tmux switch-client -t "$session_name"
else
tmux attach-session -t "$session_name"
fi
This is the complete script. Here's what changed from v4:
- Zoxide integration —
zoxide query -lreturns all tracked directories sorted by frecency. We filter to paths under yourPROJECT_DIRSso randomcd'd directories don't pollute the list. Falls back tofindif zoxide isn't installed. - Emoji prefixes —
⚡for existing sessions,📁for project directories. These get stripped before use but make the picker scannable at a glance. - Richer preview — Shows both the window list and full pane tree (command, dimensions) for existing sessions. For directories, shows a file listing.
ctrl-rto refresh — Reloads the session list without restarting the script. Useful if another terminal just created a session.set -euo pipefail— Proper bash error handling for a script you will run thousands of times.
Binding the Script to Keys
A session manager is only useful if it's one keystroke away. You need two bindings: one inside tmux and one in your shell.
Tmux Binding (prefix + f)
Add this to your ~/.tmux.conf:
# Session manager — prefix + f
bind-key f run-shell "tmux neww ~/.local/bin/tmux-sessionizer"
# Destroy mode shortcut — prefix + K
bind-key K run-shell "tmux neww ~/.local/bin/tmux-sessionizer --destroy"
The run-shell "tmux neww ..." pattern opens the script in a temporary new window. When fzf closes (on selection or Esc), the window auto-destroys and you land on the selected session. This is better than display-popup (tmux 3.2+) for compatibility, but if you're on a newer tmux:
# Floating popup — feels much snappier
bind-key f display-popup -E -w 80% -h 80% "~/.local/bin/tmux-sessionizer"
Shell Binding (Ctrl+f)
For when you're in a terminal but not in tmux yet — add to your ~/.bashrc or ~/.zshrc:
# Ctrl+f → tmux session manager
bindkey -s '^f' 'tmux-sessionizer\n' # zsh
# bind -x '"\C-f": tmux-sessionizer' # bash alternative
Now Ctrl+f from a bare terminal launches the picker, creates or attaches to your chosen session, and drops you in. It works whether tmux is running or not — if no server exists, the new-session call starts one.
Resist the urge to add more features immediately. Use this script for a week first. You'll discover what you actually need — maybe you want git branch info in the preview, or maybe you want fd instead of find. The point of building it yourself is that you can evolve it with your workflow.
Quick Reference: The Feature Map
| Feature | Key / Flag | What It Does |
|---|---|---|
| Switch session | Enter | Jump to highlighted session |
| Create session | Enter on a directory | Creates named session, cd'd to project |
| Destroy sessions | Ctrl-x, then Tab + Enter | Multi-select and destroy stale sessions |
| Refresh list | Ctrl-r | Reload sessions without restarting picker |
| Preview panes | Automatic | Right pane shows window/pane tree or file list |
| Frecency sort | Automatic (with zoxide) | Most-used projects appear first |
Extending Further
Once the core script is working, here are the most useful extensions — each is a 3-5 line addition:
# Rename session: Ctrl-n prompts for new name
--bind='ctrl-n:execute(
read -p "New name: " name;
tmux rename-session -t {##*/} "$name"
)+reload(tmux list-sessions -F "#{session_name}")'
# Git branch in preview: append to the preview block
echo "── Git ──"
git -C "$name" branch --show-current 2>/dev/null
git -C "$name" log --oneline -5 2>/dev/null
# Use fd instead of find (much faster on large dirs)
projects+=$(fd -t d --max-depth 1 . "$dir")$'\n'
Every preview command runs on each keystroke as you navigate the list. Heavy commands (like git log on a large repo, or tree on node_modules) will make fzf feel sluggish. Stick to ls, list-windows, and list-panes — they return in milliseconds. Use --preview-window=:hidden with --bind='?:toggle-preview' if your preview is expensive.
Vim/Neovim + Tmux: Seamless Pane Navigation
If you use Vim or Neovim inside tmux, you know the pain: Ctrl-w h moves between Vim splits, but prefix + h moves between tmux panes. Your brain has to track which context it's in. The vim-tmux-navigator plugin eliminates this entirely — Ctrl-h/j/k/l moves seamlessly between Vim splits and tmux panes as if they were one unified window manager.
How It Works
The trick is a two-sided contract. Tmux binds Ctrl-h/j/k/l globally, but before handling navigation itself, it checks whether the active pane is running Vim. If yes, it forwards the keypress to Vim (which handles its own split navigation). If no, tmux handles the pane switch directly.
sequenceDiagram
actor User
participant Tmux
participant Shell/Vim
User->>Tmux: Presses Ctrl-L
Tmux->>Tmux: Run is_vim check
(ps -o state=,comm= | grep -iqE 'vim|neovim')
alt Current pane IS running Vim
Tmux->>Shell/Vim: send-keys 'C-l' (forward to Vim)
Shell/Vim->>Shell/Vim: Vim moves to right split
else Current pane is NOT running Vim
Tmux->>Tmux: select-pane -R (move to right pane)
end
Both sides must be configured: the tmux side does the detection and conditional routing, and the Vim/Neovim side maps the same keys to its own split navigation commands. Without both halves, the integration breaks.
Step 1: Install the Vim/Neovim Plugin
The plugin you need is christoomey/vim-tmux-navigator. It maps Ctrl-h/j/k/l inside Vim to navigate splits — and when you hit the edge of a Vim split, it tells tmux to switch panes instead.
Neovim (Lua — lazy.nvim)
-- In your lazy.nvim plugin spec (e.g., lua/plugins/navigation.lua)
return {
"christoomey/vim-tmux-navigator",
cmd = {
"TmuxNavigateLeft",
"TmuxNavigateDown",
"TmuxNavigateUp",
"TmuxNavigateRight",
"TmuxNavigatePrevious",
},
keys = {
{ "<C-h>", "<cmd>TmuxNavigateLeft<cr>", desc = "Navigate left" },
{ "<C-j>", "<cmd>TmuxNavigateDown<cr>", desc = "Navigate down" },
{ "<C-k>", "<cmd>TmuxNavigateUp<cr>", desc = "Navigate up" },
{ "<C-l>", "<cmd>TmuxNavigateRight<cr>", desc = "Navigate right" },
},
}
Vim (vim-plug)
" In your .vimrc
Plug 'christoomey/vim-tmux-navigator'
" The plugin sets these mappings automatically:
" <C-h> => TmuxNavigateLeft
" <C-j> => TmuxNavigateDown
" <C-k> => TmuxNavigateUp
" <C-l> => TmuxNavigateRight
Step 2: Configure the Tmux Side
This is the critical half. Tmux needs to intercept Ctrl-h/j/k/l, determine if the current pane is running Vim, and either forward the key or handle navigation itself.
The is_vim Detection
The detection works by inspecting the process running in the active pane using ps. The key command is:
# Get process state + command name for the pane's PID
ps -o state= -o comm= -t '#{pane_tty}'
# Example output when vim is running:
# S+ vim
# S+ nvim
Tmux grabs the tty of the current pane, lists processes on that tty, and checks if any match a Vim-like pattern. Here's the full is_vim variable and the key bindings:
Full tmux.conf Config
# ~/.tmux.conf — vim-tmux-navigator integration
# Pattern that matches vim/neovim process names
is_vim="ps -o state= -o comm= -t '#{pane_tty}' \
| grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf)(diff)?$'"
# Bind Ctrl-h/j/k/l with conditional logic:
# if vim → forward the key to vim (send-keys)
# if not → tmux handles pane navigation (select-pane)
bind-key -n 'C-h' if-shell "$is_vim" 'send-keys C-h' 'select-pane -L'
bind-key -n 'C-j' if-shell "$is_vim" 'send-keys C-j' 'select-pane -D'
bind-key -n 'C-k' if-shell "$is_vim" 'send-keys C-k' 'select-pane -U'
bind-key -n 'C-l' if-shell "$is_vim" 'send-keys C-l' 'select-pane -R'
# Also handle Ctrl-\ for "previous pane" navigation
tmux_version='$(tmux -V | sed -En "s/^tmux ([0-9]+(.[0-9]+)?).*/\1/p")'
if-shell -b '[ "$(echo "$tmux_version >= 3.0" | bc)" = 1 ]' \
"bind-key -n 'C-\\' if-shell \"$is_vim\" 'send-keys C-\\\\' 'select-pane -l'"
# IMPORTANT: Also bind in copy-mode so scrolling doesn't break navigation
bind-key -T copy-mode-vi 'C-h' select-pane -L
bind-key -T copy-mode-vi 'C-j' select-pane -D
bind-key -T copy-mode-vi 'C-k' select-pane -U
bind-key -T copy-mode-vi 'C-l' select-pane -R
-n flag mattersbind-key -n means "no prefix required." These bindings fire immediately when you press Ctrl-h — you don't need to hit your tmux prefix first. This is what makes navigation feel instant and unified.
Step 3: Using TPM Instead (Simpler)
If you use TPM (Tmux Plugin Manager), you can skip the manual config above and let the plugin handle it:
# ~/.tmux.conf — using TPM
set -g @plugin 'christoomey/vim-tmux-navigator'
# Then reload: prefix + I to install
The TPM plugin installs the same is_vim detection and bindings automatically. Manual config gives you more control; TPM gives you less to maintain.
Edge Cases: When Detection Fails
The is_vim regex inspects processes on the local tty. This breaks in several real-world scenarios where Vim runs inside a wrapped process.
SSH Sessions
When you SSH into a remote machine and run Vim there, the local ps only sees ssh — not nvim. The regex won't match, so tmux always handles navigation itself instead of forwarding to Vim.
Fix: Add ssh to the detection pattern so tmux always forwards keys to SSH panes:
# Extended pattern: also match ssh connections
is_vim="ps -o state= -o comm= -t '#{pane_tty}' \
| grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf|ssh)(diff)?$'"
Adding ssh to the pattern means all SSH panes forward Ctrl-h/j/k/l to the remote side — even when you're not running Vim there. You'll need the plugin installed on the remote tmux too, or you'll lose local pane navigation for those panes entirely.
Docker / Container Exec
Same problem. If you run docker exec -it container nvim, the local tty shows docker not nvim. Add docker to the pattern:
# Match vim, fzf, ssh, AND docker
is_vim="ps -o state= -o comm= -t '#{pane_tty}' \
| grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf|ssh|docker)(diff)?$'"
Adding fzf Support
Notice the default regex already includes fzf. This is intentional — fzf uses Ctrl-j/Ctrl-k for list navigation. Without this, pressing Ctrl-j while fzf is open would switch tmux panes instead of moving down the fzf list.
Debugging Detection
If navigation isn't working, test the detection manually:
# Run this in a tmux pane to see what tmux sees:
ps -o state= -o comm= -t "$(tmux display-message -p '#{pane_tty}')"
# Expected output when nvim is running:
# S+ nvim
# S+ zsh
# Test the full grep match:
ps -o state= -o comm= -t "$(tmux display-message -p '#{pane_tty}')" \
| grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf)(diff)?$' \
&& echo "MATCH: would send-keys to vim" \
|| echo "NO MATCH: would select-pane"
Clipboard Sharing: Vim ↔ Tmux
Seamless navigation is only half the story. You also want yanked text in Vim to be available in tmux (and your system clipboard), and vice versa. There are two approaches.
Option A: OSC 52 (Recommended)
OSC 52 is a terminal escape sequence that tells your terminal emulator to copy text to the system clipboard. It works over SSH, inside tmux, and inside containers — no clipboard tools needed on the remote side.
# ~/.tmux.conf — enable OSC 52 clipboard
set -g set-clipboard on
# Allow tmux to pass through OSC 52 sequences to the terminal
set -g allow-passthrough on
Then in Neovim, enable OSC 52 as the clipboard provider:
-- In your init.lua
-- Neovim 0.10+ has built-in OSC 52 support
vim.g.clipboard = {
name = "OSC 52",
copy = {
["+"] = require("vim.ui.clipboard.osc52").copy("+"),
["*"] = require("vim.ui.clipboard.osc52").copy("*"),
},
paste = {
["+"] = require("vim.ui.clipboard.osc52").paste("+"),
["*"] = require("vim.ui.clipboard.osc52").paste("*"),
},
}
With this setup, "+y in Neovim copies to your system clipboard regardless of whether you're local, over SSH, or in a container.
Option B: tmux set-clipboard + Buffer Piping
If your terminal doesn't support OSC 52, you can pipe tmux's copy buffer to the system clipboard directly:
# ~/.tmux.conf — clipboard integration (macOS)
set -g set-clipboard off
bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
# Linux (X11)
# bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "xclip -sel clip"
# Linux (Wayland)
# bind-key -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "wl-copy"
Then set Neovim to use the system clipboard register:
-- Sync Neovim's unnamed register with system clipboard
vim.opt.clipboard = "unnamedplus"
Prefer OSC 52 whenever possible. It works transparently over SSH (the escape sequence travels through the connection to your local terminal), requires no clipboard tools on remote machines, and is supported by all modern terminals: kitty, Alacritty, WezTerm, iTerm2, Windows Terminal, and ghostty.
Quick Reference
| Key | In Vim (at edge of splits) | In tmux (non-vim pane) |
|---|---|---|
| Ctrl-h | Move to left split → or left tmux pane | select-pane -L |
| Ctrl-j | Move to below split → or below tmux pane | select-pane -D |
| Ctrl-k | Move to above split → or above tmux pane | select-pane -U |
| Ctrl-l | Move to right split → or right tmux pane | select-pane -R |
| Ctrl-\ | Previous split/pane | select-pane -l |
FZF Integration: Completions, File Picking, and Popups
Session switching is just the beginning. Once you wire fzf into tmux's display-popup, you get instant floating menus for file finding, process management, git branch switching, and more — all without leaving your current pane. Each recipe below is self-contained. Drop it into your ~/.tmux.conf and reload.
display-popup Essentials
Tmux 3.2 introduced display-popup, which opens a floating overlay window inside your terminal. It runs any shell command, and you control its size, position, and behavior with flags:
# Basic syntax
tmux display-popup [options] [shell-command]
# Key flags
# -E Close the popup automatically when the command exits
# -w 80% Width (percentage or absolute columns)
# -h 60% Height (percentage or absolute rows)
# -d "#{pane_current_path}" Working directory
# -T "Title" Title shown in the popup border
# -S fg=green Style the popup border
# -s bg=black Style the popup body background
The -E flag is critical — without it, the popup stays open after your fzf command finishes and you need to press q to close it. Always use -E for fzf popups.
Recipe 1: File Finder — Open in Vim
Bind a key to pop up fzf over your project files. The selected file opens in your current pane's editor. The cleanest approach uses a helper script:
#!/usr/bin/env bash
# ~/.local/bin/tmux-fzf-open — file finder popup helper
FILE=$(fd --type f --hidden --exclude .git | fzf \
--preview 'bat --color=always --line-range :50 {}' \
--header ' Enter: open in vim | Ctrl-c: cancel')
[ -n "$FILE" ] && tmux send-keys -t ! "vim $FILE" Enter
# ~/.tmux.conf
bind f display-popup -E -w 80% -h 60% -d "#{pane_current_path}" \
-T " Find File " "~/.local/bin/tmux-fzf-open"
The -t ! target in send-keys sends keystrokes to the last active pane — the one you were in before the popup opened. This pattern works for all the recipes below: fzf runs in the popup, the result is sent back via tmux send-keys.
Recipe 2: Process Killer
Fuzzy-pick from running processes and kill the selected ones. Much faster than piping ps through grep:
#!/usr/bin/env bash
# ~/.local/bin/tmux-fzf-kill — process killer popup
PID=$(ps -ef | sed 1d | fzf \
--header 'Select process to kill (Tab to multi-select)' \
--multi \
--preview 'echo {}' \
--preview-window down:3:wrap | awk '{print $2}')
if [ -n "$PID" ]; then
echo "$PID" | xargs kill -9
tmux display-message "Killed PID(s): $PID"
fi
# ~/.tmux.conf
bind K display-popup -E -w 70% -h 50% \
-T " Kill Process " "~/.local/bin/tmux-fzf-kill"
The --multi flag lets you Tab-select multiple processes before pressing Enter. The awk '{print $2}' extracts the PID column from ps -ef output.
Recipe 3: Git Branch Switcher
Quickly switch between git branches with a preview of recent commits on each branch:
#!/usr/bin/env bash
# ~/.local/bin/tmux-fzf-branch — git branch switcher
BRANCH=$(git branch --all --sort=-committerdate \
--format='%(refname:short)' | \
fzf --header 'Switch git branch' \
--preview 'git log --oneline --graph --color=always -15 {}' \
--preview-window right:50%)
if [ -n "$BRANCH" ]; then
# Handle remote branches: origin/feature-x → feature-x
BRANCH=${BRANCH#origin/}
tmux send-keys -t ! "git checkout $BRANCH" Enter
fi
# ~/.tmux.conf
bind B display-popup -E -w 75% -h 60% -d "#{pane_current_path}" \
-T " Git Branches " "~/.local/bin/tmux-fzf-branch"
The --sort=-committerdate flag puts recently-touched branches at the top. The preview window shows the last 15 commits as a graph — enough to instantly recognize what each branch is about.
Recipe 4: Man Page Lookup
Fuzzy-search through all available man pages and open the selected one:
#!/usr/bin/env bash
# ~/.local/bin/tmux-fzf-man — man page browser
PAGE=$(man -k . 2>/dev/null | \
fzf --header 'Open man page' \
--prompt 'man> ' \
--preview 'echo {} | awk "{print \$1}" | xargs man 2>/dev/null | head -80' \
--preview-window right:60%:wrap | \
awk '{print $1}')
[ -n "$PAGE" ] && tmux send-keys -t ! "man $PAGE" Enter
# ~/.tmux.conf
bind M display-popup -E -w 80% -h 70% \
-T " Man Pages " "~/.local/bin/tmux-fzf-man"
The man -k . command lists every available man page entry. The preview renders the first 80 lines of each page as you arrow through the list, so you can skim before committing.
Recipe 5: SSH Host Selector
Parse your ~/.ssh/config and fuzzy-pick a host to connect to:
#!/usr/bin/env bash
# ~/.local/bin/tmux-fzf-ssh — SSH host picker
HOST=$(awk '/^Host / && !/\*/ {print $2}' ~/.ssh/config | sort -u | \
fzf --header 'SSH into host' \
--prompt 'ssh> ' \
--preview 'awk "/^Host {}/,/^Host [^*]/" ~/.ssh/config | head -20' \
--preview-window right:40%:wrap)
if [ -n "$HOST" ]; then
# Open SSH in a new tmux window named after the host
tmux new-window -n "$HOST" "ssh $HOST"
fi
# ~/.tmux.conf
bind S display-popup -E -w 60% -h 50% \
-T " SSH Hosts " "~/.local/bin/tmux-fzf-ssh"
This script opens SSH connections in a new window (via tmux new-window) rather than sending keystrokes to the current pane. The window is named after the host, so your status bar shows prod-api instead of bash.
Popup Styling Options
You can customize the popup border and background to visually distinguish different popups. These options work in tmux 3.3+:
# Border style: -S sets border style, -s sets body style
bind f display-popup -E -w 80% -h 60% \
-S 'fg=#7aa2f7,bg=default' \
-s 'bg=#1a1b26' \
-T " Find File " \
-d "#{pane_current_path}" \
"~/.local/bin/tmux-fzf-open"
# Global popup defaults (tmux 3.4+)
set -g popup-border-style 'fg=#565f89'
set -g popup-style 'bg=#1a1b26'
# Centered positioning (default), or specify offsets
# -x and -y control popup position (C = center)
bind f display-popup -E -w 80% -h 60% -x C -y C "..."
| Flag | Purpose | Example |
|---|---|---|
-E |
Close popup when command exits | display-popup -E "fzf" |
-w / -h |
Width and height (% or absolute) | -w 80% -h 60% |
-d |
Working directory | -d "#{pane_current_path}" |
-T |
Title text in the border | -T " Files " |
-S |
Border style (fg/bg) | -S 'fg=green' |
-s |
Body background style | -s 'bg=black' |
-x / -y |
Position offset (C = center) | -x C -y C |
Fallback: fzf-tmux for Older Tmux Versions
If you're stuck on tmux < 3.2 and don't have display-popup, the fzf-tmux command (bundled with fzf) creates a temporary tmux split pane for fzf instead. It works everywhere tmux runs:
# fzf-tmux opens fzf in a split pane instead of a popup
# -p flag: use popup if available, fall back to pane if not (fzf 0.35+)
# -w/-h: width/height (same as display-popup)
# File finder using fzf-tmux with popup mode
fd --type f | fzf-tmux -p 80%,60% \
--header 'Find file' \
--preview 'bat --color=always {}'
# Without popup support, use split direction flags:
# -d 40% bottom pane, 40% height
# -u 40% top pane
# -l 50% right pane
# -r 50% left pane
fd --type f | fzf-tmux -d 40%
FZF_TMUX_OPTS for Consistent StylingSet export FZF_TMUX_OPTS="-p 80%,60%" in your shell profile. Every fzf-tmux invocation — including the built-in Ctrl-T and Ctrl-R bindings — will use popup mode with that size. No need to repeat flags everywhere.
Making Helper Scripts Portable
If you want a single script that works on both old and new tmux, check the version at runtime:
#!/usr/bin/env bash
# tmux-fzf-wrapper — use popup if available, split if not
TMUX_VERSION=$(tmux -V | sed 's/[^0-9.]//g')
MAJOR=$(echo "$TMUX_VERSION" | cut -d. -f1)
MINOR=$(echo "$TMUX_VERSION" | cut -d. -f2)
if [ "$MAJOR" -gt 3 ] || { [ "$MAJOR" -eq 3 ] && [ "$MINOR" -ge 2 ]; }; then
# tmux 3.2+: use native popup
tmux display-popup -E -w 80% -h 60% \
-d "#{pane_current_path}" \
"fd --type f | fzf --preview 'bat --color=always {}'"
else
# Older tmux: use fzf-tmux split pane
SELECTED=$(fd --type f | fzf-tmux -d 40% \
--preview 'bat --color=always {}')
[ -n "$SELECTED" ] && tmux send-keys "vim $SELECTED" Enter
fi
Quick Reference: All Bindings
Here are all five recipes as a single copy-paste block for your ~/.tmux.conf:
# ── FZF Popup Recipes ───────────────────────────────
# Requires: tmux 3.2+, fzf, fd, bat
# Prefix + f → Find & open file in vim
bind f display-popup -E -w 80% -h 60% \
-d "#{pane_current_path}" \
-T " Find File " "~/.local/bin/tmux-fzf-open"
# Prefix + K → Kill process
bind K display-popup -E -w 70% -h 50% \
-T " Kill Process " "~/.local/bin/tmux-fzf-kill"
# Prefix + B → Switch git branch
bind B display-popup -E -w 75% -h 60% \
-d "#{pane_current_path}" \
-T " Git Branches " "~/.local/bin/tmux-fzf-branch"
# Prefix + M → Browse man pages
bind M display-popup -E -w 80% -h 70% \
-T " Man Pages " "~/.local/bin/tmux-fzf-man"
# Prefix + S → SSH to host
bind S display-popup -E -w 60% -h 50% \
-T " SSH Hosts " "~/.local/bin/tmux-fzf-ssh"
Don't forget to chmod +x ~/.local/bin/tmux-fzf-* after creating the helper scripts. Also ensure ~/.local/bin is in your $PATH. If the popup opens and immediately closes, it almost always means the script either wasn't found or exited with an error — test each script directly in a shell first.
SSH, Remote Workflows, and Nested Tmux
Running tmux on a remote server is the single most compelling reason to use a terminal multiplexer. Your session persists on the server regardless of network drops, laptop reboots, or coffee-shop WiFi outages. But the moment you run tmux locally and on the remote host, you hit the nested tmux problem.
The Fundamental SSH + Tmux Workflow
The core pattern is simple: SSH into a server, start or attach to a tmux session, and work inside it. If your connection dies, the remote tmux session keeps running. You reconnect and pick up exactly where you left off.
# SSH into the server
ssh deploy@prod-server
# Create a new named session (or attach if it exists)
tmux new-session -A -s work
# ... do your work, deploy, tail logs, etc.
# Detach cleanly when done (Prefix + d)
# Or just close your laptop — the session survives
The -A flag on new-session is the key: it attaches to the session named work if it already exists, or creates it fresh if it doesn't. No more "session already exists" errors.
The Nested Tmux Problem
If you run tmux locally and SSH into a server also running tmux, you end up with tmux-inside-tmux. Both instances default to Ctrl+b as the prefix key. Every prefix keystroke gets intercepted by the outer (local) tmux — the inner (remote) tmux never sees it.
flowchart LR
A["🖥️ Your Terminal"] --> B["Local Tmux\n(outer)\nPrefix: Ctrl-a"]
B --> C["SSH Connection"]
C --> D["Remote Tmux\n(inner)\nPrefix: Ctrl-b"]
D --> E["Remote Shell\n$ _"]
style A fill:#f0f9ff,stroke:#3b82f6,color:#1e40af
style B fill:#dbeafe,stroke:#3b82f6,color:#1e40af
style C fill:#fef3c7,stroke:#f59e0b,color:#92400e
style D fill:#dcfce7,stroke:#22c55e,color:#166534
style E fill:#f0fdf4,stroke:#22c55e,color:#166534
There are three practical solutions. Pick the one that fits your workflow.
Solution 1: Different Prefix Keys
The simplest approach — set a different prefix on your local machine. Most people use Ctrl+a locally (a nod to GNU Screen) and leave the remote tmux on the default Ctrl+b.
# Local tmux: use Ctrl-a as prefix
unbind C-b
set -g prefix C-a
bind C-a send-prefix
# Remote tmux: keep default Ctrl-b
# (no change needed — this is the default)
Now Ctrl+a controls local tmux, and Ctrl+b passes through to the remote tmux. No conflicts. The downside is you need to remember two different prefix keys and keep remote servers on the default config.
Solution 2: send-prefix for Manual Forwarding
If both tmux instances use the same prefix, you can forward a command to the inner tmux by pressing the prefix twice. The first press activates local tmux's command mode. The second press sends the prefix key through to the remote tmux via send-prefix.
# Press Ctrl-b Ctrl-b to send prefix to inner tmux
bind C-b send-prefix
To create a new window in the remote tmux, you'd press Ctrl+b, Ctrl+b, c. It works, but the double-prefix gets tedious during long sessions on a remote server.
Solution 3: The F12 Toggle (Recommended)
This is the most ergonomic approach for heavy remote work. Press F12 to disable the local tmux prefix entirely, letting all keystrokes pass through to the remote tmux. Press F12 again to re-enable local control. A status bar color change gives you an instant visual indicator of which layer is active.
# ── F12: Toggle local tmux off/on for nested sessions ──
# Colors for visual feedback
color_status_active="colour39" # Blue — local tmux is active
color_status_remote="colour202" # Orange — passthrough to remote
# Default status bar style (local active)
set -g status-style "bg=$color_status_active,fg=colour232"
# F12 turns OFF local prefix: all keys go to inner (remote) tmux
bind -T root F12 \
set prefix None \;\
set key-table off \;\
set status-style "bg=$color_status_remote,fg=colour232" \;\
set window-status-current-style "bg=colour196,fg=colour232,bold" \;\
if -F '#{pane_in_mode}' 'send-keys -X cancel' \;\
refresh-client -S
# F12 again turns ON local prefix: back to normal
bind -T off F12 \
set -u prefix \;\
set -u key-table \;\
set -u status-style \;\
set -u window-status-current-style \;\
refresh-client -S
When you press F12, three things happen: the local prefix is set to None (disabling it), the key table switches to off (which only has the F12 binding), and the status bar turns orange. Pressing F12 again unsets everything with -u, restoring defaults.
Choose status bar colors that are visually impossible to confuse. Blue vs. orange works well. You never want to accidentally run rm -rf thinking you're on the remote server when you're on your local machine.
SSH Agent Forwarding Inside Tmux
SSH agent forwarding lets you use your local SSH keys on the remote server (e.g., for git push) without copying private keys. The problem: when you detach and reattach a tmux session, the SSH_AUTH_SOCK environment variable goes stale because it points to the old forwarded socket path.
# Keep SSH_AUTH_SOCK updated when reattaching
set -g update-environment "SSH_AUTH_SOCK SSH_CONNECTION DISPLAY"
The update-environment option tells tmux to refresh these variables from the connecting client each time you attach. You can also use a symlink approach for even more reliability:
#!/bin/bash
# Create a stable symlink for the SSH agent socket
if [ -S "$SSH_AUTH_SOCK" ]; then
ln -sf "$SSH_AUTH_SOCK" ~/.ssh/agent_sock
fi
# Point to the stable symlink instead of the ephemeral socket
export SSH_AUTH_SOCK=~/.ssh/agent_sock
With this setup, the symlink always points to the latest forwarded socket. Every shell in your tmux session (old or new) uses the same symlink path, so git push and ssh commands keep working after reattach.
Complete SSH + Tmux Workflow Script
This script automates the connect → create-or-attach workflow. Save it locally and use it as your daily SSH entry point.
#!/usr/bin/env bash
# ssht — SSH into a host and attach to a tmux session
# Usage: ssht user@host [session-name]
set -euo pipefail
HOST="${1:?Usage: ssht user@host [session-name]}"
SESSION="${2:-main}"
echo "Connecting to $HOST, tmux session: $SESSION"
ssh -A -t "$HOST" \
"tmux new-session -A -s '$SESSION'"
# Make it executable and use it
chmod +x ~/bin/ssht
# Connect to prod server, default "main" session
ssht deploy@prod-server
# Connect with a named session for a specific task
ssht deploy@prod-server deploy-v2.1
The -A flag enables agent forwarding. The -t flag forces a TTY allocation, which is required for tmux to work over SSH. The new-session -A -s on the remote end handles the create-or-attach logic in a single command.
If you see sessions should be nested with care, unset $TMUX to force, tmux is warning you that you're about to create a nested session. This is tmux protecting you. Use one of the solutions above instead of blindly unsetting $TMUX.
Putting It All Together
Here's a reference table for quick lookup during a nested session:
| Action | Different Prefix | send-prefix | F12 Toggle |
|---|---|---|---|
| New window (local) | Ctrl+a, c | Ctrl+b, c | Ctrl+b, c |
| New window (remote) | Ctrl+b, c | Ctrl+b, Ctrl+b, c | F12 → Ctrl+b, c |
| Setup complexity | Low | Minimal | Moderate (one-time) |
| Daily ergonomics | Good | Tedious | Excellent |
| Visual indicator | No | No | Yes (status bar color) |
If you SSH into servers occasionally, Solution 1 (different prefixes) is all you need. If you spend hours daily in remote tmux sessions, invest the 5 minutes to set up the F12 toggle — the status bar color change alone prevents costly mistakes.
Git Workflow Integration Scripts
Tmux and git are a natural pairing. Both live in the terminal, both reward scripting, and together they can eliminate the context-switching that slows down real development work. The five recipes below are independent — pick the ones that fit your workflow and drop them into your .tmux.conf or shell config.
Recipe 1: Lazygit in a Tmux Popup
Tmux's display-popup command creates a floating overlay window — perfect for launching interactive tools without disrupting your pane layout. One keybinding gives you a full-featured git UI that disappears the moment you quit.
# Open lazygit in a centered popup (90% of terminal size)
# -E closes the popup when lazygit exits
# -d sets the working directory to the current pane's path
bind g display-popup -E -w 90% -h 90% -d "#{pane_current_path}" lazygit
Press prefix + g and lazygit opens in a floating window right over your current work. Press q to quit lazygit and the popup vanishes — your panes are exactly where you left them.
The same pattern works with tig, gitui, or even git log --oneline --graph. Replace lazygit with whatever tool you prefer. The -E flag ensures the popup closes cleanly when the command exits.
Recipe 2: Git Diff Stat in a Split Pane
Sometimes you just need a quick glance at what changed — no full TUI required. This binding opens git diff --stat in a horizontal split below your current pane.
# Show git diff --stat in a 30% bottom split pane
# The pane closes automatically when you press q (less exits)
bind D split-window -v -l 30% -d -c "#{pane_current_path}" \
'git diff --stat --color=always | less -R'
# Variant: full diff with syntax highlighting via delta
bind d split-window -v -l 50% -d -c "#{pane_current_path}" \
'git diff --color=always | delta --paging=always'
The -d flag keeps focus on your original pane so you can keep typing. Glance down at the diff, then close the pane with q when you're done. The -c flag ensures the split opens in the same directory as your current pane.
Recipe 3: Git Worktree Session Manager
Git worktrees let you check out multiple branches simultaneously in separate directories. This script takes it further: each worktree gets its own tmux session, and an fzf picker lets you switch between them instantly. You can review a PR in one session while your feature work stays untouched in another.
#!/usr/bin/env bash
# tmux-worktree: fzf picker to switch between git worktree sessions
# Each worktree gets a dedicated tmux session named after its branch.
set -euo pipefail
# Get the bare repo root (works for both bare and non-bare repos)
git_root=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
git_root="${git_root%/.git}" # strip /.git suffix if present
# List all worktrees, extract path and branch
worktrees=$(git worktree list --porcelain | awk '
/^worktree / { path = $2 }
/^branch / { gsub("refs/heads/", "", $2); print path "|" $2 }
')
if [[ -z "$worktrees" ]]; then
echo "No worktrees found. Create one with: git worktree add ../branch-name branch-name"
exit 1
fi
# Use fzf to pick a worktree
selected=$(echo "$worktrees" | \
column -t -s '|' | \
fzf --prompt="Switch worktree> " \
--header="PATH | BRANCH" \
--reverse \
--height=40%)
# Parse selection
wt_path=$(echo "$selected" | awk '{print $1}')
wt_branch=$(echo "$selected" | awk '{print $2}')
# Sanitize branch name for tmux session name (replace dots/colons)
session_name="${wt_branch//[.:]/-}"
# Create or attach to the session for this worktree
if ! tmux has-session -t "=$session_name" 2>/dev/null; then
tmux new-session -d -s "$session_name" -c "$wt_path"
tmux send-keys -t "$session_name" "echo 'Worktree: $wt_branch'" Enter
fi
# Switch to session (works whether called from inside or outside tmux)
if [[ -n "${TMUX:-}" ]]; then
tmux switch-client -t "=$session_name"
else
tmux attach-session -t "=$session_name"
fi
Bind this script to a tmux key for instant access:
# Open worktree picker in a popup
bind W display-popup -E -w 60% -h 60% "tmux-worktree"
Typical workflow
-
Create worktrees for parallel workbash
# From your main repo directory git worktree add ../myapp-fix-auth fix/auth-bug git worktree add ../myapp-pr-review feature/new-api -
Press prefix + W to open the picker
fzf lists all worktrees with their paths and branch names. Select one and you're instantly switched to its tmux session.
-
Work in isolation, switch freely
Each worktree session has its own editor state, running processes, and shell history. Your main branch work stays completely untouched.
Recipe 4: Auto-Rename Windows to the Git Branch
Tmux window titles default to the running command (usually bash or zsh). That's useless when you have six windows open. This recipe automatically renames each window to the current git branch, so your status bar reads main, fix/auth, feature/api instead of zsh, zsh, zsh.
Option A: Shell precmd hook (works in zsh and bash)
# Auto-rename tmux window to git branch (or directory name if not in a repo)
tmux_auto_rename() {
# Only run inside tmux
[[ -n "${TMUX:-}" ]] || return
local branch
branch=$(git branch --show-current 2>/dev/null)
if [[ -n "$branch" ]]; then
tmux rename-window "$branch"
else
# Fall back to the current directory name
tmux rename-window "${PWD##*/}"
fi
}
# For zsh: hook into precmd
if [[ -n "$ZSH_VERSION" ]]; then
autoload -Uz add-zsh-hook
add-zsh-hook precmd tmux_auto_rename
fi
# For bash: use PROMPT_COMMAND
if [[ -n "$BASH_VERSION" ]]; then
PROMPT_COMMAND="tmux_auto_rename;${PROMPT_COMMAND:-}"
fi
Option B: Tmux status-bar format string (no shell changes needed)
# Let tmux auto-rename windows and show git branch via shell command
set -g automatic-rename on
set -g automatic-rename-format \
'#{?#{==:#{pane_current_command},zsh},#(git -C "#{pane_current_path}" branch --show-current 2>/dev/null || echo "#{b:pane_current_path}"),#{pane_current_command}}'
# Refresh status bar every 5 seconds to keep branch names current
set -g status-interval 5
The #() format runs an external command on each status refresh. With many windows and a low status-interval, this can add up. Option A (the shell hook) is more efficient — it only runs when your prompt renders. Use Option B only if you can't modify your shell config.
Recipe 5: PR Review Workflow
Reviewing a pull request in the terminal means juggling the diff, the file list, and an editor. This script creates a dedicated tmux session with everything arranged in one view: changed files on the left, the full diff on the right, and an editor pane at the bottom.
#!/usr/bin/env bash
# tmux-pr-review: Create a tmux layout for reviewing a PR/branch diff
# Usage: tmux-pr-review [base-branch]
# base-branch defaults to "main"
set -euo pipefail
BASE="${1:-main}"
BRANCH=$(git branch --show-current)
REPO_ROOT=$(git rev-parse --show-toplevel)
SESSION="review/${BRANCH}"
# Sanitize session name
SESSION="${SESSION//[.:]/-}"
if tmux has-session -t "=$SESSION" 2>/dev/null; then
echo "Review session '$SESSION' already exists. Attaching..."
if [[ -n "${TMUX:-}" ]]; then
tmux switch-client -t "=$SESSION"
else
tmux attach-session -t "=$SESSION"
fi
exit 0
fi
# Create session — first pane shows changed files list
tmux new-session -d -s "$SESSION" -c "$REPO_ROOT" \
-x "$(tput cols)" -y "$(tput lines)"
# Pane 0 (top-left): Changed files list with stats
tmux send-keys -t "$SESSION" \
"echo '=== Files changed: $BRANCH vs $BASE ===' && git diff --stat --color=always ${BASE}...HEAD | less -R" Enter
# Pane 1 (top-right): Full diff with syntax highlighting
tmux split-window -h -t "$SESSION" -c "$REPO_ROOT" \
-l 60%
tmux send-keys -t "$SESSION" \
"git diff --color=always ${BASE}...HEAD | less -R" Enter
# Pane 2 (bottom): Editor for making notes or changes
tmux split-window -v -t "$SESSION" -c "$REPO_ROOT" \
-l 35%
tmux send-keys -t "$SESSION" \
"${EDITOR:-vim} ." Enter
# Select the diff pane as the active one
tmux select-pane -t "$SESSION:.0"
# Apply a descriptive layout name
tmux rename-window -t "$SESSION" "PR: ${BRANCH}"
# Attach
if [[ -n "${TMUX:-}" ]]; then
tmux switch-client -t "=$SESSION"
else
tmux attach-session -t "=$SESSION"
fi
This produces a three-pane layout:
┌──────────────────┬─────────────────────────────────┐
│ │ │
│ Changed Files │ Full Diff │
│ (git diff │ (git diff BASE...HEAD) │
│ --stat) │ │
│ │ │
│ ├─────────────────────────────────┤
│ │ │
│ │ Editor ($EDITOR) │
│ │ │
└──────────────────┴─────────────────────────────────┘
Wire it up with a tmux keybinding for one-keypress access:
# Launch PR review session (defaults to diffing against main)
bind R display-popup -E -w 40% -h 30% \
'read -p "Base branch [main]: " base; tmux-pr-review "${base:-main}"'
If you have delta installed, replace the git diff command with git diff ${BASE}...HEAD | delta --paging=always for side-by-side syntax-highlighted diffs. The script works with any pager or diff tool — just swap the command string.
Putting It All Together
Here's a consolidated snippet of all the .tmux.conf additions from the five recipes above:
# ── Git Integration ──────────────────────────────────
# Recipe 1: Lazygit popup
bind g display-popup -E -w 90% -h 90% -d "#{pane_current_path}" lazygit
# Recipe 2: Quick diff stat / full diff
bind D split-window -v -l 30% -d -c "#{pane_current_path}" \
'git diff --stat --color=always | less -R'
bind d split-window -v -l 50% -d -c "#{pane_current_path}" \
'git diff --color=always | delta --paging=always'
# Recipe 3: Worktree session picker
bind W display-popup -E -w 60% -h 60% "tmux-worktree"
# Recipe 4: Auto-rename windows to git branch
set -g automatic-rename on
set -g status-interval 5
# Recipe 5: PR review layout
bind R display-popup -E -w 40% -h 30% \
'read -p "Base branch [main]: " base; tmux-pr-review "${base:-main}"'
Every recipe follows the same principle: keep your hands in the terminal, let tmux handle the window management, and let scripts do the repetitive work. Customize the key bindings to whatever feels natural — these are starting points, not gospel.
Advanced: Hooks, Conditionals, and Format Strings
Hooks, format strings, and conditionals are tmux’s most powerful — and least documented — features. They turn your static .tmux.conf into a reactive, programmable environment that adapts to what you’re doing, what OS you’re on, and what’s happening inside each pane.
This section is heavy on real config snippets you can drop straight into your .tmux.conf. Every example has been tested on tmux 3.3+.
Hooks: Reacting to tmux Events
A hook fires a tmux command (or chain of commands) whenever a specific event occurs — a session is created, a window is split, a pane gets focus, and so on. The syntax is straightforward:
# Global hook — applies to all sessions
set-hook -g after-new-session 'command-to-run'
# Session-specific hook — only this session
set-hook after-split-window 'command-to-run'
# Append multiple hooks to the same event (use -a)
set-hook -ga after-new-window 'run-shell "echo window created"'
# Remove a hook
set-hook -gu after-new-session
The -g flag makes the hook global. Without it, the hook applies only to the current session. Use -a to append — without it, you replace any existing hook on that event.
Hook Event Reference
Here are the most useful hook events. Each fires after the named action completes (the after- variants) or before it (before- variants where available):
| Hook Event | Fires When | Common Use |
|---|---|---|
after-new-session | A new session is created | Set session options, rename based on directory |
after-new-window | A new window is created | Set default layout, rename window |
after-split-window | A pane is split | Auto-apply layout when pane count changes |
after-kill-pane | A pane is closed | Rebalance remaining panes |
client-attached | A client attaches to a session | Refresh display, log session activity |
client-detached | A client detaches | Log session activity |
session-created | Identical to after-new-session | (alias) |
window-linked | A window is linked into a session | Update status bar |
window-renamed | A window is renamed | Trigger notifications |
pane-focus-in | A pane receives focus | Highlight active pane, sync env vars |
pane-focus-out | A pane loses focus | Dim inactive pane |
client-resized | Terminal window is resized | Adjust layout |
alert-activity | Activity detected in monitored window | Desktop notification |
Run tmux show-hooks -g to see every global hook currently set. This is invaluable for debugging when hooks seem to fire (or not fire).
Practical Example: Auto-Rename Sessions Based on Directory
When you create a new session, this hook reads the current working directory and renames the session to the directory basename — so a session started in ~/projects/my-api automatically becomes session my-api:
# Auto-name session after the working directory
set-hook -g after-new-session \
'run-shell "tmux rename-session $(basename \"#{pane_current_path}\")"'
Practical Example: Auto-Set Layout After Splitting
Every time you split a pane, this hook automatically applies the tiled layout so all panes get equal space. No more manually pressing Prefix + Space:
# Auto-tile after every split
set-hook -g after-split-window 'select-layout tiled'
# Or only apply main-vertical when 3+ panes exist
set-hook -g after-split-window \
'if-shell "[ #{window_panes} -ge 3 ]" "select-layout main-vertical"'
Practical Example: Log Session Activity
Track when you attach to and detach from sessions. Useful for time tracking or auditing which projects you worked on:
# Log attach/detach events with timestamp
set-hook -g client-attached \
'run-shell "echo \"$(date +%Y-%m-%dT%H:%M:%S) ATTACH #{session_name}\" >> ~/.tmux-activity.log"'
set-hook -g client-detached \
'run-shell "echo \"$(date +%Y-%m-%dT%H:%M:%S) DETACH #{session_name}\" >> ~/.tmux-activity.log"'
Check your log anytime with cat ~/.tmux-activity.log — you’ll see entries like 2024-06-15T09:32:01 ATTACH my-api.
Format Strings: tmux’s Template Language
Format strings are how tmux exposes internal state. Anywhere tmux accepts a string — status bar, display-message, if-shell, run-shell, hook commands — you can embed #{variable_name} placeholders that tmux replaces at runtime.
# Debug format strings interactively with display -p
tmux display -p '#{session_name}' # → my-api
tmux display -p '#{window_index}:#{pane_index}' # → 0:1
tmux display -p '#{pane_current_command}' # → vim
tmux display -p '#{pane_current_path}' # → /home/user/projects
tmux display -p '#{client_prefix}' # → 0 (or 1 if prefix held)
tmux display -p '#{window_panes}' # → 3
The display -p command is your best friend for testing format strings. It evaluates the format and prints the result to stdout — perfect for debugging before you embed a format string in your status bar.
Commonly Used Format Variables
| Variable | Returns | Example Value |
|---|---|---|
#{session_name} | Current session name | my-api |
#{window_index} | Window index number | 2 |
#{window_name} | Window name | editor |
#{pane_current_command} | Command running in active pane | vim |
#{pane_current_path} | Working directory of active pane | /home/user/src |
#{client_prefix} | 1 if prefix key is held, 0 otherwise | 0 |
#{window_panes} | Number of panes in current window | 3 |
#{pane_in_mode} | 1 if pane is in copy mode | 0 |
#{session_windows} | Number of windows in session | 5 |
#{host_short} | Hostname (short form) | devbox |
Conditional Formats: If/Else Inside Strings
The real power of format strings kicks in with conditionals. The ternary syntax #{?condition,true-value,false-value} lets you change what tmux displays based on runtime state — right inside any format string.
# Basic ternary: #{?condition,if-true,if-false}
# The condition is "truthy" if the variable is nonzero/nonempty
# Show "PREFIX" when prefix is pressed, nothing otherwise
#{?client_prefix,PREFIX,}
# String comparison with #{==:left,right}
#{==:#{pane_current_command},vim} # → 1 if running vim
# Logical AND with #{&&:cond1,cond2}
#{&&:#{client_prefix},#{pane_in_mode}} # → 1 if both true
# Logical OR with #{||:cond1,cond2}
#{||:#{window_bell_flag},#{window_activity_flag}}
# Nesting — compose conditions inside ternary
#{?#{==:#{pane_current_command},vim}, EDITOR , SHELL }
Build: Prefix-Aware Status Bar Colors
This is one of the most popular uses of format conditionals. The status bar changes color when you press the prefix key, giving you clear visual feedback:
# Status bar turns red when prefix is pressed, green otherwise
set -g status-style "bg=default"
set -g status-left \
"#{?client_prefix,#[bg=red#,fg=white#,bold] PREFIX ,#[bg=green#,fg=black] #S }"
# Right side: show COPY when in copy mode
set -g status-right \
"#{?pane_in_mode,#[bg=yellow#,fg=black] COPY ,} %H:%M %d-%b"
Inside #{?...}, commas separate the condition, true value, and false value. If your true/false value itself contains a comma (like style strings with bg=red,fg=white), you must escape it as #, — otherwise tmux silently parses it as a field separator and your format breaks.
Build: Smart Window Names That Show the Running Command
Use conditional formats in the window-status-format to display context-aware window labels — show an editor indicator when vim is running, fall back to the directory name otherwise:
# Show editor label when in vim, directory basename otherwise
set -g window-status-current-format \
" #{?#{==:#{pane_current_command},vim},VIM #{pane_current_command},#I:#{b:pane_current_path}} "
# #{b:pane_current_path} is a built-in modifier that returns the basename
Build: SSH-Aware Status Bar
Detect whether the current pane is running an SSH session and change the status indicator. This uses string comparison against the running command:
# Show remote indicator when SSH is active in the pane
set -g status-right \
"#{?#{==:#{pane_current_command},ssh},#[fg=red] SSH: #{pane_title} ,#[fg=green] LOCAL } %H:%M"
The if-shell Command: Conditional Configuration
if-shell runs a shell command and branches based on its exit code — 0 means true, anything else means false. This is how you make your .tmux.conf adapt to different operating systems, tmux versions, or installed programs.
# if-shell "test-command" "true-command" "false-command"
# The false branch is optional
if-shell "command -v fzf" "bind f run-shell 'tmux-fzf'"
# ^ test ^ runs only if fzf is installed
OS Detection: macOS vs. Linux Clipboard
The classic use case. macOS uses pbcopy, Linux uses xclip or xsel. One config, both platforms:
# macOS clipboard
if-shell "uname | grep -q Darwin" \
"bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'pbcopy'"
# Linux clipboard (X11)
if-shell "uname | grep -q Linux" \
"bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'xclip -selection clipboard'"
tmux Version Detection
Some options only exist in newer tmux versions. Guard them with version checks so your config doesn’t throw errors on older machines:
# set-clipboard was added in tmux 3.2
# Use sort -V for reliable version comparison
if-shell '[ "$(printf "%s\n" "3.2" "$(tmux -V | cut -d" " -f2)" \
| sort -V | head -1)" = "3.2" ]' \
'set -g set-clipboard on'
# A cleaner approach using awk
if-shell 'tmux -V | awk "{split(\$2,v,\".\"); \
if (v[1]>3 || (v[1]==3 && v[2]>=2)) exit 0; exit 1}"' \
'set -g set-clipboard on'
Feature Detection: Only Load If Program Exists
This pattern is better than OS detection when possible — test for the capability, not the platform:
# Only bind fzf shortcuts if fzf is actually installed
if-shell "command -v fzf >/dev/null 2>&1" {
bind C-f run-shell "tmux list-sessions -F '#{session_name}' \
| fzf-tmux | xargs tmux switch-client -t"
bind C-w run-shell "tmux list-windows \
-F '#{window_index}: #{window_name}' \
| fzf-tmux | cut -d: -f1 | xargs tmux select-window -t"
}
# Only load TPM if the directory exists
if-shell "test -d ~/.tmux/plugins/tpm" \
"run '~/.tmux/plugins/tpm/tpm'"
tmux 3.0+ supports brace-delimited blocks { ... } for multi-line commands in if-shell. Older versions require everything in a single quoted string. If you need to support tmux 2.x, stick with quoted strings.
Putting It All Together: Portable .tmux.conf
Here’s a real-world config fragment that combines hooks, conditionals, format strings, and if-shell into a portable setup that works on both macOS and Linux:
# === Basics =============================================
set -g default-terminal "tmux-256color"
set -g mouse on
set -g base-index 1
setw -g pane-base-index 1
# === Portable Clipboard =================================
# Detect clipboard: pbcopy (macOS), wl-copy (Wayland),
# xclip (X11), or fallback to tmux built-in set-clipboard
if-shell "command -v pbcopy >/dev/null" {
set -g copy-command "pbcopy"
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
} {
if-shell "command -v wl-copy >/dev/null" {
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "wl-copy"
} {
if-shell "command -v xclip >/dev/null" {
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel \
"xclip -sel clip"
} {
set -g set-clipboard on
}
}
}
# === Smart Status Bar ===================================
set -g status-left-length 40
set -g status-right-length 80
# Left: session name with prefix indicator
set -g status-left \
"#{?client_prefix,#[bg=colour196#,fg=white#,bold] PREFIX ,#[bg=colour238#,fg=colour248] #S } "
# Right: SSH vs local + copy mode + clock
set -g status-right \
"#{?pane_in_mode,#[bg=colour214#,fg=black] COPY ,}"\
"#{?#{==:#{pane_current_command},ssh},#[fg=colour196] SSH: #{pane_title},#[fg=colour114] #{host_short}} "\
"#[fg=colour248]| %H:%M "
# Window tabs
setw -g window-status-format " #I:#{b:pane_current_path} "
setw -g window-status-current-format \
"#[fg=colour114#,bold] #I:#{b:pane_current_path} "
# === Hooks ==============================================
# Auto-name sessions from working directory
set-hook -g after-new-session \
'run-shell "tmux rename-session \"$(basename #{pane_current_path})\""'
# Auto-tile when a window hits 3+ panes
set-hook -g after-split-window \
'if-shell "[ #{window_panes} -ge 3 ]" "select-layout tiled"'
# Log session attach/detach for time tracking
set-hook -g client-attached \
'run-shell "echo \"$(date +%%F\\ %%T) IN #{session_name}\" >> ~/.tmux-activity.log"'
set-hook -g client-detached \
'run-shell "echo \"$(date +%%F\\ %%T) OUT #{session_name}\" >> ~/.tmux-activity.log"'
# === Conditional Plugin Loading =========================
if-shell "test -d ~/.tmux/plugins/tpm" \
"run '~/.tmux/plugins/tpm/tpm'"
Debugging Tips
When your format strings or hooks aren’t working as expected, use these techniques to isolate the problem:
# 1. Test any format string interactively
tmux display -p '#{pane_current_command}'
tmux display -p '#{?client_prefix,YES,NO}'
# 2. Check all current hooks
tmux show-hooks -g
# 3. Start tmux with verbose logging to trace errors
tmux -vv new-session
# Then inspect: ~/.tmux-server-*.log
# 4. Test if-shell conditions manually
tmux if-shell "command -v fzf" \
"display 'fzf found'" "display 'fzf missing'"
# 5. Dump all format variables for the current pane
tmux display -p \
'sid=#{session_id} wid=#{window_id} pid=#{pane_id} cmd=#{pane_current_command}'
Edit your .tmux.conf, then run tmux source-file ~/.tmux.conf (or bind it to Prefix + r) to reload without restarting your session. Combine with display -p to test format strings in real time.
Real-World Automation Recipes Cookbook
Six battle-tested scripts you can drop into your workflow today. Each recipe is self-contained, well-commented, and designed to be saved as a file in your ~/bin or ~/.local/bin directory.
Recipe 1 — Monitoring Dashboard
A single command that creates a 4-pane monitoring station: system resources (htop), container stats (docker stats), live application logs, and Kubernetes pod status. Ideal for keeping on a secondary monitor during deployments.
#!/usr/bin/env bash
# tmux-monitor.sh — 4-pane monitoring dashboard
# Usage: ./tmux-monitor.sh [logfile]
# Layout:
# ┌──────────┬──────────┐
# │ htop │ docker │
# │ │ stats │
# ├──────────┼──────────┤
# │ tail -f │ kubectl │
# │ logs │ pods │
# └──────────┴──────────┘
SESSION="monitor"
LOGFILE="${1:-/var/log/syslog}"
# Tear down any existing session with this name
tmux kill-session -t "$SESSION" 2>/dev/null
# Create session with htop in the first pane
tmux new-session -d -s "$SESSION" \
-x "$(tput cols)" -y "$(tput lines)" "htop"
# Top-right: docker stats
tmux split-window -h -t "$SESSION" \
"docker stats --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}'"
# Bottom-left: tail logs
tmux split-window -v -t "$SESSION:0.0" "tail -f $LOGFILE"
# Bottom-right: kubectl watch pods
tmux split-window -v -t "$SESSION:0.1" \
"kubectl get pods --all-namespaces --watch"
# Even out the layout
tmux select-layout -t "$SESSION" tiled
# Attach to the session
tmux attach-session -t "$SESSION"
Don't have kubectl or Docker? Replace any pane command with a fallback using ||. For example: kubectl get pods --watch 2>/dev/null || watch -n2 'echo kubectl not available'. The dashboard stays functional regardless of which tools are installed.
Recipe 2 — Database Session
Opens a split-pane database workspace with your SQL client on the left and a scratchpad on the right. The scratchpad pre-loads a cheat sheet of common queries you can yank and paste into the live session.
#!/usr/bin/env bash
# tmux-db.sh — Database split-pane session
# Usage: ./tmux-db.sh [postgres|mysql] [dbname]
# Layout:
# ┌─────────────────┬──────────────────┐
# │ │ query reference │
# │ psql / mysql │ (vim scratchpad) │
# │ │ │
# └─────────────────┴──────────────────┘
DB_TYPE="${1:-postgres}"
DB_NAME="${2:-mydb}"
SESSION="db-${DB_NAME}"
SCRATCH="/tmp/tmux-db-scratch-${DB_NAME}.sql"
# Create a cheat-sheet scratchpad file
cat > "$SCRATCH" << 'SQL'
-- ═══ Common Query Reference ═══
-- List all tables (PostgreSQL)
\dt
-- Table schema
\d+ table_name
-- Row counts per table
SELECT schemaname, relname, n_live_tup
FROM pg_stat_user_tables
ORDER BY n_live_tup DESC;
-- Active connections
SELECT pid, usename, application_name, state
FROM pg_stat_activity
WHERE datname = current_database();
-- Slow queries (running > 5s)
SELECT pid, now() - query_start AS duration, query
FROM pg_stat_activity
WHERE state != 'idle'
AND now() - query_start > interval '5 seconds';
-- Table sizes
SELECT relname,
pg_size_pretty(pg_total_relation_size(relid))
FROM pg_catalog.pg_statio_user_tables
ORDER BY pg_total_relation_size(relid) DESC;
SQL
tmux kill-session -t "$SESSION" 2>/dev/null
# Choose the correct client command
case "$DB_TYPE" in
postgres|pg) DB_CMD="psql -d $DB_NAME" ;;
mysql|my) DB_CMD="mysql $DB_NAME" ;;
*) echo "Unknown DB type: $DB_TYPE"; exit 1 ;;
esac
# Left pane: live database client (60% width)
tmux new-session -d -s "$SESSION" "$DB_CMD"
# Right pane: query scratchpad in vim (40% width)
tmux split-window -h -t "$SESSION" -l 40% "vim $SCRATCH"
# Focus the database pane
tmux select-pane -t "$SESSION:0.0"
tmux attach-session -t "$SESSION"
To send a query from the scratchpad to the database pane, yank the line in Vim, switch panes with Ctrl+b ; and paste. You can also add a custom binding in tmux.conf to pipe the tmux buffer directly to the left pane.
Recipe 3 — Pomodoro Timer
A lightweight Pomodoro timer that counts down in your tmux status bar. It writes the remaining time to a temp file that your status bar reads via #(cat ...) format strings, and uses tmux display-message for phase-change notifications.
#!/usr/bin/env bash
# tmux-pomodoro.sh — Pomodoro timer in the tmux status bar
# Usage:
# tmux-pomodoro.sh # Start 25min/5min cycle
# tmux-pomodoro.sh 45 10 # Custom work/break minutes
# tmux-pomodoro.sh stop # Stop the running timer
WORK="${1:-25}"
BREAK="${2:-5}"
STATE_FILE="/tmp/tmux-pomodoro"
PID_FILE="/tmp/tmux-pomodoro.pid"
# ─── Stop command ────────────────────────────────────────
if [ "$1" = "stop" ]; then
if [ -f "$PID_FILE" ]; then
TIMER_PID=$(cat "$PID_FILE")
# Send TERM signal to the running timer process
/bin/kill -TERM "$TIMER_PID" 2>/dev/null
rm -f "$PID_FILE" "$STATE_FILE"
tmux display-message "🍅 Pomodoro stopped"
else
tmux display-message "No timer running"
fi
exit 0
fi
# ─── Countdown function ─────────────────────────────────
countdown() {
local label="$1" minutes="$2" emoji="$3"
local total=$((minutes * 60))
for ((s = total; s >= 0; s--)); do
printf "%s %s %02d:%02d" \
"$emoji" "$label" "$((s/60))" "$((s%60))" \
> "$STATE_FILE"
sleep 1
done
}
# ─── Main loop ──────────────────────────────────────────
echo $$ > "$PID_FILE"
trap 'rm -f "$PID_FILE" "$STATE_FILE"' EXIT
while true; do
tmux display-message "🍅 Focus: ${WORK}min — GO!"
countdown "FOCUS" "$WORK" "🍅"
tmux display-message "✅ Done! Take a ${BREAK}min break."
countdown "BREAK" "$BREAK" "☕"
tmux display-message "🍅 Break over — next cycle starting"
echo "🍅 READY" > "$STATE_FILE"
sleep 3
done
Wire it into your status bar and add keybindings to start and stop:
# Show pomodoro countdown in status bar (refresh every second)
set -g status-interval 1
set -g status-right "#(cat /tmp/tmux-pomodoro 2>/dev/null) %H:%M %d-%b"
# Prefix + P → start timer | Prefix + Alt+p → stop timer
bind-key P run-shell "~/.local/bin/tmux-pomodoro.sh &"
bind-key M-p run-shell "~/.local/bin/tmux-pomodoro.sh stop"
Recipe 4 — Auto-SSH Reconnect
Wraps an SSH connection inside a tmux pane with automatic reconnection on disconnect. When the connection drops, it waits a few seconds and retries — perfect for flaky networks or long-lived sessions to remote servers.
#!/usr/bin/env bash
# tmux-autossh.sh — Auto-reconnecting SSH in a tmux session
# Usage: ./tmux-autossh.sh user@hostname [ssh-options...]
#
# Features:
# - Reconnects automatically on disconnect
# - SSH keepalive detects dead connections fast
# - Color-coded status messages
# - Ctrl+C during retry delay to exit cleanly
HOST="$1"
shift
SSH_OPTS="$*"
if [ -z "$HOST" ]; then
echo "Usage: $0 user@hostname [ssh-options...]"
exit 1
fi
# Sanitize hostname for use as a tmux session name
SESSION="ssh-${HOST//[@.]/-}"
RETRY_DELAY=5
tmux kill-session -t "$SESSION" 2>/dev/null
# Build the reconnect loop as a temp script
PANE_SCRIPT=$(mktemp /tmp/tmux-autossh-XXXX.sh)
chmod +x "$PANE_SCRIPT"
cat > "$PANE_SCRIPT" << INNEREOF
#!/usr/bin/env bash
attempt=0
while true; do
attempt=\$((attempt + 1))
echo -e "\033[1;34m━━━ Connecting to $HOST (attempt #\$attempt) ━━━\033[0m"
echo ""
ssh -o ServerAliveInterval=30 \\
-o ServerAliveCountMax=3 \\
-o ConnectTimeout=10 \\
-o ConnectionAttempts=1 \\
$SSH_OPTS "$HOST"
EXIT_CODE=\$?
echo ""
if [ \$EXIT_CODE -eq 0 ]; then
echo -e "\033[1;33m━━━ Session closed cleanly ━━━\033[0m"
else
echo -e "\033[1;31m━━━ Connection lost (exit: \$EXIT_CODE) ━━━\033[0m"
fi
echo "Reconnecting in ${RETRY_DELAY}s... (Ctrl+C to stop)"
sleep "$RETRY_DELAY" || break
done
rm -f "$PANE_SCRIPT"
INNEREOF
tmux new-session -d -s "$SESSION" "bash $PANE_SCRIPT"
tmux attach-session -t "$SESSION"
The dedicated autossh package monitors connections via a heartbeat port tunnel and is more robust for unattended scenarios. This script is better for interactive sessions where you want visual feedback, colored status messages, and a clean tmux wrapper.
Recipe 5 — Log Multiplexer
Tails multiple log files simultaneously, each in its own color-coded pane, with optional synchronized scrolling. Pass any number of files as arguments and they auto-tile into a clean grid.
#!/usr/bin/env bash
# tmux-logs.sh — Tail multiple logs in tiled panes
# Usage: ./tmux-logs.sh file1.log file2.log ...
# TMUX_LOG_SYNC=on ./tmux-logs.sh *.log
#
# Features:
# - Auto-tiles panes to fit all log files
# - Color-coded header per pane
# - Prefix + S toggles synchronized scrolling
SESSION="logs"
SYNC="${TMUX_LOG_SYNC:-off}"
if [ $# -eq 0 ]; then
echo "Usage: $0 logfile1 [logfile2 ...]"
echo ""
echo "Examples:"
echo " $0 /var/log/syslog /var/log/auth.log"
echo " $0 ./services/*/logs/app.log"
echo ""
echo "Set TMUX_LOG_SYNC=on for synchronized scrolling"
exit 1
fi
# ANSI colors for pane differentiation
COLORS=(31 32 33 34 35 36 91 92 93 94 95 96)
tmux kill-session -t "$SESSION" 2>/dev/null
# First file creates the session
FIRST_FILE="$1"
C="${COLORS[0]}"
tmux new-session -d -s "$SESSION" \
"printf '\033[1;${C}m━━━ %s ━━━\033[0m\n' '$FIRST_FILE'; \
tail -f '$FIRST_FILE'"
shift
# Each additional file gets its own pane
IDX=1
for FILE in "$@"; do
C="${COLORS[$((IDX % ${#COLORS[@]}))]}"
tmux split-window -t "$SESSION" \
"printf '\033[1;${C}m━━━ %s ━━━\033[0m\n' '$FILE'; \
tail -f '$FILE'"
# Re-tile after every split to prevent "no space" errors
tmux select-layout -t "$SESSION" tiled
IDX=$((IDX + 1))
done
# Set initial sync mode
tmux set-option -t "$SESSION" synchronize-panes "$SYNC"
# Bind Prefix + S to toggle sync within this session
tmux bind-key -T prefix S \
set-option synchronize-panes \; \
display-message "Sync #{?synchronize-panes,ON,OFF}"
tmux attach-session -t "$SESSION"
Example usage for a microservices stack:
# Tail all service logs at once
./tmux-logs.sh \
./services/api/logs/app.log \
./services/auth/logs/app.log \
./services/worker/logs/app.log \
./services/gateway/logs/access.log
# Sync-scroll system logs
TMUX_LOG_SYNC=on ./tmux-logs.sh /var/log/{syslog,auth.log,kern.log}
Recipe 6 — Development Environment Detector
The most powerful recipe in the cookbook. This script inspects the current directory, detects the project type by looking for marker files, and spins up a tmux session with the right layout, watchers, and commands for that stack.
#!/usr/bin/env bash
# tmux-dev.sh — Detect project type, launch appropriate tmux layout
# Usage: cd /path/to/project && tmux-dev.sh
# tmux-dev.sh /path/to/project
#
# Detected project types:
# package.json → Node.js / TypeScript
# Cargo.toml → Rust
# go.mod → Go
# pyproject.toml → Python
# Gemfile → Ruby
# docker-compose.yml → Docker Compose
# (fallback) → Generic 3-pane layout
PROJECT_DIR="${1:-$(pwd)}"
PROJECT_NAME="$(basename "$PROJECT_DIR")"
SESSION="dev-${PROJECT_NAME}"
cd "$PROJECT_DIR" || { echo "Not found: $PROJECT_DIR"; exit 1; }
# ─── Detection ───────────────────────────────────────────
detect_project() {
if [ -f "package.json" ]; then echo "node"
elif [ -f "Cargo.toml" ]; then echo "rust"
elif [ -f "go.mod" ]; then echo "go"
elif [ -f "pyproject.toml" ] ||
[ -f "requirements.txt" ]; then echo "python"
elif [ -f "Gemfile" ]; then echo "ruby"
elif [ -f "docker-compose.yml" ] ||
[ -f "compose.yml" ]; then echo "docker"
else echo "generic"
fi
}
PROJECT_TYPE="$(detect_project)"
tmux kill-session -t "$SESSION" 2>/dev/null
# ─── Node.js ─────────────────────────────────────────────
setup_node() {
# Detect package manager
if [ -f "pnpm-lock.yaml" ]; then PM="pnpm"
elif [ -f "yarn.lock" ]; then PM="yarn"
elif [ -f "bun.lockb" ]; then PM="bun"
else PM="npm"; fi
# ┌────────────┬─────────────┐
# │ │ dev server │
# │ editor ├─────────────┤
# │ │ test watch │
# └────────────┴─────────────┘
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "echo '📦 Node ($PM) — $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR" \
"$PM run dev 2>&1 || echo 'No dev script'"
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR" \
"$PM test -- --watch 2>&1 || echo 'No test script'"
tmux select-pane -t "$SESSION:0.0"
}
# ─── Rust ─────────────────────────────────────────────────
setup_rust() {
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "echo '🦀 Rust — $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR" \
"cargo watch -x 'check --color=always' 2>&1 || cargo check"
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR" \
"cargo watch -x 'test --color=always' 2>&1 || cargo test"
tmux select-pane -t "$SESSION:0.0"
}
# ─── Go ───────────────────────────────────────────────────
setup_go() {
local MAIN_PKG="."
[ -d "cmd" ] && MAIN_PKG="./cmd/..."
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "echo '🐹 Go — $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:0.1" "go run $MAIN_PKG" Enter
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:0.2" "go test ./... -v" Enter
tmux select-pane -t "$SESSION:0.0"
}
# ─── Python ───────────────────────────────────────────────
setup_python() {
local ACT=""
if [ -d ".venv" ]; then ACT="source .venv/bin/activate && "
elif [ -d "venv" ]; then ACT="source venv/bin/activate && "
fi
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "${ACT}echo '🐍 Python — $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:0.1" "${ACT}python" Enter
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR"
tmux send-keys -t "$SESSION:0.2" \
"${ACT}ptw --clear 2>/dev/null || pytest -v" Enter
tmux select-pane -t "$SESSION:0.0"
}
# ─── Ruby ─────────────────────────────────────────────────
setup_ruby() {
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "echo '💎 Ruby — $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR" \
"bundle exec rails server 2>/dev/null || irb"
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR" \
"bundle exec rspec 2>/dev/null || echo 'No specs'"
tmux select-pane -t "$SESSION:0.0"
}
# ─── Docker Compose ───────────────────────────────────────
setup_docker() {
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "echo '🐳 Docker Compose — $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR" \
"docker compose logs -f --tail=50"
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR" \
"watch -n2 'docker compose ps'"
tmux select-pane -t "$SESSION:0.0"
}
# ─── Generic fallback ────────────────────────────────────
setup_generic() {
tmux new-session -d -s "$SESSION" -c "$PROJECT_DIR"
tmux send-keys "echo '📁 $PROJECT_NAME'" Enter
tmux split-window -h -t "$SESSION" -c "$PROJECT_DIR"
tmux split-window -v -t "$SESSION:0.1" -c "$PROJECT_DIR" \
"watch -n5 'git status --short 2>/dev/null || ls -la'"
tmux select-pane -t "$SESSION:0.0"
}
# ─── Project-level override ──────────────────────────────
if [ -f ".tmux-dev.sh" ]; then
echo "🔧 Found .tmux-dev.sh — using project config"
source ".tmux-dev.sh"
else
echo "🔍 Detected: $PROJECT_TYPE"
case "$PROJECT_TYPE" in
node) setup_node ;;
rust) setup_rust ;;
go) setup_go ;;
python) setup_python ;;
ruby) setup_ruby ;;
docker) setup_docker ;;
*) setup_generic ;;
esac
fi
tmux rename-window -t "$SESSION" "$PROJECT_NAME"
echo "📐 Session '$SESSION' ready"
tmux attach-session -t "$SESSION"
Add it to your shell for instant access from any project directory:
# Quick-launch tmux dev environment for current directory
alias td='~/.local/bin/tmux-dev.sh'
# Or for specific projects
alias td-api='~/.local/bin/tmux-dev.sh ~/projects/api-server'
alias td-web='~/.local/bin/tmux-dev.sh ~/projects/web-app'
Drop a .tmux-dev.sh file in any project root to override the auto-detected layout. The script sources it before applying defaults, so you can define custom pane arrangements, environment variables, or startup commands per project.
Quick Reference
| Recipe | Script | What It Does |
|---|---|---|
| Monitoring Dashboard | tmux-monitor.sh |
4-pane system overview (htop, docker, logs, k8s) |
| Database Session | tmux-db.sh |
SQL client + query cheat sheet side by side |
| Pomodoro Timer | tmux-pomodoro.sh |
Live countdown in the tmux status bar |
| Auto-SSH Reconnect | tmux-autossh.sh |
Reconnects dropped SSH sessions automatically |
| Log Multiplexer | tmux-logs.sh |
N log files → N tiled panes with sync scroll |
| Dev Environment | tmux-dev.sh |
Auto-detects project stack, builds ideal layout |
Save each script to ~/.local/bin/, run chmod +x ~/.local/bin/tmux-*.sh, and you have a complete tmux automation toolkit.
Production-Grade .tmux.conf: Annotated Walkthrough
This is the section you fork. Below is a complete, battle-tested .tmux.conf that has been refined through years of daily professional use — remote pairing, multi-project workflows, SSH tunneling, and long-running dev servers. Every line is here for a reason, and every reason is documented.
The philosophy behind this config: vim-centric navigation, minimal visual noise, sensible defaults that stay out of your way, and graceful degradation on older tmux versions or different operating systems. Copy this entire file to ~/.tmux.conf, read the annotations, then customize to taste.
Copy this entire config, save it as ~/.tmux.conf, then run tmux source ~/.tmux.conf (or prefix + Alt-r once loaded) to apply. Read through the annotations first — you will want to tweak keybindings to match your muscle memory.
The Complete Config
# ╔══════════════════════════════════════════════════════════════════╗
# ║ Production-Grade .tmux.conf ║
# ║ Tested on: tmux 3.2+ (graceful fallbacks for 3.0+) ║
# ║ Philosophy: vim-centric, quiet, fast, predictable ║
# ╚══════════════════════════════════════════════════════════════════╝
# ──────────────────────────────────────────────────────────────────
# 1. SERVER & SESSION OPTIONS
# ──────────────────────────────────────────────────────────────────
# These affect the tmux server and session-level behavior. They run
# once and shape how every session behaves by default.
# Start window and pane numbering at 1 instead of 0.
# Reason: the 0 key is far from the left hand on most keyboards.
# Window 1 maps to prefix+1, which is faster to reach.
set -g base-index 1
setw -g pane-base-index 1
# When you close window 3 out of [1,2,3,4], renumber so you get
# [1,2,3] instead of [1,2,4]. Prevents gaps that break muscle memory.
set -g renumber-windows on
# Increase scrollback buffer from the default 2000 lines.
# 50k is enough to capture most build logs without eating too much RAM.
# Each pane gets its own buffer, so 10 panes = ~5MB. That's nothing.
set -g history-limit 50000
# Reduce the delay after pressing Escape. The default (500ms) makes
# vim/neovim feel laggy inside tmux because tmux waits to see if
# Escape is part of a longer key sequence. 10ms is effectively instant
# but still long enough for terminal escape sequences to arrive.
set -sg escape-time 10
# How often tmux redraws the status bar. Default is 15 seconds.
# 5 seconds is a good balance — fast enough to feel "live" for clocks
# and git branch indicators, slow enough to not waste CPU.
set -g status-interval 5
# Enable focus events so vim/neovim can detect when you switch panes.
# Required for autoread, gitgutter refresh, and similar features.
# Your terminal must also support this (iTerm2, Alacritty, Kitty do).
set -g focus-events on
# Display pane numbers for 2 seconds (prefix + q). The default 1s
# is too fast to read pane numbers on large layouts.
set -g display-panes-time 2000
# Display status messages for 3 seconds instead of the default 750ms.
set -g display-time 3000
# ──────────────────────────────────────────────────────────────────
# 2. TERMINAL & TRUE COLOR
# ──────────────────────────────────────────────────────────────────
# Getting color right in tmux is notoriously fiddly. This block
# ensures true color (24-bit) works inside tmux for modern terminals.
# Tell tmux to advertise itself as a 256-color terminal.
# tmux-256color is preferred over screen-256color because it supports
# italics, undercurl, and strikethrough.
set -g default-terminal "tmux-256color"
# Pass through true color (RGB) from the outer terminal.
# Without this, vim themes look washed out inside tmux.
set -ag terminal-overrides ",xterm-256color:RGB"
set -ag terminal-overrides ",alacritty:RGB"
# Enable undercurl support (used by nvim for spell/diagnostics).
# Requires tmux 3.0+ and a terminal that supports it.
set -ag terminal-overrides ',*:Smulx=\E[4::%p1%dm'
set -ag terminal-overrides ',*:Setulc=\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m'
# ──────────────────────────────────────────────────────────────────
# 3. PREFIX & UNBINDS
# ──────────────────────────────────────────────────────────────────
# The default prefix (Ctrl-b) is awkward — your left hand has to
# contort to reach it, and it conflicts with "page up" in vim.
# Ctrl-a is the classic screen prefix, close to the home row.
unbind C-b
set -g prefix C-a
bind C-a send-prefix
# Double-tap Ctrl-a to send a literal Ctrl-a to the inner program.
# Essential when running tmux inside tmux (nested SSH sessions).
bind a send-prefix
# Unbind defaults we are going to replace with better bindings.
unbind '"' # Default horizontal split
unbind % # Default vertical split
unbind & # Default window closer (dangerous without confirmation)
unbind , # Rename window (we will rebind it)
# ──────────────────────────────────────────────────────────────────
# 4. WINDOW & PANE OPTIONS
# ──────────────────────────────────────────────────────────────────
# Enable mouse support. Controversial choice — some purists disable
# it to force keyboard-only habits. But mouse is genuinely useful for:
# - Scrolling through logs (way faster than PageUp)
# - Resizing panes by dragging borders
# - Clicking to select panes in complex layouts
# Keyboard bindings still work, so this does not cost anything.
set -g mouse on
# Do not let programs rename your windows. When you name a window
# "api-server", you want it to stay "api-server" — not change to
# "vim" every time you open a file. Automatic rename is confusing
# in projects with many windows.
setw -g allow-rename off
setw -g automatic-rename off
# Activity monitoring — highlight windows that have new output.
# Useful when watching test runners or build processes in other
# windows. The visual bell prevents terminal beeping.
setw -g monitor-activity on
set -g visual-activity off
# Aggressive resize: size windows based on the smallest client
# CURRENTLY VIEWING that window, not the smallest client attached
# to the session. This matters for pair programming — your partner's
# small terminal won't shrink YOUR window when they look at a
# different window.
setw -g aggressive-resize on
# ──────────────────────────────────────────────────────────────────
# 5. KEY BINDINGS — NAVIGATION
# ──────────────────────────────────────────────────────────────────
# Navigation should feel like vim. h/j/k/l for panes, capital H/L
# for windows. No thinking, no reaching for arrow keys.
# Pane navigation with vim keys (prefix + h/j/k/l)
bind h select-pane -L
bind j select-pane -D
bind k select-pane -U
bind l select-pane -R
# Window navigation — prefix + H/L to move left/right.
# The -r flag makes these repeatable without re-pressing prefix.
bind -r H previous-window
bind -r L next-window
bind -r C-h previous-window
bind -r C-l next-window
# Smart pane switching with awareness of vim splits.
# Requires the vim-tmux-navigator plugin on the vim side.
# Ctrl+h/j/k/l moves seamlessly between vim splits AND tmux panes.
is_vim="ps -o state= -o comm= -t '#{pane_tty}' | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf)(diff)?$'"
bind -n 'C-h' if-shell "" 'send-keys C-h' 'select-pane -L'
bind -n 'C-j' if-shell "" 'send-keys C-j' 'select-pane -D'
bind -n 'C-k' if-shell "" 'send-keys C-k' 'select-pane -U'
bind -n 'C-l' if-shell "" 'send-keys C-l' 'select-pane -R'
# Swap panes — move the current pane up/down in the layout.
bind -r J swap-pane -D
bind -r K swap-pane -U
# Switch to last active window (like Ctrl-^ in vim).
bind Space last-window
# Cycle through sessions.
bind -r ) switch-client -n
bind -r ( switch-client -p
# ──────────────────────────────────────────────────────────────────
# 6. KEY BINDINGS — SPLITS, CREATION & MANAGEMENT
# ──────────────────────────────────────────────────────────────────
# Splits use | and - because they visually represent the split
# direction. Much more intuitive than the defaults % and ".
# Splits — open new panes in the CURRENT directory, not /Users/ashutoshmaheshwari.
# The -c "#{pane_current_path}" is critical; without it, every new
# pane starts in whatever directory tmux was originally launched from.
bind | split-window -h -c "#{pane_current_path}"
bind - split-window -v -c "#{pane_current_path}"
bind \\ split-window -h -c "#{pane_current_path}" # No shift needed
bind _ split-window -v -c "#{pane_current_path}" # Full-height variant
# New window also inherits current path.
bind c new-window -c "#{pane_current_path}"
# Resize panes with prefix + arrow keys. The -r flag makes these
# repeatable — hold prefix, then tap arrow keys multiple times.
# 5-cell increments feel right for most terminal sizes.
bind -r Left resize-pane -L 5
bind -r Down resize-pane -D 5
bind -r Up resize-pane -U 5
bind -r Right resize-pane -R 5
# Zoom toggle (already default prefix+z, but worth documenting).
# Zooms the current pane to fill the entire window. Press again
# to restore. Essential for temporarily maximizing a pane to
# read a stack trace, then popping back to your layout.
# bind z resize-pane -Z # (default — listed for documentation)
# Close pane without confirmation (prefix + x).
# Default prompts you every time, which gets tedious fast.
bind x kill-pane
# Close window with confirmation (prefix + X).
bind X confirm-before -p "Close window #W? (y/n)" kill-window
# Rename window and session.
bind r command-prompt -I "#W" "rename-window '%%'"
bind R command-prompt -I "#S" "rename-session '%%'"
# Reload config — the single most useful binding while tweaking.
# Break pane out to its own window.
bind b break-pane
# Join a pane from another window into the current window.
# Prompts for the source pane in "window.pane" format.
bind V command-prompt -p "Join pane from:" "join-pane -s '%%'"
# ──────────────────────────────────────────────────────────────────
# 7. KEY BINDINGS — UTILITY (POPUPS & SCRIPTS)
# ──────────────────────────────────────────────────────────────────
# tmux 3.2+ popup windows are game-changing for quick lookups.
# They float above your panes and disappear when done.
# Quick popup terminal (prefix + t). Great for running a one-off
# command without leaving your current context.
if-shell "tmux -V | awk '{print ($2 >= 3.2)}' | grep -q 1" \
"bind t display-popup -E -w 80% -h 70% -d '#{pane_current_path}'"
# Popup with lazygit — the fastest way to stage, commit, and push.
# Falls back to a regular split on tmux < 3.2.
if-shell "tmux -V | awk '{print ($2 >= 3.2)}' | grep -q 1" \
"bind g display-popup -E -w 90% -h 85% -d '#{pane_current_path}' 'lazygit'" \
"bind g split-window -h -c '#{pane_current_path}' 'lazygit'"
# Popup with htop for quick system monitoring.
if-shell "tmux -V | awk '{print ($2 >= 3.2)}' | grep -q 1" \
"bind m display-popup -E -w 90% -h 85% 'htop'" \
"bind m split-window -h 'htop'"
# FZF session switcher — fuzzy-find across all sessions.
if-shell "tmux -V | awk '{print ($2 >= 3.2)}' | grep -q 1" \
"bind s display-popup -E -w 60% -h 50% 'tmux list-sessions -F #{session_name} | fzf --reverse --header=Switch | xargs tmux switch-client -t'" \
"bind s split-window -v 'tmux list-sessions | fzf --reverse | cut -d: -f1 | xargs tmux switch-client -t'"
# ──────────────────────────────────────────────────────────────────
# 8. COPY MODE
# ──────────────────────────────────────────────────────────────────
# Copy mode should work like vim visual mode. Enter with prefix+[,
# select with v, yank with y, paste with prefix+].
# Use vi keys in copy mode (the single most important setting here).
setw -g mode-keys vi
# Enter copy mode with prefix+Escape (more natural than prefix+[).
bind Escape copy-mode
# v starts selection (like vim visual mode).
bind -T copy-mode-vi v send-keys -X begin-selection
# V starts line selection.
bind -T copy-mode-vi V send-keys -X select-line
# Ctrl-v toggles block/rectangle selection.
bind -T copy-mode-vi C-v send-keys -X rectangle-toggle
# y yanks selection to tmux buffer AND system clipboard.
# Platform-specific clipboard commands are set in section 11 below.
bind -T copy-mode-vi y send-keys -X copy-selection-and-cancel
# MouseDragEnd copies selection without leaving copy mode.
# Select text with the mouse and it is immediately in the clipboard.
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-selection -x
# Incremental search in copy mode (like / in vim).
bind -T copy-mode-vi / command-prompt -i -p "Search:" \
"send -X search-forward-incremental '%%'"
bind -T copy-mode-vi ? command-prompt -i -p "Search backward:" \
"send -X search-backward-incremental '%%'"
# Paste from tmux buffer.
bind p paste-buffer
bind P choose-buffer # Pick from the buffer list
# ──────────────────────────────────────────────────────────────────
# 9. STATUS BAR DESIGN
# ──────────────────────────────────────────────────────────────────
# A clean, informative status bar that tells you what you need
# without stealing attention. Session name on the left, window list
# in the center, git branch and time on the right.
set -g status-position top
set -g status-justify centre
set -g status-left-length 40
set -g status-right-length 60
# Left: session name in brackets.
set -g status-left "#[fg=colour4,bold] [#S] "
# Right: current git branch, hostname, and clock.
set -g status-right "#[fg=colour8]#(cd #{pane_current_path}; git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '-') #[fg=colour7]│ #H │ %H:%M "
# Window list format — current window is highlighted.
setw -g window-status-format "#[fg=colour8] #I:#W "
setw -g window-status-current-format "#[fg=colour4,bold] #I:#W* "
setw -g window-status-activity-style "fg=colour3"
setw -g window-status-separator ""
# ──────────────────────────────────────────────────────────────────
# 10. COLORS & VISUAL STYLE
# ──────────────────────────────────────────────────────────────────
# Minimal theme using terminal palette colors (colour0-colour15)
# so it adapts to whatever scheme you are running — Catppuccin,
# Gruvbox, Tokyo Night, etc.
# Status bar — transparent background, blends with terminal.
set -g status-style "bg=default,fg=colour7"
# Pane borders — subtle, not distracting.
set -g pane-border-style "fg=colour8"
set -g pane-active-border-style "fg=colour4"
# Message bar.
set -g message-style "bg=colour0,fg=colour4,bold"
set -g message-command-style "bg=colour0,fg=colour4"
# Copy mode highlight color.
setw -g mode-style "bg=colour4,fg=colour0"
# Clock mode color.
setw -g clock-mode-colour colour4
# Pane number display (prefix + q).
set -g display-panes-active-colour colour4
set -g display-panes-colour colour8
# ──────────────────────────────────────────────────────────────────
# 11. PLATFORM-SPECIFIC — macOS vs Linux
# ──────────────────────────────────────────────────────────────────
# System clipboard integration differs between platforms.
# if-shell checks run at config load time — zero runtime overhead.
# macOS: use pbcopy/pbpaste for clipboard.
if-shell "uname | grep -q Darwin" {
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "pbcopy"
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe "pbcopy"
bind ] run "pbpaste | tmux load-buffer - && tmux paste-buffer"
bind -T copy-mode-vi o send-keys -X copy-pipe-and-cancel "xargs open"
}
# Linux: use xclip (X11) or wl-copy (Wayland) for clipboard.
if-shell "uname | grep -q Linux" {
if-shell "command -v wl-copy" {
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel "wl-copy"
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe "wl-copy"
} {
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel \
"xclip -selection clipboard -i"
bind -T copy-mode-vi MouseDragEnd1Pane send-keys -X copy-pipe \
"xclip -selection clipboard -i"
}
bind ] run "xclip -selection clipboard -o | tmux load-buffer - \
&& tmux paste-buffer"
bind -T copy-mode-vi o send-keys -X copy-pipe-and-cancel "xargs xdg-open"
}
# ──────────────────────────────────────────────────────────────────
# 12. PLUGINS (via TPM — Tmux Plugin Manager)
# ──────────────────────────────────────────────────────────────────
# Install TPM first:
# git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
# Then press prefix + I (capital I) to install plugins.
set -g @plugin 'tmux-plugins/tpm'
# Sensible defaults — fills in anything this config might miss.
set -g @plugin 'tmux-plugins/tmux-sensible'
# Persist tmux sessions across system restarts.
# prefix + Ctrl-s to save, prefix + Ctrl-r to restore.
set -g @plugin 'tmux-plugins/tmux-resurrect'
set -g @resurrect-capture-pane-contents 'on'
set -g @resurrect-strategy-nvim 'session'
# Auto-save sessions every 15 min (builds on tmux-resurrect).
set -g @plugin 'tmux-plugins/tmux-continuum'
set -g @continuum-restore 'on'
set -g @continuum-save-interval '15'
# Yank to system clipboard — safety net for clipboard integration.
set -g @plugin 'tmux-plugins/tmux-yank'
# FZF-based URL picker — prefix + u opens URLs found in the pane.
set -g @plugin 'wfxr/tmux-fzf-url'
# Initialize TPM (MUST be the very last line in the plugin section).
run '~/.tmux/plugins/tpm/tpm'
# ──────────────────────────────────────────────────────────────────
# 13. LOCAL OVERRIDES
# ──────────────────────────────────────────────────────────────────
# Source a machine-specific config if it exists. Put settings that
# differ between work laptop, home desktop, and remote servers here.
#
# Examples of what goes in .tmux.conf.local:
# - Work-specific status bar (VPN status, k8s context)
# - Different color scheme per machine
# - Override prefix key for nested remote sessions
if-shell '[ -f ~/.tmux.conf.local ]' 'source-file ~/.tmux.conf.local'
Key Decisions Explained
A few choices in this config are intentionally opinionated. Here is the reasoning behind the most debated ones.
Why Ctrl-a instead of Ctrl-b?
Ctrl-a sits directly under your left pinky on the home row. Ctrl-b forces an awkward stretch. If you have never used GNU Screen, you have no muscle memory for either — in that case, Ctrl-a is objectively closer. The trade-off: Ctrl-a is "go to beginning of line" in bash/readline. You can still send it with prefix a (double-tap), or just use Home.
Why Mouse On?
The keyboard-only philosophy is admirable but impractical for certain tasks. Scrolling through a 500-line stack trace is faster with a scroll wheel. Resizing panes by dragging a border is more precise than guessing pixel counts. Mouse support does not disable keyboard bindings — it adds to them. Enable it, use whichever input is fastest for the task at hand.
Why Disable Automatic Rename?
With automatic rename on, your window named api-server changes to vim when you edit a file, then to bash when you close vim. In a session with 6+ windows, you lose your mental map of what is where. Explicit naming (prefix + r) keeps your workspace legible.
The popup bindings (display-popup) require tmux 3.2+. The brace syntax for if-shell blocks requires tmux 3.0+. If you are on an older version, replace braces with quoted strings: if-shell "test" "then-cmd" "else-cmd". Check your version with tmux -V.
Quick Reference: Keybinding Cheatsheet
Here is every custom binding from the config above, organized by function. Prefix is Ctrl-a unless noted otherwise.
# ─── Navigation ───────────────────────────────────
# prefix + h/j/k/l Move between panes (vim-style)
# Ctrl + h/j/k/l Move between panes AND vim splits (no prefix)
# prefix + H/L Previous/next window
# prefix + Space Toggle last window
# prefix + (/) Previous/next session
# ─── Splits & Windows ─────────────────────────────
# prefix + | or \ Vertical split (in current dir)
# prefix + - or _ Horizontal split (in current dir)
# prefix + c New window (in current dir)
# prefix + Arrow keys Resize pane (repeatable)
# prefix + z Zoom/unzoom pane
# ─── Management ───────────────────────────────────
# prefix + x Close pane (no confirmation)
# prefix + X Close window (with confirmation)
# prefix + r Rename window
# prefix + R Rename session
# prefix + Alt-r Reload config
# prefix + b Break pane to its own window
# prefix + V Join pane from another window
# prefix + J/K Swap pane down/up
# ─── Copy Mode ────────────────────────────────────
# prefix + [ or Esc Enter copy mode
# v Begin selection
# V Select line
# Ctrl-v Rectangle selection
# y Yank to clipboard
# prefix + p Paste
# prefix + P Choose from buffer list
# / and ? Search forward/backward
# ─── Utility Popups ───────────────────────────────
# prefix + t Popup terminal
# prefix + g Popup lazygit
# prefix + m Popup htop
# prefix + s FZF session switcher
# ─── Plugins ──────────────────────────────────────
# prefix + I Install plugins (TPM)
# prefix + Ctrl-s Save session (resurrect)
# prefix + Ctrl-r Restore session (resurrect)
# prefix + u Open URLs in pane (fzf-url)
This config is a starting point, not a religion. If you do not use vim, swap the hjkl bindings for arrow keys. If you prefer Ctrl-Space as prefix, change it. The important thing is that every binding in your config is one you chose deliberately — not a default you never questioned.
Common Pitfalls, Troubleshooting, and Debugging
Tmux is reliable once configured correctly, but the road there is paved with cryptic color glitches, phantom key delays, and clipboard mysteries. This section catalogs the problems every tmux user encounters — and the exact commands to fix them.
Before diving into specific pitfalls, familiarize yourself with the debugging decision tree below. When something looks wrong, start here:
flowchart TD
A["Something looks wrong in tmux"] --> B{{"What kind of problem?"}}
B -->|"Colors / themes broken"| C["Run tmux info, grep Tc or RGB"]
C --> C1{{"Output shows Tc or RGB?"}}
C1 -->|No| C2["Add: set -as terminal-features RGB"]
C1 -->|Yes| C3["Check TERM inside tmux"]
C3 --> C4{{"Is it tmux-256color?"}}
C4 -->|No| C5["set -g default-terminal tmux-256color"]
C4 -->|Yes| C6["Verify terminal emulator true color support"]
B -->|"Key delay / Esc lag"| D["set -sg escape-time 0"]
D --> D1["Test: press Esc in Vim"]
B -->|"Clipboard not working"| E{{"Which OS?"}}
E -->|macOS| E1["Use pbcopy in tmux 3.3+"]
E -->|Linux X11| E2["Install xclip or xsel"]
E -->|Linux Wayland| E3["Install wl-copy"]
E -->|WSL| E4["Use clip.exe or win32yank"]
B -->|"Plugin not loading"| F["Check TPM install"]
F --> F1["ls ~/.tmux/plugins/tpm"]
F1 --> F2["Ensure run line is LAST in tmux.conf"]
B -->|"Display too small"| G["tmux detach-client -a"]
G --> G1["Detaches other clients constraining size"]
B -->|"Keybinding conflict"| H["tmux list-keys, grep for key"]
H --> H1["Identify and unbind conflicting key"]
style A fill:#f59e0b,color:#000,stroke:#d97706
style B fill:#3b82f6,color:#fff,stroke:#2563eb
Pitfall 1: Colors and Themes Look Wrong
Symptom: Your carefully crafted color scheme looks washed out, your Vim theme shows wrong colors, or you see blocks of incorrect color in Neovim. Colors that work perfectly outside tmux break the moment you are inside a session.
Cause: Tmux does not know your terminal supports true color (24-bit), so it falls back to 256-color or even 16-color mode. This typically happens when $TERM is misconfigured or terminal-overrides are not set.
Fix: Run the diagnostic first, then apply the correct settings:
# Step 1: Check if tmux knows about true color
tmux info | grep -i "Tc\|RGB\|colors"
# Step 2: Check your TERM value inside tmux
echo $TERM
# Should output: tmux-256color (NOT screen-256color or xterm-256color)
Add these lines to your ~/.tmux.conf:
# Tell tmux to advertise itself as a 256-color terminal
set -g default-terminal "tmux-256color"
# Tell tmux that the OUTSIDE terminal supports true color
set -as terminal-features ',xterm-256color:RGB'
# For older tmux versions (< 3.2), use terminal-overrides instead:
# set -as terminal-overrides ',xterm-256color:Tc'
After sourcing the config, verify the fix with a quick true-color test:
# Print a smooth gradient — should show no banding
awk 'BEGIN{for(i=0;i<256;i++)printf "\033[48;2;%d;0;0m ",i;print "\033[0m"}'
Pitfall 2: Escape Key Delay in Vim/Neovim
Symptom: Pressing Esc in Vim or Neovim to exit insert mode has a noticeable delay (roughly half a second). Switching modes feels sluggish and breaks your muscle memory.
Cause: Tmux waits after receiving an escape character to determine if it is the start of a function key sequence or a standalone Escape press. The default escape-time is 500 milliseconds — far too long.
Fix: Set escape-time to 0 (or 10ms if you experience issues on slow connections):
# Eliminate escape key delay — essential for Vim users
set -sg escape-time 0
This is the single most impactful tmux setting for Vim/Neovim users. If you take nothing else from this section, add set -sg escape-time 0 to your config. The default 500ms delay makes Vim feel broken inside tmux.
Pitfall 3: Clipboard Not Working
Symptom: You yank text in tmux copy mode, but it does not appear in your system clipboard. Pasting with Cmd+V (macOS) or Ctrl+V (Linux) gives you old content.
Cause: Tmux has its own internal paste buffer that is separate from the system clipboard. You need to explicitly bridge the two, and the solution varies by operating system and display server.
| Platform | Clipboard Tool | tmux.conf Configuration |
|---|---|---|
| macOS | pbcopy / pbpaste | set -s copy-command 'pbcopy' |
| Linux (X11) | xclip or xsel | set -s copy-command 'xclip -sel clipboard' |
| Linux (Wayland) | wl-copy | set -s copy-command 'wl-copy' |
| WSL | clip.exe or win32yank | set -s copy-command 'clip.exe' |
For tmux 3.2+, the copy-command option is the cleanest approach. For older versions, use copy-pipe-and-cancel:
# tmux 3.2+ (recommended)
set -s copy-command 'pbcopy' # macOS — change per platform
# Older tmux: bind yank in copy mode to pipe to clipboard
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'pbcopy'
Test it by copying something in tmux copy mode and verifying with:
# Check tmux internal buffer
tmux show-buffer
# Verify system clipboard has the same content (macOS)
pbpaste
Pitfall 4: Mouse Mode Confuses Copy-Paste
Symptom: You enabled set -g mouse on for scrolling and pane resizing, but now you cannot select text normally. Clicking and dragging enters tmux copy mode instead of your terminal's native selection.
Cause: When mouse mode is on, tmux intercepts all mouse events before your terminal emulator sees them. Your terminal's native click-to-select behavior is completely hijacked by tmux copy mode.
Fix: Hold Shift while clicking and dragging. This tells your terminal emulator to bypass tmux mouse capture entirely.
| Action | Without Shift (tmux handles it) | With Shift (terminal handles it) |
|---|---|---|
| Click + drag | Enters tmux copy mode and selects | Native terminal text selection |
| Right-click | Tmux context menu (if configured) | Terminal paste or context menu |
| Middle-click | Pastes tmux buffer | Pastes system clipboard (X11) |
| Scroll wheel | Scrolls tmux scrollback | Sends arrow key events |
On iTerm2 and Alacritty on macOS, hold Option (⌥) instead of Shift to bypass tmux mouse capture. The exact modifier key depends on your terminal emulator — check its documentation.
Pitfall 5: Session Resurrection Fails (tmux-resurrect / tmux-continuum)
Symptom: You restore a tmux session with prefix + Ctrl-r (tmux-resurrect), but panes are empty, programs are not running, or Vim sessions come back blank.
Cause: Tmux-resurrect saves your pane layout and working directories, but it cannot magically restart arbitrary processes. Common failure causes include:
- Interactive processes (ssh, docker exec, etc.) — these are not restorable by nature.
- Vim/Neovim sessions — require the
tpope/vim-obsessionplugin or:mksessionto save session files that resurrect can detect. - Corrupted resurrect files — saved state in
~/.tmux/resurrect/may be truncated if the save was interrupted. - Changed paths — if you renamed or moved a directory that a pane CWD pointed to, the restore opens in
$HOMEinstead.
# Inspect saved resurrect files
ls -la ~/.tmux/resurrect/
# View the latest save file to see what was captured
cat ~/.tmux/resurrect/last
# Each line represents a pane — format is:
# pane[tab]session[tab]window[tab]pane_index[tab]dir[tab]command
To get Vim restoration working, install vim-obsession and add this to your resurrect config:
# Enable Vim session restoration via vim-obsession
set -g @resurrect-strategy-vim 'session'
set -g @resurrect-strategy-nvim 'session'
# Restore additional programs beyond the defaults
set -g @resurrect-processes 'vim nvim man less tail top htop'
Pitfall 6: Plugin Not Loading
Symptom: You added a plugin to ~/.tmux.conf, ran prefix + I to install, but the plugin has no effect. Keybindings do not work, status bar additions do not appear.
Cause: TPM (Tmux Plugin Manager) is sensitive to ordering and file permissions. Here is the debugging checklist:
- Is TPM itself installed? Check that
~/.tmux/plugins/tpm/exists. - Is the
runline at the very bottom of.tmux.conf? TPM must be initialized after all plugin declarations. - Did you press
prefix + I(capital I) to install? Lowercaseiwill not work. - Does the plugin directory exist? Check
~/.tmux/plugins/<plugin-name>/. - Are the plugin scripts executable? Check file permissions.
# Verify TPM is installed
ls ~/.tmux/plugins/tpm/tpm
# List all installed plugins
ls ~/.tmux/plugins/
# Check plugin scripts are executable
find ~/.tmux/plugins/ -name "*.tmux" -exec ls -la {} \;
# Fix permissions if needed
chmod +x ~/.tmux/plugins/*/*.tmux 2>/dev/null
# Reinstall everything from scratch
rm -rf ~/.tmux/plugins/*
git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm
# Then press prefix + I inside tmux
Make sure your .tmux.conf structure follows this order:
# 1. General settings
set -g mouse on
set -sg escape-time 0
# 2. Plugin declarations
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
set -g @plugin 'tmux-plugins/tmux-resurrect'
# 3. Plugin options (AFTER declarations, BEFORE run)
set -g @resurrect-strategy-nvim 'session'
# 4. Initialize TPM — THIS MUST BE THE LAST LINE
run '~/.tmux/plugins/tpm/tpm'
Pitfall 7: Tmux Inside Tmux — Key Conflicts
Symptom: You SSH into a remote server that also runs tmux. Pressing prefix is intercepted by the local tmux, and you can never reach the remote one. You are trapped in a prefix inception.
Cause: Both the local and remote tmux instances are listening for the same prefix key. The local tmux always wins because it sees the key first.
Fix: Use send-prefix to forward the prefix to the inner tmux. Press the prefix key twice — the first activates local tmux, the second gets sent through to the remote:
# Double-tap prefix to send it to the nested tmux
bind C-b send-prefix
# Alternative: use a completely different prefix for inner sessions
# On the REMOTE machine's .tmux.conf:
# set -g prefix C-a
# unbind C-b
# bind C-a send-prefix
A more sophisticated approach is to toggle the outer tmux "off" when you are focused on the inner session. This binds F12 to disable/enable the local tmux prefix:
# Press F12 to disable local prefix (all keys go to inner tmux)
# Press F12 again to re-enable
bind -T root F12 \
set prefix None \;\
set key-table off \;\
set status-style "bg=colour238" \;\
refresh-client -S
bind -T off F12 \
set -u prefix \;\
set -u key-table \;\
set -u status-style \;\
refresh-client -S
Pitfall 8: Display Too Small — Dotted Border Everywhere
Symptom: Your tmux window is inexplicably tiny, surrounded by dots or blank space. The usable area is a small rectangle in the corner of your terminal.
Cause: Multiple clients (terminal windows) are attached to the same session. Tmux sizes the session to fit the smallest client. If you have a laptop with a small screen and a monitor both attached, the session shrinks to the laptop dimensions.
Fix: Detach the other clients or use aggressive-resize:
# See all clients attached to the current session
tmux list-clients
# Detach ALL other clients, keeping only the current one
tmux detach-client -a
# Or attach and detach others in one command
tmux attach -d
For a permanent fix, enable per-window resizing:
# Resize windows based on the smallest client VIEWING that window,
# not the smallest client attached to the session
set -g aggressive-resize on
Essential Debugging Commands
When the pitfalls above do not cover your issue, these are the commands you will reach for. Think of them as your tmux debugger — they let you inspect every aspect of tmux internal state.
| Command | What It Reveals |
|---|---|
tmux info | Full terminal capabilities, features, escape sequences supported |
tmux show-options -g | All global options and their current values |
tmux show-options -s | Server-level options (escape-time, copy-command, etc.) |
tmux list-keys | Every key binding — find conflicts by piping to grep |
tmux display-message -p '#{...}' | Evaluate format strings to inspect session/window/pane variables |
tmux show-messages | Recent tmux log messages (errors, warnings, config reload output) |
tmux list-clients | All attached clients and their terminal sizes |
tmux source ~/.tmux.conf | Reload config without restarting — shows errors inline |
# Find who stole your keybinding
tmux list-keys | grep "C-l"
# Inspect a specific option current value
tmux show-option -gv status-style
# Debug format strings interactively
tmux display-message -p 'Session: #{session_name}, Window: #{window_index}'
# Start tmux with verbose logging for deep debugging
tmux -vv new-session -d -s debug
# Logs written to: tmux-client-*.log and tmux-server-*.log in CWD
# View tmux server logs (check current directory or /tmp)
ls -la tmux-server-*.log 2>/dev/null
If your session state is truly corrupted and nothing works, run tmux kill-server to terminate the tmux server and all sessions. This is the hard reset — use it as a last resort. After running it, start fresh with tmux new-session.