jovian.nvim turns Neovim into a Jupyter-like environment for Python. Edit
.py files using the # %% cell format, execute code against a live IPython
kernel, and view rich output — text, tables, errors, and plots — inline below
each cell or in a side-by-side preview window, without leaving your editor.
Execution is powered by jovian-core, a small Rust backend that speaks the
Jupyter wire protocol directly. No libzmq, no pyzmq, no Python bridge
process — just a single static binary talking msgpack-RPC to Neovim.
- Features
- Try with Nix
- Requirements
- Installation
- Usage Guide
- Visual Features
- Recommended Keybindings
- Configuration
- How it Works
- Command Reference
- Customization
- Roadmap
- Acknowledgements
| Feature | Description |
|---|---|
| 🦀 Rust core | A single jovian-core binary speaks the Jupyter wire protocol directly over msgpack-RPC. No libzmq/pyzmq/Python-bridge dependencies. |
| 🚀 Low latency | Streamed stdout/stderr, cell status, and completions come straight off the kernel's IOPUB/SHELL sockets. |
| 👀 Live preview | Rich output (text, tables, errors, plots) in a side-by-side window, rendered from a structured output store. DataFrames keep every column supplied by Pandas and scroll horizontally. |
| 🃏 Inline cell cards | Optional bordered "cards" around each cell with output rendered directly beneath it, à la notebook UIs. |
| 📝 Markdown cells | Optional styling for # %% [markdown] cells: headings, bold/italic, code spans, bullets, and aligned tables. |
| 🖼️ Inline plots | Image MIME output such as Matplotlib PNGs rendered in-terminal via the Kitty graphics protocol (Kitty, Ghostty, recent WezTerm). |
| 📊 Variables & DataFrames | :JovianVars and :JovianView inspect the live namespace and paginate DataFrames. |
| ⚡ Quick eval / REPL | Try throwaway code against the kernel without polluting In[]/Out[] history. |
| 🔁 Persistent outputs | Cell output is cached to an nbformat-shaped JSON sidecar, so results survive restarts. |
| ☁️ Remote kernels | Run the kernel on an SSH host; jovian-core tunnels its ZMQ ports back to localhost. Key/agent auth. |
| 🪄 Magic commands | %timeit, !ls, and other IPython magics work, with LSP false-positives suppressed. |
No install needed. If you have Nix, the flake bundles the
prebuilt jovian-core binary and a minimal Python environment:
nix run github:m-tky/jovian.nvim -- demo_jovian.pyThe demo launches with the inline cell cards, markdown styling, and inline output features all enabled so you can see everything at once.
Tip
For Nix / NixOS users: everything below — the Rust binary, the Python environment, and a Kitty-capable terminal — is handled by the flake. See Installation (Nix).
A Python 3.10+ environment containing:
ipykernel— the kernel jovian connects to.ipython— normally installed alongsideipykernel; jovian's REPL evaluates against this same live kernel and does not requirejupyter-console.
That's it for the kernel. There is no pyzmq, jupyter-client, or
jupyter-console requirement anymore — jovian-core talks to the kernel
directly.
By default jovian auto-resolves a usable Python at startup — you don't need
to register a Jupyter kernel.json. It probes the following sources in
order and picks the first one that can import ipykernel:
$JOVIAN_PYTHON(explicit env var, used as-is)- PATH
python3/python— the typicalnix develop, asdf, or system case: drop into a shell that has the right Python, then launch Neovim $VIRTUAL_ENV/bin/python— active venv$CONDA_PREFIX/bin/python— active conda env./.venv/bin/python,./venv/bin/python— project-local venvs (poetry, uv, vanilla)
If nothing matches, jovian falls back to the bare string python3 and the
Rust core looks for a registered Jupyter kernelspec as a last resort.
To pin a specific interpreter, set python_interpreter explicitly in
setup() — the resolver is then skipped and your value is used verbatim
(:checkhealth jovian warns if ipykernel is missing instead of
overriding your choice). To switch at runtime, run :JovianPickPython and
choose from the detected pythons + registered kernelspecs; the kernel is
restarted with the new selection.
The native backend. You get it one of three ways, in order of preference:
- Prebuilt download (default): the plugin's
buildhook downloads the release binary matching your platform (Linux/macOS, x86_64/aarch64). - Built from source: if no prebuilt matches, the hook runs
cargo build --release— this needs a Rust toolchain. - Nix: the flake drops the binary in place; nothing to download or build.
- A Kitty-graphics terminal (Kitty, Ghostty 1.3+, recent WezTerm) — required for inline plot rendering. Without one, images are skipped; text output is unaffected.
- nvim-treesitter — for code highlighting and markdown injection inside cells.
Using Lazy.nvim
The build hook installs jovian-core (prebuilt download, or cargo fallback):
{
"m-tky/jovian.nvim",
build = function(plugin)
require("jovian.install").run(plugin)
end,
config = function()
require("jovian").setup({
-- python_interpreter is auto-resolved by default — set it
-- explicitly only to pin a specific interpreter (absolute
-- path skips Jupyter kernelspec lookup entirely).
-- python_interpreter = "/path/to/your/python",
-- Opt-in visuals (all off by default):
cell_frame = true, -- bordered cell cards
markdown_cell_style = true, -- styled markdown cells
inline_outputs = true, -- output rendered below cells
})
end,
}The recommended way on Nix is the overlay, which adds jovian-nvim (with
the binary bundled) to pkgs.vimPlugins.
# Example integration in your flake.nix
{
inputs.jovian.url = "github:m-tky/jovian.nvim";
outputs = { nixpkgs, jovian, ... }: {
# 1. Apply the overlay
pkgs = import nixpkgs {
inherit system;
overlays = [ jovian.overlays.default ];
};
# 2. Add it to your Neovim plugins like any other plugin
# Example using Home Manager:
programs.neovim.plugins = [
pkgs.vimPlugins.jovian-nvim
];
# 3. (Optional) Use the provided minimal Python environment
# require("jovian").setup({
# python_interpreter = "${pkgs.jovian-minimal-python}/bin/python3",
# })
};
}Jovian provides context-aware completion powered by the kernel — dictionary
keys, DataFrame columns, and dynamic attributes that a static LSP can miss. It
is registered as omnifunc (trigger with <C-x><C-o>).
Integration with blink.cmp:
require('blink.cmp').setup({
sources = {
default = { 'lsp', 'path', 'snippets', 'buffer', 'jovian' },
providers = {
jovian = {
name = 'Jovian',
module = 'blink.cmp.sources.omnifunc',
enabled = true,
}
}
}
})- Define cells with
# %%. IDs (# %% id="...") are generated on first run. :JovianStartconnects a kernel (or it starts lazily on first run).:JovianRunruns the current cell;:JovianRunAndNext,:JovianRunAll,:JovianRunAbove,:JovianRunLine, and:JovianSendSelectioncover the rest.- No preview or side panel is required to execute code. Open
:JovianOpenonly when you want the preview/pinned-output panels; results and status still persist without them. - Cell headers show virtual-text status:
Running…,Done,Error,Stale.
:JovianVars— show the live namespace in a floating window.:JovianView [var]— inspect a DataFrame (paginated, 50 rows/page; use<PageDown>/<PageUp>).- DataFrame results below a cell are a compact summary: visible headers are
never abbreviated, values may be, and columns are reselected when the source
window is resized. The preview keeps all columns supplied by Pandas and uses
normal horizontal buffer scrolling (
zl/zh). - Pandas may omit middle columns before jovian receives the HTML result (the
familiar
…column). That matches JupyterLab behavior; setpd.set_option("display.max_columns", 100)in your kernel when you want a larger, bounded MIME representation. Re-run the cell after changing it.
:JovianEval [code]runs a one-off expression against the kernel with history disabled — it sees all your variables but doesn't bumpIn[]/Out[]or get recorded.:JovianREPL, or pressingi/ein the Output window, opens an interactive loop on top of the same mechanism: type, run, repeat. An empty line exits. Nojupyter consoleneeded.
By default the Output (REPL) window is on-demand — toggle it with
:JovianToggleOutput. Output still accumulates in the background, so the full
cross-cell log and live \r progress bars (tqdm) are there when you open it.
Set output_window = "always" to dock it, or "off" to drop it entirely.
Run the kernel on a remote host while editing locally. jovian-core starts the
kernel over SSH and tunnels its ZMQ ports back to localhost, so everything
else (output, plots, completion) works identically.
:JovianConnect— pick a host from~/.ssh/config(or Tailscale), then enter the remote python and working directory. The host becomes active. (:JovianAddHostregisters a host manually;:JovianUse <name>switches.):JovianRun— the kernel launches on the remote and output streams back.:JovianSync [path]—rsyncyour local files to the remote working dir.:JovianTunnelStatus— show the active host and whether the kernel is up.
Requirements: key- or ssh-agent-based auth (no interactive password
prompt), and ipykernel installed in the remote python. Closing Neovim or
:JovianRestart tears the remote kernel down cleanly.
Three independent, opt-in rendering layers. Enable any combination:
require("jovian").setup({
cell_frame = true, -- ┌─ [3] Code ──┐ card borders around cells
markdown_cell_style = true, -- conceal/style # %% [markdown] cells
inline_outputs = true, -- render kernel output below each cell
})cell_framedraws a bordered card around each cell (code vs. markdown cells get distinct border colors). It shifts the right edge of cell lines, so it's off by default.markdown_cell_styleconceals markdown punctuation (#,**,`) in# %% [markdown]cells and renders headings, bold/italic, code spans, bullets, and box-drawn tables. The raw source re-appears on the cursor line while you edit. It also renders inline images in markdown cells: adata URI (e.g. a screenshot pasted into a notebook and exported with jupytext — the long base64 is hidden) or a localfile path. Images need a Kitty-graphics terminal; without one the base64 is still hidden so it never shows as raw text.inline_outputsrenders each cell's output (stdout/stderr, text results, error tracebacks, and Kitty images) as virtual lines beneath the cell. Long text output is elided toinline_output_max_lines(default 20) — the full text stays in the preview pane and Output window. DataFrames use a compact, resize-aware column summary; use the preview for horizontal scrolling. Requirescell_frame.
Toggle at runtime with :JovianToggleCellFrame and :JovianToggleMarkdownStyle.
local map = vim.keymap.set
-- Execution
map("n", "<leader>r", "<cmd>JovianRun<CR>", { desc = "Run Cell" })
map("n", "<leader>x", "<cmd>JovianRunAndNext<CR>", { desc = "Run & Next" })
map("n", "<leader>R", "<cmd>JovianRunAll<CR>", { desc = "Run All Cells" })
-- UI
map("n", "<leader>jo", "<cmd>JovianOpen<CR>", { desc = "Open UI" })
map("n", "<leader>jt", "<cmd>JovianToggle<CR>", { desc = "Toggle UI" })
map("n", "<leader>jv", "<cmd>JovianVars<CR>", { desc = "Variables" })
map("n", "<leader>je", "<cmd>JovianEval<CR>", { desc = "Quick eval" })
-- Navigation
map("n", "]c", "<cmd>JovianNextCell<CR>", { desc = "Next Cell" })
map("n", "[c", "<cmd>JovianPrevCell<CR>", { desc = "Prev Cell" })Click to expand the full configuration options
require("jovian").setup({
-- Visuals
flash_highlight_group = "Visual",
flash_duration = 300,
float_border = "rounded", -- single | double | rounded | solid | shadow
-- Python — nil auto-resolves a usable interpreter. An explicit value is
-- used verbatim; $JOVIAN_PYTHON wins when no explicit value is set.
python_interpreter = nil,
-- Or pin a registered kernelspec. python_interpreter takes precedence.
kernel_name = nil,
-- Behavior
notify_threshold = 10, -- seconds before a long run notifies
notify_mode = "all", -- "all" | "error" | "none"
show_execution_time = true,
folding = false, -- cell-based folds for Python files
dataframe_page_size = 50,
remote_cwd = ".",
default_keymaps = false, -- opt in to jovian's buffer-local defaults
-- Output (REPL) window: "ondemand" (default) | "always" | "off"
output_window = "ondemand",
-- `plt.show()` display: "inline" (Jovian/terminal) or "native" (OS GUI).
-- :JovianTogglePlot switches modes and restarts the kernel.
plot_mode = "inline",
native_plot_backend = "TkAgg", -- e.g. "QtAgg" or "MacOSX"
-- Opt-in visual layers (all off by default)
cell_frame = false,
cell_frame_style = "square", -- "square" | "rounded"
cell_frame_priority = 100,
cell_frame_right_pad = 0, -- reserve N columns to the right of the
-- cell frame for scrollbar plugins
-- (nvim-scrollbar / nvim-scrollview)
markdown_cell_style = false,
table_border = "round", -- "round" | "none" | "heavy" | "double"
math = { enabled = true, position = "center", converter = nil },
inline_outputs = false, -- requires cell_frame
inline_output_max_lines = 20, -- elide longer inline text output
-- Inline image placement (terminal cells)
image_rows = 14,
image_cols = 56,
-- Preview-pane image sizing (parses PNG/GIF headers; never upscales)
preview_cell_pixel_height = 16,
preview_cell_pixel_aspect = 0.5,
preview_image_max_cols = nil, -- nil = fill the preview text area
preview_image_max_rows = nil,
-- Default persistent panels. Variables and Output are intentionally not
-- here: Variables is a float via :JovianVars, Output is governed by
-- `output_window` above.
ui = {
cell_separator_highlight = "text", -- "line" | "text" | "none"
-- winblend = 0, -- optionally override Neovim's float transparency
layouts = {
{
elements = {
{ id = "preview", size = 0.70 },
{ id = "pin", size = 0.30 },
},
position = "left", -- "left" | "right" | "top" | "bottom"
size = 0.35,
},
},
},
-- Cell status virtual text
ui_symbols = {
running = " Running...",
done = " Done",
error = " Error",
interrupted = " Interrupted",
stale = " Stale",
},
-- Magic commands
suppress_magic_command_errors = true,
-- Per-group highlight overrides — see Customization below. Keys include
-- cell_border_code / cell_border_markdown, md_text / md_h1 … md_h6,
-- md_bold / md_em / md_code / md_bullet / md_quote / md_table_divider /
-- md_table_header / md_math, and out_divider / out_stdout / out_stderr /
-- out_result / out_error.
highlights = {},
}):JovianRun
→ core.send_cell()
→ jovian-core (Rust) execute_request over the SHELL socket
├─ IOPUB stream/status/result/error events
│ → msgpack-RPC notifications → handlers → UI (inline / preview / pin)
└─ outputs mirrored to .jovian_cache/<filename>/outputs.json (sidecar)
jovian-core(Rust, incore/) spawns and owns the IPython kernel, signs messages with HMAC-SHA256, and speaks the Jupyter v5 wire protocol over pure-Rust ZeroMQ. It exposes a small msgpack-RPC API over stdio.- Neovim spawns the binary via
vim.uvpipes and exchanges length-prefixed msgpack frames (request/response/notification). - Outputs are stored in an
nbformat-shaped JSON sidecar at.jovian_cache/<filename>/outputs.json, keyed by cell ID. The inline, preview, and pin renderers all read from this structured store — output is not tied to a markdown file format. Inline DataFrames are compacted to the source window; preview DataFrames arenowrapbuffers with horizontal scrolling. - Images are transmitted to the terminal via the Kitty graphics protocol (Unicode placeholder mode); Neovim only places the placeholder glyphs.
- Remote kernels are the same path with
sshstanding in for the localpython: the remote picks its own free ports and writes the connection file, then a singlessh -L …process both forwards those ports tolocalhostand runs the kernel — so the ZMQ layer connects to127.0.0.1either way.
Why a .py + # %% source format with IDs? Cell IDs stored in the file let
the output sidecar correlate with specific cells across edits and sessions,
while keeping the source plain, diff-friendly, and source-controllable.
Execution
| Command | Description |
|---|---|
:JovianStart |
Start / connect a kernel |
:JovianRun |
Run current cell |
:JovianRunAndNext |
Run cell and jump to next |
:JovianRunAll |
Run all cells |
:JovianRestartAndRunAll |
Restart kernel, then run all cells |
:JovianRunOnly <tag>... |
Run cells with any of the given tags |
:JovianRunAllExcept <tag>... |
Run cells whose tags don't intersect the given set |
:JovianImport <path.ipynb> |
One-shot: convert a notebook to .py + sidecar and open it |
:JovianExport [path.ipynb] |
Export the current .py buffer to .ipynb |
:JovianRunAbove |
Run all cells above the cursor |
:JovianRunLine |
Run the current line |
:JovianSendSelection |
Run the visual selection |
:JovianRestart |
Restart the kernel |
:JovianInterrupt |
Interrupt execution |
:JovianEval [code] |
Quick-eval an expression (no history) |
:JovianREPL |
Continuous eval prompt in the live kernel |
:JovianPickPython |
Pick a Python interpreter / kernelspec; restarts kernel |
Native
.ipynbediting: just:edit foo.ipynb(ornvim foo.ipynbfrom the shell). The on-disk file stays Jupyter format, but the buffer is the rendered# %%cell view;:wre-serializes back to nbformat. LSP /:JovianRunwork exactly as for.py. Setvim.g.jovian_disable_ipynb_open = 1to opt out.
UI & Layout
| Command | Description |
|---|---|
:JovianOpen / :JovianToggle |
Open / toggle the panels |
:JovianToggleOutput |
Toggle the Output (REPL) window |
:JovianTogglePlot |
Toggle OS-native Matplotlib windows; restarts the kernel |
:JovianToggleVars |
Toggle the Variables pane |
:JovianToggleStatus |
Toggle cell status virtual text |
:JovianToggleCellFrame |
Toggle bordered cell cards |
:JovianToggleInlineOutputs |
Toggle inline outputs across all python buffers |
:JovianToggleCellOutput |
Collapse / expand the cursor cell's inline output |
:JovianToggleMarkdownStyle |
Toggle markdown cell styling |
:JovianPin / :JovianUnpin |
Pin / unpin current cell output |
:JovianTogglePin |
Toggle the pinned output window |
:JovianClearREPL |
Clear the Output buffer |
Cell management
| Command | Description |
|---|---|
:JovianNextCell / :JovianPrevCell |
Navigate cells |
:JovianNewCellBelow / :JovianNewCellAbove |
Insert a cell |
:JovianNewMarkdownCellBelow |
Insert a markdown cell |
:JovianDeleteCell |
Delete the current cell |
:JovianMoveCellUp / :JovianMoveCellDown |
Reorder cells |
:JovianSplitCell |
Split the cell at the cursor |
:JovianMergeBelow / :JovianMergeAbove |
Merge with next / previous cell |
Data & inspection
| Command | Description |
|---|---|
:JovianVars |
Show variables in a float |
:JovianView [var] |
Inspect a variable / DataFrame |
:JovianInspect [expr] |
Show kernel's docstring for the cursor symbol (Jupyter's ?foo) |
Remote / host
| Command | Description |
|---|---|
:JovianConnect |
Pick an SSH/Tailscale host and activate it |
:JovianAddHost / :JovianAddLocal |
Register a host manually |
:JovianUse [name] / :JovianRemoveHost [name] |
Switch / remove host |
:JovianSync [path] |
rsync local files to the remote host |
:JovianTunnelStatus |
Show the active remote host + kernel state |
Cache & diagnostics
| Command | Description |
|---|---|
:JovianClean[!] |
Clean stale / orphaned cache |
:JovianClearCache[!] |
Clear cell output cache |
:JovianClearDiag |
Clear LSP diagnostics |
:JovianDebugImages |
Probe the Kitty image pipeline |
:checkhealth jovian |
Validate dependencies |
Override highlight groups to match your theme. Most groups default to a
fallback chain that picks up your colorscheme's own heading/accent colors, so
they look reasonable out of the box. Override per-group via the highlights
table — a string is treated as a link target, a table as nvim_set_hl
attributes:
require("jovian").setup({
highlights = {
cell_border_code = "Function", -- link
cell_border_markdown = { fg = "#e0af68" }, -- attrs
md_h1 = "@markup.heading.1.markdown",
out_error = "ErrorMsg",
},
})| Area | Groups |
|---|---|
| Cell cards | JovianCellBorderCode, JovianCellBorderMarkdown |
| Markdown cells | JovianMdH1…JovianMdH6, JovianMdBold, JovianMdEm, JovianMdCode, JovianMdBullet, JovianMdQuote, JovianMdTableDivider, JovianMdTableHeader |
| Inline / preview output | JovianOutDivider, JovianOutStdout, JovianOutStderr, JovianOutResult, JovianOutError |
| Floats & panes | JovianFloat, JovianFloatBorder, JovianHeader, JovianSeparator, JovianVariable, JovianType, JovianValue |
| Cell separator | JovianCellMarker |