I ran into a frustrating bug in my tmux and Neovim setup.

Most of the time, pane navigation worked exactly as expected. Inside tmux window 0, the left half of the screen contained Neovim. Inside that Neovim instance, the left side was split again: Neo-tree on the left, code on the right. The right half of the tmux window was just a normal terminal.

So the workflow was simple:

  • Ctrl-s h should move from code to Neo-tree
  • Ctrl-s l should move from Neo-tree back to code
  • another Ctrl-s l should move from the rightmost Neovim split to the right-hand tmux pane

But intermittently, tmux skipped Neo-tree altogether and jumped straight between tmux panes.

Even stranger, restarting tmux appeared to fix it.

That made it tempting to blame stale keybindings or session state. That was not the real issue.

The layout

The failing window looked like this:

tmux window 0
+-------------------------------+---------------------------+
| Neovim                        | terminal                  |
| +-----------+---------------+ |                           |
| | Neo-tree  | code          | |                           |
| +-----------+---------------+ |                           |
+-------------------------------+---------------------------+

The bug appeared when moving left and right from the code split. Sometimes tmux passed the key to Neovim correctly. Sometimes it treated the whole left tmux pane as an ordinary shell and handled the movement itself.

The first assumption, and why it was wrong

My first thought was that the tmux keybindings were the problem.

That was only partly true.

I had smart navigation bindings in tmux, and the logic depended on a matcher usually called is_vim. The idea is straightforward: if the current pane is running Vim or Neovim, tmux should pass Ctrl-h or Ctrl-l through to the editor. Otherwise, tmux should move between tmux panes.

A simple version looks like this:

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 "$is_vim" "send-keys C-h" "select-pane -L"
bind -n C-l if-shell "$is_vim" "send-keys C-l" "select-pane -R"

This works if the pane is really running nvim directly.

My pane was not.

What was really happening

The key clue came from checking the actual tmux panes:

tmux list-panes -t :0 -F 'pane=#{pane_index} active=#{pane_active} title=#{pane_title} cmd=#{pane_current_command} tty=#{pane_tty} pid=#{pane_pid} path=#{pane_current_path}'

That showed something surprising. The left pane, the one I thought of as “the Neovim pane”, was often being reported as Python, not nvim.

So I inspected the processes attached to that pane:

ps -t /dev/ttys001 -o pid=,ppid=,state=,comm=,args=

That gave me this kind of chain:

12718 12517 Ss   -zsh             -zsh
12829 12718 S+   Python           /usr/local/bin/pipenv shell

Still no nvim.

That looked wrong, because I was clearly inside Neovim. The missing piece was that pipenv shell had created a nested interactive shell on a child tty. So I widened the search:

ps ax -o pid=,tty=,comm=,args= | rg 'n?vim|pipenv shell|python'

This revealed the real stack:

-zsh
└── Python /usr/local/bin/pipenv shell
    └── zsh -i
        └── nvim

The top-level tmux pane process was Python, because pipenv shell was fronting the pane. Neovim was running further down the descendant process tree.

That meant tmux was making the wrong decision. It was looking at the pane’s visible process and concluding, “this is not Vim”, then handling h and l itself.

Why restarting tmux seemed to help

Restarting tmux changed the process state enough to make the bug appear intermittent.

That is why it felt inconsistent. The underlying problem was not random. It was that the process-detection logic was too shallow for a wrapped shell setup.

If Neovim is launched directly, a tty-based matcher is often enough.

If it is launched under something like pipenv shell, it is not.

Verifying the process tree

To confirm the theory, I checked the relationships explicitly:

ps -o pid=,ppid=,pgid=,tty=,comm=,args= -p 12718,12829,12834,41729

That produced a structure like this:

12718 12517 12718 ttys001  -zsh             -zsh
12829 12718 12829 ttys001  Python           /usr/local/bin/pipenv shell
12834 12829 12834 ttys002  /bin/zsh         /bin/zsh -i
41729 12834 41729 ttys002  nvim             nvim

At that point the bug was obvious.

The pane root process was not nvim. But nvim did exist under that pane’s descendant tree.

The wrong fix

One version of the fix was to make the tty-based matcher a bit more permissive:

is_vim="ps -o state= -o comm= -t '#{pane_tty}' | grep -iqE '...' \
  || ps -t '#{pane_tty}' -o comm= | grep -iqE '...'"

That helped in some cases, but it still relied on the visible tty relationship. In my setup, the nested shell and Neovim could appear on a different child tty, so this was still not reliable enough.

The real fix had to follow the process tree.

The actual fix

I replaced the old tty-based matcher with a descendant-process matcher rooted at #{pane_pid}.

That means tmux now asks:

“Does this pane, or anything launched under this pane’s process tree, look like Vim, Neovim or a similar full-screen tool?”

Here is the matcher:

is_vim="ps -axo pid=,ppid=,comm= | awk -v root='#{pane_pid}' '
BEGIN { pat = \"(^|/)?g?(view|l?n?vim?x?|fzf)(diff)?$\" }
{ pid[NR] = $1; ppid[NR] = $2; comm[NR] = $3 }
END {
  want[root] = 1
  changed = 1
  while (changed) {
    changed = 0
    for (i = 1; i <= NR; i++) {
      if (want[ppid[i]] && !want[pid[i]]) {
        want[pid[i]] = 1
        changed = 1
      }
      if (want[pid[i]] && comm[i] ~ pat) exit 0
    }
  }
  exit 1
}'"

This is doing three things:

  1. It reads the full process list using ps.
  2. It starts from the tmux pane PID.
  3. It walks downward through child processes until it either finds vim, nvim, view, fzf, or nothing relevant.

That was enough to make the wrapped pipenv shell -> zsh -> nvim case behave properly.

The tmux bindings

I also made sure the tmux bindings matched my actual workflow.

I use both raw Ctrl-h/j/k/l and prefix navigation with Ctrl-s h/j/k/l, so I bound both:

# Smart Neovim-aware navigation
is_vim="ps -axo pid=,ppid=,comm= | awk -v root='#{pane_pid}' '
BEGIN { pat = \"(^|/)?g?(view|l?n?vim?x?|fzf)(diff)?$\" }
{ pid[NR] = $1; ppid[NR] = $2; comm[NR] = $3 }
END {
  want[root] = 1
  changed = 1
  while (changed) {
    changed = 0
    for (i = 1; i <= NR; i++) {
      if (want[ppid[i]] && !want[pid[i]]) {
        want[pid[i]] = 1
        changed = 1
      }
      if (want[pid[i]] && comm[i] ~ pat) exit 0
    }
  }
  exit 1
}'"

unbind -T prefix h
unbind -T prefix j
unbind -T prefix k
unbind -T prefix l

unbind -n C-h
unbind -n C-j
unbind -n C-k
unbind -n C-l

bind -T prefix h if-shell "$is_vim" "send-keys C-h" "select-pane -L"
bind -T prefix j if-shell "$is_vim" "send-keys C-j" "select-pane -D"
bind -T prefix k if-shell "$is_vim" "send-keys C-k" "select-pane -U"
bind -T prefix l if-shell "$is_vim" "send-keys C-l" "select-pane -R"

bind -n C-h if-shell "$is_vim" "send-keys C-h" "select-pane -L"
bind -n C-j if-shell "$is_vim" "send-keys C-j" "select-pane -D"
bind -n C-k if-shell "$is_vim" "send-keys C-k" "select-pane -U"
bind -n C-l if-shell "$is_vim" "send-keys C-l" "select-pane -R"

bind -n C-\\ if-shell "$is_vim" "send-keys C-\\\\" "select-pane -l"
bind -T copy-mode-vi C-h select-pane -L
bind -T copy-mode-vi C-j select-pane -D
bind -T copy-mode-vi C-k select-pane -U
bind -T copy-mode-vi C-l select-pane -R
bind -T copy-mode-vi C-\\ select-pane -l

The important part is not the exact regex. It is that the decision is made from the pane PID and its descendants, not only from the pane tty.

Live verification

Once the new matcher was in place, I checked it directly against the real panes in tmux window 0.

For the wrapped Neovim pane:

tmux if-shell -t :0.0 "ps -axo pid=,ppid=,comm= | awk -v root='#{pane_pid}' 'BEGIN { pat = \"(^|/)?g?(view|l?n?vim?x?|fzf)(diff)?$\" } { pid[NR] = \$1; ppid[NR] = \$2; comm[NR] = \$3 } END { want[root] = 1; changed = 1; while (changed) { changed = 0; for (i = 1; i <= NR; i++) { if (want[ppid[i]] && !want[pid[i]]) { want[pid[i]] = 1; changed = 1 } if (want[pid[i]] && comm[i] ~ pat) exit 0 } } exit 1 }'" "display-message yes" "display-message no"

That returned:

yes

For the plain terminal pane beside it, the same test returned:

no

That was exactly what I wanted.

The result

After switching to descendant-process detection, the navigation became stable again.

The same keys now behave consistently:

  • code to Neo-tree works
  • Neo-tree back to code works
  • code to the neighbouring tmux terminal works

Most importantly, the fix survived the pipenv shell wrapper instead of breaking whenever Neovim was not the visible foreground pane process.

Takeaway

If tmux pane navigation breaks only in some panes, especially ones launched under wrappers such as pipenv shell, do not trust pane_current_command or a simple tty-based process match.

They are often too shallow.

If your shell stack looks like:

shell -> wrapper -> shell -> nvim

then tmux needs to inspect the descendant process tree, not just the first visible process in the pane.

Methodical inspection beats guessing. In this case, the bug was not in Neo-tree, not in Neovim split logic, and not really in the keybindings themselves. It was in how tmux decided whether the pane was “Vim enough” to hand the movement key through.