Skip to content
nkapila6Public

About

Terminal UI for Jupyter notebooks, built with Go and Charm

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

 

History

40 Commits

Folders and files

Repository files navigation

jupytui

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.

running a notebook in kitty: progress bar, DataFrame table and a plot shown with kitty graphics

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.

How it works

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.

Install

curl -fsSL https://raw.githubusercontent.com/nkapila6/jupytui/main/install.sh | sh

That 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@latest

Or from a clone: make install (static, stripped build). make dist builds the release tarballs.

You also need uv on your PATH.

Usage

jupytui notebook.ipynb

If 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.

Stale cells

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.

Remote kernels

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 and reattach

: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

Variables and data

: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.

AI

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.

Settings

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.

Python environments

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_ENV and ~/.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.

picking a Python environment and exporting to a percent .py

Export to .py

jupytui export notebook.ipynb            # writes notebook.py
jupytui export -o out.py -f notebook.ipynb

or :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.

Keys

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)

vim editing, relative line numbers across cells and flash jumps

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.

kernel and LSP completions, signature help, diagnostics, hover and go to definition

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 / [d jump between them across cells
  • K hover docs, gd go 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.

Outputs

  • stdout/stderr stream in live; \r progress 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() and getpass() 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.

    gx on 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 with JUPYTUI_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/VBox layouts and Output widgets, so interact() does too. tab moves into the cell's widgets, tab/j/k between them, h/l nudge a slider or pick an option (H/L ten steps), enter or space clicks, toggles or starts typing, esc leaves. 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 gx opens 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.

Neovim (LazyVim)

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.

marimo

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 back

Cells, 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.

Git

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 place

After 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.

Recordings

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/).

Status

Early but usable. Python kernels only (via uv), and no ipywidgets.

About

Terminal UI for Jupyter notebooks, built with Go and Charm

Topics

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages