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.

bash
# 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.

bash — list-sessions
$ 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
bash — list-windows
# 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
bash — list-panes
# 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
The Targeting Syntax

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.

bash
# 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.

bash
# 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.

bash — new -s vs new -t
# 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 ;
bash
# 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
Socket Permissions Matter

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.

bash
# 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
Quick Reference: Verify the Architecture

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.

ActionPrefix KeyCLI Command
New sessiontmux new-session -s work
New session (detached)tmux new-session -ds background
Detach from sessionC-b dtmux detach-client
List sessionsC-b stmux list-sessions
Attach to sessiontmux attach-session -t work
Switch to another sessionC-b s (then select)tmux switch-client -t other
Rename current sessionC-b $tmux rename-session -t old new
End a sessiontmux kill-session -t work
End all other sessionstmux kill-session -a
Move to previous sessionC-b (tmux switch-client -p
Move to next sessionC-b )tmux switch-client -n
Choose tree (visual picker)C-b stmux choose-tree -s
bash
# 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.

ActionPrefix KeyCLI Command
New windowC-b ctmux new-window -n logs
Next windowC-b ntmux select-window -t :+
Previous windowC-b ptmux select-window -t :-
Select window by numberC-b 09tmux select-window -t :3
Last active windowC-b ltmux last-window
Rename windowC-b ,tmux rename-window -t :1 api
Find window (by name)C-b ftmux find-window -N pattern
Close windowC-b &tmux kill-window -t :2
Move window to index 5tmux move-window -t :5
Swap window 1 and 3tmux swap-window -s :1 -t :3
Visual window pickerC-b wtmux choose-tree -w
Rotate layoutC-b Spacetmux next-layout
bash
# 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.

