smart-splits-nvim/smart-splits.nvim

🧠 Smart, seamless, directional navigation and resizing of Neovim + terminal multiplexer splits. Supports Zellij, Tmux, Wezterm, and Kitty. Think about splits in terms of "up/down/left/right".

Lua

1,737

819 commits

updated Oct 9, 2026

See the code

README

🧠 smart-splits.nvim

[!NOTE] This repo recently moved to a new smart-splits-nvim GitHub org! See #488 / #492 for details.

🧠 Smarter and more intuitive split pane management for Neovim. Resize splits using a mental model of left/right/up/down instead of wider/narrower/taller/shorter. Navigate seamlessly between Neovim windows and terminal multiplexer panes with a clean, backend-driven architecture.

Key features:

  • Intuitive resize model that matches how you think about splits
  • Seamless navigation between Neovim and multiplexer panes
  • Modular architecture: multiplexer support lives in separate plugins
  • Configurable edge behavior: wrap, stop, split, or custom logic
  • Ignore lists for buffers and filetypes
  • Count-aware resizing with v:count1 support

Table of Contents

Requirements

  • Neovim 0.11 or newer
  • (Optional) A terminal multiplexer with a supported smart-splits backend plugin (e.g. smart-splits-nvim/backend-zellij-lua)

Installation

smart-splits.nvim is the core plugin. Multiplexer backends (Zellij, Ghostty, etc.) are separate plugins you install alongside it.

With lazy.nvim:

return {
  'smart-splits-nvim/smart-splits.nvim',
  version = '^3.0.0',
  -- optional: install a backend
  dependencies = {
    {
      'smart-splits-nvim/backend-zellij-lua',
      main = 'smart-splits-backend-zellij-lua',
      opts = {
        --[[ backend-specific options go here ]]
      },
    },
  },
  opts = {
    mux = { backend = 'smart-splits-backend-zellij-lua' },
  },
}

Calling setup() is optional. Without it you get the defaults and no multiplexer integration.

Quick Start

Minimal working example configuration with the Zellij backend (click to expand)
-- Install smart-splits.nvim and a backend
return {
  'smart-splits-nvim/smart-splits.nvim',
  version = '^3.0.0',
  dependencies = {
    { 'smart-splits-nvim/backend-zellij-lua', main = 'smart-splits-backend-zellij-lua' },
  },
  opts = {
    mux = { backend = 'smart-splits-backend-zellij-lua' },
  },
  keys = {
    -- Resize splits
    {
      '<A-h>',
      function()
        require('smart-splits').resize_left()
      end,
      desc = 'Resize left',
    },
    {
      '<A-j>',
      function()
        require('smart-splits').resize_down()
      end,
      desc = 'Resize down',
    },
    {
      '<A-k>',
      function()
        require('smart-splits').resize_up()
      end,
      desc = 'Resize up',
    },
    {
      '<A-l>',
      function()
        require('smart-splits').resize_right()
      end,
      desc = 'Resize right',
    },
    -- Move between splits
    {
      '<C-h>',
      function()
        require('smart-splits').move_cursor_left()
      end,
      desc = 'Move left',
    },
    {
      '<C-j>',
      function()
        require('smart-splits').move_cursor_down()
      end,
      desc = 'Move down',
    },
    {
      '<C-k>',
      function()
        require('smart-splits').move_cursor_up()
      end,
      desc = 'Move up',
    },
    {
      '<C-l>',
      function()
        require('smart-splits').move_cursor_right()
      end,
      desc = 'Move right',
    },
    -- Swap buffers
    {
      '<leader><leader>h',
      function()
        require('smart-splits').swap_buf_left()
      end,
      desc = 'Swap left',
    },
    {
      '<leader><leader>j',
      function()
        require('smart-splits').swap_buf_down()
      end,
      desc = 'Swap down',
    },
    {
      '<leader><leader>k',
      function()
        require('smart-splits').swap_buf_up()
      end,
      desc = 'Swap up',
    },
    {
      '<leader><leader>l',
      function()
        require('smart-splits').swap_buf_right()
      end,
      desc = 'Swap right',
    },
  },
}

Configuration

Defaults:

require('smart-splits').setup({
  -- buffers to leave alone, see Ignore Lists below
  ignored_buftypes = { 'nofile', 'quickfix', 'prompt' },
  ignored_filetypes = { 'NvimTree' },

  resize = {
    -- cells to resize by, multiplied by `v:count1`
    amount = 3,
    -- added to `eventignore` for the duration of a resize
    ignored_events = { 'BufEnter', 'WinEnter' },
  },

  move = {
    -- 'stop' | 'wrap' | 'split' | fun(ctx), see At Edge Behavior below
    at_edge = 'wrap',
    -- keep the cursor on the same screen row when moving horizontally
    same_row = false,
  },

  swap = {
    -- follow the buffer into its new window
    move_cursor = false,
  },

  mux = {
    -- a backend, a list of backends in priority order, or a function returning
    -- either; see Backends below
    backend = nil,
    -- warn when backends are configured but none of them detected
    warn_if_unusable = true,
  },

  log = {
    -- 'trace' | 'debug' | 'info' | 'warn' | 'error'
    level = 'info',
    -- `true` for the default path, a string for a custom one, `false` to disable
    file = true,
  },
})

Ignore Lists

ignored_buftypes and ignored_filetypes mark windows that smart-splits.nvim should leave alone: they are skipped when working out resize geometry, and at_edge = 'split' will not split them.

Both can be overridden per feature. A bare list replaces the top level one:

require('smart-splits').setup({
  ignored_filetypes = { 'NvimTree' },
  -- resizing ignores only Trouble, moving still ignores only NvimTree
  resize = { ignored_filetypes = { 'Trouble' } },
})

Set inherit = true to add to the top level list instead of replacing it:

require('smart-splits').setup({
  ignored_filetypes = { 'NvimTree', 'neo-tree' },
  -- resizing ignores NvimTree, neo-tree and Trouble
  resize = { ignored_filetypes = { inherit = true, 'Trouble' } },
})

At Edge Behavior

move.at_edge decides what happens when there is no Neovim window in the direction you moved, and the multiplexer backend did not handle it either.

ValueBehavior
'wrap'Jump to the Neovim window on the opposite edge
'stop'Stay where you are
'split'Create a split, asking the backend first, falling back to a Neovim split
fun(ctx)Decide for yourself

at_edge only ever runs when the backend declined the move, so how far 'wrap' reaches depends on the multiplexer. Take a Neovim pane sitting at the right edge of the multiplexer, with another pane to its left, and press the "move right" key from Neovim's rightmost window:

  • A multiplexer that can wrap around its own edges moves focus to that left pane and reports the move as handled, so you wrap across the whole screen and at_edge never runs.
  • One that cannot reports the move as unhandled, and 'wrap' then wraps among Neovim's own windows.

Core tells the backend which behavior you asked for, so 'stop' also stops at the multiplexer's edges rather than letting a multiplexer that wraps by default wrap anyway. Backends are expected to honour that, but it is their code doing it, so check yours if 'stop' does not stop.

The function form receives:

---@class SmartSplitsAtEdgeContext
---@field backend SmartSplitsBackend|nil the resolved backend, `nil` if none resolved
---@field direction SmartSplitsDirection the direction you moved, so also the edge you are on
---@field split fun() split the current window towards `direction`
---@field wrap fun() jump to the window on the opposite edge

For example, wrap horizontally but stop vertically:

require('smart-splits').setup({
  move = {
    at_edge = function(ctx)
      if ctx.direction == 'left' or ctx.direction == 'right' then
        ctx.wrap()
      end
    end,
  },
})

Keybindings

smart-splits.nvim sets no mappings for you. The recommended set:

-- resizing splits, these accept a count, so `10<A-h>` resizes by 10 * config.resize.amount
vim.keymap.set('n', '<A-h>', require('smart-splits').resize_left)
vim.keymap.set('n', '<A-j>', require('smart-splits').resize_down)
vim.keymap.set('n', '<A-k>', require('smart-splits').resize_up)
vim.keymap.set('n', '<A-l>', require('smart-splits').resize_right)
-- moving between splits
vim.keymap.set('n', '<C-h>', require('smart-splits').move_cursor_left)
vim.keymap.set('n', '<C-j>', require('smart-splits').move_cursor_down)
vim.keymap.set('n', '<C-k>', require('smart-splits').move_cursor_up)
vim.keymap.set('n', '<C-l>', require('smart-splits').move_cursor_right)
-- swapping buffers between windows
vim.keymap.set('n', '<leader><leader>h', require('smart-splits').swap_buf_left)
vim.keymap.set('n', '<leader><leader>j', require('smart-splits').swap_buf_down)
vim.keymap.set('n', '<leader><leader>k', require('smart-splits').swap_buf_up)
vim.keymap.set('n', '<leader><leader>l', require('smart-splits').swap_buf_right)

Lua API

local ss = require('smart-splits')

-- resize, `opts` is `{ amount = n }` or a bare number, defaulting to
-- `config.resize.amount`; either way `v:count1` multiplies it
ss.resize_left(opts)
ss.resize_right(opts)
ss.resize_up(opts)
ss.resize_down(opts)

-- move the cursor, `opts` may override `same_row` and `at_edge` for this call
ss.move_cursor_left(opts)
ss.move_cursor_right(opts)
ss.move_cursor_up(opts)
ss.move_cursor_down(opts)

-- swap the current buffer with a neighbor, `opts` may override `move_cursor`
ss.swap_buf_left(opts)
ss.swap_buf_right(opts)
ss.swap_buf_up(opts)
ss.swap_buf_down(opts)

Commands

CommandDescription
:SmartResizeLeft [amount]Resize left
:SmartResizeRight [amount]Resize right
:SmartResizeUp [amount]Resize up
:SmartResizeDown [amount]Resize down
:SmartCursorMoveLeftMove the cursor left
:SmartCursorMoveRightMove the cursor right
:SmartCursorMoveUpMove the cursor up
:SmartCursorMoveDownMove the cursor down
:SmartSwapLeftSwap the buffer left
:SmartSwapRightSwap the buffer right
:SmartSwapUpSwap the buffer up
:SmartSwapDownSwap the buffer down
:SmartSplitsLogOpen the log file
:SmartSplitsLogLevel {level}Change the log level for this session

Backends

Core ships no backends. Support for tmux, Zellij, WezTerm, Kitty and anything else lives in separate plugins, so each one can be maintained by people who actually use it.

Install a backend plugin and name it:

return {
  'smart-splits-nvim/smart-splits.nvim',
  dependencies = {
    {
      'smart-splits-nvim/backend-zellij-lua',
      main = 'smart-splits-backend-zellij-lua',
      -- backend options belong to the backend plugin
      opts = { disable_nav_when_zoomed = true },
    },
  },
  opts = {
    mux = { backend = 'smart-splits-backend-zellij-lua' },
  },
}

There is no auto-detection. Naming your backend explicitly is what replaced it.

Available Backends

MultiplexerBackendMaintainerNotes
Zellijbackend-zellij-lua@TorsteinwsA Rust rewrite, backend-zellij-rs, is in progress and will become the recommended Zellij backend once stable
Ghosttybackend-ghostty@geodimmmacOS only
Tmuxbackend-tmux@mrjones2014
Kittybackend-kitty@maatthc
WezTermbackend-wezterm@a-morales

Using Herdr? That backend shipped in core through v2 and needs a maintainer. Either stay on the v2 tag, or volunteer to maintain it.

The community is also free to make 3rd party backends.

Multiple Backends

mux.backend accepts a list in priority order. The first backend whose detect() returns true wins, which covers the case where you are usually in one multiplexer and occasionally in another:

require('smart-splits').setup({
  mux = {
    -- inside zellij, use zellij; otherwise fall back to ghostty
    backend = { 'smart-splits-backend-zellij-lua', 'smart-splits-backend-ghostty' },
  },
})

It also accepts a module table directly, or a function returning any of the above. The function form defers the require until startup, which helps if your backend plugin is lazy loaded:

require('smart-splits').setup({
  mux = {
    backend = function()
      return { require('smart-splits-backend-zellij-lua'), require('smart-splits-backend-ghostty') }
    end,
  },
})

Resolution happens once, during setup(). A backend that fails to load, implements an unsupported protocol version, or is missing a required field is reported and skipped, and the next one is tried.

Writing a Backend

See PROTOCOL.md, or :help smart-splits-protocol. A backend needs at least four fields for basic functionality:

return {
  name = 'my-mux',
  protocol_version = '3.0.0',
  detect = function()
    return vim.env.MY_MUX ~= nil
  end,
  move = function(direction, opts)
    return focus_pane(direction, { wrap = opts.wrap })
  end,
}

Every operation takes (direction, opts), where opts holds the options core resolved for that call, such as wrap for move and amount for resize. Fields are optional, so a backend falls back to its own defaults for anything the user did not ask for.

There is one optional lifecycle hook, activate(). Core calls it once, only on the backend it resolved, and that is where autocommands, caches and anything else with a side effect belong.

Backend options are the backend's own business and core passes none of them along. Take them however you like, setup(opts) or vim.g or a config module; the protocol does not care and core calls none of them. It only asks that whichever path you pick stays inert, storing values and nothing more, because every installed backend gets configured on startup including the ones whose multiplexer is not running. Only the resolved backend gets activate().

You can also pass a table like this inline, without publishing a plugin at all.

Migration from v2

v2 remains available via its git tag. You may pin to the latest 2.x.x version if you do not wish to migrate to v3 (e.g. if you use a terminal multiplexer that does not yet have a v3 backend).

v3 introduces a backend architecture where multiplexer support lives in separate plugins. This is a breaking change.

1. Install a backend plugin. Core ships none. See Available Backends.

2. Rename your config keys. Everything is grouped by feature now. setup() reports any v2 keys it finds, with the new location, and :checkhealth smart-splits repeats the report.

v2v3
default_amountresize.amount
ignored_eventsresize.ignored_events
at_edgemove.at_edge
move_cursor_same_rowmove.same_row
cursor_follows_swapped_bufsswap.move_cursor
multiplexer_integrationmux.backend
log_levellog.level
ignored_buftypesunchanged
ignored_filetypesunchanged

3. Drop what no longer exists. Some of these may have migrated to backend-specific configuration.

RemovedWhy
disable_multiplexer_nav_when_zoomedZoom is handled inside the backend, configure it there
float_win_behaviorFloating windows always act on the previous window
wezterm_cli_path, kitty_password, zellij_move_focus_or_tabMoved to their backend plugins
tmux_integration, wrap_at_edgeDeprecated in v2, now gone
vim.g.smart_splits_multiplexer_integrationUnnecessary, backends are required lazily
move_cursor_previousRemoved, it could never work across a multiplexer boundary
require('smart-splits.mux')Replaced by require('smart-splits.backend')
ctx.mux in at_edgeNow ctx.backend

4. Post install hooks are gone. The Kitty kittens, the tmux plugin file, the WezTerm plugin and the Herdr manifest all moved out of this repository. Remove any build/run hook you had.

Troubleshooting

Start with :checkhealth smart-splits. It reports your resolved config, every backend it tried and why each one was accepted or skipped, and runs each backend's own health check.

To trace behavior, you can set the log level and view the log file with:

:SmartSplitsLogLevel debug
:SmartSplitsLog
multiplexer
mux
neovim
neovim-plugin
nvim
split
split-pane
terminal-multiplexer
window-management
window-manager

smart-splits-nvim/smart-splits.nvim

🧠 Smart, seamless, directional navigation and resizing of Neovim + terminal multiplexer splits. Supports Zellij, Tmux, Wezterm, and Kitty. Think about splits in terms of "up/down/left/right".

Lua

1,737

819 commits

updated Oct 9, 2026

See the code

README

🧠 smart-splits.nvim

[!NOTE] This repo recently moved to a new smart-splits-nvim GitHub org! See #488 / #492 for details.

🧠 Smarter and more intuitive split pane management for Neovim. Resize splits using a mental model of left/right/up/down instead of wider/narrower/taller/shorter. Navigate seamlessly between Neovim windows and terminal multiplexer panes with a clean, backend-driven architecture.

Key features:

  • Intuitive resize model that matches how you think about splits
  • Seamless navigation between Neovim and multiplexer panes
  • Modular architecture: multiplexer support lives in separate plugins
  • Configurable edge behavior: wrap, stop, split, or custom logic
  • Ignore lists for buffers and filetypes
  • Count-aware resizing with v:count1 support

Table of Contents

Requirements

  • Neovim 0.11 or newer
  • (Optional) A terminal multiplexer with a supported smart-splits backend plugin (e.g. smart-splits-nvim/backend-zellij-lua)

Installation

smart-splits.nvim is the core plugin. Multiplexer backends (Zellij, Ghostty, etc.) are separate plugins you install alongside it.

With lazy.nvim:

return {
  'smart-splits-nvim/smart-splits.nvim',
  version = '^3.0.0',
  -- optional: install a backend
  dependencies = {
    {
      'smart-splits-nvim/backend-zellij-lua',
      main = 'smart-splits-backend-zellij-lua',
      opts = {
        --[[ backend-specific options go here ]]
      },
    },
  },
  opts = {
    mux = { backend = 'smart-splits-backend-zellij-lua' },
  },
}

Calling setup() is optional. Without it you get the defaults and no multiplexer integration.

Quick Start

Minimal working example configuration with the Zellij backend (click to expand)
-- Install smart-splits.nvim and a backend
return {
  'smart-splits-nvim/smart-splits.nvim',
  version = '^3.0.0',
  dependencies = {
    { 'smart-splits-nvim/backend-zellij-lua', main = 'smart-splits-backend-zellij-lua' },
  },
  opts = {
    mux = { backend = 'smart-splits-backend-zellij-lua' },
  },
  keys = {
    -- Resize splits
    {
      '<A-h>',
      function()
        require('smart-splits').resize_left()
      end,
      desc = 'Resize left',
    },
    {
      '<A-j>',
      function()
        require('smart-splits').resize_down()
      end,
      desc = 'Resize down',
    },
    {
      '<A-k>',
      function()
        require('smart-splits').resize_up()
      end,
      desc = 'Resize up',
    },
    {
      '<A-l>',
      function()
        require('smart-splits').resize_right()
      end,
      desc = 'Resize right',
    },
    -- Move between splits
    {
      '<C-h>',
      function()
        require('smart-splits').move_cursor_left()
      end,
      desc = 'Move left',
    },
    {
      '<C-j>',
      function()
        require('smart-splits').move_cursor_down()
      end,
      desc = 'Move down',
    },
    {
      '<C-k>',
      function()
        require('smart-splits').move_cursor_up()
      end,
      desc = 'Move up',
    },
    {
      '<C-l>',
      function()
        require('smart-splits').move_cursor_right()
      end,
      desc = 'Move right',
    },
    -- Swap buffers
    {
      '<leader><leader>h',
      function()
        require('smart-splits').swap_buf_left()
      end,
      desc = 'Swap left',
    },
    {
      '<leader><leader>j',
      function()
        require('smart-splits').swap_buf_down()
      end,
      desc = 'Swap down',
    },
    {
      '<leader><leader>k',
      function()
        require('smart-splits').swap_buf_up()
      end,
      desc = 'Swap up',
    },
    {
      '<leader><leader>l',
      function()
        require('smart-splits').swap_buf_right()
      end,
      desc = 'Swap right',
    },
  },
}

Configuration

