Skip to content

Instantly share code, notes, and snippets.

@fcoury
Last active February 14, 2026 17:48
Show Gist options
  • Select an option

  • Save fcoury/e3ed9465e9735531d1c6fa272cd80920 to your computer and use it in GitHub Desktop.

Select an option

Save fcoury/e3ed9465e9735531d1c6fa272cd80920 to your computer and use it in GitHub Desktop.
My tmux workflow

My tmux Workflow

I use tmux as my daily terminal multiplexer. Over time I've built a small set of shell helpers and keybindings that make session management nearly frictionless β€” both locally and on remote machines.

The Core Idea

Every project gets its own tmux session, named after the directory. If I'm in ~/code/myapp, the session is called myapp. Dots get replaced with underscores so tmux doesn't complain (e.g. my.project becomes my_project). This convention means I never have to think about session names β€” the directory is the name.

Getting Into a Session

I have a handful of fish shell functions that all follow the same pattern: attach if the session exists, create it if it doesn't.

  • tm β€” The one I use most. It attaches to (or creates) a session named after the current directory. It also sets the terminal title with a laptop emoji so I can spot it in my tab bar.

  • tp β€” A session picker. With no arguments, it pops up an fzf list of all running sessions. Pick one and you're in. Press Escape and it falls back to creating a session for the current directory. You can also pass a name directly: tp work attaches to (or creates) the work session.

  • tv β€” Like tm, but the session starts with neovim running. I use this when I know I'm about to edit code. Pass a filename to open it directly: tv src/main.rs.

  • tn β€” Just a quick alias for tmux new-session -s. For when I want an explicit name that doesn't match a directory.

Remote Sessions

I also work on a remote MacBook M3 Pro Max (called m3pro in my SSH config). For that I have a separate script called tms that does the same session management over SSH.

Running tms without arguments fetches the list of tmux sessions from the remote machine and presents them in fzf. I can pick one to attach, or choose "Create new session" to start fresh. If fzf isn't available, it falls back to a numbered menu.

The terminal title gets a link emoji (πŸ”—) prefix so I can immediately tell which tabs are remote vs. local. Fish completions auto-suggest remote session names as I type.

Key Bindings

I remapped the prefix from the default Ctrl+B to Ctrl+S β€” it's easier to reach and I never need to send Ctrl+S to anything. Pressing the prefix twice (Ctrl+S Ctrl+S) sends a literal Ctrl+S through if I ever do need it.

Everything else follows vim conventions:

Binding Action
prefix + h/j/k/l Navigate between panes
prefix + H/J/K/L Resize panes (repeatable)
prefix + d Split horizontally
prefix + s Split vertically
prefix + z Zoom (toggle fullscreen) a pane
prefix + x Kill a pane
prefix + i Pull the last pane back into this window
prefix + e Open scrollback in neovim
prefix + o Fuzzy session switcher (sessionx plugin)
prefix + g Reload tmux config
Alt+k Clear screen and scrollback (like Cmd+K on macOS)

New splits open in the same directory as the current pane, which seems obvious but isn't the default.

The Alt+k Trick

One binding I'm particularly happy with is Alt+k. In a plain shell, it clears the screen and scrollback β€” just like hitting Cmd+K in a native macOS terminal. But if a program is running in the pane (neovim, a dev server, etc.), it sends Ctrl+L instead so it doesn't blow away whatever the program is doing. It detects this by checking ps for processes that aren't shells.

Plugins

I keep plugins minimal and managed through TPM:

  • tmux-resurrect and tmux-continuum β€” Save and restore sessions across restarts. I don't auto-restore on startup (it can be slow), but having the option to manually restore is a lifesaver.
  • tmux-sessionx β€” A fuzzy session switcher bound to prefix + o. This is the in-tmux complement to my shell-level tp function.
  • tmux-fuzzback β€” Search through scrollback with fzf in a popup. Great for finding that error message that scrolled past.
  • tmux-fzf-url β€” Pick URLs from scrollback with fzf and open them. Saves a lot of copy-pasting.

