Navigate between Neovim splits and Ghostty panes on macOS with the same keys.
Ghostty integration for smart-splits.nvim v3. smart-splits v2 is still supported; see smart-splits v2.
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.
-
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.
Install the plugin, then add the Neovim mappings and Ghostty configuration. All three are required.
{
'smart-splits-nvim/smart-splits.nvim',
version = '^3.0.0',
lazy = false,
opts = {
mux = {
backend = 'smart-splits-backend-ghostty',
},
move = {
at_edge = 'stop',
},
},
dependencies = {
{
'smart-splits-nvim/backend-ghostty',
opts = {}, -- See Configuration below.
},
},
}With vim.pack (Neovim 0.12+):
vim.pack.add({
{
src = 'https://github.com/smart-splits-nvim/smart-splits.nvim',
version = vim.version.range('3'),
},
'https://github.com/smart-splits-nvim/backend-ghostty',
})
require('smart-splits-backend-ghostty').setup({}) -- Optional.
require('smart-splits').setup({
mux = {
backend = 'smart-splits-backend-ghostty',
},
move = {
at_edge = 'stop',
},
})smart-splits activates the selected backend during its own setup, so start Neovim with its Ghostty pane focused.
Every option is optional; these are the defaults. See Options for details.
opts = {
-- Set false to make detect() fail without uninstalling the plugin.
enable = true,
-- Ghostty key table claimed while Neovim is active.
-- Must match the `nvim/` bindings in your Ghostty config.
key_table = 'nvim',
-- 'persistent' keeps one osascript process running.
-- 'ephemeral' starts osascript for every request.
transport = 'persistent',
}Configure the backend before smart-splits selects and activates it.
lazy.nvim does this for you, since it sets up dependencies first.
With vim.pack, call setup before require('smart-splits').setup, as above.
Neither plugin creates mappings automatically.
Add these after your plugin setup (after require('lazy').setup(...) when using lazy.nvim).
They work with both v3 and v2 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 smart-splitssmart-splits includes this backend's report.
:checkhealth smart-splits-backend-ghostty runs the same report on its own, and also works with v2.
It reports local prerequisites, the selected transport, and whether the persistent process is running, without starting it.
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 smart-splits v3 and v2 take the same backend options.
Defaults to true.
Set it to false to make detect() fail without uninstalling the plugin: smart-splits v3 skips the backend, and the v2 setup returns false.
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.
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('smart-splits-backend-ghostty.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 before 0.65.0 reports goto_split as performed even when no pane lies in that direction, so there a move is called successful only once focus has actually left the Neovim pane.
That costs one extra pane lookup per move; cmux 0.65.0 and later, like Ghostty, answer accurately and skip 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.
smart-splits v2 has no backend protocol.
Instead, require('smart-splits-backend-ghostty.v2').setup() registers the adapter v2 loads for multiplexer_integration = 'ghostty', selects it, and activates the backend.
Pin smart-splits to v2.1.1-final, the last v2 release.
lazy.nvim's version = '^2' resolves to v2.1.0 instead, because it treats the -final suffix as a prerelease.
With lazy.nvim:
{
'smart-splits-nvim/smart-splits.nvim',
tag = 'v2.1.1-final',
lazy = false,
dependencies = { 'smart-splits-nvim/backend-ghostty' },
config = function()
require('smart-splits').setup({}) -- Your existing options.
require('smart-splits-backend-ghostty.v2').setup()
end,
}With vim.pack (Neovim 0.12+):
vim.pack.add({
{
src = 'https://github.com/smart-splits-nvim/smart-splits.nvim',
version = 'v2.1.1-final',
},
'https://github.com/smart-splits-nvim/backend-ghostty',
})
require('smart-splits').setup({}) -- Your existing options.
require('smart-splits-backend-ghostty.v2').setup()The v2 setup takes the same options as the v3 backend.
It 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 or the backend is disabled; terminal attachment and key-table activation continue asynchronously after it returns.
local backend = require('smart-splits-backend-ghostty')
backend.claim_keys()
backend.release_keys()
require('smart-splits-backend-ghostty.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.
The v2 module, require('smart-splits-backend-ghostty.v2'), exposes the same claim_keys() and release_keys().
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.