No description
  • Lua 86.5%
  • Python 11.1%
  • Nix 2.3%
  • Shell 0.1%
Find a file
Kasper J. Seweryn ff622b3abc
Some checks failed
Test / test (push) Failing after 5m14s
fix: moving to the cell below a long output
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>
2026-10-20 18:27:13 +02:00
.forgejo/workflows feat: nixvim module, plugins.ipynb 2026-10-19 01:09:14 +02:00
bench bench: time opening without loading the plugin 2026-10-17 19:27:17 +02:00
dev build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00
docs docs: screenshots and recording made by media, keyboard only 2026-10-18 13:15:33 +02:00
examples docs: screenshots and recording made by media, keyboard only 2026-10-18 13:15:33 +02:00
lua/ipynb fix: moving to the cell below a long output 2026-10-20 18:27:13 +02:00
nix feat: sync with JupyterLab's real-time collaboration rooms 2026-10-20 07:33:49 +02:00
plugin build!: require Neovim 0.12 2026-10-17 19:50:15 +02:00
python/ipynb_bridge feat: two-way live sync with JupyterLab's real-time collaboration 2026-10-20 12:40:29 +02:00
tests fix: moving to the cell below a long output 2026-10-20 18:27:13 +02:00
.envrc build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00
.gitignore build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00
devenv.lock build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00
devenv.nix feat: sync with JupyterLab's real-time collaboration rooms 2026-10-20 07:33:49 +02:00
devenv.yaml build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00
flake.lock build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00
flake.nix feat: nixvim module, plugins.ipynb 2026-10-19 01:09:14 +02:00
LICENSE docs: readme with screenshots, keybindings and license 2026-10-17 16:31:08 +02:00
README.md feat: two-way live sync with JupyterLab's real-time collaboration 2026-10-20 12:40:29 +02:00
stylua.toml build: nix flake package and devenv environment 2026-10-17 16:31:09 +02:00

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.

Running cells, an image output, documentation from the language server, widgets changed with the keyboard, selecting in an output, and folds

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 @interact all 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 and outputs Image output
Cells with their outputs; the cell under the cursor stands out A matplotlib plot right under its cell
Widgets LSP hover
An @interact slider moved with the keyboard Documentation from the language server
Output focus Folds
Selecting text in an output A folded cell and a folded output
markview.nvim render-markdown.nvim
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 through jupyter_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, with jupyter_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 that u skips.
  • 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 no injection.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 indent and sort_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.

License

GPL-3.0-or-later