Other Settings Worth Mentioning

  • escape-time 0 β€” Without this, pressing Escape in neovim has a noticeable delay. This is a must for vim users.
  • base-index 1 β€” Windows start at 1, not 0. My keyboard's number row starts at 1, so should my windows.
  • mouse on β€” I use the keyboard for everything, but mouse support is nice for the occasional scroll or pane resize.
  • history-limit 100000 β€” A generous scrollback buffer. Memory is cheap, losing context isn't.
  • Copy mode uses vi keys β€” y and Enter both yank the selection and exit copy mode. Clipboard passthrough is enabled so OSC 52 works for copying to the system clipboard from remote sessions.

Zellij

I also keep a zm function around as a zellij equivalent of tm β€” same "attach or create by directory name" pattern. I switch between multiplexers occasionally and having the same muscle memory helps.

The Full Picture

Local workflow:
  cd ~/code/myproject
  tm                    β†’ attached to "myproject" session

  tp                    β†’ fzf pick any session
  tp work               β†’ attach/create "work" session

  tv                    β†’ "myproject" session with neovim
  tv src/main.rs        β†’ "myproject" session, neovim opens file

Remote workflow:
  tms                   β†’ fzf pick from m3pro's sessions
  tms deploy            β†’ attach/create "deploy" on m3pro

All the configuration lives in a dotfiles repo managed by a setup script that symlinks everything into place. The tmux config goes to ~/.tmux.conf, the fish functions to ~/.config/fish/conf.d/tmux.fish, and the tms script to ~/.config/scripts/tms.

