Intelligently navigating tmux panes and Vim splits

Suraj N. Kurapati

NOTE: The code in this article is also available as an installable, dual tmux and Vim plugin at https://github.com/sunaku/tmux-navigate.

  1. Problem
    1. Approach
      1. Solution

        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:

        1. If the current pane is zoomed but isn’t running Vim, then do nothing.

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

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


        Updates