Navigate between Neovim splits and Ghostty panes on macOS with the same keys.
A macOS bridge for smart-splits.nvim.
ghostty-smart-splits-demo.mp4
smart-splits handles Neovim windows first; at an editor edge, the matching Ghostty binding handles the pane.
Ghostty's performable bindings give Neovim first chance at each key.
This plugin uses Ghostty's AppleScript API when smart-splits reaches an editor edge, and keeps a temporary key table active while Neovim is running.
Both smart-splits v2 and the experimental v3 backend are supported.
-
Neovim 0.11+, smart-splits.nvim, and Ghostty 1.3+ or cmux on macOS.
-
Ghostty AppleScript enabled (the default) and macOS Automation permission.
-
Neovim running locally, inside a Ghostty or cmux pane.
Choose one integration below, then add the Neovim mappings and Ghostty configuration. Both are required.
With lazy.nvim:
{
'smart-splits-nvim/smart-splits.nvim',
lazy = false,
dependencies = { 'smart-splits-nvim/backend-ghostty' },
config = function()
require('smart-splits').setup({}) -- Your existing options.
require('ghostty-smart-splits').setup()
end,
}With vim.pack (Neovim 0.12+):
vim.pack.add({
'https://github.com/smart-splits-nvim/smart-splits.nvim',
'https://github.com/smart-splits-nvim/backend-ghostty',
})
require('smart-splits').setup({}) -- Your existing options.
require('ghostty-smart-splits').setup()The v2 setup preserves existing smart-splits options and adds multiplexer_integration = 'ghostty' and at_edge = 'stop'.
Call it once at startup with the Neovim pane focused.
It returns false when the session is unsupported; terminal attachment and key-table activation continue asynchronously after it returns.
Experimental.
Use smart-splits' v3 Git ref.
The protocol and backend may change before release.
With lazy.nvim:
{
'smart-splits-nvim/smart-splits.nvim',
branch = 'v3',
lazy = false,
dependencies = {
{
'smart-splits-nvim/backend-ghostty',
main = 'smart-splits-backend-ghostty',
},
},
opts = {
mux = {
backend = 'smart-splits-backend-ghostty',
},
move = {
at_edge = 'stop',
},
},
}With vim.pack (Neovim 0.12+):
vim.pack.add({
{
src = 'https://github.com/smart-splits-nvim/smart-splits.nvim',
version = 'v3',
},
'https://github.com/smart-splits-nvim/backend-ghostty',
})
require('smart-splits').setup({
mux = {
backend = 'smart-splits-backend-ghostty',
},
move = {
at_edge = 'stop',
},
})Do not call the v2 ghostty-smart-splits setup when using v3.
smart-splits activates the selected backend during its own setup, so start Neovim with its Ghostty pane focused.
Neither plugin creates mappings automatically.
Add these after your plugin setup (after require('lazy').setup(...) when using lazy.nvim).
They work with both v2 and v3 and match the Ghostty configuration below.
local splits = require('smart-splits')
vim.keymap.set('n', '<C-h>', splits.move_cursor_left)
vim.keymap.set('n', '<C-j>', splits.move_cursor_down)
vim.keymap.set('n', '<C-k>', splits.move_cursor_up)
vim.keymap.set('n', '<C-l>', splits.move_cursor_right)
vim.keymap.set('n', '<M-h>', splits.resize_left)
vim.keymap.set('n', '<M-j>', splits.resize_down)
vim.keymap.set('n', '<M-k>', splits.resize_up)
vim.keymap.set('n', '<M-l>', splits.resize_right)These mappings apply in Normal mode.
<M-...> is the Mac Option/Alt key.
Copy this to your Ghostty config, reload it (cmux reload-config on cmux), then start Neovim in the target pane.
The same content is in examples/ghostty.conf.
# Outside Neovim.
# Move
keybind = performable:ctrl+h=goto_split:left
keybind = performable:ctrl+j=goto_split:down
keybind = performable:ctrl+k=goto_split:up
keybind = performable:ctrl+l=goto_split:right
# Resize
keybind = performable:alt+h=resize_split:left,30
keybind = performable:alt+j=resize_split:down,30
keybind = performable:alt+k=resize_split:up,30
keybind = performable:alt+l=resize_split:right,30
# Inside Neovim.
keybind = nvim/
# Move
keybind = nvim/ctrl+h=text:\x08
keybind = nvim/ctrl+j=text:\x0a
keybind = nvim/ctrl+k=text:\x0b
keybind = nvim/ctrl+l=text:\x0c
# Resize
keybind = nvim/alt+h=esc:h
keybind = nvim/alt+j=esc:j
keybind = nvim/alt+k=esc:k
keybind = nvim/alt+l=esc:lThe keys in Neovim and Ghostty must match.
Start Neovim in a Ghostty pane and run:
:checkhealth ghostty-smart-splitsIt reports local prerequisites, the selected transport, and whether the persistent process is running, without starting it.
With v3, :checkhealth smart-splits also includes backend diagnostics.
Then open a second Ghostty pane beside it and press <C-h> and <C-l> from the edges of your Neovim layout.
Focus should cross into the neighboring pane and back.
Both integrations accept the same backend options.
The Ghostty key table used while Neovim is active.
Defaults to 'nvim' and must be a non-empty string.
Changing it to a different name while it is claimed is an error; release it first.
How actions and pane lookups reach Ghostty.
Defaults to 'persistent', which keeps one osascript process running and falls back to 'ephemeral' when that process cannot answer.
'ephemeral' starts osascript for every request.
Switching to 'ephemeral' stops a running persistent process immediately.
Switching to 'persistent' takes effect on the next attachment or action.
Deprecated alias for transport.
true selects 'persistent' and false selects 'ephemeral'.
It still works and warns once per session.
With v2, pass them to the plugin setup:
require('ghostty-smart-splits').setup({
key_table = 'nvim',
transport = 'persistent', -- Or 'ephemeral' to start osascript per request.
})With v3, configure the backend before smart-splits selects and activates it:
require('smart-splits-backend-ghostty').setup({
key_table = 'nvim',
transport = 'persistent', -- Or 'ephemeral' to start osascript per request.
})
require('smart-splits').setup({
mux = { backend = 'smart-splits-backend-ghostty' },
move = { at_edge = 'stop' },
})setup merges over the current options: a call that names one option leaves the rest alone, so the transport can be switched at runtime without repeating key_table.
An unknown option name is an error rather than a silent no-op.
Call require('ghostty-smart-splits.config').reset() to restore every default.
Configuration alone does not run AppleScript, start the persistent process, attach to Ghostty, or register autocommands.
Set move.at_edge in smart-splits, not in the backend options:
require('smart-splits').setup({
mux = { backend = 'smart-splits-backend-ghostty' },
move = { at_edge = 'wrap' }, -- 'stop', 'wrap', or 'split'
})Movement first tries a Neovim window, then a neighboring Ghostty pane. If neither exists in the requested direction:
move.at_edge |
Behavior |
|---|---|
'stop' |
Stay in the current Neovim window. |
'wrap' |
Wrap to the opposite edge of the Neovim layout within the current Ghostty pane. With one Neovim window, stay there. Ghostty panes are not wrapped. |
'split' |
Create and focus a Ghostty pane in that direction. If Ghostty cannot create it, smart-splits falls back to creating a Neovim split. |
All three modes navigate to an existing Ghostty neighbor.
In particular, 'stop' does not prevent crossing the Neovim/Ghostty boundary.
A custom move.at_edge function is handled by smart-splits after the backend cannot move.
The backend does not detect zoom/fullscreen or suppress navigation in those states.
Movement inside Neovim still takes priority.
At an editor edge, Ghostty handles the usual goto_split action.
smart-splits v2's disable_multiplexer_nav_when_zoomed has no effect.
Navigating to a neighbor from a zoomed pane follows Ghostty's split-preserve-zoom setting.
By default it leaves split zoom.
With split-preserve-zoom = navigation, the neighbor becomes the zoomed pane, and so does Neovim's pane when you navigate back.
Window fullscreen also allows navigation between Neovim windows and Ghostty panes.
cmux embeds Ghostty and works in its place.
It reads the same Ghostty config file, so the Ghostty configuration applies unchanged; run cmux reload-config after editing it.
cmux reports goto_split as performed even when no pane lies in that direction, so a move is called successful only once focus has actually left the Neovim pane.
That costs one extra pane lookup per move in cmux; Ghostty answers accurately and skips it.
The default, transport = 'persistent', keeps scripts/ghostty.js running in one osascript process per Neovim instance, instead of starting a new process for every request.
The JavaScript engine and the Ghostty process lookup are then set up once rather than for every request.
Nothing needs to be built.
Set transport = 'ephemeral' if you would rather not keep a process running.
Both transports address the Ghostty process that owns Neovim, so separate Ghostty instances can run alongside each other.
Each Neovim instance owns one persistent process and stops it on exit.
The initial terminal lookup always uses ephemeral osascript, asynchronously, before the persistent process is running.
smart-splits v2 brackets each pane move with a terminal lookup, so one move is three Ghostty requests. All three use the persistent process when it is enabled.
In local measurements, actions took about 15 ms with 'persistent' versus about 100 ms with 'ephemeral'; results vary by machine.
Run just bench from the Nix development shell to benchmark locally.
Earlier versions started osascript for every request unless you built and enabled a compiled Swift bridge.
The persistent transport is now the default, and both bridge settings still work but are deprecated.
-
bridge = trueselects'persistent', the new default, andbridge = falseselects'ephemeral'. Either warns once per session: removebridge = true, or replacebridge = falsewithtransport = 'ephemeral'. -
make bridgeno longer builds anything; it prints a deprecation notice and succeeds. Removebuild = 'make bridge'or thePackChangedbuild hook from your config.
require('ghostty-smart-splits').claim_keys()
require('ghostty-smart-splits').release_keys()
require('ghostty-smart-splits.config').reset()-
claim_keys()pushes the configured Ghostty key table if it is not already claimed. -
release_keys()pops the table claimed by this instance. -
config.reset()restores every option to its default.setup()merges rather than replaces, so this is the only way back to the defaults.
Keys are released on VimSuspend and VimLeavePre, and claimed again on VimEnter and VimResume.
Use the functions above only when managing the table manually.
See Check the setup for :checkhealth.
-
macOS only. Action AppleScript calls are synchronous and time out after one second.
-
The initial Ghostty terminal comes from the focused pane and the lookup is asynchronous. Later actions keep using that terminal instead of following focus changes. A failed lookup is retried when Neovim next regains focus, which is when the Automation prompt has been answered and is also proof the focused pane is still ours. After five failures it stops trying and warns once.
-
Do not stack another Ghostty key table above this one while Neovim is active. If a crash or config reload leaves stale state, Ghostty's
deactivate_all_key_tablesaction can recover it, but clears every table. -
Native
performable:goto_splitbindings can give Ghostty priority. This plugin is for Neovim-first navigation.
Issues and pull requests are welcome.
CONTRIBUTING.md covers the Nix development shell, just check, the real-Ghostty end-to-end tests, and how the documentation is generated.
MIT.