Neovim Jupyter
nvim-jupyter is a Neovim plugin that turns a .ipynb file into a live notebook you can run without leaving the editor. Open a notebook, execute cells in place, and see output render inline right below the cell: text, errors, and images. Save with :w and it serializes back to real .ipynb on disk. No browser, no Jupyter web UI, no context switch.
Source: github.com/bobcowher/neovim-jupyter
I built this for the same reason as most of my tools: I live in Neovim, and bouncing out to a browser tab to poke at a notebook was friction I didn’t want. I wanted the cell-based, run-and-see-it workflow that makes notebooks useful, but with my own keymaps, my own colorscheme, and my own editor underneath it.
It’s a Rust binary and a Lua plugin.
Requirements
-
A terminal that speaks the Kitty graphics protocol, for inline images. I build and test against Ghostty; Kitty works too. Everything else (text output, errors, execution) works anywhere. You just won’t see plots.
-
Linux.
-
Neovim 0.9+.
-
Jupyter and a kernel. You need
jupyterand at least one kernel (ipykernel) installed. The plugin discovers standard Jupyter kernelspecs and your conda environments automatically. If a conda env is missingipykernel, it offers to install it for you when you pick it. -
A Rust toolchain, to build the daemon. The easiest path is rustup, which installs
cargo:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Installation
The Rust daemon has to be compiled before first use. With Lazy.nvim the build hook handles that automatically, on install and on every update, so there’s nothing to run by hand.
Lazy.nvim
{
"bobcowher/neovim-jupyter",
build = "bash build.sh",
config = function()
require("nvim-jupyter").setup()
end,
}
build.sh runs cargo build --release and copies the binary to bin/nvim-jupyter, where the plugin looks for it. It won’t search $PATH, which keeps behavior predictable.
Manual steps (if it doesn’t work automatically)
If the build hook didn’t fire, or you pulled changes and want to rebuild, run :JupyterBuild from inside Neovim. Or do it from a shell:
git clone https://github.com/bobcowher/neovim-jupyter.git
cd neovim-jupyter
bash build.sh
Usage
Open any .ipynb file and the plugin activates automatically. The notebook loads as a virtual cell buffer: each cell separated by a labelled divider, code cells editable as plain Python, markdown cells rendered inline. Save with :w and your changes serialize straight back to .ipynb.
Default keymaps
| Key | Action |
|---|---|
<CR> or <C-CR> |
Execute cell in place |
<S-CR> |
Execute cell and advance to the next |
<M-CR> |
Execute cell and insert a new one below |
]c |
Next cell |
[c |
Previous cell |
<Space>b |
Add cell below |
<Space>a |
Add cell above |
<Space>d |
Delete current cell |
<Space>m |
Toggle cell type (code / markdown) |
(Ghostty captures ctrl+enter by default to toggle its header. Use shift+enter, or unbind ctrl+enter in Ghostty.)
Commands
Execution:
:JupyterExecuteruns the current cell in place.:JupyterExecuteAndAdvanceruns, then moves to the next cell.:JupyterExecuteAndInsertruns, then opens a fresh cell below.:JupyterExecuteAllruns every cell top to bottom.:JupyterExecuteAbove/:JupyterExecuteBelowrun everything above, or the current cell and everything below.
Navigation & editing:
:JupyterNextCell/:JupyterPrevCellmove between cells.:JupyterAddCellBelow/:JupyterAddCellAboveadd a code cell.:JupyterDeleteCelldeletes the current cell.:JupyterChangeCellTypetoggles between code and markdown.:JupyterClearOutput/:JupyterClearAllOutputsclear output for the current cell, or the whole buffer.
Kernel management:
:JupyterKernelopens a picker to choose a kernel (Jupyter kernelspecs and conda envs).:JupyterRestartKernelrestarts the current kernel.:JupyterInterruptinterrupts a running cell (SIGINT).:JupyterKernelStatusprints the current kernel status.
Other:
:JupyterInspectshows hover documentation for the symbol under the cursor.:JupyterShowOutputdumps the current cell’s full output into a scratch split.:JupyterBuildcompiles the Rust daemon.
Configuration
Everything is overridable through setup():
require("nvim-jupyter").setup({
keymaps = true, -- set false to disable the configurable keymaps below
keymap = {
execute = "<C-CR>",
execute_advance = "<S-CR>",
execute_insert = "<M-CR>",
next_cell = "]c",
prev_cell = "[c",
delete_cell = "<Space>d",
toggle_cell_type = "<Space>m",
add_cell_below = "<Space>b",
add_cell_above = "<Space>a",
},
max_output_lines = 50, -- output longer than this is truncated inline
auto_save = true, -- write the notebook back to disk when the cursor goes idle
})
Notes
- Save is real.
:wwrites valid.ipynb, including cells, outputs, and execution counts, so notebooks stay interoperable with Jupyter proper and with anything else that reads the format. - Execution counts come from the kernel, not a local guess, so they match what a normal Jupyter session would show.
- Kitty graphics protocol only for images. Built and tested against Ghostty on Linux. Text, errors, and completion work regardless of terminal.
- If the daemon isn’t built yet, Neovim notifies you to run
:JupyterBuild.
How it works
If you just want to use it, stop here. This is for the curious.
The notebook file is the easy part. The hard part is talking to a kernel. Kernels communicate over ZeroMQ across five sockets, with HMAC-signed multipart messages and a handshake you have to get exactly right, or the kernel silently drops everything you send. That’s fiddly, timing-sensitive work, and it doesn’t belong in Lua. So the plugin splits in two:
- A Rust daemon (
nvim-jupyter) that owns the entire ZeroMQ Jupyter protocol: launching kernels, signing and sending messages, and draining the shell/iopub/control sockets. - A Lua plugin that owns everything editor-shaped: the cell buffer, output rendering via extmarks, kernel selection, and keymaps.
The two talk over newline-delimited JSON on stdin/stdout. Lua sends commands down:
{"cmd":"start_kernel","kernel_id":"k1","kernel_name":"python3","cwd":"/home/me/nb"}
{"cmd":"execute","kernel_id":"k1","msg_id":"m1","code":"print('hi')"}
{"cmd":"interrupt_kernel","kernel_id":"k1"}
{"cmd":"restart_kernel","kernel_id":"k1"}
{"cmd":"quit"}
The daemon streams events back up as they arrive from the kernel:
{"event":"kernel_ready","kernel_id":"k1"}
{"event":"stream","msg_id":"m1","name":"stdout","text":"hi\n"}
{"event":"execute_result","msg_id":"m1","execution_count":3,"text":"42","image_png":"iVBORw0K..."}
{"event":"execute_done","msg_id":"m1","status":"ok","execution_count":3}
Every request carries a msg_id, and the Lua side keys a small registry off it so each reply routes back to the cell that asked for it, then cleans itself up when the request finishes. One kernel runs as one async task inside the daemon. That’s what lets a long-running cell keep taking control commands: interrupts are serviced concurrently with the cell’s output stream, so :JupyterInterrupt actually stops a runaway loop instead of waiting for it to end on its own.
A few things I learned the hard way building this:
- The signing key is deceptive. The connection file’s
keylooks like hex, but Jupyter uses its raw UTF-8 bytes as the HMAC key, not the hex-decoded value. Decode it and every message you send fails verification and gets dropped with no error. It cost me an afternoon. - Finishing a cell is a two-part signal. A cell is done only once both the shell
execute_replyand the iopubstatus: idlehave arrived. The idle status is published after all output, so waiting for it guarantees the last line of output makes it to the screen instead of racing the “done” signal. - Images bypass the screen renderer. Inline plots are written to the terminal as Kitty graphics escape sequences straight to the pts device, so they don’t get mangled by Neovim’s own redraws. They’re re-anchored to the cell’s last line on every edit, so they stay pinned to the bottom of their cell as you type above them.