Intelligently navigating tmux panes and Vim splits
NOTE: The code in this article is also available as an installable, dual tmux and Vim plugin at https://github.com/sunaku/tmux-navigate.
Problem
Running tmux select-pane
in a zoomed tmux pane causes it to become unzoomed.
This causes frequent, unintentional pane unzooming when I perform seamless
navigation (which refers to using the same shortcut keys to navigate
tmux panes and Vim splits alike) as provided by the vim-tmux-navigator plugin
for Vim.
Thankfully, I learned that the plugin contains an undocumented option to disable pane
unzooming, called g:tmux_navigator_disable_when_zoomed
. However, this victory
was short-lived as I soon discovered that the plugin doesn’t work when Vim is
running remotely, tunneled in from a different machine through SSH.
For example, I often have tmux panes that run Vim on remote machines through SSH
(sometimes several layers deep!). Here, the plugin fails to navigate because the
tmux select-pane
commands that it runs on my behalf (from Vim) can’t locate
my tmux session, which happens to be running on a completely different machine!
Approach
First, we’ll enhance the seamless navigation shortcuts to be more intelligent:
If the current pane is zoomed but isn’t running Vim, then do nothing.
If the current pane is zoomed and is also running Vim, then only navigate between Vim splits inside the zoomed pane: don’t venture outside of it.
If the current pane is unzoomed, seamlessly navigate between tmux and Vim.
We can codify these behaviors as a “truth table” composed of four major clauses:
inside Vim? | is Zoomed? | Action taken by key binding |
---|---|---|
No | No | Focus directional tmux pane |
No | Yes | Nothing: ignore key binding |
Yes | No | Seamlessly focus Vim / tmux |
Yes | Yes | Focus directional Vim split |
Second, we’ll place the decision-making authority of where and how to navigate in tmux’s hands (and not in Vim’s hands, as the vim-tmux-navigator plugin does) because we should be able to run and control Vim on/through any remote machines.
To achieve this, we’ll inspect #{pane_current_command}
to discover the local
commands currently running inside tmux panes. And for panes running SSH, we’ll
inspect #{pane_title}
to help us detect which remote command they’re
currently running, since their local command is always going to be an
uninformative “ssh”.
Solution
This is a pure tmux scripting solution that uses Vim’s titlestring
feature to
detect when Vim changes focus between its split windows and/or tabs. It doesn’t
depend on the assistance of any special Vim plugins, like vim-tmux-navigator.
First, add this snippet to your ~/.vimrc
file and then restart your Vim.
" Intelligently navigate tmux panes and Vim splits using the same keys.
" See https://sunaku.github.io/tmux-select-pane.html for documentation.
let progname = substitute($VIM, '.*[/\\]', '', '')
set title titlestring=%{progname}\ %f\ +%l\ #%{tabpagenr()}.%{winnr()}
if &term =~ '^screen' && !has('nvim') | exe "set t_ts=\e]2; t_fs=\7" | endif
Second, add this snippet to your ~/.tmux.conf
file and then reload your tmux.
# Intelligently navigate tmux panes and Vim splits using the same keys.
# See https://sunaku.github.io/tmux-select-pane.html for documentation.
#
# +-------------+------------+-----------------------------+
# | inside Vim? | is Zoomed? | Action taken by key binding |
# +-------------+------------+-----------------------------+
# | No | No | Focus directional tmux pane |
# | No | Yes | Nothing: ignore key binding |
# | Yes | No | Seamlessly focus Vim / tmux |
# | Yes | Yes | Focus directional Vim split |
# +-------------+------------+-----------------------------+
#
vim_navigation_timeout=0.05 # number of seconds we give Vim to navigate
navigate=' \
pane_title="#{q:pane_title}"; \
pane_current_command="#{q:pane_current_command}"; \
pane_is_zoomed() { \
test #{window_zoomed_flag} -eq 1; \
}; \
pane_title_changed() { \
test "$pane_title" != "$(tmux display -p "##{q:pane_title}")"; \
}; \
command_is_vim() { \
case "${1%% *}" in \
(vi|?vi|vim*|?vim*|view|?view|vi??*) true ;; \
(*) false ;; \
esac; \
}; \
pane_contains_vim() { \
case "$pane_current_command" in \
(git|*sh) command_is_vim "$pane_title" ;; \
(*) command_is_vim "$pane_current_command" ;; \
esac; \
}; \
pane_contains_neovim_terminal() { \
case "$pane_title" in \
(nvim?term://*) true ;; \
(*) false ;; \
esac; \
}; \
navigate() { \
tmux_navigation_command=$1; \
vim_navigation_command=$2; \
vim_navigation_only_if=${3:-true}; \
if pane_contains_vim && eval "$vim_navigation_only_if"; then \
if pane_contains_neovim_terminal; then \
tmux send-keys C-\\ C-n; \
fi; \
eval "$vim_navigation_command"; \
if ! pane_is_zoomed; then \
sleep $vim_navigation_timeout; : wait for Vim to change title; \
if ! pane_title_changed; then \
tmux send-keys BSpace; \
eval "$tmux_navigation_command"; \
fi; \
fi; \
elif ! pane_is_zoomed; then \
eval "$tmux_navigation_command"; \
fi; \
}; \
navigate '
navigate_left=" $navigate 'tmux select-pane -L' 'tmux send-keys C-w h'"
navigate_down=" $navigate 'tmux select-pane -D' 'tmux send-keys C-w j'"
navigate_up=" $navigate 'tmux select-pane -U' 'tmux send-keys C-w k'"
navigate_right="$navigate 'tmux select-pane -R' 'tmux send-keys C-w l'"
navigate_back=" $navigate 'tmux select-pane -l || tmux select-pane -t1'\
'tmux send-keys C-w p' \
'pane_is_zoomed' "
# QWERTY keys - comment these out if you don't use QWERTY layout!
bind-key -n M-h run-shell -b "$navigate_left"
bind-key -n M-j run-shell -b "$navigate_down"
bind-key -n M-k run-shell -b "$navigate_up"
bind-key -n M-l run-shell -b "$navigate_right"
bind-key -n M-\ run-shell -b "$navigate_back"
# Dvorak keys - comment these out if you don't use Dvorak layout!
bind-key -n M-d run-shell -b "$navigate_back"
bind-key -n M-h run-shell -b "$navigate_left"
bind-key -n M-t run-shell -b "$navigate_up"
bind-key -n M-n run-shell -b "$navigate_down"
bind-key -n M-s run-shell -b "$navigate_right"
Finally, adjust the key bindings and Vim navigation timeout per your situation.