> ## Content Index
> Fetch the complete content index at: https://neumachen.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# The Shortcut That Never Reached.
- URL: https://neumachen.dev/articles/the-shortcut-that-never-reached/
- Published: 2026-09-16T22:29:49.000Z
- Updated: 2026-09-16T22:29:49.000Z
- Author: Kareem Hepburn

I broke my pane navigation with a copy-paste.

The configuration was valid Lua. [WezTerm](https://wezterm.org/?ref=neumachen.dev) loaded it without complaining. My [tmux](https://github.com/tmux/tmux/wiki?ref=neumachen.dev) bindings were there, and Neovim was still using smart-splits.nvim. Everything looked reasonable until I pressed Ctrl+H and didn’t move anywhere.

I wanted the same shortcuts everywhere: Ctrl+H, J, K, and L to move between panes, and Alt with those keys to resize them. With WezTerm around tmux and Neovim inside that, I had three places to investigate.

I started with tmux. When movement between tmux panes stops working, tmux makes a convenient suspect.

The bindings were correct. The problem was that tmux never received the key.

## Three places to move left

There are a few different things I call a “pane” in this setup.

WezTerm has its own panes. Inside one of those, I can run tmux, which has another set of panes. Inside a tmux pane, Neovim can have several splits open.

I use [smart-splits.nvim](https://github.com/smart-splits-nvim/smart-splits.nvim?ref=neumachen.dev) for movement and resizing in Neovim, including its integration with the surrounding multiplexer. That gives me a consistent set of shortcuts, but each layer still needs to know when to handle a key and when to pass it along.

WezTerm gets the first decision. It can move between its own panes or send the shortcut to the program inside the current pane. If that program is tmux, tmux then decides whether to select one of its panes or forward the key into Neovim.

In the layout I was investigating, I had two tmux panes inside a single WezTerm pane.

That distinction turned out to matter quite a bit.

## Following the key backwards

Before changing anything, I checked whether the configuration I was reading was the configuration my applications were using.

My dotfiles live in a chezmoi source directory, while the applications read deployed copies elsewhere. I didn’t want to spend time debugging a source file while testing an older copy. The relevant files matched.

Then I checked tmux’s running key table.

Ctrl+H had the expected binding: if the pane was running Neovim and carried its pane marker, send the key through. Otherwise, select the tmux pane to the left. The other directions and resize bindings followed the same pattern.

I also found an old smart-splits directory under tmux’s plugins folder. That looked like a possible source of competing bindings, but its plugin declaration was commented out. The running key table showed my configured bindings. The directory was there; the plugin wasn’t being loaded through tmux’s plugin manager.

On the Neovim side, smart-splits.nvim was configured to load at startup. My navigation and resize mappings called its functions as expected.

Everything I’d checked so far assumed the shortcut reached tmux or Neovim. I still hadn’t checked whether it got that far.

WezTerm’s resolved key configuration showed all eight shortcuts assigned to Lua callbacks. Ctrl+H wasn’t simply being passed into the terminal. My WezTerm code intercepted it and decided what to do.

I checked the foreground process for the attached local tmux session. It was `tmux`, which gave me a concrete input to follow through the callback.

The callback called a function named `is_vim`. If that returned true, it forwarded the shortcut. Otherwise, it requested a native WezTerm pane movement.

Near the top of the file, `is_vim` checked a user variable. Further down, I found another function with the same name.

There was my copy-paste.

## The bit I copied

My WezTerm configuration had a local file called `plugins/smart-splits.lua`. Despite the similar name, it wasn’t the Neovim plugin itself. It was the WezTerm-side code deciding where the shortcuts should go.

I’d added it from the plugin’s [WezTerm integration example](https://github.com/smart-splits-nvim/smart-splits.nvim?ref=neumachen.dev#wezterm).

That example included two implementations of `is_vim`, with comments explaining when to use each one. One was for loading smart-splits.nvim at startup. The other was an alternative for lazy loading.

I kept both, which was one more than I needed.

Stripped of the surrounding comments, the relevant code looked like this:

```lua
local function is_vim(pane)
  return pane:get_user_vars().IS_NVIM == 'true'
end

local function is_vim(pane)
  local process_name = string.gsub(
    pane:get_foreground_process_name(),
    '(.*[/\\])(.*)',
    '%2'
  )

  return process_name == 'nvim' or process_name == 'vim'
end

```

Lua allows this. From the second declaration onward, the name `is_vim` refers to the second function. The first is shadowed.

Both definitions were in the same WezTerm file. Different applications weren’t choosing different versions, and the callback wasn’t trying one check followed by the other. Because the callbacks were defined below both functions, they used the second one.

The `IS_NVIM` check was still there, reassuringly visible and doing absolutely nothing.

My Neovim configuration already loaded smart-splits.nvim at startup. I hadn’t needed to copy the alternative implementation in the first place.

## Why tmux never got the shortcut

The duplicate definition explained why the user-variable check was ignored. The surviving function had another limitation: it recognized `nvim` and `vim`, but not `tmux`.

Given the foreground process I’d just checked, the decision was straightforward:

```text
Foreground program: tmux
Does it equal nvim or vim? No.
Use WezTerm's own pane navigation.

```

WezTerm therefore tried to move left between its own panes. There was only one in that tab, so nothing visibly happened.

Inside it, tmux had two panes and a perfectly usable Ctrl+H binding. It never received the key.

That also meant deleting the duplicate wasn’t the whole fix. An ordinary shell pane inside tmux wouldn’t normally carry an `IS_NVIM` marker. I needed to account for tmux explicitly.

I also found that the [process lookup could return nil](https://wezterm.org/config/lua/pane/get%5Fforeground%5Fprocess%5Fname.html?ref=neumachen.dev). The old function passed that straight into `string.gsub`, so an unavailable process name could cause the callback to throw an error.

There were three things to address: the shadowed marker check, the missing tmux case, and the unsafe process-name lookup.

## One routing decision

I replaced the two functions with one called `pane_handles_navigation`.

That was the question I actually needed to ask. Vim and Neovim weren’t the only programs that could handle movement between panes.

The new decision was:

- Honor `IS_NVIM` when it’s present and set to `'true'`.
- Otherwise, only trust foreground-process detection for a pane in WezTerm’s `local` domain.
- Forward the shortcut when that local process is `nvim`, `vim`, or `tmux`.
- Use native WezTerm navigation for the remaining cases, including an unknown process name.

For local tmux, the change looked like this:

![Ctrl+H routing before and after the fix: WezTerm previously handled the key itself; the corrected callback forwards it to local tmux, which selects a shell pane or passes the key to Neovim.](https://storage.ghost.io/c/8f/20/8f201354-cbaa-4a83-819b-904739107948/content/images/2026/09/63f208-shortcut-routing.svg)

[View the diagram at full size](https://storage.ghost.io/c/8f/20/8f201354-cbaa-4a83-819b-904739107948/content/images/2026/09/63f208-shortcut-routing.svg?ref=neumachen.dev)

The domain check kept the process-name fallback from making decisions about remote programs it couldn’t reliably identify. Remote tmux routing stayed outside this fix.

I kept smart-splits.nvim and the existing tmux bindings. The directions, modifiers, and resize amount stayed the same, too. The change was in the WezTerm code that decided whether those other layers would receive the shortcut.

## Testing without rearranging my terminal

Checking every case by hand would have meant opening shells, starting tmux, launching Neovim, arranging splits, and repeating the same shortcuts. If something failed, I’d still have to work backwards to find out which layer handled the key.

The routing function gave me a more direct way to test it. It inspected a pane and chose an action. I could supply the pane’s details and check that choice without creating a real terminal layout for every case.

I added a Lua harness that loaded the actual plugin with a small stand-in for WezTerm’s API. It supplied the domain, foreground process name, and user variables, then recorded the action requested by each shortcut.

A simplified representation of one test case looks like this:

```lua
{
  pane = {
    domain = 'local',
    foreground_process = 'tmux',
  },
  shortcut = { key = 'h', mods = 'CTRL' },
  expected = {
    action = 'SendKey',
    key = 'h',
    mods = 'CTRL',
  },
}

```

For a local pane running tmux, Ctrl+H should be forwarded unchanged. Change the foreground program to an ordinary shell, and the expected action becomes a native WezTerm move to the left.

I could also describe cases that were less convenient to reproduce manually: a missing process name, a remote domain, or a Neovim marker without readable process information.

The expanded matrix covered 24 scenarios across eight shortcuts. Before the fix, 72 of the 192 checks failed. Afterwards, all 192 passed.

The assertions checked the details. A forwarded shortcut had to preserve its key and modifier. A leftward movement had to request left. Resizing had to keep the configured amount of three.

## Making sure the tests noticed

I wanted to know whether the harness would catch a mistake, so I deliberately broke copies of the plugin.

Changing the resize amount from three to five caused failures. Changing left to right did too. I also removed tmux recognition, dropped the domain check, ignored the Neovim marker, removed the nil guard, and changed the modifiers on forwarded keys.

All seven deliberate regressions were caught.

That gave me something useful for the next edit. I could change the routing code and immediately see which decisions I’d affected, instead of rebuilding several terminal arrangements and trying to remember what I’d already checked.

These tests validate the callback’s decisions. Actual keyboard delivery is a separate check. But I can now verify the routing behavior without manually performing every movement in every scenario.

## Until next time

I started with what looked like a broken tmux binding. Following the key backwards led me to a WezTerm file where I’d copied two alternative functions and left them both in place.

The copy-paste silently disabled the check I thought I was using. The function that took its place didn’t account for tmux. Together, they explained why the shortcut never reached the binding I kept inspecting.

The routing checks pass now, and I have something repeatable to run the next time I touch this file.

I’ll also try reading the comments between the code snippets before copying both.

## References

- **tmux:** [Official project and documentation](https://github.com/tmux/tmux/wiki?ref=neumachen.dev), including the [manual](https://man.openbsd.org/tmux?ref=neumachen.dev) for key bindings and pane selection.
- **WezTerm:** [Official site and documentation](https://wezterm.org/?ref=neumachen.dev), plus the [get\_foreground\_process\_name() API reference](https://wezterm.org/config/lua/pane/get%5Fforeground%5Fprocess%5Fname.html?ref=neumachen.dev).
- **smart-splits.nvim:** [Plugin repository](https://github.com/smart-splits-nvim/smart-splits.nvim?ref=neumachen.dev), with the [WezTerm integration example](https://github.com/smart-splits-nvim/smart-splits.nvim?ref=neumachen.dev#wezterm) and [tmux integration instructions](https://github.com/smart-splits-nvim/smart-splits.nvim?ref=neumachen.dev#tmux).