Defaults:

require('smart-splits').setup({
  -- buffers to leave alone, see Ignore Lists below
  ignored_buftypes = { 'nofile', 'quickfix', 'prompt' },
  ignored_filetypes = { 'NvimTree' },

  resize = {
    -- cells to resize by, multiplied by `v:count1`
    amount = 3,
    -- added to `eventignore` for the duration of a resize
    ignored_events = { 'BufEnter', 'WinEnter' },
  },

  move = {
    -- 'stop' | 'wrap' | 'split' | fun(ctx), see At Edge Behavior below
    at_edge = 'wrap',
    -- keep the cursor on the same screen row when moving horizontally
    same_row = false,
  },

  swap = {
    -- follow the buffer into its new window
    move_cursor = false,
  },

  mux = {
    -- a backend, a list of backends in priority order, or a function returning
    -- either; see Backends below
    backend = nil,
    -- warn when backends are configured but none of them detected
    warn_if_unusable = true,
  },

  log = {
    -- 'trace' | 'debug' | 'info' | 'warn' | 'error'
    level = 'info',
    -- `true` for the default path, a string for a custom one, `false` to disable
    file = true,
  },
})

Ignore Lists

ignored_buftypes and ignored_filetypes mark windows that smart-splits.nvim should leave alone: they are skipped when working out resize geometry, and at_edge = 'split' will not split them.

Both can be overridden per feature. A bare list replaces the top level one:

require('smart-splits').setup({
  ignored_filetypes = { 'NvimTree' },
  -- resizing ignores only Trouble, moving still ignores only NvimTree
  resize = { ignored_filetypes = { 'Trouble' } },
})

Set inherit = true to add to the top level list instead of replacing it:

require('smart-splits').setup({
  ignored_filetypes = { 'NvimTree', 'neo-tree' },
  -- resizing ignores NvimTree, neo-tree and Trouble
  resize = { ignored_filetypes = { inherit = true, 'Trouble' } },
})

At Edge Behavior

move.at_edge decides what happens when there is no Neovim window in the direction you moved, and the multiplexer backend did not handle it either.

ValueBehavior
'wrap'Jump to the Neovim window on the opposite edge
'stop'Stay where you are
'split'Create a split, asking the backend first, falling back to a Neovim split
fun(ctx)Decide for yourself

at_edge only ever runs when the backend declined the move, so how far 'wrap' reaches depends on the multiplexer. Take a Neovim pane sitting at the right edge of the multiplexer, with another pane to its left, and press the "move right" key from Neovim's rightmost window:

  • A multiplexer that can wrap around its own edges moves focus to that left pane and reports the move as handled, so you wrap across the whole screen and at_edge never runs.
  • One that cannot reports the move as unhandled, and 'wrap' then wraps among Neovim's own windows.

Core tells the backend which behavior you asked for, so 'stop' also stops at the multiplexer's edges rather than letting a multiplexer that wraps by default wrap anyway. Backends are expected to honour that, but it is their code doing it, so check yours if 'stop' does not stop.

The function form receives:

---@class SmartSplitsAtEdgeContext
---@field backend SmartSplitsBackend|nil the resolved backend, `nil` if none resolved
---@field direction SmartSplitsDirection the direction you moved, so also the edge you are on
---@field split fun() split the current window towards `direction`
---@field wrap fun() jump to the window on the opposite edge

For example, wrap horizontally but stop vertically:

require('smart-splits').setup({
  move = {
    at_edge = function(ctx)
      if ctx.direction == 'left' or ctx.direction == 'right' then
        ctx.wrap()
      end
    end,
  },
})

Keybindings

smart-splits.nvim sets no mappings for you. The recommended set:

