demo.mp4
Seamless navigation between your window manager and applications without plugins.
nvg can be used as a drop-in replacement for your window manager's focus command. This allows using your WM's keybindings to control movement also within supported applications.
| Window Manager | Platform | Status |
|---|---|---|
| Sway | Linux | Full support |
| i3 | Linux | Full support (same IPC protocol as Sway) |
| Hyprland | Linux | Full support |
| Niri | Linux | Full support |
| River | Linux | Full support |
| dwm | Linux | Full support (dwmfifo patch required) |
| GlazeWM | Windows | Full support |
The window manager is auto-detected from environment variables (SWAYSOCK, I3SOCK, HYPRLAND_INSTANCE_SIGNATURE, NIRI_SOCKET, XDG_CURRENT_DESKTOP=river, DWM_FIFO) on Linux, and defaults to GlazeWM on Windows. The backend can be specified explicitly with --wm.
| Application | Platform | Status |
|---|---|---|
| Neovim | Linux + Windows | Full support |
| tmux | Linux | Full support |
| WezTerm | Linux | Full support |
| Kitty | Linux | Full support (requires allow_remote_control yes in kitty.conf) |
On Windows, only the Neovim hook is enabled. The terminal hooks (tmux/WezTerm/Kitty) rely on reading other processes' environment blocks from /proc, which has no clean Windows equivalent.
Install from the Microsoft Store via winget. No SmartScreen prompt, and nvg is added to your PATH automatically.
winget install nvg -s msstorePrebuilt binaries for Linux (amd64, arm64, armv7) and Windows (amd64) are available from GitHub Releases.
Linux:
curl -Lo nvg https://github.com/heppu/nvg/releases/latest/download/nvg-linux-amd64
chmod +x nvg
sudo mv nvg /usr/local/bin/Windows (PowerShell):
Invoke-WebRequest -Uri https://github.com/heppu/nvg/releases/latest/download/nvg-windows-amd64.exe -OutFile $env:USERPROFILE\bin\nvg.exeMake sure %USERPROFILE%\bin is on your PATH.
Requires Zig. No other dependencies.
git clone https://github.com/heppu/nvg.git
cd nvg
sudo zig build install -Doptimize=ReleaseSafe --prefix /usr/localIn your ~/.config/sway/config (or ~/.config/i3/config) change all focus bindings to use exec nvg:
bindsym $mod+h exec nvg left
bindsym $mod+j exec nvg down
bindsym $mod+k exec nvg up
bindsym $mod+l exec nvg right
That's it. nvg automatically detects the running window manager and navigates through Neovim, tmux, WezTerm, and Kitty splits/panes before moving to the next window.
To explicitly select a window manager backend:
bindsym $mod+h exec nvg --wm sway left
To limit detection to specific applications use explicit hooks list:
bindsym $mod+h exec nvg --hooks nvim,tmux left
In your ~/.config/hypr/hyprland.conf, replace the default movefocus bindings:
bind = $mod, h, exec, nvg left
bind = $mod, j, exec, nvg down
bind = $mod, k, exec, nvg up
bind = $mod, l, exec, nvg right
In your ~/.config/niri/config.kdl, add bindings that call nvg:
binds {
Mod+H { spawn "nvg" "left"; }
Mod+J { spawn "nvg" "down"; }
Mod+K { spawn "nvg" "up"; }
Mod+L { spawn "nvg" "right"; }
}In your ~/.config/river/init, replace the default focus-view bindings:
riverctl map normal Super H spawn 'nvg left'
riverctl map normal Super J spawn 'nvg down'
riverctl map normal Super K spawn 'nvg up'
riverctl map normal Super L spawn 'nvg right'River is detected via XDG_CURRENT_DESKTOP=river (which river sets
for itself) together with WAYLAND_DISPLAY.
Requires the dwmfifo patch.
Set the DWM_FIFO environment variable to enable auto-detection, or use --wm dwm:
export DWM_FIFO=/tmp/dwm.fifoIn your config.h, add keybindings that call nvg:
static const char *nvg_left[] = { "nvg", "left", NULL };
static const char *nvg_down[] = { "nvg", "down", NULL };
static const char *nvg_up[] = { "nvg", "up", NULL };
static const char *nvg_right[] = { "nvg", "right", NULL };
static const Key keys[] = {
{ MODKEY, XK_h, spawn, {.v = nvg_left} },
{ MODKEY, XK_j, spawn, {.v = nvg_down} },
{ MODKEY, XK_k, spawn, {.v = nvg_up} },
{ MODKEY, XK_l, spawn, {.v = nvg_right} },
};GlazeWM exposes a WebSocket IPC on ws://127.0.0.1:6123 that nvg speaks
directly — no plugin or sidecar process needed. Make sure the GlazeWM IPC
server is enabled (it is by default).
In your ~/.glzr/glazewm/config.yaml, replace the default focus keybindings
with shell-exec invocations of nvg:
keybindings:
- commands: ["shell-exec nvg left"]
bindings: ["alt+h"]
- commands: ["shell-exec nvg right"]
bindings: ["alt+l"]
- commands: ["shell-exec nvg up"]
bindings: ["alt+k"]
- commands: ["shell-exec nvg down"]
bindings: ["alt+j"]If you've moved GlazeWM's IPC server to a non-default port, set
GLAZEWM_PORT so nvg can find it.
- Connect to the window manager (auto-detect or
--wmflag). - Get the focused window PID from the WM.
- Walk the process tree and detect supported applications.
- Try the innermost application first (e.g. nvim before tmux):
- If it can move in the requested direction, move internally. Done.
- If at edge, bubble up to the next layer.
- If all layers are at their edge, move WM window focus.
- When entering a new window, jump to the split closest to where you came from.
sway window A sway window B
+----------------------------+ +----------------------------+
| tmux | | tmux |
| +---------++--------------+| |+--------------++---------+ |
| | nvim || || || || nvim | |
| | [split1]|| pane 2 || --> || pane 1 || [split1]| |
| | *split2 || || || || split2 | |
| +---------++--------------+| |+--------------++---------+ |
+----------------------------+ +----------------------------+
focus right from nvim split2:
1. nvim: at right edge -> bubble up
2. tmux: at right edge -> bubble up
3. wm: move focus to window B
4. enter window B -> land at leftmost tmux pane -> leftmost nvim split
| Variable | Description |
|---|---|
NVG_DEBUG |
Set to 1 to enable debug logging to stderr |
SWAYSOCK |
Path to sway IPC socket (set automatically by sway) |
I3SOCK |
Path to i3 IPC socket (set automatically by i3) |
HYPRLAND_INSTANCE_SIGNATURE |
Hyprland instance ID (set automatically by Hyprland) |
NIRI_SOCKET |
Niri IPC socket path (set automatically by niri) |
XDG_CURRENT_DESKTOP |
Used to auto-detect River (river sets this to river) |
WAYLAND_DISPLAY |
Wayland display socket name, used by the River backend |
DWM_FIFO |
Path to dwm FIFO for dwmfifo patch (default: /tmp/dwm.fifo) |
XDG_RUNTIME_DIR |
Used to locate Hyprland, Niri, River and Neovim sockets |
TMUX_TMPDIR |
Tmux socket directory (defaults to /tmp) |
WEZTERM_PANE / WEZTERM_UNIX_SOCKET |
Read from the focused process's /proc/<pid>/environ to drive the WezTerm hook |
KITTY_LISTEN_ON / KITTY_WINDOW_ID |
Read from the focused process's /proc/<pid>/environ to drive the Kitty hook |
GLAZEWM_PORT |
Override the default GlazeWM IPC port (6123) |
Usage: nvg <left|right|up|down> [options]
Options:
-t, --timeout <ms> IPC timeout in milliseconds (default: 100)
--hooks <hook,hook,...> Comma-separated hooks to enable (default: all)
Linux: nvim, tmux, wezterm, kitty
Windows: nvim
--wm <name> Window manager backend (default: auto-detect)
Linux: sway, i3, hyprland, niri, river, dwm
Windows: glazewm
-v, --version Print version
-h, --help Print this help
zig build test # Run tests
zig build coverage -- --junit zig-out/junit.xml # Coverage (requires kcov)
zig fmt --check src/ build.zig test_runner.zig # Check formatting
zig build run -- left # Debug build and runAdding support for a new WM requires changes to exactly 5 files (4 source + 1 test):
Create a new file implementing the WindowManager vtable (getFocusedPid, moveFocus, disconnect). Use src/niri.zig or src/hyprland.zig as a template.
- Add the new variant to the
Backendenum - Add the name to
Backend.fromString() - Add the variant to the
Connectionunion - Add auto-detection in
detectBackend()(check the WM's env var) - Add the connection case in
connect() - Add the name to
backendNames() - Update the tests
Add the new WM name to the --wm help text in the usage string.
- Add the WM to the "Supported Window Managers" table
- Add a setup section with example keybindings
- Add any env vars to the "Configuration" table
Create a new test script that implements the adapter interface and calls run_tests. The shared test runner in test/e2e/helpers.sh exercises the same 6 test groups for every WM. Your script only needs to define the adapter functions:
| Function | Purpose |
|---|---|
install_deps |
Install WM packages via apt |
start_wm |
Start the WM in headless mode |
wm_cleanup |
Clean up WM resources, call cleanup at the end |
spawn_window |
Spawn a window (terminal) in the WM |
count_windows |
Print the current number of visible windows (helpers polls this for wait_for_windows) |
get_focused |
Return an identifier for the focused window |
wm_focus DIRECTION |
Move focus using the WM's native command |
run_nvg DIRECTION |
Invoke $NVG_BIN with the correct env/args |
Use test/e2e/test-sway.sh as a template. Then register the new test in CI:
.github/workflows/ci.yml— add the WM name to thematrix.wmarraycodecov.yml— add a new flag underindividual_flags