Smart splits navigation and resizing for Herdr and Neovim
herdr-splits.nvim
Check out my other Herdr-related projects.
Seshagy - Agent aware TUI session manager | Herdr tab rename - Autorename tabs for Herdr
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.
Features
- 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.
Requirements
- Neovim โฅ 0.10
- Herdr โฅ 0.7.0 (for plugin actions)
Installation
Three steps โ install on both the Herdr side and the Neovim side, then add keybinds.
1. Install the Herdr plugin
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
2. Install the Neovim plugin
lazy.nvim
{
'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 autosyncherdr = 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',
},
movecursorsame_row = false,
herdrbin = nil, -- auto-detected from HERDRBIN_PATH
floatingzindexmax = 50, -- floats with zindex < this are treated as embedded sidebars
ignore_previewwindows = false, -- opt-in: also treat previewwindow windows (e.g. .dbout) as sidebars
-- autosyncherdr = 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>' },
unzoomonnav = true, -- auto-unzoom when navigating away from a zoomed pane
navatedge = 'wrap', -- 'wrap' | 'stop' โ Herdr pane-boundary wrap (distinct from at_edge)
})
end,
keys = {
{ '<C-h>', function() require('herdr-splits').movecursorleft() end, desc = 'Navigate left' },
{ '<C-j>', function() require('herdr-splits').movecursordown() end, desc = 'Navigate down' },
{ '<C-k>', function() require('herdr-splits').movecursorup() end, desc = 'Navigate up' },
{ '<C-l>', function() require('herdr-splits').movecursorright() 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' },
},
}
packer.nvim
use {
'lmilojevicc/herdr-splits.nvim',
config = function()
require('herdr-splits').setup()
end,
}
3. Add keybinds to Herdr config
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: optionasalt = "Both"
- kitty: macosoptionas_alt yes
- iTerm: Profiles โ Keys โ Left Option key โ Esc+>
If you're using smart-splits.nvim for tmux, add cond = vim.env.HERDRENV ~= '1' to its spec so the two plugins don't conflict.
Auto-unzoom and wrap-around
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 | nvim wraps ctrl+l back to the pane, and
pane | nvim win1 | win2 wraps from win2 to the pane. With navatedge=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
- Wrap works even when you're on a sidebar (dbui, neo-tree, quickfix, ...),
To disable auto-unzoom entirely, pass unzoomonnav = false to setup():
require('herdr-splits').setup({ unzoomonnav = 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 HERDRSPLITSCONFIG to override the path on both sides.
To stop at layout edges instead of wrapping, pass navatedge = 'stop':
require('herdr-splits').setup({ navatedge = 'stop' })
This single switch controls wrap-across-boundary on both sides:
- Plain Herdr panes:
wrap(the default) wraps to the opposite pane at an
stop halts.
- Neovim edge wrap: when Neovim's
at_edge='wrap',wrap(the default)
ctrl+l from
the last nvim split lands on the pane); stop keeps the wrap within Neovim
(e.g. ctrl+l from the last split cycles to the first split instead of
leaving Neovim). atedge='stop' halts regardless of navat_edge.
Note: at_edge (Neovim window-edge behaviour: wrap/stop/split/function) and navatedge (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 unzoomonnav=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 unzoomonnav=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 navkeys / resizekeys 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 customnavkeys/resizekeysneedsetup()to run eagerly
or at VeryLazy โ 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 uses
event = 'VeryLazy'.
Local development
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
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'.
Lua API
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 samerow and atedge per-call) require('herdr-splits').movecursorleft({ samerow = true, atedge = 'stop' }) require('herdr-splits').movecursordown(opts) require('herdr-splits').movecursorup(opts) require('herdr-splits').movecursorright(opts)
Compatibility
herdr-splits.nvim ships with an opinionated default ignoredfiletypes 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 atedge 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).snacksdashboard/snacksexplorer/snackspickerโfolke/snackssidebar/picker windows (the float case is handled byisembeddedfloatingwindowbelow).dadbod-ui/dboutโkristijanhusak/vim-dadbod-uidrawer andtpope/vim-dadbodpreview result buffer.aerial/Outlineโ code-outline sidebars.Trouble/quickfixโ diagnostics and quickfix lists.
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 < floatingzindexmax heuristic (default threshold 50, Neovim's default float zindex) and treated as sidebars for navigation/resize decisions. Override the threshold via the floatingzindexmax config field.
Add your own at runtime from any VeryLazy autocmd:
require('herdr-splits').addignoredfiletype('lazy')
require('herdr-splits').addignoredbuftype('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.tbldeepextend('force', M, opts) concatenates the ignoredfiletypes / ignoredbuftypes 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').addignoredfiletype('mything') -- then add back what you want
How It Works
Two 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
Navigation
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 navatedge=wrap (the default), else
wraps within Neovim)
Resizing
1. Only one Neovim window in this dimension AND fills terminal?
โโ Yes โ herdr pane resize --direction --amount <ratio> โ done
- Otherwise:
โโ Native Neovim resize with position-aware +/- operators
Float and embedded-float classification
Two predicates classify the current window before any nav/resize decision:
win.isfloating(winid)โnvimwingetconfig(winid).relative ~= ''(Neovim's built-in float check).win.isembeddedfloatingwindow(winid)โ true only for floats withzindex < floatingzindex_max(default 50); matches snacks/neo-tree/aerial float-mode sidebars.
Herdr Detection
Detected automatically through environment variables Herdr injects into every pane:
HERDR_ENV=1โ set when running inside a Herdr-managed paneHERDRPANEIDโ the public pane ID (e.g.,w1:p1)HERDRBINPATHโ path to the herdr binary (for subprocess calls)
Comparison with vim-herdr-navigation
| Feature | vim-herdr-navigation | herdr-splits.nvim | | --------------------------- | -------------------- | ---------------------------- | | Navigation | โ | โ | | Resizing | โ | โ | | at_edge behaviours | โ | wrap / stop / split / custom | | Count prefix | โ | โ (3
Updating
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 autosyncherdr = 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.
Also add a build hook to attempt synchronization after lazy installs or updates when configuration is already active. Normal setup-time synchronization still provides convergence on startup:
{
'lmilojevicc/herdr-splits.nvim',
build = ':lua require("herdr-splits").sync_herdr()',
config = function()
require('herdr-splits').setup({ autosyncherdr = 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.
License
MIT