-- resizing splits, these accept a count, so `10<A-h>` resizes by 10 * config.resize.amount
vim.keymap.set('n', '<A-h>', require('smart-splits').resize_left)
vim.keymap.set('n', '<A-j>', require('smart-splits').resize_down)
vim.keymap.set('n', '<A-k>', require('smart-splits').resize_up)
vim.keymap.set('n', '<A-l>', require('smart-splits').resize_right)
-- moving between splits
vim.keymap.set('n', '<C-h>', require('smart-splits').move_cursor_left)
vim.keymap.set('n', '<C-j>', require('smart-splits').move_cursor_down)
vim.keymap.set('n', '<C-k>', require('smart-splits').move_cursor_up)
vim.keymap.set('n', '<C-l>', require('smart-splits').move_cursor_right)
-- swapping buffers between windows
vim.keymap.set('n', '<leader><leader>h', require('smart-splits').swap_buf_left)
vim.keymap.set('n', '<leader><leader>j', require('smart-splits').swap_buf_down)
vim.keymap.set('n', '<leader><leader>k', require('smart-splits').swap_buf_up)
vim.keymap.set('n', '<leader><leader>l', require('smart-splits').swap_buf_right)

Lua API

local ss = require('smart-splits')

-- resize, `opts` is `{ amount = n }` or a bare number, defaulting to
-- `config.resize.amount`; either way `v:count1` multiplies it
ss.resize_left(opts)
ss.resize_right(opts)
ss.resize_up(opts)
ss.resize_down(opts)

-- move the cursor, `opts` may override `same_row` and `at_edge` for this call
ss.move_cursor_left(opts)
ss.move_cursor_right(opts)
ss.move_cursor_up(opts)
ss.move_cursor_down(opts)

-- swap the current buffer with a neighbor, `opts` may override `move_cursor`
ss.swap_buf_left(opts)
ss.swap_buf_right(opts)
ss.swap_buf_up(opts)
ss.swap_buf_down(opts)

Commands

CommandDescription
:SmartResizeLeft [amount]Resize left
:SmartResizeRight [amount]Resize right
:SmartResizeUp [amount]Resize up
:SmartResizeDown [amount]Resize down
:SmartCursorMoveLeftMove the cursor left
:SmartCursorMoveRightMove the cursor right
:SmartCursorMoveUpMove the cursor up
:SmartCursorMoveDownMove the cursor down
:SmartSwapLeftSwap the buffer left
:SmartSwapRightSwap the buffer right
:SmartSwapUpSwap the buffer up
:SmartSwapDownSwap the buffer down
:SmartSplitsLogOpen the log file
:SmartSplitsLogLevel {level}Change the log level for this session

Backends

Core ships no backends. Support for tmux, Zellij, WezTerm, Kitty and anything else lives in separate plugins, so each one can be maintained by people who actually use it.

Install a backend plugin and name it:

return {
  'smart-splits-nvim/smart-splits.nvim',
  dependencies = {
    {
      'smart-splits-nvim/backend-zellij-lua',
      main = 'smart-splits-backend-zellij-lua',
      -- backend options belong to the backend plugin
      opts = { disable_nav_when_zoomed = true },
    },
  },
  opts = {
    mux = { backend = 'smart-splits-backend-zellij-lua' },
  },
}

There is no auto-detection. Naming your backend explicitly is what replaced it.

Available Backends

MultiplexerBackendMaintainerNotes
Zellijbackend-zellij-lua@TorsteinwsA Rust rewrite, backend-zellij-rs, is in progress and will become the recommended Zellij backend once stable
Ghosttybackend-ghostty@geodimmmacOS only
Tmuxbackend-tmux@mrjones2014
Kittybackend-kitty@maatthc
WezTermbackend-wezterm@a-morales

Using Herdr? That backend shipped in core through v2 and needs a maintainer. Either stay on the v2 tag, or volunteer to maintain it.

The community is also free to make 3rd party backends.

Multiple Backends

mux.backend accepts a list in priority order. The first backend whose detect() returns true wins, which covers the case where you are usually in one multiplexer and occasionally in another:

require('smart-splits').setup({
  mux = {
    -- inside zellij, use zellij; otherwise fall back to ghostty
    backend = { 'smart-splits-backend-zellij-lua', 'smart-splits-backend-ghostty' },
  },
})

It also accepts a module table directly, or a function returning any of the above. The function form defers the require until startup, which helps if your backend plugin is lazy loaded:

require('smart-splits').setup({
  mux = {
    backend = function()
      return { require('smart-splits-backend-zellij-lua'), require('smart-splits-backend-ghostty') }
    end,
  },
})

Resolution happens once, during setup(). A backend that fails to load, implements an unsupported protocol version, or is missing a required field is reported and skipped, and the next one is tried.

Writing a Backend

See PROTOCOL.md, or :help smart-splits-protocol. A backend needs at least four fields for basic functionality:

return {
  name = 'my-mux',
  protocol_version = '3.0.0',
  detect = function()
    return vim.env.MY_MUX ~= nil
  end,
  move = function(direction, opts)
    return focus_pane(direction, { wrap = opts.wrap })
  end,
}

Every operation takes (direction, opts), where opts holds the options core resolved for that call, such as wrap for move and amount for resize. Fields are optional, so a backend falls back to its own defaults for anything the user did not ask for.

There is one optional lifecycle hook, activate(). Core calls it once, only on the backend it resolved, and that is where autocommands, caches and anything else with a side effect belong.

Backend options are the backend's own business and core passes none of them along. Take them however you like, setup(opts) or vim.g or a config module; the protocol does not care and core calls none of them. It only asks that whichever path you pick stays inert, storing values and nothing more, because every installed backend gets configured on startup including the ones whose multiplexer is not running. Only the resolved backend gets activate().

You can also pass a table like this inline, without publishing a plugin at all.

Migration from v2

v2 remains available via its git tag. You may pin to the latest 2.x.x version if you do not wish to migrate to v3 (e.g. if you use a terminal multiplexer that does not yet have a v3 backend).

v3 introduces a backend architecture where multiplexer support lives in separate plugins. This is a breaking change.

1. Install a backend plugin. Core ships none. See Available Backends.

2. Rename your config keys. Everything is grouped by feature now. setup() reports any v2 keys it finds, with the new location, and :checkhealth smart-splits repeats the report.

v2v3
default_amountresize.amount
ignored_eventsresize.ignored_events
at_edgemove.at_edge
move_cursor_same_rowmove.same_row
cursor_follows_swapped_bufsswap.move_cursor
multiplexer_integrationmux.backend
log_levellog.level
ignored_buftypesunchanged
ignored_filetypesunchanged

3. Drop what no longer exists. Some of these may have migrated to backend-specific configuration.

RemovedWhy
disable_multiplexer_nav_when_zoomedZoom is handled inside the backend, configure it there
float_win_behaviorFloating windows always act on the previous window
wezterm_cli_path, kitty_password, zellij_move_focus_or_tabMoved to their backend plugins
tmux_integration, wrap_at_edgeDeprecated in v2, now gone
vim.g.smart_splits_multiplexer_integrationUnnecessary, backends are required lazily
move_cursor_previousRemoved, it could never work across a multiplexer boundary
require('smart-splits.mux')Replaced by require('smart-splits.backend')
ctx.mux in at_edgeNow ctx.backend

4. Post install hooks are gone. The Kitty kittens, the tmux plugin file, the WezTerm plugin and the Herdr manifest all moved out of this repository. Remove any build/run hook you had.

Troubleshooting

Start with :checkhealth smart-splits. It reports your resolved config, every backend it tried and why each one was accepted or skipped, and runs each backend's own health check.

To trace behavior, you can set the log level and view the log file with:

:SmartSplitsLogLevel debug
:SmartSplitsLog
multiplexer
mux
neovim
neovim-plugin
nvim
split
split-pane
terminal-multiplexer
window-management
window-manager

Significant stargazers

(top 24 of 29)

Ryan

177 followers · starred Jul 2022

Ian Stapleton Cordasco

1,673 followers · starred Jan 2026

Benny Powers

328 followers · starred Mar 2022

Lucio Franco

1,157 followers · starred Apr 2023