{"id":50962694,"url":"https://github.com/sei40kr/jupyter.nvim","last_synced_at":"2026-06-18T16:02:16.379Z","repository":{"id":349148344,"uuid":"1114668152","full_name":"sei40kr/jupyter.nvim","owner":"sei40kr","description":null,"archived":false,"fork":false,"pushed_at":"2026-05-07T16:58:44.000Z","size":178,"stargazers_count":9,"open_issues_count":5,"forks_count":0,"subscribers_count":2,"default_branch":"main","last_synced_at":"2026-05-07T17:36:45.430Z","etag":null,"topics":["ipython","jupyter","jupyter-client","jupyter-notebook","lua","neovim","neovim-plugin","nvim","nvim-plugin","python"],"latest_commit_sha":null,"homepage":null,"language":"Lua","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/sei40kr.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2025-12-11T17:52:21.000Z","updated_at":"2026-05-07T16:58:49.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/sei40kr/jupyter.nvim","commit_stats":null,"previous_names":["sei40kr/jupyter.nvim"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/sei40kr/jupyter.nvim","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sei40kr%2Fjupyter.nvim","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sei40kr%2Fjupyter.nvim/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sei40kr%2Fjupyter.nvim/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sei40kr%2Fjupyter.nvim/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/sei40kr","download_url":"https://codeload.github.com/sei40kr/jupyter.nvim/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/sei40kr%2Fjupyter.nvim/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34497372,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-18T02:00:06.871Z","response_time":128,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["ipython","jupyter","jupyter-client","jupyter-notebook","lua","neovim","neovim-plugin","nvim","nvim-plugin","python"],"created_at":"2026-06-18T16:02:15.366Z","updated_at":"2026-06-18T16:02:16.373Z","avatar_url":"https://github.com/sei40kr.png","language":"Lua","funding_links":[],"categories":[],"sub_categories":[],"readme":"\u003cdiv align=\"center\"\u003e\n\n# jupyter.nvim\n\nThe Jupyter Notebook experience, native to Neovim. Run code, see output,\nand get kernel-backed completion and hover — without leaving the editor.\n\n[![Neovim](https://img.shields.io/badge/Neovim-0.10+-57A143?logo=neovim\u0026logoColor=white\u0026style=flat-square)](https://neovim.io)\n[![Python](https://img.shields.io/badge/Python-3.10+-3776AB?logo=python\u0026logoColor=white\u0026style=flat-square)](https://python.org)\n[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](#license)\n\n\u003c/div\u003e\n\n## Status\n\n\u003e [!IMPORTANT]\n\u003e Early. Phase 1 covers Python, Julia, and R source files using the\n\u003e percent format (`# %%`); Phase 2 adds round-trip conversion with\n\u003e `.ipynb` — opening a notebook expands the JSON into the percent format\n\u003e in the buffer, and `:w` writes it back as JSON with outputs and\n\u003e metadata for unchanged cells preserved.\n\n## Features\n\n- **Cell detection via Treesitter** — `# %%` and `# %% [markdown]` markers\n  parsed from Python, Julia, and R buffers\n  ([`queries/python/jupyter.scm`](queries/python/jupyter.scm),\n  [`queries/julia/jupyter.scm`](queries/julia/jupyter.scm),\n  [`queries/r/jupyter.scm`](queries/r/jupyter.scm)).\n- **Cell execution against a live Jupyter kernel** — code dispatched\n  through a Python remote plugin built on `jupyter_client`.\n- **Virtual-text output rendering** — results, streams, and tracebacks are\n  shown as extmarks below each cell. The buffer is never modified.\n- **Cell navigation and editing** — jump between cells, insert above /\n  below, delete, merge, split.\n- **`.ipynb` round-trip** — opening a notebook expands it into the\n  percent format in the buffer; `:w` writes the JSON back, preserving\n  outputs, cell ids, and metadata for cells whose source has not\n  changed (see [`.ipynb` round-trip](#ipynb-round-trip)).\n- **In-process virtual LSP** — a Lua-`cmd` LSP server registered with\n  `vim.lsp.start` exposes `textDocument/completion` and `textDocument/hover`\n  backed by the kernel's `complete_request` / `inspect_request`. Any\n  LSP-aware client (built-in, nvim-cmp, blink.cmp, …) picks it up through\n  its generic LSP source — no plugin-specific adapter required.\n- **Fully async I/O** — the Python remote plugin runs a single asyncio event\n  loop in a daemon thread; every kernel is a coroutine context inside it.\n  Completion and hover round-trips are non-blocking, so the editor stays\n  responsive while the kernel is busy executing a long-running cell.\n\n## Comparison with [molten-nvim][molten]\n\nmolten-nvim is the closest neighbour — both run code against a Jupyter\nkernel and render outputs in-buffer. A best-effort snapshot at the time of\nwriting; check the project for its current state.\n\n| Feature                       | jupyter.nvim                          | [molten-nvim][molten]    |\n| ----------------------------- | ------------------------------------- | ------------------------ |\n| Jupyter kernel execution      | Yes                                   | Yes                      |\n| Cell detection via Treesitter | Yes                                   | Range-based; pair with NotebookNavigator/jupytext for cells |\n| Virtual-text output rendering | Yes                                   | Yes                      |\n| Inline images / rich MIME     | `image/png` and `image/jpeg` via [snacks.nvim][snacks] (Kitty Graphics Protocol) | Yes (image.nvim)         |\n| Kernel-backed completion      | Yes — generic LSP source              | No                       |\n| Kernel-backed hover           | Yes — generic LSP source              | No                       |\n| `.ipynb` round-trip           | Yes — load expands to percent, save writes JSON | Via jupytext             |\n| Multi-buffer / multi-kernel   | Yes (one kernel per buffer)           | Yes                      |\n| Non-blocking completion/hover | Yes — async RPC, editor stays responsive while a cell is running | N/A (no kernel completion) |\n\n**TL;DR:** molten-nvim is the more feature-complete option today, especially\nif you need inline images. jupyter.nvim's distinguishing bet is exposing\nkernel-backed completion and hover through an in-process LSP server — any\nLSP-aware client (built-in, nvim-cmp, blink.cmp, …) picks them up for free,\nand they're served by a fully async rplugin so the editor stays responsive\neven while a cell is executing.\n\n[molten]: https://github.com/benlubas/molten-nvim\n[snacks]: https://github.com/folke/snacks.nvim\n\n## Requirements\n\n- Neovim with `vim.lsp.start` (recent stable release).\n- Python 3.10+ available to Neovim's `python3` provider, with:\n  - `pynvim`\n  - `jupyter_client`\n- After installing or updating the plugin, run `:UpdateRemotePlugins` and\n  restart Neovim so the Python remote plugin's manifest is picked up.\n- *Optional, for inline images:* [`folke/snacks.nvim`][snacks] and a\n  terminal that supports the [Kitty Graphics Protocol][kitty-graphics]\n  (kitty, ghostty, wezterm). When either is missing, image outputs fall\n  back to their `text/plain` representation.\n\n[kitty-graphics]: https://sw.kovidgoyal.net/kitty/graphics-protocol/\n\n## Installation\n\nWith [lazy.nvim](https://github.com/folke/lazy.nvim):\n\n```lua\n{\n  \"sei40kr/jupyter.nvim\",\n  build = \":UpdateRemotePlugins\",\n  opts = {},\n}\n```\n\n## Quick start\n\n```lua\nrequire(\"jupyter\").setup({\n  -- Skip the kernelspec picker by pinning a default.\n  default_kernel = \"python3\",\n})\n```\n\nThen open a Python file with cell markers:\n\n```python\n# %% [markdown]\n# # Demo\n\n# %%\nprint(\"hello from the kernel\")\n\n# %%\nimport math\n[math.sqrt(n) for n in range(1, 6)]\n```\n\n1. `:lua require(\"jupyter\").start_kernel()` — pick a kernelspec (or pass one:\n   `require(\"jupyter\").start_kernel(\"python3\")`).\n2. Place the cursor inside a cell and call `require(\"jupyter\").execute_cell()`.\n3. Output appears below the cell as virtual text.\n\nThe same workflow applies to `.ipynb` notebooks. `nvim foo.ipynb`\nexpands the JSON into percent format in the buffer; edit and execute\ncells normally, then `:w` to round-trip back to the file on disk. See\n[`.ipynb` round-trip](#ipynb-round-trip).\n\n### Keymaps\n\nThere are **no default keymaps**. The set below splits into two groups:\n\n- **Editing keymaps** — cell navigation, insertion, deletion, plus the\n  kernel start/stop verbs. These don't need a live kernel and are bound\n  buffer-local on `FileType`.\n- **Kernel-bound keymaps** — execution, hover, restart, clear. These\n  only make sense once a kernel is attached, so they're bound on the\n  [`JupyterKernelReady`](#user-autocommands) `User` autocommand and\n  removed on `JupyterDeinitPre`. Hitting `\u003clocalleader\u003ejj` before\n  starting a kernel falls through to your default mapping (or beeps),\n  which is exactly the right feedback.\n\n```lua\nlocal KERNEL_BOUND_KEYS = {\n  \"\u003cM-CR\u003e\",\n  \"\u003clocalleader\u003ejj\", \"\u003clocalleader\u003eja\",\n  \"\u003clocalleader\u003ejc\", \"\u003clocalleader\u003ejC\",\n  \"\u003clocalleader\u003ejr\", \"\u003clocalleader\u003ejq\",\n  \"\u003clocalleader\u003eji\",\n}\n\n-- Editing verbs: always available on supported filetypes\nvim.api.nvim_create_autocmd(\"FileType\", {\n  pattern = { \"python\", \"julia\", \"r\" },\n  callback = function(ev)\n    local jupyter = require(\"jupyter\")\n    local function map(lhs, rhs, desc)\n      vim.keymap.set(\"n\", lhs, rhs, { buffer = ev.buf, silent = true, desc = desc })\n    end\n\n    -- Cell navigation (bracket-motion family: ]d, ]g, ]q, ]j…)\n    map(\"]j\", jupyter.next_cell, \"Next Cell\")\n    map(\"[j\", jupyter.prev_cell, \"Previous Cell\")\n\n    -- Cell editing\n    map(\"\u003clocalleader\u003ejo\", jupyter.insert_cell_below, \"Insert Cell Below\")\n    map(\"\u003clocalleader\u003ejO\", jupyter.insert_cell_above, \"Insert Cell Above\")\n    map(\"\u003clocalleader\u003ejd\", jupyter.delete_cell,       \"Delete Cell\")\n    map(\"\u003clocalleader\u003ejm\", jupyter.merge_with_prev,   \"Merge with Previous\")\n    map(\"\u003clocalleader\u003ejs\", jupyter.split_at_cursor,   \"Split Cell at Cursor\")\n\n    -- Kernel lifecycle entry point\n    map(\"\u003clocalleader\u003ejk\", function() jupyter.start_kernel() end, \"Start Kernel\")\n  end,\n})\n\n-- Kernel-bound verbs: live only between JupyterKernelReady and JupyterDeinitPre\nvim.api.nvim_create_autocmd(\"User\", {\n  pattern = \"JupyterKernelReady\",\n  callback = function(ev)\n    local jupyter = require(\"jupyter\")\n    local function map(lhs, rhs, desc)\n      vim.keymap.set(\"n\", lhs, rhs, { buffer = ev.data.bufnr, silent = true, desc = desc })\n    end\n\n    map(\"\u003cM-CR\u003e\",          jupyter.execute_and_advance, \"Execute Cell and Advance\")\n    map(\"\u003clocalleader\u003ejj\", jupyter.execute_cell,        \"Execute Cell\")\n    map(\"\u003clocalleader\u003eja\", jupyter.execute_all,         \"Execute All Cells\")\n    map(\"\u003clocalleader\u003ejc\", jupyter.clear_cell,          \"Clear Cell Output\")\n    map(\"\u003clocalleader\u003ejC\", jupyter.clear_all_outputs,   \"Clear All Outputs\")\n    map(\"\u003clocalleader\u003ejr\", jupyter.restart_kernel,      \"Restart Kernel\")\n    map(\"\u003clocalleader\u003ejq\", jupyter.stop_kernel,         \"Stop Kernel\")\n    map(\"\u003clocalleader\u003eji\", jupyter.hover,               \"Inspect Symbol\")\n  end,\n})\n\nvim.api.nvim_create_autocmd(\"User\", {\n  pattern = \"JupyterDeinitPre\",\n  callback = function(ev)\n    for _, lhs in ipairs(KERNEL_BOUND_KEYS) do\n      pcall(vim.keymap.del, \"n\", lhs, { buffer = ev.data.bufnr })\n    end\n  end,\n})\n```\n\n`K` is intentionally not bound — when a kernel is attached the\nin-process LSP serves `textDocument/hover`, so the editor's normal\nLSP `K` mapping already produces kernel-backed inspection.\n\n## Lua API\n\n`require(\"jupyter\")` exposes:\n\n| Function                                    | Description                                        |\n| ------------------------------------------- | -------------------------------------------------- |\n| `start_kernel(spec_name?)`                  | Start a kernel for the current buffer              |\n| `stop_kernel()`                             | Stop the buffer's kernel                           |\n| `restart_kernel()`                          | Restart the buffer's kernel                        |\n| `execute_cell()`                            | Execute the cell at the cursor                     |\n| `execute_and_advance()`                     | Execute, then move to the next cell (or create one) |\n| `execute_all()`                             | Execute every cell in the buffer in order          |\n| `clear_cell()`                              | Clear the output of the cell at the cursor         |\n| `clear_all_outputs()`                       | Clear every cell output in the buffer              |\n| `next_cell()`                               | Move cursor to the next cell                       |\n| `prev_cell()`                               | Move cursor to the previous cell                   |\n| `insert_cell_below(cell_type?)`             | Insert a new cell below the current cell           |\n| `insert_cell_above(cell_type?)`             | Insert a new cell above the current cell           |\n| `delete_cell()`                             | Delete the cell at the cursor                      |\n| `merge_with_prev()`                         | Merge the current cell with the previous cell      |\n| `split_at_cursor()`                         | Split the current cell at the cursor               |\n| `hover()`                                   | Kernel-backed hover for the symbol under cursor    |\n\n`start_kernel` accepts an optional kernelspec name. Without an argument\nit uses `default_kernel`, then falls back to a filetype-based default\n(`python` → `python3`, `julia` → first `julia*`, `r` → `ir`), and\nfinally to a `vim.ui.select` prompt when no installed kernel matches.\n\n## Configuration\n\n`require(\"jupyter\").setup({...})` accepts the following options. All are\noptional; unknown keys produce a warning rather than a hard error so older\nplugin versions tolerate newer configs.\n\n```lua\nrequire(\"jupyter\").setup({\n  -- Kernelspec name to use when start_kernel() is invoked without arguments.\n  default_kernel = nil,\n\n  -- Virtual-text output rendering. nil uses the built-in defaults.\n  display = {\n    max_lines = 20,                       -- truncate output at this many lines\n    truncation_hint = \"+ %d more lines\",  -- printf-style; %d gets the elided count\n    hl_group = \"Comment\",                 -- highlight group for output text\n    status_hl = {                         -- per-state status indicator highlights\n      starting = \"DiagnosticHint\",\n      idle     = \"DiagnosticHint\",\n      busy     = \"DiagnosticInfo\",\n      error    = \"DiagnosticError\",\n    },\n\n    -- Inline image rendering. Default is text-only.\n    -- Set renderer to \"snacks\" to render image/png and image/jpeg via\n    -- snacks.nvim (https://github.com/folke/snacks.nvim). Requires a\n    -- Kitty Graphics Protocol terminal (kitty / ghostty / wezterm).\n    -- Silently falls back to text/plain when snacks is missing or the\n    -- terminal is unsupported.\n    image = {\n      renderer   = nil,                   -- \"snacks\" | nil\n      max_width  = 60,                    -- columns (raise for larger plots)\n      max_height = 20,                    -- rows\n    },\n  },\n})\n```\n\n## User autocommands\n\nLifecycle hooks fire as `User` autocommands so configuration, statusline\nplugins, and other integrations can react without polling. Every event\ncarries `ev.data.bufnr`; events that fire while a kernel exists also\ncarry `ev.data.kernel_id`. See [Keymaps](#keymaps) for an example that\ngates execution mappings on `JupyterKernelReady` / `JupyterDeinitPost`.\n\n| Pattern              | When                                                 | `ev.data`               |\n| -------------------- | ---------------------------------------------------- | ----------------------- |\n| `JupyterInitPre`     | Before a kernel is started for a buffer              | `{ bufnr }`             |\n| `JupyterInitPost`    | After the kernel is registered and the LSP attached  | `{ bufnr, kernel_id }`  |\n| `JupyterKernelReady` | After `start_kernel` or `restart_kernel` completes   | `{ bufnr, kernel_id }`  |\n| `JupyterDeinitPre`   | Before `stop_kernel` tears the kernel down           | `{ bufnr, kernel_id }`  |\n| `JupyterDeinitPost`  | After the kernel, display, and LSP have been cleaned | `{ bufnr }`             |\n\n```lua\nvim.api.nvim_create_autocmd(\"User\", {\n  pattern = \"JupyterKernelReady\",\n  callback = function(ev)\n    vim.notify((\"kernel %s ready in buffer %d\"):format(ev.data.kernel_id, ev.data.bufnr))\n  end,\n})\n```\n\n## Architecture\n\nThe implementation lives in two Lua modules backed by a Python remote plugin:\n\n- `lua/jupyter_core/` — typed Lua API (`Kernel`, `KernelSpec`, `Output`)\n  over the RPC surface exposed by `rplugin/python3/jupyter_plugin.py`. Knows\n  nothing about buffers, extmarks, or cells.\n- `lua/jupyter/` — everything editor-facing: cell detection, navigation,\n  virtual-text display, the in-process LSP, commands, and keymaps. Always\n  goes through `jupyter_core`.\n\nThe rplugin is built on `jupyter_client`'s `AsyncKernelManager` /\n`AsyncKernelClient`. A single daemon thread hosts one asyncio event loop;\neach kernel is a coroutine context inside it, with a per-kernel\n`asyncio.Lock` to preserve `jupyter_client`'s channel-ordering invariants.\nAsync RPCs (completion, hover) route their reply back to Lua via\n`nvim.async_call` + `nvim.exec_lua`, so neither Neovim's main loop nor the\nLSP client ever blocks on a kernel round-trip.\n\nSee [`CLAUDE.md`](CLAUDE.md) for the full architecture, repository layout,\nmodule responsibilities, and design rationale.\n\n## `.ipynb` round-trip\n\nOpening any `*.ipynb` file routes through `BufReadCmd`: the JSON is\nparsed, cells are expanded into percent format, and the buffer's\nfiletype is set from `metadata.kernelspec.language` (falling back to\n`metadata.language_info.name`, then `python`). The original document is\nstashed on `b:jupyter_ipynb`.\n\nSaving with `:w` is the symmetric `BufWriteCmd`: the buffer is\nre-parsed by `jupyter.cell`, cells are matched against the stashed\ndocument by position, and a new JSON file is written. For cells whose\nsource is unchanged, the cell id, metadata, `execution_count`, and\n`outputs` are preserved verbatim. When the source changes the cell id\nis preserved but outputs are dropped; when the cell type changes a\nfresh id is minted.\n\nSee [`examples/example.ipynb`](examples/example.ipynb) for a runnable\nnotebook.\n\n## Roadmap\n\nPhase 2 (in progress):\n\n- Content-type-aware output rendering — `image/png` and `image/jpeg`\n  are inline via [`snacks.nvim`][snacks] (see [Configuration](#configuration));\n  pretty-printed JSON, formatted tracebacks, `text/html`, and\n  `image/svg+xml` are still to come.\n- Enhanced cell visualization — execution counters, timestamps, and\n  highlighting on cell boundaries.\n\n## Contributing / Development\n\nA `flake.nix` is provided. `nix develop` drops you into a shell with Neovim,\nthe plugin, `jupyter_client`, an isolated `ipykernel` (plus `numpy` /\n`pandas`), Julia (`IJulia`) and R (`IRkernel`) kernels, `vusted`, the Lua\nlanguage server, and `basedpyright`. The Jupyter runtime is rooted under a\ntemporary directory so it does not touch your host's Jupyter installation.\n\n```sh\nnix develop             # drop into the dev shell\n\nmake test               # run both Lua and Python unit tests\nmake test-lua           # vusted (lua/)\nmake test-python        # pytest (rplugin/)\nmake test-integration   # opt-in; spawns a real ipykernel\nmake lint-lua           # lua-language-server --check\nmake check              # lint + tests\n```\n\nFormatters and linters (`stylua`, `luacheck` / `selene`, `ruff`,\n`basedpyright`) are wired through the dev shell's pre-commit hook; running\n`nix develop` installs the hook automatically.\n\nCommit messages follow [Angular Conventional Commits](https://github.com/angular/angular/blob/main/contributing-docs/commit-message-guidelines.md):\n`\u003ctype\u003e(\u003cscope\u003e): \u003csubject\u003e`. Common scopes: `core`, `cell`, `display`,\n`completion`, `rplugin`, `flake`.\n\n## License\n\nMIT\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsei40kr%2Fjupyter.nvim","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fsei40kr%2Fjupyter.nvim","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fsei40kr%2Fjupyter.nvim/lists"}