Seamless navigation and resizing between Neovim splits and Herdr panes. Makes Herdr terminal splits behave like native Neovim windows — move and resize as if they were all part of the same editor.
Inspired by smart-splits.nvim, ported to Herdr's CLI.
- Seamless navigation: Same keys work in Neovim and plain shells — Herdr forwards them to Neovim when appropriate, Neovim delegates to Herdr at edges.
- Seamless resizing:
<M-h/j/k/l>resizes Neovim splits natively, delegates to Herdr when a window fills the terminal. - at_edge behaviours:
wrap(default),stop,split, or a custom function. - Plugin-aware: Ignores snacks/neo-tree/dadbod-ui/aerial sidebars and embedded floats (zindex < 50) by default — your keybinds never get trapped inside a picker.
- Smart unzoom + wrap-around: Auto-unzooms when leaving a zoomed pane. At a layout edge, navigation wraps to the opposite side — crossing into a sibling Herdr pane when one exists (e.g.
pane | nvim→ctrl+lwraps to the pane), otherwise wrapping within Neovim. Herdr-pane navigation also wraps around at edges (past the last pane → the first). Does not unzoom when moving between Neovim splits in one pane. - Count prefix support:
3<C-h>moves three splits left;5<M-l>resizes five steps right.
- Neovim ≥ 0.10
- Herdr ≥ 0.7.0 (for plugin actions)
Three steps — install on both the Herdr side and the Neovim side, then add keybinds.
This provides the nav-* and resize-* actions that Herdr keybinds invoke.
# From GitHub (once published):
herdr plugin install lmilojevicc/herdr-splits.nvim
# Local development:
herdr plugin link /path/to/herdr-splits{
'lmilojevicc/herdr-splits.nvim',
-- For local development, swap the repo line for `dir = '/path/to/herdr-splits'`
-- (see "Local development" below).
cond = vim.env.HERDR_ENV == '1',
event = 'VeryLazy',
-- Optional: auto-sync the Herdr-side scripts when lazy updates this plugin.
-- Requires `auto_sync_herdr = true` in setup() below to take effect.
-- build = 'lua require("herdr-splits").sync_herdr()',
config = function()
require('herdr-splits').setup({
-- Defaults shown. All fields optional.
default_amount = 0.03, -- Herdr resize ratio
neovim_amount = 3, -- Neovim resize cells
at_edge = 'wrap', -- 'wrap' | 'stop' | 'split' | function
ignored_buftypes = { 'nofile', 'quickfix', 'prompt', 'help', 'terminal' },
ignored_filetypes = {
'NvimTree',
-- sidebars
'neo-tree',
'snacks_dashboard',
'snacks_explorer',
'snacks_picker',
-- DB / REPL / data sidebars
'dadbod-ui',
'dbout',
-- outlines / symbols
'aerial',
'Outline',
-- diagnostics / quick lists
'Trouble',
'quickfix',
},
move_cursor_same_row = false,
herdr_bin = nil, -- auto-detected from HERDR_BIN_PATH
floating_zindex_max = 50, -- floats with zindex < this are treated as embedded sidebars
ignore_previewwindows = false, -- opt-in: also treat previewwindow windows (e.g. .dbout) as sidebars
-- auto_sync_herdr = true, -- opt-in: sync Herdr-side scripts on update
-- Managed keys — written to the generated herdr-splits.conf so the
-- Herdr-side scripts agree. Pass Neovim notation (e.g. <M-Left>).
nav_keys = { left = '<C-h>', down = '<C-j>', up = '<C-k>', right = '<C-l>' },
resize_keys = { left = '<M-h>', down = '<M-j>', up = '<M-k>', right = '<M-l>' },
unzoom_on_nav = true, -- auto-unzoom when navigating away from a zoomed pane
nav_at_edge = 'wrap', -- 'wrap' | 'stop' — Herdr pane-boundary wrap (distinct from at_edge)
})
end,
keys = {
{ '<C-h>', function() require('herdr-splits').move_cursor_left() end, desc = 'Navigate left' },
{ '<C-j>', function() require('herdr-splits').move_cursor_down() end, desc = 'Navigate down' },
{ '<C-k>', function() require('herdr-splits').move_cursor_up() end, desc = 'Navigate up' },
{ '<C-l>', function() require('herdr-splits').move_cursor_right() end, desc = 'Navigate right' },
{ '<M-h>', function() require('herdr-splits').resize_left() end, desc = 'Resize left' },
{ '<M-j>', function() require('herdr-splits').resize_down() end, desc = 'Resize down' },
{ '<M-k>', function() require('herdr-splits').resize_up() end, desc = 'Resize up' },
{ '<M-l>', function() require('herdr-splits').resize_right() end, desc = 'Resize right' },
},
}use {
'lmilojevicc/herdr-splits.nvim',
config = function()
require('herdr-splits').setup()
end,
}Add these to ~/.config/herdr/config.toml, then run herdr server reload-config:
[[keys.command]]
key = "ctrl+h"
type = "plugin_action"
command = "herdr-splits.nav-left"
[[keys.command]]
key = "ctrl+j"
type = "plugin_action"
command = "herdr-splits.nav-down"
[[keys.command]]
key = "ctrl+k"
type = "plugin_action"
command = "herdr-splits.nav-up"
[[keys.command]]
key = "ctrl+l"
type = "plugin_action"
command = "herdr-splits.nav-right"
[[keys.command]]
key = "alt+h"
type = "plugin_action"
command = "herdr-splits.resize-left"
[[keys.command]]
key = "alt+j"
type = "plugin_action"
command = "herdr-splits.resize-down"
[[keys.command]]
key = "alt+k"
type = "plugin_action"
command = "herdr-splits.resize-up"
[[keys.command]]
key = "alt+l"
type = "plugin_action"
command = "herdr-splits.resize-right"Note for macOS: Terminals treat Option as a special character modifier by default. You need to set Option = Alt/Meta:
- Ghostty:
macos-option-as-alt = true- Alacritty:
option_as_alt = "Both"- kitty:
macos_option_as_alt yes- iTerm: Profiles → Keys → Left Option key → Esc+
If you're using smart-splits.nvim for tmux, add
cond = vim.env.HERDR_ENV ~= '1'to its spec so the two plugins don't conflict.
Auto-unzoom. When you navigate away from a zoomed pane, herdr-splits unzooms first so the destination is visible — whether you're crossing into a sibling Herdr pane or wrapping at a layout edge. It does not unzoom when moving between Neovim splits inside the same pane.
Wrap-around. When you reach a layout edge in the direction you pressed
(nowhere further to go that way), the default wrap behaviour continues on
the opposite side:
- If a Herdr pane sits on the opposite side, focus crosses into it — so
pane | nvimwrapsctrl+lback to the pane, andpane | nvim win1 | win2wraps from win2 to the pane. Withnav_at_edge=stop(see below) this cross-to-opposite-pane is suppressed and the wrap stays within Neovim instead. - Otherwise it wraps within Neovim (smart-splits default).
- Between plain Herdr panes, navigation wraps around at edges too (past the last pane → the first).
- Wrap works even when you're on a sidebar (dbui, neo-tree, quickfix, ...), so you can leave it at an edge; only embedded floating overlays are gated.
To disable auto-unzoom entirely, pass unzoom_on_nav = false to setup():
require('herdr-splits').setup({ unzoom_on_nav = false })These options are written to a generated herdr-splits.conf (default
~/.config/herdr/plugins/config/herdr-splits/herdr-splits.conf; print the
path with herdr plugin config-dir herdr-splits) so the Herdr-side scripts
agree — you configure them once, from setup(). Do not hand-edit that
file: it is regenerated on every setup(). Any values already present are
adopted once the first time setup() runs after an upgrade, then
overwritten thereafter. Set HERDR_SPLITS_CONFIG to override the path on
both sides.
To stop at layout edges instead of wrapping, pass nav_at_edge = 'stop':
require('herdr-splits').setup({ nav_at_edge = 'stop' })This single switch controls wrap-across-boundary on both sides:
- Plain Herdr panes:
wrap(the default) wraps to the opposite pane at an edge;stophalts. - Neovim edge wrap: when Neovim's
at_edge='wrap',wrap(the default) lets the wrap cross to the Herdr pane on the opposite side (e.g.ctrl+lfrom the last nvim split lands on the pane);stopkeeps the wrap within Neovim (e.g.ctrl+lfrom the last split cycles to the first split instead of leaving Neovim).at_edge='stop'halts regardless ofnav_at_edge.
Note: at_edge (Neovim window-edge behaviour: wrap/stop/split/function)
and nav_at_edge (Herdr pane-boundary wrap: wrap/stop) are two different
things despite the shared name — at_edge is Neovim-side only and is not
written to the conf.
Note: when a pane is zoomed and unzoom_on_nav=false, the edge flags can't be
trusted so stop can't be detected on the plain-pane side — navigation proceeds
in the requested direction in that case. With the default unzoom_on_nav=true,
pressing toward an edge while zoomed first unzooms the pane (so the edge can be
detected) and then halts — the pane unzooms even though focus doesn't move.
To remap the keys forwarded into Neovim, pass nav_keys / resize_keys
to setup() in Neovim notation (e.g. <M-Left>). Override only the
directions you rebind; the rest keep their defaults. By default the scripts
forward ctrl+h/j/k/l for navigation and alt+h/j/k/l for resizing — the
chords the documented Neovim keymaps bind:
require('herdr-splits').setup({
nav_keys = { left = '<M-Left>', down = '<M-Down>', up = '<M-Up>', right = '<M-Right>' },
resize_keys = { left = '<M-S-Left>', down = '<M-S-Down>', up = '<M-S-Up>', right = '<M-S-Right>' },
})- Pass Neovim notation in
setup()(<C-h>,<M-Left>); it is translated to the Herdr chord notation (ctrl+h,alt+left) the generated conf writes andherdr pane send-keysaccepts. - Any direction left unset keeps its default.
- The chord forwarded into Neovim must match the Neovim keymap that catches it:
<M-Left>insetup()is the same keyalt+leftthe script forwards, so keep yourvim.keymap.setin sync withnav_keys.
Eager load required for custom keys. The generated conf is written by
setup(), so customnav_keys/resize_keysneedsetup()to run eagerly or atVeryLazy— do not lazy-load this plugin only from the same custom key mappings it must publish. Otherwise the conf carrying your custom chords isn't written before Herdr forwards them, and those keys can't reach Neovim to trigger the lazy load. The recommended spec above already usesevent = 'VeryLazy'.
Both sides load straight from your clone, so edits take effect immediately (on the Herdr side; Neovim picks them up on reload/restart).
-
Link the Herdr plugin to your local repo:
herdr plugin link /path/to/herdr-splits
-
Point lazy.nvim at the same path — change the first line of the spec from the GitHub repo to
dir:{ dir = '/path/to/herdr-splits', cond = vim.env.HERDR_ENV == '1', -- ...rest of the spec unchanged }
Run herdr plugin list to confirm it shows herdr-splits ... [local:/...].
To switch back to the published version: herdr plugin unlink herdr-splits,
then herdr plugin install lmilojevicc/herdr-splits.nvim, and revert the
lazy spec to 'lmilojevicc/herdr-splits.nvim'.
require('herdr-splits').setup(opts)
-- Resize (amount defaults to config.default_amount, multiplied by count prefix)
require('herdr-splits').resize_left(amount)
require('herdr-splits').resize_down(amount)
require('herdr-splits').resize_up(amount)
require('herdr-splits').resize_right(amount)
-- Navigate (opts can override same_row and at_edge per-call)
require('herdr-splits').move_cursor_left({ same_row = true, at_edge = 'stop' })
require('herdr-splits').move_cursor_down(opts)
require('herdr-splits').move_cursor_up(opts)
require('herdr-splits').move_cursor_right(opts)herdr-splits.nvim ships with an opinionated default ignored_filetypes list so your resize keybinds don't get trapped inside common sidebars and pickers. These filetypes are treated as sidebars: split-at-edge and Herdr resize delegation are skipped from them, and the flag is passed to a custom at_edge function as is_sidebar. wrap, however, still works from a sidebar so you can leave it (e.g. ctrl+l on a dadbod-ui drawer wraps to the opposite side); only embedded floating overlays are gated.
The default ignored_filetypes covers:
NvimTree—nvim-tree/nvim-tree.luadefault file tree (buftype=nofile,winfixwidth).neo-tree—nvim-neo-tree/neo-tree.nvimLazyVim default (winfixwidthsidebar).snacks_dashboard/snacks_explorer/snacks_picker—folke/snackssidebar/picker windows (the float case is handled byis_embedded_floating_windowbelow).dadbod-ui/dbout—kristijanhusak/vim-dadbod-uidrawer andtpope/vim-dadbodpreview result buffer.aerial/Outline— code-outline sidebars.Trouble/quickfix— diagnostics and quickfix lists.
The default ignored_buftypes covers nofile, quickfix, prompt, help, and terminal.
Embedded floating windows (snacks explorer in float mode, neo-tree in float mode, aerial in float mode) are detected via the zindex < floating_zindex_max heuristic (default threshold 50, Neovim's default float zindex) and treated as sidebars for navigation/resize decisions. Override the threshold via the floating_zindex_max config field.
Add your own at runtime from any VeryLazy autocmd:
require('herdr-splits').add_ignored_filetype('lazy')
require('herdr-splits').add_ignored_buftype('help')Debug with :checkhealth herdr-splits (Neovim ≥ 0.10) to see which filetypes/buftypes are ignored, whether Herdr is in session, and whether the current window is classified as a sidebar.
If you want to completely replace the default list (e.g. to opt out of one of the entries), note that vim.tbl_deep_extend('force', M, opts) concatenates the ignored_filetypes / ignored_buftypes tables you pass; you keep what you pass AND the defaults are appended. To opt out, assign directly after setup():
require('herdr-splits').setup({ ignored_filetypes = {} }) -- inherit nothing
require('herdr-splits').add_ignored_filetype('mything') -- then add back what you wantTwo sides cooperate for seamless two-way navigation:
You press C-h in a Herdr pane:
┌─ Herdr intercepts the key (plugin_action keybind)
│
├─ Is the focused pane running Neovim?
│ │
│ ├─ YES → forward "ctrl+h" into that pane
│ │ └─ Neovim plugin receives it
│ │ ├─ Has a window to the left? → wincmd h
│ │ └─ At Neovim edge? → herdr pane focus --direction left
│ │
│ └─ NO (plain shell, etc.) → herdr pane focus --direction left
│
└─ Result: same keys move you everywhere
1. Try moving within Neovim (wincmd h/j/k/l)
├─ Window changed → done (stayed within Neovim splits)
└─ Window didn't change → at Neovim edge
├─ Zoomed? → unzoom first (neighbours are reliable once visible)
├─ Check if Herdr is running (HERDR_ENV=1)
├─ Check if Herdr pane has a neighbour in this direction
│ └─ Yes → herdr pane focus --direction → done
└─ No (at both Neovim AND Herdr edge) →
apply at_edge behaviour (`wrap` crosses to the opposite Herdr
pane if one exists and nav_at_edge=wrap (the default), else
wraps within Neovim)
1. Only one Neovim window in this dimension AND fills terminal?
└─ Yes → herdr pane resize --direction --amount <ratio> → done
2. Otherwise:
└─ Native Neovim resize with position-aware +/- operators
Two predicates classify the current window before any nav/resize decision:
win.is_floating(winid)—nvim_win_get_config(winid).relative ~= ''(Neovim's built-in float check).win.is_embedded_floating_window(winid)— true only for floats withzindex < floating_zindex_max(default 50); matches snacks/neo-tree/aerial float-mode sidebars.
True floats forward to Herdr; embedded floats behave as sidebars (no movement, no Herdr delegation).
Detected automatically through environment variables Herdr injects into every pane:
HERDR_ENV=1— set when running inside a Herdr-managed paneHERDR_PANE_ID— the public pane ID (e.g.,w1:p1)HERDR_BIN_PATH— path to the herdr binary (for subprocess calls)
| Feature | vim-herdr-navigation | herdr-splits.nvim |
|---|---|---|
| Navigation | ✓ | ✓ |
| Resizing | ✗ | ✓ |
| at_edge behaviours | ✗ | wrap / stop / split / custom |
| Count prefix | ✗ | ✓ (3 = move 3 left) |
| Auto-unzoom + wrap-around | ✗ | ✓ |
| Floating window handling | ✗ | ✓ |
| Herdr plugin (for keybinds) | ✓ | ✓ |
| Neovim plugin | ✓ | ✓ |
The Neovim side (lua) auto-updates via lazy.nvim — it pulls main on
LazySync. The Herdr side (the bash scripts under the Herdr-managed
checkout) does not auto-update by default: Herdr v1 has no
herdr plugin update, so a managed checkout stays frozen at the commit it
was installed from.
Option A — opt into automatic sync (recommended): set
auto_sync_herdr = true in setup(). The plugin then reinstalls the
Herdr-managed checkout pinned to the exact commit lazy fetched, so the bash
scripts always match the lua side. It is a no-op when already in sync, when
in local-dev (plugin link) mode, or when the herdr binary is unavailable.
For maximum efficiency, also add a build hook so the sync fires only when
lazy actually updates the plugin, instead of on every Neovim startup:
{
'lmilojevicc/herdr-splits.nvim',
build = 'lua require("herdr-splits").sync_herdr()',
config = function()
require('herdr-splits').setup({ auto_sync_herdr = true })
end,
}Option B — manual: after an update, re-run
herdr plugin install lmilojevicc/herdr-splits.nvim to refresh the
Herdr-side scripts.
Release notes for each version live in GitHub Releases.
MIT