Easy pane switching between tmux, vim, nested tmux, ssh

When working with vim inside of tmux, and with nested tmux sessions inside ssh, pane switching can quickly become an annoyance. You need to use different key sequences to switch between tmux panes than you need to switch between vim panes (vim “windows”); and when working with nested sessions, it always takes a moment to stop and think about how many layers deep need to be prefixed.

I wanted to remove some of the friction from pane and window switching in these circumstances, so came up with the following requirements:

  • Be able to switch between both tmux panes and vim windows using a consistent mechanism (in my case, using Alt + arrow keys). When transitioning between tmux and vim, switching should work “as expected”, navigating to the closest pane in the selected direction.
  • If using nested tmux sessions, pane switching keys should always be delegated to the inner-most session (typically the most commonly desired behavior).
  • If using ssh within tmux, pane switching keys should be sent over ssh in case there’s a remote vim or nested tmux session running.
  • Switching between tmux windows should work similarly (but without delegation to vim). Alt + number should switch to the corresponding window; and if running a nested tmux session or ssh, should be sent to the inner session.
  • The standard tmux prefix-key-based pane switching should be unaffected by this so it can be used to switch windows/panes other than in the innermost nested session.
  • Should work on both Linux and Mac.

As it turns out, this isn’t too hard to do.

Starting with window switching

Before getting into pane switching, handling the window switching component is a bit easier and doesn’t involve integrating with vim.

When hitting the window switch key sequence (eg. Alt-3), tmux first receives the keystroke. tmux then needs to decide whether to switch its own window, or to delegate the keystroke to the active pane. For this, I created a helper script, check_maybe_nested_tmux.sh:

TTY="`tmux list-panes -F "#{pane_active} #{pane_tty}" | grep -E "^1" | cut -d " " -f 2- | cut -d / -f 3-`"
if [ -z $TTY ]; then exit 1; fi
ps -ao tty,comm | grep -E "^${TTY}\\s" | grep -E "ssh|tmux" &>/dev/null
exit $?

This script is pretty simple. It exits with a 0 exit status if a nested tmux or ssh session was found, or a status of 1 if not found or error. The first step is to determine the tty associated with the active pane; then find the name of the process currently attached to that tty. If that process is tmux or ssh, it is treated as a nested session.

To use this, add the following to tmux.conf:

bind-key -T root M-0 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-0' 'select-window -t 0'
bind-key -T root M-1 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-1' 'select-window -t 1'
bind-key -T root M-2 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-2' 'select-window -t 2'
bind-key -T root M-3 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-3' 'select-window -t 3'
bind-key -T root M-4 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-4' 'select-window -t 4'
bind-key -T root M-5 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-5' 'select-window -t 5'
bind-key -T root M-6 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-6' 'select-window -t 6'
bind-key -T root M-7 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-7' 'select-window -t 7'
bind-key -T root M-8 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-8' 'select-window -t 8'
bind-key -T root M-9 if-shell '~/.userenv/scripts/check_maybe_nested_tmux.sh' 'send-keys M-9' 'select-window -t 9'

This binds Alt + number keys to this window-switching logic. After reloading tmux, Alt + number should switch to that window, or re-send the same key sequence to the nested tmux or ssh session if present.

Pane switching, +vim

Handling pane switching now involves adding vim into the mix. My first thought was to handle it the same way, with an external script checking whether tmux should perform the pane-switching action itself, or delegate to the active process in the pane. The issue is, even if vim is the active process, we only want to delegate to vim if not trying to navigate to a pane off the “edge” of the vim screen – and querying vim for this from an external process isn’t easy.

Instead, I decided to always delegate pane switching to vim if vim is the active process, then let vim decide whether to “send it back” to tmux (depending on if vim has a valid pane to switch to in that direction). This requires a short vim script to detect the pane switch keystroke and perform this logic.

I decided to use a vim key mapping to communicate the pane switch command to vim. Specifically, Alt-w D, Alt-w U, Alt-w L, and Alt-w R to switch to the pane down, up, left, or right from the current location. These keybinds are never really triggered by the user so don’t have to be remembered. When vim receives one of these keys, it should attempt to switch windows in that direction; and if it fails, it should send the pane-switch back to tmux instead.

Here’s what that looks like in the vimrc:

function! PaneNavTmuxTry(d)
	let wid = win_getid()
	if a:d == 'D'
		wincmd j
	elseif a:d == 'U'
		wincmd k
	elseif a:d == 'L'
		wincmd h
	elseif a:d == 'R'
		wincmd l
	if win_getid() == wid
		call system('tmux select-pane -' . a:d)
map <silent> <M-w>U :call PaneNavTmuxTry('U')<CR>
map <silent> <M-w>D :call PaneNavTmuxTry('D')<CR>
map <silent> <M-w>L :call PaneNavTmuxTry('L')<CR>
map <silent> <M-w>R :call PaneNavTmuxTry('R')<CR>
imap <silent> <M-w>U <Esc>:call PaneNavTmuxTry('U')<CR>
imap <silent> <M-w>D <Esc>:call PaneNavTmuxTry('D')<CR>
imap <silent> <M-w>L <Esc>:call PaneNavTmuxTry('L')<CR>
imap <silent> <M-w>R <Esc>:call PaneNavTmuxTry('R')<CR>

The function just stores the current window ID prior to attempting a switch; then attempts the switch, and if the switch is unsuccessful (the window ID doesn’t change), it instead executes the corresponding tmux command to switch panes.

Now we need the shell script to detect if vim/ssh/tmux is running inside a tmux pane and delegate accordingly. switch_pane_directional.sh is similar to the script for window switching, but adds an additional case for vim:

# Arguments are:
# $1 - U|D|L|R
# $2 - Keys pressed (to forward on in the first case above) in tmux format
if [ $# -ne 2 ]; then echo Invalid args; exit 1; fi

TTY="`tmux list-panes -F "#{pane_active} #{pane_tty}" | grep -E "^1" | cut -d " " -f 2- | cut -d / -f 3-`"
if [ -z $TTY ]; then exit 1; fi
PROCS="`ps -ao tty,comm | grep -E "^${TTY}\\s" | awk '{print $2}'`"

if echo "$PROCS" | grep -E "ssh|tmux" &>/dev/null; then
	# pane running ssh or tmux
	tmux send-keys $2
elif echo "$PROCS" | grep -E "vim" &>/dev/null; then
	# pane running vim
	tmux send-keys $VIMPREFIX $1
	# pane not running anything special
	tmux select-pane -$1

Like the last script, this starts by getting a list of processes running in the tmux pane. If tmux or ssh is running inside, then the keystroke is delegated there. If vim is running inside, then the corresponding vim key sequence is sent to it. Otherwise, the current tmux session pane is switched.

All that’s left is to add it to tmux.conf:

bind-key -T root M-Left run-shell -b '~/.userenv/scripts/switch_pane_directional.sh L M-Left'
bind-key -T root M-Right run-shell -b '~/.userenv/scripts/switch_pane_directional.sh R M-Right'
bind-key -T root M-Up run-shell -b '~/.userenv/scripts/switch_pane_directional.sh U M-Up'
bind-key -T root M-Down run-shell -b '~/.userenv/scripts/switch_pane_directional.sh D M-Down'


Leave a Reply

Your email address will not be published. Required fields are marked *