Notebook-style Python in classic Vim, with a rich Euporie console managed automatically in a neighboring tmux pane.
The primary path is Vim 9 → tmux → XFCE Terminal with Sixel. Edit ordinary .py files, divide them into VS Code-style # %% cells, and send a cell without leaving Vim. The sidecar uses the current uv project, while Euporie shows text, tracebacks, Matplotlib figures, LaTeX, and supported Jupyter widgets inline.
# %%
import matplotlib.pyplot as plt
x = range(20)
plt.plot(x, [n**2 for n in x])
plt.show()
# %% [markdown]
# ## A rendered equation
#
# $E = mc^2$Press \er with the cursor in either cell. The first execution opens an Euporie pane automatically; subsequent cells reuse the same kernel and namespace.
vim-euporie composes existing tools instead of forking Vim, Euporie, or Jupyter:
- Vim handles editing and
# %%cell boundaries. uv runsupplies the project's interpreter and dependencies.- An IPython kernel maintains the interactive namespace.
- Euporie attaches as a second Jupyter client and renders rich output.
- tmux owns the pane layout and survives ordinary redraws.
Vim talks to the sidecar through its built-in channel API. It does not need +python3, Neovim, a Vim terminal buffer, or a globally installed Jupyter stack.
- Vim 9.0 or newer with
+channel,+job, and JSON support - tmux 3.4 or newer, built with
--enable-sixel(see below) uv- a Sixel-capable terminal, such as XFCE Terminal built against a Sixel-enabled VTE
- Python 3.10 or newer for current Euporie releases
Install uv separately, then let the plugin resolve Euporie and ipykernel on first use. No permanent Python tool installation is required. Stable VTE releases currently omit Sixel support, so XFCE Terminal needs a Sixel-enabled VTE build. tmux 3.4 and newer can manage Sixel images as pane content, including scrolling, clipping, and redraws.
The default Sixel path deliberately disables Euporie's multiplexer passthrough. Euporie sends Sixel to tmux, and tmux parses and redraws it before XFCE Terminal renders it. This avoids the inactive-pane passthrough and repaint problems of terminal-specific graphics protocols.
Sixel in tmux is a compile-time option, not a runtime one. A tmux built
without --enable-sixel still reports sixel in #{client_termfeatures},
because that string only mirrors the outer terminal's DA1 response and is
never checked against tmux's own build. Such a tmux then discards every image
it receives, so everything else works and figures simply never appear. This is
the usual cause of "no plots" on macOS, where Homebrew's tmux is built without
the flag.
Put %matplotlib widget at the top of a script and figures become live: drag a
3D plot to rotate it, and use the toolbar to pan and zoom a 2D one. ipympl
supplies that backend and is installed alongside Euporie. If you set
g:vim_euporie_with, keep ipympl in it: the setting replaces the default
list rather than adding to it, and without ipympl the magic fails with
'widget' is not a recognised backend name.
End such a cell with plt.show() and nothing else. A trailing fig.canvas
displays the same canvas a second time, and the console renders both. The same
goes for drawing into an ipywidgets.Output: Euporie renders what is captured
there as the cell's own output and inside the widget, so it appears twice.
Without the widget backend, figures are inline images, which cost nothing to
keep and scroll with the rest of the history.
A widget stays live in the console's output area until the next cell is
submitted, and only the mouse can drive it, since Euporie binds no keys to
widgets, so vim-euporie passes --mouse-support.
The mouse also needs the console to know its own height. A console learns how
far it sits from the bottom of the terminal by asking for a cursor position
report, and prompt_toolkit drops every mouse event until the answer arrives,
before any handler runs. tmux does not answer that request from a pane which is
not the active one, and cells are run from Vim, so the request that follows a
cell is exactly the one left unanswered: the widget renders and then ignores
the mouse. tmux does publish every pane's cursor row as #{cursor_y}, so
vim-euporie answers prompt_toolkit from tmux and the height is never unknown.
Euporie scales each figure to the width of the output area, computed from the
terminal's cell size in pixels. Inside tmux, TIOCGWINSZ and CSI 14 t both
answer with tmux's own rounded geometry rather than the attached terminal's,
and where tmux reports no pixel size at all Euporie falls back to a hardcoded
10x20 guess, which is what makes figures look small on HiDPI screens.
vim-euporie takes the real size from tmux's #{client_cell_width} instead, so
figures fill the pane.
Figures are therefore as wide as the Euporie pane: widen it with
g:vim_euporie_pane_percent to make them larger. Matplotlib's figsize
controls the aspect ratio, so it changes a figure's height rather than its
width.
g:vim_euporie_graphics = 'auto' (the default) probes the running tmux for
this directly: it renders a 27-byte Sixel into a throwaway tmux server and
checks whether the cursor advanced. A build that really parses Sixel consumes
cell rows; one that does not leaves the cursor on the first line. When the
probe fails, vim-euporie warns and falls back to kitty-unicode instead of
silently rendering nothing. To keep the native path, build tmux with
--enable-sixel.
Kitty graphics remain available as a compatibility mode. For that mode, add this to ~/.tmux.conf:
set -g allow-passthrough ontmux drops graphics passthrough emitted by an inactive pane. For
kitty-unicode, vim-euporie automatically sends the position-independent
image upload and virtual-placement commands to the attached tmux client's TTY;
the Unicode placeholder cells still pass through tmux, so images scroll and
redraw with the surrounding output while Vim keeps focus.
The plugin also enables that option in the running tmux server by default. It is harmless in Sixel mode.
Ghostty 1.3.1 has upstream Kitty Unicode-placement bugs inside tmux. Use a
post-1.3.1 build containing the graphics cache and cell-geometry fixes (or the
next stable release) for reliable kitty-unicode output. As a compatibility
fallback, set g:vim_euporie_graphics = 'kitty'.
With Vim 8/9 native packages:
git clone https://github.com/krystophny/vim-euporie \
~/.vim/pack/plugins/start/vim-euporie
vim -u NONE -c 'helptags ~/.vim/pack/plugins/start/vim-euporie/doc' -c quitOr with vim-plug:
Plug 'krystophny/vim-euporie'For a new notebook-style directory, first create a minimal uv project and add plotting support:
uv init --bare
uv add matplotlibIf you prefer a single self-contained script with PEP 723 metadata, use:
uv add --script analysis.py matplotlibvim-euporie detects the inline metadata and launches the kernel with those
dependencies. Each metadata-bearing script receives its own pane and kernel.
If you change its dependency metadata while Vim is open, run
:EuporieRestart.
Then start Vim from inside tmux and edit a Python file:
tmux
uv run vim analysis.pyStarting Vim through uv run is optional. The plugin itself launches the
kernel through the detected uv project or the current script's inline
metadata.
The default Python-buffer mappings use maplocalleader (a backslash unless you changed it):
| Mapping | Action |
|---|---|
Ctrl+J |
Run the current cell and advance (or run the Visual selection) |
Alt+Enter |
Same |
Shift+Enter |
Same, on terminals whose keyboard protocol can report it |
\er |
Run current cell and advance |
\ec |
Run current cell without moving |
\el |
Run current line |
\e in Visual mode |
Run selection |
\ei |
Interrupt the kernel |
\ef |
Focus the Euporie pane |
Commands include :EuporieStart, :EuporieStop, :EuporieRestart, :EuporieFocus, :EuporieStatus, :EuporieInterrupt, :EuporieSendCell, :EuporieRunCell, :[range]EuporieSend, and :EuporieSendFile.
Shift+Enter and inline graphics each depend on a chain of terminal, tmux and
Vim settings, and a break anywhere in it is silent. :EuporieDoctor checks
every hop and names the one that is wrong.
The Euporie pane is a real interactive console: focus it with \ef, type exploratory code directly, inspect and scroll output, use completions, or work with widgets. Input sent from Vim and input typed into Euporie share the same kernel. When the final attached Vim exits, the pane and kernel close automatically. Set a positive idle timeout if you want a grace period for reopening Vim.
Defaults are tuned for native Sixel inside tmux:
let g:vim_euporie_auto_start = 1
let g:vim_euporie_graphics = 'auto'
let g:vim_euporie_split = 'horizontal'
let g:vim_euporie_pane_percent = 40
let g:vim_euporie_idle_timeout = 0
let g:vim_euporie_configure_keyboard = 1Alt+Enter and Shift+Enter work in Normal and Insert mode, and send the
selected text in Visual mode. Inside tmux, the plugin enables extended-keys
and Vim's modifyOtherKeys level 2 so modified Enter keys remain distinct from
Enter between tmux and Vim. Set g:vim_euporie_configure_keyboard = 0 to
manage that protocol yourself. A terminal-key fallback covers the CSI-u
encoding emitted by Debian Bookworm's tmux 3.3a for Vim 9.0.
Ctrl+J is bound because it is the one key that survives every setup, and it
is worth knowing why. iTerm2 is very commonly configured to remap Shift+Enter
to Ctrl+J so that other tools accept multi-line input; Vim then never sees
Shift+Enter at all, no matter how tmux and Vim are configured. VTE terminals
cannot report Shift+Enter either, but do report Ctrl+J distinctly. Note that
the mapping must be spelled <NL> rather than <C-J>: Insert mode does not
match a mapping registered under the latter name, so it works in Normal mode
while silently doing nothing in Insert mode.
Whether Shift+Enter reaches tmux at all depends on the outermost terminal. VTE-based terminals such as XFCE Terminal implement neither xterm's modifyOtherKeys nor the kitty keyboard protocol (vte#2601), so they send Shift+Enter as a plain Enter that no tmux or Vim setting can recover. On the primary XFCE Terminal path, use Alt+Enter, which VTE reports distinctly and which behaves identically. Shift+Enter additionally works under outer terminals with a modern keyboard protocol, such as kitty, Ghostty, WezTerm, foot, and xterm.
Set g:vim_euporie_split = 'vertical' for a pane below Vim. To keep only <Plug> mappings and define your own:
let g:vim_euporie_no_mappings = 1
nmap <silent> <Leader>x <Plug>(EuporieRunCell)
xmap <silent> <Leader>x <Plug>(EuporieSendVisual)Add packages to the ephemeral uv overlay if they are not project dependencies:
let g:vim_euporie_with = ['euporie', 'ipykernel', 'matplotlib', 'ipywidgets']Pass additional arguments directly to Euporie:
let g:vim_euporie_euporie_args = ['--edit-mode', 'vi']Project detection uses the nearest pyproject.toml, uv.lock, or .git directory. Override it with b:vim_euporie_root or g:vim_euporie_root.
The sharing key is (tmux window, project root) for uv projects, so related
Python buffers share one pane and one live namespace. Standalone PEP 723
scripts are keyed separately because they may declare different dependencies.
Vim clients register and send heartbeats. A second Vim in the same tmux window
can attach to the existing sidecar; stale clients expire, and the sidecar
cleans up the kernel, connection file, control state, and tmux pane when
unused.
Control traffic is bound to 127.0.0.1 and authenticated with a random token stored in a mode-0600 runtime file. No Jupyter port is exposed beyond its normal localhost connection sockets.
This is an initial implementation. Python/uv projects, code cells, Markdown cells, rich display output, interruption, pane reuse, and automatic cleanup are in scope. Other Jupyter kernels can be added later without changing the Vim-facing cell model.
MIT licensed.