- Lua 86.5%
- Python 11.1%
- Nix 2.3%
- Shell 0.1%
|
Some checks failed
Test / test (push) Failing after 5m14s
Neovim (0.12, 0.13) moving the cursor to a line below virtual lines that are filler of a hidden line puts it at the end of the buffer when they're about as tall as the window: <C-j>, and `j` past the hidden marker lines, jumped to the last cell. Place the view ourselves then, and let a hidden top line showing part of its filler stay at the top. Smooth scrolling (snacks.nvim scroll, mini.animate) is turned off in notebook buffers: it resets the view the plugin places to animate it, and the cursor jumped back to the cell it came from. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> |
||
|---|---|---|
| .forgejo/workflows | ||
| bench | ||
| dev | ||
| docs | ||
| examples | ||
| lua/ipynb | ||
| nix | ||
| plugin | ||
| python/ipynb_bridge | ||
| tests | ||
| .envrc | ||
| .gitignore | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| flake.lock | ||
| flake.nix | ||
| LICENSE | ||
| README.md | ||
| stylua.toml | ||
ipynb.nvim
Work with Jupyter notebooks without leaving Neovim. Open an .ipynb file,
run its cells against a real Python kernel, and see the results — text,
coloured tracebacks, plots, interactive widgets — right under each cell. Your
own language server, syntax highlighting and markdown plugins keep working
inside the notebook.
Warning
This project is vibe-coded. I need notebooks that work, every day, in my own editor — and I don't have the time to implement it, maintain it and fix its issues myself. Vibe-coding is what makes it exist at all. If that's something that's triggering you, then in words of Edsel Dope: I don't give a fuck about you, or your point of view, or your stupid selfish attittude. Cause it seems, to be, that you never really gave a fuck about me.
What you get
- A notebook view. Every cell gets a header with its run counter, and when
it last ran:
✓ 1.2s · 17:21:05. Results appear underneath, and the cell under your cursor stands out in your colorscheme's accent colour. - A real kernel. If the notebook is already open in JupyterLab, ipynb.nvim connects to the same kernel. If a Jupyter server (JupyterLab, JupyterHub's server) is running for its directory, it starts the kernel there, where it keeps running after you quit; otherwise it starts one of its own.
- Plots and images, drawn inline in terminals that support it (kitty, ghostty, Rio).
- Interactive widgets. Sliders, checkboxes, dropdowns, buttons and
@interactall work — focus them and change values with the keyboard. - Your usual LSP setup inside code cells: completion, hover, go to definition, rename, diagnostics and code actions.
- Folding for long cells and long outputs.
- Outputs you can copy from. Jump into an output to search, select and yank it.
- Clean saves. Notebooks are written the way Jupyter writes them, so diffs stay small.
Screenshots
These were taken in Rio with Neovim 0.13, the
ayu colorscheme, and
ty and
ruff as language servers — the dev
setup of this repository, recorded by media (see
Development). Markdown cells are shown as plain markdown;
the last two show them rendered by a markdown plugin.
![]() |
![]() |
| Cells with their outputs; the cell under the cursor stands out | A matplotlib plot right under its cell |
![]() |
![]() |
An @interact slider moved with the keyboard |
Documentation from the language server |
![]() |
![]() |
| Selecting text in an output | A folded cell and a folded output |
![]() |
![]() |
| Markdown cells rendered by markview.nvim | … and by render-markdown.nvim |
Getting started
You need Neovim 0.12 or newer (0.13 for the built-in image support) and a
Python with jupyter_client and ipykernel installed — plus ipywidgets if
you use widgets. If you install with Nix, all of that is included.
With Nix (flake):
{
inputs.ipynb-nvim.url = "git+https://forge.wvffle.net/wvffle/ipynb.nvim";
# Either add the overlay and use pkgs.vimPlugins.ipynb-nvim …
nixpkgs.overlays = [ inputs.ipynb-nvim.overlays.default ];
# … or add the package directly, e.g. with home-manager. The extra Python
# packages are available to the default python3 kernel.
programs.neovim.plugins = [
(inputs.ipynb-nvim.packages.${pkgs.stdenv.hostPlatform.system}.default.override {
extraPythonPackages = ps: [ ps.numpy ps.matplotlib ];
})
];
}
With nixvim:
{
imports = [ inputs.ipynb-nvim.nixvimModules.default ];
plugins.ipynb = {
enable = true;
# available to the default python3 kernel, like `extraPythonPackages` above
extraPythonPackages = ps: [ ps.numpy ps.matplotlib ];
# passed to require("ipynb").setup(), see Configuration
settings = {
max_output_lines = 80;
keymaps.widget = "<localleader>W";
};
};
}
With lazy.nvim:
{
url = "https://forge.wvffle.net/wvffle/ipynb.nvim",
lazy = false, -- it has to be loaded before you open a notebook
opts = {
python = "python3", -- a Python that has jupyter_client installed
},
}
Then open any .ipynb file. Run :checkhealth ipynb if something doesn't
work — it checks Python, the kernels, the treesitter parsers and image
support.
Using it
Move between cells with <C-j> and <C-k>, edit them like any other text,
and run a cell with <C-CR>. Save with :w as usual.
All keys are local to notebook buffers and can be changed (see
Configuration). <localleader> is your maplocalleader,
\ unless you've set it.
Cells
| key | what it does |
|---|---|
<C-j> / <C-k> |
next / previous cell |
<C-CR> |
run the cell (also in insert mode) |
<S-CR> |
run the cell and move to the next one |
<localleader>ra |
run all cells |
<localleader>rk |
run all cells above |
<localleader>b / <localleader>a |
new code cell below / above |
<localleader>d |
delete the cell (paste it back with p) |
<localleader>m |
switch between code and markdown |
<localleader>s |
split the cell at the cursor |
<localleader>J |
join with the cell below |
<localleader>c / <localleader>C |
clear this cell's output / all outputs |
u / <C-r> |
undo / redo; with JupyterLab's real-time collaboration, your own changes only |
Kernel
| key | what it does |
|---|---|
<localleader>ki |
interrupt (stop the running cell) |
<localleader>kr |
restart |
<localleader>kR |
restart and run everything |
<localleader>kq |
shut down |
:Ipynb connect switches to another kernel of a running Jupyter server: it
lists them (this notebook's first) and connects to the one you pick. It also
takes a kernel id or a connection file.
Folding
| key | what it does |
|---|---|
za / zo / zc |
fold / unfold the cell's code down to its first line |
zM / zR |
fold / unfold every cell |
<localleader>z |
fold / unfold the cell's output |
<localleader>Z |
fold / unfold all outputs |
Clicking a fold's text (⋯ 3 more lines, ▸ output folded, a folded
image) opens it. Folds are saved in the notebook, the same way JupyterLab
saves "collapsed" cells.
Outputs
Moving into a cell with output scrolls the window so that the whole cell,
code and output, is on screen (images are only drawn when they're entirely
visible). When the cell is taller than the window, its In [n] line goes to
the top instead, so its code stays in view. Scrolling with <C-e>, <C-y>
or the mouse wheel never repositions the window.
Images are drawn with vim.ui.img (Neovim 0.13), which can't show part of
an image. An image that wouldn't be entirely in the window where it is is
folded (▸ image 640×480 · no room here, move into the cell to show it).
It unfolds when you scroll it entirely into view, or move into its cell,
which brings the cell into view and draws the image — scaled down just
enough when the cell is taller than the window. Scrolling never folds an
image, so the text doesn't jump under the mouse wheel.
Press <localleader>o to step into a cell's output. It works like a normal
read-only buffer: search with /, select with v, copy with y. Long
outputs are shown in full there. k at the top or j at the bottom takes you
back to the code or on to the next cell, and q returns to the cell.
<localleader>O opens the output in a split instead, and offers to open
images in your image viewer.
Widgets
Click, drag and toggle widgets with the mouse, like in JupyterLab: drag a
slider's handle, click an option, a tab or a button. Or press
<localleader>w to focus the widgets of a cell and use the keyboard:
| key | what it does |
|---|---|
h / l |
decrease / increase, pick the previous / next option |
H / L |
bigger steps |
j / k |
next / previous widget |
Space / Enter |
toggle, click, or open the choices |
i |
type a value |
Esc |
done |
Changes reach the kernel immediately, so @interact functions re-run as you
move a slider.
Saving keeps the widgets' state in the notebook, the way JupyterLab does, so
reopening it (here or in JupyterLab) shows the widgets before any cell runs.
Until the cell runs again they're only a picture of their last state, from
an earlier kernel: they're grayed out, marked ⟲ saved with the notebook,
and clicking or focusing them says so instead of changing anything.
Supported: sliders (also logarithmic, range and selection sliders), progress
bars, number and text boxes, checkboxes, buttons, toggle buttons, dropdowns,
radio buttons, single and multiple selections, combo boxes, color, date and
time pickers, tags, Valid, Play (it runs here, like in a browser), labels
and HTML, images (drawn like image outputs), boxes, tabs, accordions and
stacks, output widgets, and jslink / jsdlink. Other widget types show
their name.
Language server
The usual LSP keys (K, grn, gra, grr, completion, …) work in code
cells. The language server sees your cells the way Jupyter runs them: a bare
expression at the end of a cell isn't flagged as useless (it's the cell's
result), and import rules apply to each cell separately.
Edits from the server — code actions, "fix all", organizing imports, rename,
auto-imports — are applied to the code cells they touch, and only there: a
change that would reach a markdown cell, the lines between cells or a
%magic line is left out. Formatting (vim.lsp.buf.format(), or your
format-on-save) works the same way, so each cell is formatted on its own,
with magics and markdown left alone.
Commands
Everything is also available as :Ipynb <command>: run, run_all,
run_above, interrupt, restart, restart_and_run_all, shutdown,
start, connect [kernel-id|connection-file], clear, clear_all, output,
output_split, widget, fold, fold_all, unfold_all, fold_output,
add_below, add_above, delete, toggle, split, join, redraw,
imgtest and info.
Configuration
Call setup only for what you want to change. These are the defaults:
require("ipynb").setup({
python = nil, -- Python for the kernel bridge (Nix sets this for you)
default_kernel = "python3", -- kernel for notebooks that don't name one
connect_existing = true, -- use a running Jupyter server (see below)
collaboration = true, -- edit together with JupyterLab's real-time collaboration (see below)
auto_start = true, -- start the kernel as soon as a notebook opens
shutdown_on_exit = true, -- stop kernels of our own when Neovim exits
max_output_lines = 40, -- longer outputs are cut (see <localleader>o)
mouse = true, -- click, drag and toggle widgets with the mouse
highlight = true, -- syntax highlighting
filetype = "markdown", -- see "Markdown plugins" below
timing = {
show = true, -- run time and finish time in the cell header
save = true, -- keep them in the notebook (like JupyterLab)
},
image = {
backend = "auto", -- "auto", "ui.img", "kitty" or "none"
max_height = 20, -- in lines
max_width = nil, -- in columns (default: the window width)
},
lsp = {
enabled = true,
mask_magics = true, -- hide %magic and !shell lines from the server
-- diagnostics that don't apply to a cell's final expression
cell_result_codes = { "B018", "reportUnusedExpression", "W0104", "pointless-statement", "expression-not-assigned" },
},
keymaps = { -- set any of these to false to turn it off
next_cell = "<C-j>",
prev_cell = "<C-k>",
run_cell = "<C-CR>",
run_cell_and_next = "<S-CR>",
run_all = "<localleader>ra",
run_above = "<localleader>rk",
interrupt = "<localleader>ki",
restart = "<localleader>kr",
restart_and_run_all = "<localleader>kR",
shutdown = "<localleader>kq",
clear_output = "<localleader>c",
clear_all_outputs = "<localleader>C",
add_cell_below = "<localleader>b",
add_cell_above = "<localleader>a",
delete_cell = "<localleader>d",
toggle_cell_type = "<localleader>m",
split_cell = "<localleader>s",
join_cells = "<localleader>J",
fold_toggle = "za",
fold_open = "zo",
fold_close = "zc",
fold_close_all = "zM",
fold_open_all = "zR",
fold_output = "<localleader>z",
fold_all_outputs = "<localleader>Z",
focus_output = "<localleader>o",
open_output = "<localleader>O",
widget = "<localleader>w",
undo = "u", -- undo/redo that skip JupyterLab's changes (with real-time collaboration)
redo = "<C-r>",
},
})
To show the kernel's state in your statusline:
vim.o.statusline = "%f %m%=%{v:lua.require'ipynb'.status()} "
-- shows e.g. "python3 ● busy"
The colours follow your colorscheme: cells are a tint of its background, and
the cell under the cursor takes its accent (the colour of Function) — an
accent header, an accent-tinted background, a bar along its output and
accent line numbers. To change them, override the Ipynb* highlight groups
(IpynbHeader, IpynbHeaderActive, IpynbCodeCell, IpynbCodeCellActive,
IpynbMarkdownActive, IpynbActiveBar, IpynbActiveNumber,
IpynbOutputRule, IpynbFolded, …).
Good to know
Images need a terminal that speaks the kitty graphics protocol: kitty,
ghostty or Rio. Other terminals are asked whether they support it. Inside tmux,
enable set -g allow-passthrough on. In other terminals images show up as a
one-line placeholder, and <localleader>O opens them in your image viewer.
While a floating window or the completion menu covers a plot, the plot steps
aside so the popup stays readable. A plot that's only partly scrolled into
view appears once it's fully visible.
<C-CR> and <S-CR> only reach Neovim if your terminal reports modified
Enter keys (kitty, WezTerm, foot, Ghostty, Rio, Alacritty; iTerm2 with the
option enabled; tmux with extended-keys). :Ipynb run always works.
Markdown plugins like markview.nvim and render-markdown.nvim work out of
the box, because the notebook is shown as a markdown document. The flip side
is that anything you've set up for markdown files also applies to notebooks —
a markdown language server or a markdown formatter, for example. If you don't
want that, set filetype = "ipynb" and add "ipynb" to your markdown
plugin's file types.
Working alongside JupyterLab. When a notebook is open in JupyterLab, both share one kernel, so variables defined in one are available in the other. Without real-time collaboration, each side only shows the outputs of the cells it ran itself, and JupyterLab only sees what you saved here when you reload the notebook (or save it there: it then asks which version to keep).
JupyterLab with real-time collaboration (jupyter-collaboration) is
detected, and ipynb.nvim joins the notebook like another collaborator, with
or without a kernel. Changes go both ways as they're made: what you type
here, the cells you run ([*], outputs as they come) show in JupyterLab;
what's typed, added, deleted or moved there, and its outputs, show here. When
both sides changed the same cell, the changes are merged line by line; where
both changed the same lines, the version from here wins. While you're in
insert mode, JupyterLab's changes to the text wait until you leave it. u
and <C-r> undo and redo your own changes only, leaving JupyterLab's in
place. :w hands the notebook to the server, which saves it (the server also
saves on its own a moment after each change, from either side). This needs
jupyter_ydoc for the bridge's Python (the Nix package has it). Without it,
or with collaboration = false, :w writes the file, and JupyterLab reloads
the notebook from it.
Kernels in a Jupyter server. When a Jupyter server you run (JupyterLab,
jupyter server, your JupyterHub server) serves the notebook's directory,
ipynb.nvim uses it: it connects to the server's kernel for the notebook, or
starts one there, as if you opened the notebook in JupyterLab. Such a kernel
belongs to the server — closing the notebook, quitting Neovim or losing your
SSH connection leaves it running, and opening the notebook again picks it up
where you left. <localleader>kq (:Ipynb shutdown) shuts it down. Set
connect_existing = false to always start kernels of your own.
Known limitations
- Third-party widget types (and
FileUpload,Audio,Video,Controller) show up as a placeholder. - HTML outputs are shown as plain text, LaTeX as source.
- Copying several lines across a cell boundary also copies the hidden lines that separate cells. That's how whole cells get copied and pasted, but it can be surprising.
How it works
For the curious, and for anyone who wants to contribute:
the notebook buffer a hidden Python buffer
┌──────────────────────────────┐ ┌──────────────────────────────┐
│<!-- cell:markdown id=… --> │ │ │
│# Title │ │ │
│<!-- cell:code id=… --> │ synced │ │
│```python │ ───────▶ │ │
│import numpy as np │ │import numpy as np │
│``` │ │ │
└──────────────────────────────┘ └──────────────────────────────┘
▲ a small LSP proxy ▲ your language servers
└──────── forwards requests ───────────────┘
python -m ipynb_bridge ── jupyter_client ──▶ kernel
- The notebook is a markdown document in the buffer. Markdown cells are plain markdown, code cells are fenced code blocks, and each cell starts with a small marker comment. Markers and fences are hidden, and headers and outputs are drawn as virtual lines. Because the cells are ordinary text, undo, yank/paste and macros just work. If an edit breaks the structure (say, a deleted fence), it's repaired within the same undo step.
- Language servers see a hidden Python buffer that contains only the code, on the same line numbers. A small proxy forwards requests from the notebook to your servers and merges answers when you run several (e.g. ty and ruff). Edits coming back are applied to a copy of the hidden buffer, compared with it line by line, cell by cell, and only the changed lines inside code cells reach the notebook.
- The kernel is driven by a small Python program (
python/ipynb_bridge) that talks to Neovim over stdin/stdout and to the kernel throughjupyter_client. To find JupyterLab's kernel for a notebook, or to start one in a server, it uses the REST API of the running Jupyter servers (found through their files in Jupyter's runtime directory). - Real-time collaboration: a second process (
collab.py, withjupyter_ydoc/pycrdt) joins the notebook's room over a websocket. Both sides remember the version of each cell they last agreed on: nvim sends the cells it changed since, the room applies them unless it changed the same field meanwhile, and reports its own changes, which nvim merges (three-way,lua/ipynb/merge.lua) into the buffer with as small edits as possible, as undo steps thatuskips. - Images are drawn with Neovim 0.13's
vim.ui.img, or with kitty's Unicode placeholders on Neovim 0.12. - Workarounds: a few Neovim redraw bugs around hidden lines are worked
around;
tests/repro/has small standalone reproductions.
Performance
Two benchmarks open generated notebooks and time what you do in them. In the notebooks, every third cell is markdown; the others have 15 lines of code and 30 lines of coloured output, and every tenth also shows a plot. Times are medians, measured on Neovim 0.12.5 on a laptop (Ryzen 5 7640U).
bench-ui (bench/ui.sh) runs Neovim's terminal UI, so redraws and
treesitter highlighting are included. Times are until the screen is drawn,
and in brackets the CPU used, which also counts what the highlighter parses
in the background afterwards. Opening is timed with the plugin already
loaded:
| 50 cells | 200 cells | 1000 cells | |
|---|---|---|---|
| Open the notebook | 30 ms (50 ms CPU) | 36 ms (105 ms CPU) | 83 ms (308 ms CPU) |
| Type a character | 7.9 ms (8.1 ms CPU) | 7.2 ms (7.9 ms CPU) | 22 ms (22 ms CPU) |
| Move to another cell | 7.4 ms (7.4 ms CPU) | 6.5 ms (6.5 ms CPU) | 13 ms (13 ms CPU) |
| Scroll a page | 5.5 ms (5.5 ms CPU) | 4.7 ms (4.7 ms CPU) | 3.9 ms (3.9 ms CPU) |
bench (nvim --headless --clean -l bench/run.lua) measures the plugin's own
work without a UI, and streams output from a real kernel:
| 50 cells | 200 cells | 1000 cells | |
|---|---|---|---|
| Open the notebook | 16 ms | 31 ms | 63 ms |
| Type a character | 0.7 ms | 2.1 ms | 9.3 ms |
| Move to another cell | 0.6 ms | 2.4 ms | 6.1 ms |
| Running-cell timer tick | 0.1 ms | 0.6 ms | 3.6 ms |
| Stream output (median of 500 messages) | 0.5 ms | 1.0 ms | 4.0 ms |
| Save | 2.6 ms | 8.9 ms | 45 ms |
A real kernel printing and flushing 3000 lines (about 0.9 s, the kernel's own pace):
| Cells | Messages | Redraws | Average redraw |
|---|---|---|---|
| 4 | 3030 | 1747 | 0.4 ms |
| 200 | 3027 | 589 | 1.2 ms |
What keeps these low:
- Only changed cells are redrawn. Each cell keeps its marks (header, output, hidden lines, background) and is redrawn when what it shows changes, or when an edit moved them. Marks move with the text, so typing redraws one cell.
- Outputs are rendered when they're on screen. Opening a notebook renders
the outputs near the window; the rest follow in the background, a few at a
time. Only the lines that are shown are rendered (
max_output_lines). - Treesitter only looks at the visible rows. The notebook uses Neovim's
markdown queries with two changes. The highlights don't hide code fences
with
conceal_lines(the plugin hides them itself): with it, every change makes Neovim re-check each visible row, and each check parses the code cells of the whole notebook again. And the injections have noinjection.combined(for HTML blocks, which the cell markers are): one combined injection makes every parse scan the whole notebook instead of the visible rows. - Saving uses Neovim's JSON encoder (written in C) with
indentandsort_keys, fixed up to write exactly what Jupyter writes: the few floats it would round differently and the DEL character.
Rewriting parts in Rust was considered and rejected: the time went into redrawing and parsing more than needed, and Rust would have had to hand Neovim the same virtual lines. What still grows with the notebook is mostly reading and writing its JSON, which happens in C.
Development
The development environment comes from devenv: run
devenv shell --no-tui or direnv allow. It has Neovim 0.13 nightly (and
0.12 as nvim-stable), a Python with Jupyter, widgets and matplotlib, ty,
ruff, the formatters, treesitter parsers, markview.nvim, render-markdown.nvim
and the ayu colorscheme.
dev # open examples/demo.ipynb with the dev config
unit [filter] # run the tests on Neovim nightly
unit-stable # … and on Neovim 0.12
bench [sizes] # time common actions on generated notebooks
bench-ui [sizes] # … with Neovim's terminal UI, redraws included
lab # start JupyterLab next to the examples
media # record the demo and the screenshots (Rio, in a headless compositor)
nix build # build the plugin
nix flake check # run the tests in the Nix sandbox
IPYNB_DEV_MARKDOWN=markview|render-markdown|none picks the markdown plugin
for dev, and IPYNB_DEV_THEME=ayu-dark|ayu-mirage|ayu-light the theme.
media runs Rio (with its config in docs/rio.toml: no title or tab bar)
in cage with a headless 2560x1440
output at scale 2 (sharp when zoomed; nothing shows on your screen), where
Neovim plays docs/record.lua on copies of the notebooks in examples/,
with the keyboard only: it runs cells, opens documentation, moves widgets,
selects in an output and folds, and takes the screenshots (grim) and the
recording (wf-recorder, lossless), which end up in docs/screenshots as
lossless WebP. Plots are made at twice the resolution (matplotlib's
retina format), like the screen.
Use
devenv shell --no-tui(or direnv). devenv's default interactive screen sits between your terminal and the shell, and it doesn't let images through — not for this plugin, and not for any other program.
The tests are plain Lua files in tests/. Tests that need something that
isn't installed (a kernel, ty, a markdown plugin) skip themselves. CI runs the
format checks and both test suites on every push.








