A small terminal UI for Jupyter notebooks. Open an .ipynb, run cells, see output, save. No browser, no Jupyter server, no VS Code.
Built with Go and the Charm stack. Ships as a single static binary.
Recorded in kitty. The other recordings below are made with VHS, whose terminal can't show images, so plots there fall back to half blocks.
jupytui starts an ipykernel in your project with uv and talks to it directly over ZMQ (pure Go, no cgo). Your project's .venv packages are available in the kernel, and ipykernel itself is pulled in on the fly, so you don't need to add it to your project.
Notebooks are saved in the same format Jupyter writes, so diffs stay clean and the file still opens fine in Jupyter or VS Code.
curl -fsSL https://raw.githubusercontent.com/nkapila6/jupytui/main/install.sh | shThat grabs the right binary for macOS or Linux (arm64/amd64) from releases, checks it against the release checksums and puts it in ~/.local/bin (set INSTALL_DIR to change that, JUPYTUI_VERSION=v0.4.0 to pin a version). No sudo.
Or with Go:
go install github.com/nkapila6/jupytui/cmd/jupytui@latestOr from a clone: make install (static, stripped build). make dist builds the release tarballs.
You also need uv on your PATH.
jupytui notebook.ipynbIf the file doesn't exist it gets created on first save. The kernel starts in the background, so you can move around while uv sets it up.
jupytui exec notebook.ipynb runs every code cell headless and prints the output, mostly useful for debugging.
jupytui keeps track of which cells depend on which (it asks the kernel to parse each cell with Python's ast, so it knows exactly which names a cell defines and uses). In the prompt column:
~[3]: you edited this cell since it ran![4]: something it depends on changed or ran again since it ran
:runstale reruns all of them, top to bottom. :set reactive goes further, like marimo: run a cell and everything downstream of it reruns on its own. Cells that only depend on an edited cell wait until you run that one. The footer counts what's stale.
Run the kernel on another machine (a GPU box, a beefy server) and keep editing locally:
:remote gpubox # host from ~/.ssh/config, or user@host
:remote gpubox:~/proj # pick the working dir on the remote
or pick an ssh host in :env. jupytui starts the kernel there with the remote's own uv (uv run --with ipykernel, in the same folder relative to ~ as your notebook, falling back to ~) and tunnels its ports back over ssh. Outputs, plots, completions, :vars and the dataframe viewer all work as if it were local. The choice is saved in the notebook. It needs key-based ssh (no password prompts) and uv on the remote. If the connection drops or jupytui is killed, the remote kernel exits on its own. The language server still runs locally, so it only knows about packages installed locally.
:detach quits jupytui but leaves the kernel running, like detaching from tmux. Cells that are still running keep going, and a small background process writes their output into the notebook as it comes. Open the notebook again and you're back on the same kernel, variables and all, with running cells still streaming. Handy for long training runs or closing the nvim float.
jupytui sessions # what's running in the background
jupytui sessions kill 1 # stop one:vars (or gv) lists what's in the kernel: name, type, shape, memory and a preview. It refreshes after cells finish, and asking doesn't touch your namespace or the [n] counter.
enter on a pandas or polars DataFrame/Series or a numpy array opens it full screen (:view df does it directly). Rows are fetched from the kernel a page at a time, sorted and filtered there, so a 10M row frame browses as fast as a small one:
| key | action |
|---|---|
hjkl, ctrl+d ctrl+u, gg G, 0 $ |
move |
s |
sort by the current column (asc, desc, off) |
/ |
filter rows containing some text |
enter |
show the full value |
q |
back |
enter on anything else shows its full repr.
ga opens a chat on the right that sees the notebook: every cell, the outputs (trimmed), and the selected cell's plots as images. Ask why something fails, what a plot shows, how to do the next step. It streams; esc goes back to the notebook with the chat still open, ga returns to it, gA closes it, ctrl+c stops a reply, /clear forgets the conversation. The chat survives :detach.
Pick a model with environment variables. Keys are only ever read from the environment:
export ANTHROPIC_API_KEY=... # Claude, the default (claude-opus-5-5)
export JUPYTUI_AI=openrouter/<vendor>/<model> OPENROUTER_API_KEY=... # anything on OpenRouter
export JUPYTUI_AI=ollama/<model> # local Ollama
export JUPYTUI_AI=openai/<model> JUPYTUI_AI_BASE_URL=http://... # llama.cpp, LM Studio, vLLM...Models that can't see images get [image WxH] placeholders and the pane tells you. Nothing is sent until you ask something, and the footer shows the tokens used.
For now it only reads: it suggests code, it doesn't edit or run cells. That's next.
gs (or :settings) lists every setting: vim keys, line numbers, the language server and diagnostics, saving outputs, reactive mode, how images are drawn, the Python environment, and the AI model, effort and whether plots go to it as images. j/k to move, enter to toggle or edit, h/l to cycle.
Whatever you change there or with :set is remembered in ~/.config/jupytui/config.json (or under $XDG_CONFIG_HOME) and applied next time. API keys never go in it. JUPYTUI_IMAGES still beats the saved image mode, and the model setting beats JUPYTUI_AI when it isn't empty.
By default the kernel runs in your project's environment (uv run in the notebook's folder). :env lists everything else it found:
- venvs (anything with a
pyvenv.cfg) in the notebook's folder and its parents, its subfolders,$VIRTUAL_ENVand~/.virtualenvs - conda / mamba / micromamba envs, if you have one of them
- Pythons uv knows about (
uv python list)
Pick one and the kernel restarts in it. ipykernel gets layered on by uv, so the env doesn't need it installed and nothing is written into it. The choice is saved in the notebook's metadata (metadata.jupytui.python, relative to the notebook when it lives in the same folder) and used next time you open it. Jupyter and VS Code ignore that key.
jupytui export notebook.ipynb # writes notebook.py
jupytui export -o out.py -f notebook.ipynbor :export / :export other.py inside jupytui (:export! to overwrite).
The output is the "percent" format: every cell starts with # %%, markdown cells become # %% [markdown] with the text as comments, and magics like %matplotlib or !pip are commented out so the file is plain Python. jupytext, VS Code and the nvim notebook plugins all read it, and jupytext --to ipynb turns it back into the same cells.
Press ? inside jupytui for the full list.
There are two levels, like jupyterlab-vim: the notebook (moving between cells) and inside a cell (a small vim). enter goes into a cell, esc comes back out.
Notebook mode
| key | action |
|---|---|
j k |
move between cells |
5j 3k 12G |
jump by the line numbers, opening that cell |
gg G |
first / last cell |
ctrl+d ctrl+u |
scroll half a page |
enter / i / A |
open cell in vim normal / insert / append |
s |
flash jump (see below) |
e |
edit cell in nvim (see below) |
ctrl+enter |
run cell |
shift+enter ctrl+r |
run cell and move to the next |
ctrl+c |
interrupt, or quit when nothing runs |
o O |
new cell below / above |
dd u |
delete cell / undo delete |
yy p P |
yank / paste below / paste above |
J K |
move cell down / up |
M C R |
make it markdown / code / raw |
x |
clear cell output |
/ n N |
search every cell (smartcase), next, previous |
za zM zR |
fold a cell's output / fold all / unfold all |
gO |
outline: headings and definitions |
gx |
open the cell's chart in the browser, or its image in the system viewer |
tab |
use the cell's widgets (sliders, buttons...) |
ga gA |
AI chat about the notebook / close it |
gs |
settings |
ctrl+s |
save |
q |
quit (asks again if there are unsaved changes) |
Inside a cell it's vim: motions (hjkl w b e W B E 0 ^ $ gg G f t F T ; , % { }), operators with motions and counts (d c y > <, dd cc yy >> <<, 3dw, d2j), text objects (iw aw i" a" i( a( i[ i{ ...), x X s S D C Y p P J ~ r, u / ctrl+r undo/redo, . repeat, visual v / V. Enter after a : indents in Python.
/, n, N and * search across all cells from inside one too, and :s/old/new/g replaces on the current line (:%s/old/new/g in every cell; Go regex syntax, \1 and & work in the replacement).
esc goes insert to normal, and normal back out to the notebook. ctrl+enter runs the cell, shift+enter runs and moves on, ctrl+e opens it in nvim.
Completions pop up in insert mode after . or a couple of letters (or on tab / ctrl+space). They come from two places: the running kernel, which knows what's actually in memory (df. lists your real columns), and basedpyright, which knows code that hasn't run yet. Kernel results come first. ctrl+n / ctrl+p (or tab, arrows) to move, enter to accept, esc to close the menu. tab still indents when there's nothing to complete.
LSP: basedpyright starts the first time you open a code cell, via uvx (first run downloads it once; nothing goes into your project, and you don't need node). It sees all code cells as one file and uses the same Python env as the kernel. You get:
- diagnostics: the line number turns red/yellow, the range is underlined, the message shows in the footer when your cursor is on the line, and the footer counts them.
]d/[djump between them across cells Khover docs,gdgo to definition (jumps to the cell, or opens library code in your host nvim), signature help after(and,
It runs basedpyright's basic checks with "unused expression" off, since a bare df.head() at the end of a cell is the point of a notebook. :set nolsp turns it off, :set nodiag just hides diagnostics.
Line numbers run across the whole notebook as if it were one buffer, relative by default like LazyVim. There's one numbering for everything: 5j moves exactly the number of lines shown, crossing into other cells, in a cell or from the notebook view (where it opens the cell at that line), and :42 / 42G go to notebook line 42. In the notebook view the numbers count from the selected cell's first line. Outputs don't count as lines.
Flash jump: press s, type a couple of characters, and every match on screen gets a label; type the label to land there (inside the cell, in vim normal mode). Like flash.nvim, labels never use a letter that could continue your search, enter takes the closest match, esc cancels.
Commands
| command | action |
|---|---|
:w :q :q! :wq |
the usual |
:42 |
go to notebook line 42 |
:%s/old/new/g |
replace in every cell |
:noh |
clear search highlighting |
:outline |
headings and definitions |
:runall |
run every code cell |
:runstale |
rerun stale and edited cells |
:set reactive |
rerun dependents automatically |
:detach |
quit, keep the kernel running |
:remote host[:dir] |
run the kernel over ssh |
:clear |
clear all outputs |
:export[!] [file.py] |
export as a # %% .py |
:interrupt |
interrupt the kernel |
:restart |
restart the kernel |
:env |
pick the Python environment |
:vars / :view df |
variable explorer / dataframe viewer |
:set nu :set nonu |
line numbers on / off |
:set rnu :set nornu |
relative line numbers on / off |
:set novim :set vim |
plain editing in cells instead of vim |
:set nolsp :set lsp |
language server off / on |
:set nodiag :set diag |
hide / show diagnostics |
ctrl+enter and shift+enter need a terminal that supports the kitty keyboard protocol (Ghostty, kitty, WezTerm, recent iTerm2). In the notebook, ctrl+r runs and moves on everywhere.
-
stdout/stderr stream in live;
\rprogress bars (tqdm) redraw in place -
pandas and polars DataFrames are drawn as real tables (index, dtypes, MultiIndex, the
...rows); wide ones keep readable columns and drop the rest behind… -
tracebacks keep their colours
-
other HTML shows as text, markdown output is rendered
-
input()andgetpass()prompt right under the cell -
plots and images (matplotlib, seaborn, PIL, anything that outputs PNG/JPEG) show inline:
- kitty and Ghostty: full resolution, via the kitty graphics protocol
- WezTerm, iTerm2, foot: full resolution, via sixel
- everywhere else, including inside nvim's terminal and tmux (neither passes images through): half-block characters, two pixels per cell. Low-res but readable.
gxon the cell opens the full image in your system viewer (also how you see SVG output). jupytui picks the mode from your terminal; force one withJUPYTUI_IMAGES=kitty|sixel|blocks,:set images=...or the settings (gs). -
ipywidgets work: sliders, dropdowns, toggle and radio buttons, checkboxes, buttons, text boxes, progress bars, labels,
HBox/VBoxlayouts andOutputwidgets, sointeract()does too.tabmoves into the cell's widgets,tab/j/kbetween them,h/lnudge a slider or pick an option (H/Lten steps),enterorspaceclicks, toggles or starts typing,escleaves. Widgets jupytui can't draw show up as[Name widget]. -
charts that need JavaScript (plotly, altair/vega, bokeh, folium, any HTML with scripts) can't run in a terminal, so they show a one-line note and
gxopens the real interactive chart in your browser. A static PNG is shown inline when the output has one.
ipywidgets and nbformat (plotly needs it to display anything) are layered into the kernel by uv like ipykernel, so your project doesn't need them.
Same idea as <leader>gg for lazygit. Drop this in lua/config/keymaps.lua:
vim.keymap.set("n", "<leader>jn", function()
Snacks.terminal({ "jupytui", vim.fn.expand("%:p") }, { cwd = LazyVim.root() })
end, { desc = "jupytui (current notebook)" })Open a notebook buffer, hit <leader>jn, and jupytui comes up in a float.
Pressing e on a cell (or ctrl+e inside one) while in nvim opens it in your actual nvim, not a nested one: the cell lands in a new tab as a .py file so your LSP, treesitter and keymaps all work. Every :w syncs straight back into the notebook, and :wq drops you back in the jupytui float. This works because nvim sets $NVIM for its terminals.
Outside nvim, e opens $VISUAL / $EDITOR (falling back to vi) full screen.
jupytui opens marimo notebooks (.py files with app = marimo.App()) directly:
jupytui analysis.py
jupytui convert notebook.ipynb analysis.py # Jupyter -> marimo
jupytui convert analysis.py notebook.ipynb # and backCells, markdown (mo.md), with app.setup:, @app.function / @app.class_definition and cell options like hide_code=True are kept. Reactive mode is on by default, like in marimo, and the kernel gets marimo layered in by uv so import marimo as mo works even if your project doesn't list it. Saving writes marimo's format back, with each cell's parameters and returns worked out from what it uses and defines, so the file still runs with python analysis.py and marimo edit.
Notebooks are JSON, which makes for miserable diffs and merges. jupytui can handle both:
jupytui diff old.ipynb new.ipynb # cell-by-cell, coloured; --outputs for output changes too
jupytui git setup # once per clone: wires the drivers below into this repo
jupytui git setup --strip-outputs # same, and commit notebooks without outputs
jupytui clean nb.ipynb # strip outputs and execution counts in placeAfter git setup, git diff, git log -p and git show print notebooks as code (# %% cells) with outputs summarised as #> comments, and git merge merges notebooks cell by cell: a cell changed on one side takes that side, and only a cell changed differently on both sides is a conflict, marked with <<<<<<< inside that cell (the file stays a valid notebook you can open and fix). --strip-outputs keeps outputs in your working copy but leaves them out of commits. .gitattributes gets committed; git config doesn't, so collaborators run jupytui git setup once too.
Inside jupytui, :set nosaveoutputs saves without outputs.
The top GIF is a screen recording of kitty: demo/play.py drives jupytui through the tour inside whatever terminal you run it in, so images show for real (start a screen recording, run make demo-kitty, press enter). The others are made with VHS from the tapes in demo/. make demos re-records them (needs vhs and uv; the demo notebook has its own uv project in demo/).
Early but usable. Python kernels only (via uv), and no ipywidgets.



