A Neovim plugin for Jupytext-style Python files with a live browser output view. Edit cells in Neovim; outputs (plots, DataFrames, errors, markdown) stream to the browser as you run them.
- Multi-file, kernel-backed, fully local.
- Cells follow the jupytext "percent"
format (
# %%,# %% [markdown]). - Intentionally not an
.ipynbworkflow. The source of truth is plain.py. - External edits — coding agents,
git pull, another editor — auto-reload in both Neovim and the browser. - No pandas dependency; uses polars for any tabular rendering.
- Per-file Python kernels, stale-output tracking, and browser-side search controls for exploratory work.
You need two pieces:
- The Python server (
neolabcommand). - The Neovim plugin (this repo).
Install the server into a tool environment so neolab is on $PATH:
uv tool install neolab # recommended
# or
pipx install neolabBuilding from a local checkout:
uv tool install --force . # or: pipx install --force .Drop this in ~/.config/nvim/lua/plugins/neolab.lua (or wherever your lazy
specs live):
return {
"<your-gh-user>/neolab",
ft = "python",
cmd = {
"NeolabPing",
"NeolabRun",
"NeolabRunAndAdvance",
"NeolabRunAll",
"NeolabRunAbove",
"NeolabRunBelow",
"NeolabRunSelection",
"NeolabRunStale",
"NeolabInterrupt",
"NeolabRestart",
"NeolabClear",
"NeolabSync",
},
opts = {
server = { host = "127.0.0.1", port = 9494 },
},
config = function(_, opts)
require("neolab").setup(opts)
end,
}To pin a release: add version = "v0.1.0" (or tag = "v0.1.0"). Until you
tag, lazy tracks the default branch.
Building the server as part of the lazy install (skip step 1):
return {
"<your-gh-user>/neolab",
ft = "python",
build = "uv tool install --force .", -- or: pipx install --force .
cmd = {
"NeolabPing",
"NeolabRun",
"NeolabRunAndAdvance",
"NeolabRunAll",
"NeolabRunAbove",
"NeolabRunBelow",
"NeolabRunSelection",
"NeolabRunStale",
"NeolabInterrupt",
"NeolabRestart",
"NeolabClear",
"NeolabSync",
},
config = function() require("neolab").setup({}) end,
}Start the server in a shell before using the plugin:
neolab # binds 127.0.0.1:9494
neolab --host 0.0.0.0 --port 9494 # remote-reachable
neolab --port 9595 --log-level DEBUGOpen http://127.0.0.1:9494 in your browser. Then open any .py file in
Neovim — the plugin attaches automatically, syncs the file tree, and streams
cell outputs to the browser.
If the server is not running yet, :NeolabPing will retry the configured
WebSocket endpoint.
Buffer-local, applied to Python files only. All are normal-mode.
| Key | Command | What it does |
|---|---|---|
<leader>r |
:NeolabRun |
Execute the cell under the cursor |
<leader>j |
:NeolabRunAndAdvance |
Execute current cell and jump to next cell |
<leader>ra |
:NeolabRunAll |
Execute all code cells |
<leader>rA |
:NeolabRunAbove |
Execute code cells above the cursor |
<leader>rb |
:NeolabRunBelow |
Execute code cells from cursor to EOF |
<leader>rs |
:NeolabRunSelection |
Execute the visual selection |
<leader>rt |
:NeolabRunStale |
Execute cells with stale outputs |
<leader>ri |
:NeolabInterrupt |
Interrupt the current file kernel |
<leader>rk |
:NeolabRestart |
Restart the current file kernel |
<leader>R |
:NeolabClear |
Clear all cell outputs for the current file |
Override or disable per keymap:
opts = {
keymaps = {
execute_cell = "<leader>jr", -- remap
execute_all = "<leader>ja",
clear_outputs = false, -- disable
},
}| Command | Description |
|---|---|
:NeolabPing |
Connect to (or re-check) the server. |
:NeolabRun |
Execute the cell at the cursor. |
:NeolabRunAndAdvance |
Execute the cell at the cursor and jump to next. |
:NeolabRunAll |
Execute all code cells in the current buffer. |
:NeolabRunAbove |
Execute code cells above the cursor. |
:NeolabRunBelow |
Execute code cells from the cursor to EOF. |
:NeolabRunSelection |
Execute the selected source in the file kernel. |
:NeolabRunStale |
Execute cells whose prior outputs are stale. |
:NeolabInterrupt |
Interrupt the current file kernel. |
:NeolabRestart |
Restart the current file kernel. |
:NeolabClear |
Clear all outputs for the current buffer. |
:NeolabSync |
Force a cell re-sync to the server. |
:NeolabCellmarksToggle |
Toggle visual cell delimiters in the current buffer. |
Neovim shows lightweight cell status using signs and virtual text:
runningwhile a cell is executing.In [n]when a cell completed successfully.errorwith a quickfix traceback when execution fails.stalewhen an edited cell or downstream executed cell may no longer match the current source.
# %% headers get a tinted background bar in the buffer plus a horizontal
separator above them. Markdown cells (# %% [markdown]) use a different
tint so they're visually distinct.
Override the highlight groups in your colorscheme config if needed:
NeolabCellDelim(links toCursorLineby default) — code cellsNeolabCellDelimMd(links toVisualby default) — markdown cellsNeolabCellSep(links toNonTextby default) — separator line
When an external process modifies a file you have open:
- Neovim notices via libuv's
fs_eventand runs:checktime— buffers refresh automatically (autoreadis set on attached buffers), and the resultingBufReadPostre-syncs cells to the server. - The server polls tracked-file mtimes on its own and re-broadcasts
file_synced— so the browser updates even if Neovim is closed or unfocused.
Both paths are idempotent; if Neovim already pushed the new content, the server-side watcher sees no diff and stays silent.
Full defaults:
require("neolab").setup({
server = {
host = "127.0.0.1",
port = 9494,
},
keymaps = {
execute_cell = "<leader>r",
execute_cell_and_advance = "<leader>j",
execute_selection = "<leader>rs",
execute_all = "<leader>ra",
execute_above = "<leader>rA",
execute_below = "<leader>rb",
execute_stale = "<leader>rt",
interrupt_kernel = "<leader>ri",
restart_kernel = "<leader>rk",
clear_outputs = "<leader>R",
},
render = {
virtual_line = true,
status_signs = true,
},
cellmarks = {
enabled = true,
separator = "─",
max_width = 120,
show_index = false, -- show cell number at end of `# %%` line
},
sync = {
cursor_debounce_ms = 100,
buffer_debounce_ms = 250,
},
})neolab uses Jupytext's percent format in plain Python files. Code before the first header is treated as an implicit first code cell.
import polars as pl
# %%
print("first explicit cell")
# %% [markdown]
# # A heading
# Some narrative. **Bold**, _italic_, `code`, [links](https://example.com).
# %%
df = pl.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
df # repr renders as a styled HTML table in the browserSupported headers:
# %% # code cell
# %% [markdown] # markdown cell
# %% [md] # markdown cell
# %% [raw] # raw/non-executable cellMarkdown cells are written as Python comments and rendered in the browser:
# %% [markdown]
# # Heading
# Narrative text with **formatting** and `inline code`.Outputs are not saved into the source file. The .py file remains clean,
diffable, and agent-friendly.
The browser is an output cockpit, not an editor:
- File tree for project files and read-only viewers for markdown, CSV, TSV, Parquet, JSON, YAML/TOML/text/log files.
- Collapsible cells.
- Search box for filtering rendered cells.
- Keyboard navigation:
j/kmoves between cells,ccollapses/expands,/focuses search. - Image/SVG outputs include zoom, open, save, and copy controls.
- HTML tables get row filtering and clickable column sorting.
MIT — see LICENSE.