ActionPrefix KeyCLI 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 paneC-b otmux select-pane -t :.+
Toggle last active paneC-b ;tmux last-pane
Zoom pane (fullscreen toggle)C-b ztmux resize-pane -Z
Resize pane down 5 rowsC-b C-↓ (repeat)tmux resize-pane -D 5
Resize pane right 10 colsC-b C-→ (repeat)tmux resize-pane -R 10
Display pane numbersC-b qtmux display-panes
Swap pane with nextC-b }tmux swap-pane -D
Swap pane with previousC-b {tmux swap-pane -U
Break pane to own windowC-b !tmux break-pane
Join pane from another windowtmux join-pane -s :2 -t :1
Close current paneC-b xtmux kill-pane
Rotate panes in windowC-b C-otmux rotate-window
bash
# 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.

LayoutCLI CommandDescription
even-horizontaltmux select-layout even-horizontalAll panes side by side, equal widths
even-verticaltmux select-layout even-verticalAll panes stacked, equal heights
main-horizontaltmux select-layout main-horizontalOne large pane on top, rest below
main-verticaltmux select-layout main-verticalOne large pane on left, rest stacked right
tiledtmux select-layout tiledAll panes arranged in a grid
bash
# 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.

ActionCLI Command
List all clientstmux list-clients
Detach a specific clienttmux detach-client -t /dev/ttys003
Detach all other clientstmux detach-client -a
Switch this client to sessiontmux switch-client -t other-session
Lock clienttmux lock-client
Refresh client displaytmux refresh-client
Suspend client (like C-z)tmux suspend-client

Commands I Actually Use Daily

The 15 Commands That Cover 95% of My Day

Out of 100+ tmux commands, these are the ones my muscle memory fires without thinking:

  1. C-b c — new window
  2. C-b , — rename window (do this immediately)
  3. C-b 19 — jump to window by number
  4. C-b % — vertical split
  5. C-b " — horizontal split
  6. C-b ↑↓←→ — move between panes
  7. C-b z — zoom pane (absolute lifesaver for reading logs)
  8. C-b d — detach (go home, come back, reattach)
  9. C-b s — visual session/window tree picker
  10. C-b w — visual window picker (with preview!)
  11. C-b x — close pane
  12. C-b ! — break pane to its own window
  13. C-b [ — enter copy mode (scroll up)
  14. C-b l — toggle to last window
  15. C-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 TableWhen ActiveExample Keys
prefixAfter pressing C-bc, n, %, ", z
rootAlways (no prefix needed)MouseDown1Pane, custom binds with -n
copy-modeIn emacs-style copy modeC-Space, M-w
copy-mode-viIn vi-style copy modev, 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

bash
# 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
example output
$ 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
list-keys vs list-keys -T

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.

bash
# 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.

bash
# 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
Discover commands you don’t know about

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.

bash
# 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.

tmux.conf
# 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:

KeyAction
h / j / k / lMove left / down / up / right
w / bNext word / previous word
0 / $Start / end of line
g / GTop / bottom of scrollback
C-u / C-dHalf-page up / half-page down
C-b / C-fFull page up / full page down
/Search forward
?Search backward
n / NNext / 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.

tmux.conf
# 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.

tmux.conf
# 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.

bash
# 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.

bash
# 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 + =
Buffer Picker

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.

tmux.conf
# 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.

tmux.conf
# 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.

tmux.conf
# 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

tmux.conf
# 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
tmux.conf
# 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
tmux.conf
# 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)

tmux.conf
# 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.

tmux.conf
# 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' }
if-shell Evaluation Order

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.

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
tmux-yank Plugin Alternative

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

StepKeysWhat Happens
1. Enter copy modeprefix + [Pane freezes, cursor appears
2. Navigateh/j/k/l, /patternMove to target text or search
3. Start selectionvVisual highlight begins
4. Adjust selectionh/j/k/l, e, $Extend highlight to desired end
5. YankyCopied to tmux buffer + system clipboard
6. Paste (tmux)prefix + PInserts text into active pane
6. Paste (system)Cmd+V / Ctrl+Shift+VPaste 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.

bash
# 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.

Modern tmux simplification

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:

bash
# 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.

bash
# 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

bash
# 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:

bash
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:

bash
# Reload config with prefix + r
bind r source-file ~/.tmux.conf \; display-message "Config reloaded!"
Pro Tip

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:

bash
# 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.

bash
# ~/.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
Config not taking effect?

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:

bash
# 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:

bash
# 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.

Alt+L Conflict

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:

bash
# 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:

bash
# 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:

bash
# 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:

bash
# 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:

bash
# 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 -n is shorthand for bind -T root).
  • copy-mode-vi (or copy-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:

bash
# 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:

bash
# 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:

bash
# 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:

bash
# 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:

bash
# 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
Debug in Real-Time

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:

bash
# ── 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:

bash
# ┌──────────────┬──────────────────────────┬──────────────┐
# │ 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
#SSession namework
#WWindow namenvim
#IWindow index0
#PPane index1
#TPane title~/projects
#HHostname (short)devbox
#hHostname (FQDN)devbox.local
#{pane_current_path}Full path of active pane/home/user/app
#{b:pane_current_path}Basename of pane pathapp
#(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.

bash
# 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:

bash
# 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.

bash
# 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)
Watch your status-interval

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.

bash
# 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:

bash
# 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.

bash — Minimalist theme
# ── 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.

bash — Developer theme
# ── 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.

bash — DevOps theme
# ── 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"
Cache slow commands

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.

bash
# 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

bash
# 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

bash
# 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}"
Reload without restarting

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

bash
# ── 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:

plaintext
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)

tmux.conf
# 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:

tmux.conf
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
Check your tmux version

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.

truecolor-test.sh
#!/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:

bash
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:

bash
# 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:

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:

vim
" 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)

plaintext
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

alacritty.toml
[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.conf
# 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

wezterm.lua
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

json
// 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:

  1. Check TERM outside tmux
    bash
    # Outside tmux — should be xterm-256color
    echo $TERM

    If this shows xterm-kitty, alacritty, or something unexpected, fix it in your terminal emulator settings first.

  2. Check TERM inside tmux
    bash
    # Inside tmux — should be tmux-256color (or screen-256color)
    echo $TERM

    If it shows screen (without -256color), your default-terminal setting isn't applied. Reload with tmux source ~/.tmux.conf and open a new pane.

  3. Verify the tmux-256color terminfo exists
    bash
    infocmp tmux-256color > /dev/null 2>&1 && echo "OK" || echo "MISSING"

    If MISSING, the terminfo entry isn't installed. On macOS, a brew install ncurses usually fixes it. On older Linux distros, install ncurses-term or ncurses-base. As a fallback, set default-terminal to "screen-256color" instead.

  4. Confirm RGB capability in tmux
    bash
    # Inside tmux — look for RGB or Tc flags
    tmux info | grep -E "Tc|RGB"
    # Expected: 203: Tc: (flag) true
    #       or: RGB: (flag) true

    If neither flag shows true, your terminal-features or terminal-overrides setting isn't applied. You need to fully stop the tmux server and start a fresh session.

  5. Run the color gradient test
    bash
    # 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
Don't set TERM in your shell RC file

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:

bash
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.

bash — ~/.tmux.conf
# ── 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 BindingActionWhat It Does
prefix + IInstallClones any plugins listed in .tmux.conf that aren't yet in ~/.tmux/plugins/
prefix + UUpdatePulls the latest commits for all installed plugins
prefix + alt + uUninstallRemoves plugins whose @plugin line has been deleted from .tmux.conf
Capital I, Not Lowercase

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:

bash — ~/.tmux.conf
# 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.

bash — ~/.tmux.conf
# 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:

  1. Delete (or comment out) the set -g @plugin '...' line from ~/.tmux.conf
  2. Press prefix + alt + u inside 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.

bash
# 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:

bash — scripts/setup-tmux.sh
#!/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:

bash — ~/.tmux.conf (bottom)
# 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'
How the tmux if command works

The 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

bash
# 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.

bash
# 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

bash
# 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.

Don't Reload With a Server Restart

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.

bash
# 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 environment
  • prefix + 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.

Neovim session restore

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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
# 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.

bash
# 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?

Featuretmux-thumbstmux-fingers
Written inRust (compiled binary)Shell + awk (v1) / Crystal (v2)
Speed on large panesVery fastFast (v2 improved significantly)
InstallationAuto-compiles or downloads binaryNo compilation (v1), Crystal needed (v2)
Custom regexYesYes
Multi-action hintsLowercase = copy, Uppercase = customConfigurable per-action
Hint displayCompact, overlays source textHighlights with positioned labels
MaturityNewer, very actively maintainedOlder, 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.

Watch for key binding conflicts

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.

bash
# -- 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' 
Install everything in one shot

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:

  1. Your repo must contain a .tmux file at the root (e.g., project-switcher.tmux)
  2. That file must be executable (chmod +x)
  3. TPM runs it as a shell script — whatever tmux commands it issues take effect

Directory Layout

Here's the structure you'll build for tmux-project-switcher:

directory structure
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.

project-switcher.tmux
#!/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 -g flag reads global options, -q suppresses errors if the option doesn't exist, and -v returns 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 -w and -h flags 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.

scripts/switch.sh
#!/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"
Why 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:

bash
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:

.tmux.conf (user configuration)
# 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:

bash
# 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:

bash
# 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.

Debug Tip

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:

bash
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 @plugin line to copy
  • Configuration — every @option with 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:

project-switcher.tmux (complete)
#!/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}'"
scripts/switch.sh (complete)
#!/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"
Extend from here

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:

bash
# 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:

bash
# 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
The -d Flag Is Your Best Friend

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:

bash
# 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.

Watch Your Semicolons

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:

bash
# 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:

bash
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:

bash
# 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:

bash
#!/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
Make It Portable

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.

bash
#!/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"
Why detached first?

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.

bash — tmux-webdev.sh
#!/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.

bash — tmux-devops.sh
#!/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.

bash — tmux-datasci.sh
#!/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.

bash — tmux-microservices.sh
#!/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

bash — .tmux-project (web project)
# .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

bash — tmux-smart.sh
#!/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:

bash — .tmux-project (Go project)
# .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:

bash
# 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
Combine with fzf for maximum speed

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)
Pane numbering changes as you split

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

bash
# 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:

bash
# 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:

yaml — tmuxinator schema
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)

yaml — ~/.config/tmuxinator/webapp.yml
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

yaml — ~/.config/tmuxinator/platform.yml
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

yaml — ~/.config/tmuxinator/datascience.yml
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

bash
# 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

bash
# 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

yaml — tmuxp schema
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

yaml — ~/.config/tmuxp/webapp.yaml
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

yaml — ~/.config/tmuxp/platform.yaml
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

yaml — ~/.config/tmuxp/datascience.yaml
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
Notice the repetition

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:

bash
# 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

bash
# 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
Which one should you pick?

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

bash — ~/.bashrc or ~/.zshrc
# 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:

bash — ~/.bashrc or ~/.zshrc
# 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; }
bash — ~/.bashrc or ~/.zshrc
# 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.

Guard against nested sessions

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:

bash
# 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:

bash
# 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:

bash — ~/.local/bin/tmux-sessionizer (v1)
#!/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
bash
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.

bash — tmux-sessionizer (v2: preview windows)
#!/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.

bash — tmux-sessionizer (v3: project directories)
#!/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:

bash — tmux-sessionizer (v4: session destroy 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.

bash — tmux-sessionizer (v5: final version with zoxide)
#!/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 integrationzoxide query -l returns all tracked directories sorted by frecency. We filter to paths under your PROJECT_DIRS so random cd'd directories don't pollute the list. Falls back to find if 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-r to 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:

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:

tmux.conf — popup variant (tmux 3.2+)
# 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:

~/.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.

Make it part of muscle memory

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

FeatureKey / FlagWhat It Does
Switch sessionEnterJump to highlighted session
Create sessionEnter on a directoryCreates named session, cd'd to project
Destroy sessionsCtrl-x, then Tab + EnterMulti-select and destroy stale sessions
Refresh listCtrl-rReload sessions without restarting picker
Preview panesAutomaticRight pane shows window/pane tree or file list
Frecency sortAutomatic (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:

bash — extension snippets
# 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'
Don't shell-out too aggressively in previews

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)

lua
-- 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)

vim
" 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:

bash
# 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

bash
# ~/.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
The -n flag matters

bind-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:

bash
# ~/.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:

bash
# 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)?$'"
SSH trade-off

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:

bash
# 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:

bash
# 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.

bash
# ~/.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:

lua
-- 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:

bash
# ~/.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:

lua
-- Sync Neovim's unnamed register with system clipboard
vim.opt.clipboard = "unnamedplus"
OSC 52 is the future

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:

bash
# 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:

bash
#!/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
bash
# ~/.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:

bash
#!/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
bash
# ~/.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:

bash
#!/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
bash
# ~/.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:

bash
#!/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
bash
# ~/.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:

bash
#!/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
bash
# ~/.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+:

bash
# 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:

bash
# 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%
Use FZF_TMUX_OPTS for Consistent Styling

Set 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:

bash
#!/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:

bash
# ── 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"
Make Scripts Executable

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.

bash
# 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.

~/.tmux.conf (local machine)
# Local tmux: use Ctrl-a as prefix
unbind C-b
set -g prefix C-a
bind C-a send-prefix
~/.tmux.conf (remote server)
# 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.

~/.tmux.conf (local machine)
# 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.

~/.tmux.conf (local machine — F12 toggle)
# ── 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.

Pick Distinct Colors

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.

~/.tmux.conf (remote server)
# 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:

~/.ssh/rc (remote server)
#!/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
~/.bashrc or ~/.zshrc (remote server)
# 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.

~/bin/ssht (SSH + Tmux connector)
#!/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'"
bash
# 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.

Don't Nest Accidentally

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 F12Ctrl+b, c
Setup complexity Low Minimal Moderate (one-time)
Daily ergonomics Good Tedious Excellent
Visual indicator No No Yes (status bar color)
Start Simple, Upgrade Later

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.

~/.tmux.conf
# 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.

No lazygit? Substitute any TUI

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.

~/.tmux.conf
# 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.

~/bin/tmux-worktree (chmod +x)
#!/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:

~/.tmux.conf
# Open worktree picker in a popup
bind W display-popup -E -w 60% -h 60% "tmux-worktree"

Typical workflow

  1. Create worktrees for parallel work
    bash
    # From your main repo directory
    git worktree add ../myapp-fix-auth fix/auth-bug
    git worktree add ../myapp-pr-review feature/new-api
  2. 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.

  3. 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)

~/.zshrc (or ~/.bashrc)
# 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)

~/.tmux.conf
# 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
Option B spawns a shell every 5 seconds per window

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.

~/bin/tmux-pr-review (chmod +x)
#!/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:

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:

~/.tmux.conf
# 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}"'
Level it up with delta

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:

~/.tmux.conf — git integration bindings
# ── 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:

bash
# 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 EventFires WhenCommon Use
after-new-sessionA new session is createdSet session options, rename based on directory
after-new-windowA new window is createdSet default layout, rename window
after-split-windowA pane is splitAuto-apply layout when pane count changes
after-kill-paneA pane is closedRebalance remaining panes
client-attachedA client attaches to a sessionRefresh display, log session activity
client-detachedA client detachesLog session activity
session-createdIdentical to after-new-session(alias)
window-linkedA window is linked into a sessionUpdate status bar
window-renamedA window is renamedTrigger notifications
pane-focus-inA pane receives focusHighlight active pane, sync env vars
pane-focus-outA pane loses focusDim inactive pane
client-resizedTerminal window is resizedAdjust layout
alert-activityActivity detected in monitored windowDesktop notification
List all hooks

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:

~/.tmux.conf
# 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:

~/.tmux.conf
# 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:

~/.tmux.conf
# 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.

bash
# 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

VariableReturnsExample Value
#{session_name}Current session namemy-api
#{window_index}Window index number2
#{window_name}Window nameeditor
#{pane_current_command}Command running in active panevim
#{pane_current_path}Working directory of active pane/home/user/src
#{client_prefix}1 if prefix key is held, 0 otherwise0
#{window_panes}Number of panes in current window3
#{pane_in_mode}1 if pane is in copy mode0
#{session_windows}Number of windows in session5
#{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.

tmux format syntax
# 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:

~/.tmux.conf
# 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"
Escaping commas in format strings

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:

~/.tmux.conf
# 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:

~/.tmux.conf
# 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.

syntax
# 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:

~/.tmux.conf
# 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:

~/.tmux.conf
# 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:

~/.tmux.conf
# 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'"
Brace syntax vs. quoted strings

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:

~/.tmux.conf — portable config
# === 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:

bash — debugging commands
# 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}'
Quick iteration loop

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.

tmux-monitor.sh
#!/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"
Pro Tip

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.

tmux-db.sh
#!/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.

tmux-pomodoro.sh
#!/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:

tmux.conf
# 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.

tmux-autossh.sh
#!/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"
Consider autossh for production

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.

tmux-logs.sh
#!/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:

bash
# 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.

tmux-dev.sh
#!/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:

~/.bashrc or ~/.zshrc
# 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' 
Project-Level Overrides

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.

Fork and Customize

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

bash
# ╔══════════════════════════════════════════════════════════════════╗
# ║  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.

Version Compatibility

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.

bash
# ─── 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)
Make It Yours

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:

bash
# 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:

~/.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:

bash
# 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):

~/.tmux.conf
# Eliminate escape key delay — essential for Vim users
set -sg escape-time 0
Do Not Skip This One

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.

PlatformClipboard Tooltmux.conf Configuration
macOSpbcopy / pbpasteset -s copy-command 'pbcopy'
Linux (X11)xclip or xselset -s copy-command 'xclip -sel clipboard'
Linux (Wayland)wl-copyset -s copy-command 'wl-copy'
WSLclip.exe or win32yankset -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.conf
# 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:

bash
# 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.

ActionWithout Shift (tmux handles it)With Shift (terminal handles it)
Click + dragEnters tmux copy mode and selectsNative terminal text selection
Right-clickTmux context menu (if configured)Terminal paste or context menu
Middle-clickPastes tmux bufferPastes system clipboard (X11)
Scroll wheelScrolls tmux scrollbackSends arrow key events
macOS Terminal Users

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-obsession plugin or :mksession to 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 $HOME instead.
bash
# 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:

~/.tmux.conf
# 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:

  1. Is TPM itself installed? Check that ~/.tmux/plugins/tpm/ exists.
  2. Is the run line at the very bottom of .tmux.conf? TPM must be initialized after all plugin declarations.
  3. Did you press prefix + I (capital I) to install? Lowercase i will not work.
  4. Does the plugin directory exist? Check ~/.tmux/plugins/<plugin-name>/.
  5. Are the plugin scripts executable? Check file permissions.
bash
# 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:

~/.tmux.conf — correct plugin ordering
# 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:

~/.tmux.conf
# 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:

~/.tmux.conf — toggle outer tmux off/on
# 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:

bash
# 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:

~/.tmux.conf
# 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.

CommandWhat It Reveals
tmux infoFull terminal capabilities, features, escape sequences supported
tmux show-options -gAll global options and their current values
tmux show-options -sServer-level options (escape-time, copy-command, etc.)
tmux list-keysEvery key binding — find conflicts by piping to grep
tmux display-message -p '#{...}'Evaluate format strings to inspect session/window/pane variables
tmux show-messagesRecent tmux log messages (errors, warnings, config reload output)
tmux list-clientsAll attached clients and their terminal sizes
tmux source ~/.tmux.confReload config without restarting — shows errors inline
bash — debugging recipes
# 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
The Nuclear Option

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.