# =============================================================================
# tmux configuration
#
# Symlinked from this repo to ~/.tmux.conf by setup.sh.
# Prefix is Ctrl+S (not the default Ctrl+B).
#
# Helper scripts:
# fish/conf.d/tmux.fish - shell functions (tm, tp, tv, tn, zm)
# scripts/tms - remote tmux session manager over SSH
# =============================================================================
# --- Shell & terminal setup ---
set-option -g default-shell /opt/homebrew/bin/fish
set-option -g default-terminal "tmux-256color"
set-option -g terminal-overrides ",xterm-256color:Tc" # enable true color
set-option -g renumber-windows on # reorder window numbers when one is closed
set-option -g history-limit 100000 # generous scrollback buffer
# --- Core behavior ---
unbind r
bind g source-file ~/.tmux.conf # prefix + g to reload this config
set -sg escape-time 0 # no delay after pressing Escape (critical for neovim)
set -g base-index 1 # number windows starting from 1, not 0
set -g prefix C-s # remap prefix from Ctrl+B to Ctrl+S
set -g mouse on # enable mouse for scrolling, pane selection, resizing
setw -g mode-keys vi # use vim keybindings in copy mode
# restore last environment automatically (disabled β€” can be slow)
# set -g @continuum-restore 'on'
# --- Plugin settings ---
# sessionx: fuzzy session switcher (prefix + o)
set -g @sessionx-bind 'o'
# --- Pane navigation (vim-style with prefix) ---
bind-key h select-pane -L
bind-key j select-pane -D
bind-key k select-pane -U
bind-key l select-pane -R
# --- Clear screen (Alt+k) ---
# If a program (not a shell) is running in the pane, send Ctrl+L instead.
# Otherwise, clear the screen and scrollback like macOS Cmd+K.
is_program="ps -o comm= -t '#{pane_tty}' | grep -vE '^-?(fish|bash|zsh|sh|ps|grep|awk|sed|cut|sort|uniq|head|cat|echo|printf)$' | grep -q ."
bind -n M-k if-shell "$is_program" 'send-keys -R; clear-history' 'send-keys C-l'
# --- Pane resizing (vim-style, repeatable with prefix) ---
bind-key -r H resize-pane -L 5
bind-key -r J resize-pane -D 5
bind-key -r K resize-pane -U 5
bind-key -r L resize-pane -R 5
# --- Pane zoom ---
bind-key z resize-pane -Z # toggle fullscreen for the active pane
# --- Edit scrollback in neovim ---
bind-key e run-shell "fish -c tmux-edit"
# --- Pane splitting (open in the same directory) ---
bind d split-window -h -c '#{pane_current_path}' # horizontal split
bind s split-window -v -c '#{pane_current_path}' # vertical split
# --- Pane management ---
bind-key i join-pane -s ! # pull the last pane back into this window
bind-key - detach # quick detach
bind x kill-pane # close the active pane without confirmation
# --- Clipboard integration ---
set -g set-clipboard on
set -g allow-passthrough on # let apps (e.g. OSC 52) write to clipboard
set -as terminal-features ',xterm*:clipboard'
# Copy mode: yank and Enter both copy selection and exit copy mode
bind-key -T copy-mode-vi y send -X copy-selection-and-cancel
bind-key -T copy-mode-vi Enter send -X copy-selection-and-cancel
# --- Plugins (managed by TPM) ---
set -g @plugin 'tmux-plugins/tpm' # plugin manager
set -g @plugin 'omerxx/tmux-sessionx' # fuzzy session switcher
set -g @plugin 'tmux-plugins/tmux-resurrect' # save/restore sessions across restarts
set -g @plugin 'tmux-plugins/tmux-continuum' # auto-save sessions periodically
# set -g @plugin 'christoomey/vim-tmux-navigator'
set -g @plugin 'roosta/tmux-fuzzback' # fzf search through scrollback
set -g @plugin 'wfxr/tmux-fzf-url' # fzf pick URLs from scrollback
# set -g @plugin 'tmux-plugins/tmux-urlview'
# set -g @plugin 'nhdaly/tmux-better-mouse-mode'
# set -g @plugin 'hendrikmi/tmux-cpu-mem-monitor'
# fuzzback: search scrollback in a popup with fzf
set -g @fuzzback-popup 1
set -g @fuzzback-hide-preview 1
set -g @fuzzback-popup-size '60%'
# --- Theme ---
# Dracula theme (disabled)
# set -g @plugin 'dracula/tmux'
# set -g @dracula-show-powerline true
# set -g @dracula-fixed-location "Aventura, FL"
# set -g @dracula-plugins "wather"
# set -g @dracula-show-flags true
# set -g @dracula-show-left-icon session
# set -g status-position top
# Nord theme (disabled)
# source-file ~/.config/tmux/nord-theme.conf
source-file ~/.config/tmux/lackluster.conf
# source-file ~/.config/tmux/rose-pine.conf
# source-file ~/.config/tmux/catppuccin.conf
# --- Initialize TPM (must be the last line before overrides) ---
run '~/.tmux/plugins/tpm/tpm'
# --- Overrides (after TPM so plugins don't clobber them) ---
# Press prefix twice (Ctrl+S Ctrl+S) to send a literal Ctrl+S to the app
bind C-s send-prefix
#!/bin/bash
#
# tms - Remote tmux session manager
#
# Manages tmux sessions on the remote host "m3pro" over SSH.
# Think of it as a remote version of the local `tp` function
# (defined in fish/conf.d/tmux.fish).
#
# Usage:
# tms - list remote sessions with fzf picker, attach or create
# tms <name> - attach to (or create) a named session directly
#
# The terminal title is prefixed with πŸ”— to indicate a remote connection.
#
# Prerequisites:
# - SSH access to "m3pro" (configured in ~/.ssh/config)
# - tmux installed on the remote host
# - fzf (optional, falls back to numbered menu)
#
# Fish completions are provided in fish/completions/tms.fish.
# Set the terminal title bar to show we're on a remote connection
set_title() {
printf '\033]0;πŸ”— %s\007' "$1"
}
# If a session name was passed as an argument, skip the picker
# and go straight to attaching (or creating) that session
if [ -n "$1" ]; then
session_name="$1"
set_title "m3pro:$session_name"
ssh -t m3pro "tmux attach -t '$session_name' || tmux new -s '$session_name'"
exit 0
fi
# Fetch the list of existing tmux sessions from the remote host
sessions=$(ssh m3pro "tmux list-sessions -F '#S' 2>/dev/null" | sort)
# No sessions at all β€” prompt for a name and create one
if [ -z "$sessions" ]; then
echo "No tmux sessions found. Creating new session..."
read -p "Session name: " session_name
set_title "m3pro:$session_name"
ssh -t m3pro "tmux new -s '$session_name'"
exit 0
fi
# Sessions exist β€” let the user pick one
if command -v fzf >/dev/null 2>&1; then
# fzf is available β€” use an interactive fuzzy picker
echo "Select a session (or type new name):"
choice=$(echo -e "── Create new session ──\n$sessions" | fzf --height=~50% --prompt="tmux> " --header="↑/↓ to navigate, Enter to select, Esc to cancel")
if [ -z "$choice" ]; then
echo "Cancelled."
exit 0
elif [ "$choice" = "── Create new session ──" ]; then
read -p "New session name: " session_name
set_title "m3pro:$session_name"
ssh -t m3pro "tmux new -s '$session_name'"
else
set_title "m3pro:$choice"
ssh -t m3pro "tmux attach -t '$choice'"
fi
else
# No fzf β€” fall back to a numbered menu
echo "Available sessions:"
echo "$sessions" | nl
echo "0) Create new session"
read -p "Choose session (or enter name): " choice
if [[ "$choice" =~ ^[0-9]+$ ]]; then
if [ "$choice" -eq 0 ]; then
read -p "New session name: " session_name
set_title "m3pro:$session_name"
ssh -t m3pro "tmux new -s '$session_name'"
else
# Pick session by number from the list
session_name=$(echo "$sessions" | sed -n "${choice}p")
set_title "m3pro:$session_name"
ssh -t m3pro "tmux attach -t '$session_name'"
fi
else
# Input wasn't a number β€” treat it as a session name
set_title "m3pro:$choice"
ssh -t m3pro "tmux attach -t '$choice' || tmux new -s '$choice'"
fi
fi
# Terminal multiplexer helpers (tmux & zellij)
#
# Session naming convention: all functions derive the session name from the
# current directory's basename, replacing dots with underscores so tmux
# doesn't choke on the name (e.g. "my.project" -> "my_project").
#
# Commands:
# tn <name> - create a new named tmux session
# tm - attach to (or create) a session for the current directory
# tp [name] - fuzzy-pick an existing session, or attach/create by name
# tv [file] - like tm, but starts nvim inside the session
# zm - zellij equivalent of tm
# tn: shorthand for creating a new named tmux session
alias tn 'tmux new-session -s'
# tm: attach to the session matching cwd, or create it if it doesn't exist
# also sets the terminal title to the session name
function tm
set session_name (basename (pwd) | string replace '.' '_')
printf '\033]0;πŸ’» %s\007' "$session_name"
if tmux has-session -t "$session_name"
tmux attach-session -t "$session_name"
else
tmux new-session -s "$session_name"
end
end
# tp: tmux session picker
# no args -> fzf over existing sessions; Esc falls back to cwd session
# with arg -> attach to that session name, creating it if needed
# no sessions at all -> create one for cwd
function tp
if not command -q tmux
echo "tmux is not installed"
return 1
end
# no sessions exist yet β€” just create one for the current directory
if test (tmux list-sessions 2>/dev/null | wc -l) -eq 0
set session_name (basename (pwd) | string replace '.' '_')
tmux new-session -s "$session_name"
return
end
if test (count $argv) -eq 0
# interactive pick via fzf
set selected_session (tmux list-sessions -F "#{session_name}" | fzf --height 40% --reverse)
if test -n "$selected_session"
tmux attach-session -t "$selected_session"
else
# fzf cancelled β€” fall back to cwd session
set session_name (basename (pwd) | string replace '.' '_')
if tmux has-session -t "$session_name" 2>/dev/null
tmux attach-session -t "$session_name"
else
tmux new-session -s "$session_name"
end
end
else
# explicit session name provided
set session_name $argv[1]
if tmux has-session -t "$session_name" 2>/dev/null
tmux attach-session -t "$session_name"
else
tmux new-session -s "$session_name"
end
end
end
# tv: like tm but launches nvim as the initial command
# no args -> opens nvim with no file
# with arg -> opens nvim on that file
function tv
set session_name (basename (pwd) | string replace '.' '_')
if test (count $argv) -gt 0
set file_to_edit $argv[1]
else
set file_to_edit ""
end
if tmux has-session -t "$session_name"
tmux attach-session -t "$session_name"
else
if test -n "$file_to_edit"
tmux new-session -s "$session_name" "nvim $file_to_edit"
else
tmux new-session -s "$session_name" "nvim"
end
end
end
# zm: zellij equivalent of tm β€” attach to cwd session or create it
function zm
set session_name (basename (pwd) | string replace '.' '_')
if zellij list-sessions -n | grep -q "^$session_name"
zellij attach "$session_name"
else
zellij --session "$session_name"
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment