Self-contained Neovim <-> IPython workspace with a custom WebSocket protocol
Project description
neolab
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.
Install
You need two pieces:
- The Python server (
neolabcommand). - The Neovim plugin (this repo).
1. Install the Python server
Install the server into a tool environment so neolab is on $PATH:
uv tool install neolab # recommended
# or
pipx install neolab
Building from a local checkout:
uv tool install --force . # or: pipx install --force .
2. Add the plugin with lazy.nvim
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,
}
3. Run the Python server
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 DEBUG
Open 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.
Default keymaps
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
},
}
Commands
| 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.
Cell delimiters
# %% 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
Agent-friendly auto-reload
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.
Configuration
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,
},
})
Cell syntax
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 browser
Supported headers:
# %% # code cell
# %% [markdown] # markdown cell
# %% [md] # markdown cell
# %% [raw] # raw/non-executable cell
Markdown 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.
Browser UI
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.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file neolab-0.0.1.tar.gz.
File metadata
- Download URL: neolab-0.0.1.tar.gz
- Upload date:
- Size: 127.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
5b0d6ffc698a75fff1f903c32ebcc0da94090f64da292a58a2dc160a06c6b3f3
|
|
| MD5 |
8ed9f09c8a2c20118d9df604b20a981f
|
|
| BLAKE2b-256 |
1b0d5d20f1b573256a6ec4b53f64e898e9267e04de46bd65fac8bb4d992868a1
|
Provenance
The following attestation bundles were made for neolab-0.0.1.tar.gz:
Publisher:
release.yml on lostcycle/neolab
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
neolab-0.0.1.tar.gz -
Subject digest:
5b0d6ffc698a75fff1f903c32ebcc0da94090f64da292a58a2dc160a06c6b3f3 - Sigstore transparency entry: 1595380984
- Sigstore integration time:
-
Permalink:
lostcycle/neolab@3acaae2c3a08593c189ee51e9fe5f7428d4b7907 -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/lostcycle
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3acaae2c3a08593c189ee51e9fe5f7428d4b7907 -
Trigger Event:
push
-
Statement type:
File details
Details for the file neolab-0.0.1-py3-none-any.whl.
File metadata
- Download URL: neolab-0.0.1-py3-none-any.whl
- Upload date:
- Size: 41.2 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9ba43908029c9eec2de5ac1821b6428fbfda1fc917d376045b14a2102e9802b9
|
|
| MD5 |
276bbbcc929b3428879c0c317a83e328
|
|
| BLAKE2b-256 |
b1d41d6056c5c605cf7ae6c9e534832dee231a3a05a425062080d1d87ba4a43d
|
Provenance
The following attestation bundles were made for neolab-0.0.1-py3-none-any.whl:
Publisher:
release.yml on lostcycle/neolab
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
neolab-0.0.1-py3-none-any.whl -
Subject digest:
9ba43908029c9eec2de5ac1821b6428fbfda1fc917d376045b14a2102e9802b9 - Sigstore transparency entry: 1595381129
- Sigstore integration time:
-
Permalink:
lostcycle/neolab@3acaae2c3a08593c189ee51e9fe5f7428d4b7907 -
Branch / Tag:
refs/tags/v0.0.1 - Owner: https://github.com/lostcycle
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@3acaae2c3a08593c189ee51e9fe5f7428d4b7907 -
Trigger Event:
push
-
Statement type: