Skip to main content

Inline matplotlib plots in your terminal via sixel, in a tmux pane, over SSH

Project description

plotty

CI security: pip-audit Python License: MIT

Inline matplotlib plots in your terminal — rendered as sixel in a dedicated tmux pane, including over SSH. No browser, no X11, no Jupyter server — and zero dependencies beyond matplotlib (the sixel encoder is built in).

plotty demo

plotty is a matplotlib backend that draws figures directly in your terminal, so a tmux + ipython (+ nvim) workflow shows plots the way a Jupyter or VS Code notebook does. Activate it once and your figures appear in a tmux pane next to your REPL — locally or on a remote machine over SSH. It's inspired by and the Python analogue of MuxDisplay.jl.

import plotty
plotty.enable()

import matplotlib.pyplot as plt
plt.plot([1, 4, 9, 16])     # shows up in the plot pane

Why / when to use it

If you do interactive analysis in a terminal — ipython inside tmux, editing in nvim, frequently SSH'd into a remote box — you normally lose inline plots: plt.show() wants a GUI and Jupyter wants a browser. plotty fills that gap and covers three setups:

  • Local tmux. Run your REPL in one pane; plots render in another.
  • Remote over SSH. Run everything on the remote inside tmux. Only the rendered sixel bytes cross the wire (drawn by your local terminal); the control plane — signals, pidfile, image hand-off — stays host-local, so it behaves exactly like a local session.
  • Nested tmux (local tmux → ssh → remote tmux). Supported with a small, one-time tmux config change — see Nested tmux.

Requirements

Python ≥ 3.7
tmux 3.4, built with sixel support (--enable-sixel)
Terminal a sixel-capable terminal — e.g. WezTerm, foot, Konsole, xterm -ti vt340 — or ghostty/kitty (kitty graphics protocol, auto-detected)
Python deps matplotlib (and numpy, which ships with matplotlib) — nothing else: rendering uses plotty's built-in sixel encoder by default, no external tools

Check tmux:

tmux -V                                                   # need >= 3.4
strings "$(command -v tmux)" | grep -qi sixel && echo "sixel: yes" || echo "sixel: MISSING"

Not in tmux? plotty falls back to writing sixel straight to your terminal's stdout, so it still works in any sixel-capable terminal without tmux.

Install

plotty installs with uv (which indexes PyPI) or pip:

uv add plotty            # add to your project (resolved + locked)
# or
uv pip install plotty    # into the active environment
# or
pip install plotty

From source:

git clone https://github.com/xuesoso/plotty && cd plotty
uv pip install .

Quick start

import plotty
plotty.enable()                 # auto-detect the renderer (sixel or kitty
                                # graphics), use a dedicated plot pane, and
                                # spawn a tiny viewer there

import matplotlib.pyplot as plt
plt.plot([1, 4, 9, 16])
# IPython: the figure appears automatically after each cell.
# Plain REPL: call plt.show().

plotty.disable()                # stop the viewer, restore matplotlib, and close
                                # plotty's auto-created plot pane

Inside tmux, plotty draws into a dedicated plot pane in the current window. The first enable() splits one off your REPL — along its longer side, so a wide pane gets the plot beside it and a tall pane gets it below — and reuses that pane on later calls; if you close it, the next enable() makes a new one. So plotty never hijacks a pane you opened yourself (an editor, logs, …). Target a specific pane instead with enable(target_pane=...) — an int indexes the window's panes (-1 = last), or pass a name like sess:win.pane.

Public API: enable(), disable(), redraw(), show(fig), save(path), status(), view(), __version__. status() prints a diagnostic summary (mode, renderer, viewer state, tmux health); save("out.png") copies the last figure at full resolution; disable() closes plotty's auto-created plot pane by default (a pane you passed via target_pane is left open) — pass disable(close_pane=False) to keep the auto-created pane and its last figure.

Demo

Run the bundled example to see it in action (split off a plot pane first, then python examples/demo.py). The GIF below is the expected output:

python examples/demo.py

plotty rendering the examples/demo.py plots in a tmux pane

Plotly figures

plotty can render plotly figures in the same plot pane, the way they appear inline in a Jupyter notebook — no browser. It's an opt-in extra (plotly exports static images through kaleido, which needs a Chrome/Chromium; the core matplotlib path stays zero-dependency and never imports either):

uv pip install 'plotty[plotly]'     # or: pip install 'plotty[plotly]'

Then nothing special is needed — enable() registers a plotly renderer and makes it the default, so a figure left as a cell's result auto-displays in the pane, exactly like matplotlib:

import plotty
plotty.enable()

import plotly.express as px
fig = px.scatter(x=[1, 2, 3, 4], y=[1, 4, 9, 16])
fig                      # IPython: shows up in the plot pane after the cell
# fig.show()             # plain REPL: routes to the pane too
# plotty.show(fig)       # or display any figure explicitly

Both kinds coexist — matplotlib via its backend + auto-display hook, plotly via its renderer — so you can mix them in one session. disable() restores plotly's previous default renderer. Source resolution is controlled by PLOTTY_PLOTLY_SCALE (kaleido's scale multiplier, default 2); raise it for crisper plots at large size, the plotly analogue of dpi.

Needs the [plotly] extra installed and a Chrome/Chromium for kaleido. If the export can't run, plotty prints a one-time hint (plotly_get_chrome installs one) instead of failing your cell.

Performance

plotly exports static images by driving a headless Chrome through kaleido, which is inherently heavier than matplotlib's in-process Agg renderer (~30 ms/figure). Left alone, plotly relaunches Chrome on every figure (~1.3 s each). plotty avoids that by starting a persistent kaleido server the first time you plot and keeping Chrome warm, so only the first figure pays the startup cost and later ones render in ~65 ms (≈2× matplotlib):

first figure each later figure
matplotlib ~50 ms ~30 ms
plotly (persistent server, default) ~1.3 s ~65 ms
plotly (PLOTTY_PLOTLY_SERVER=0) ~1.3 s ~1.3 s

The server (and its Chrome) is torn down by disable() and at interpreter exit. It cannot be orphaned even on a sudden, uncatchable kill of your REPL (SIGKILL, crash, dropped SSH): kaleido drives Chrome over a pipe, so when the parent dies the pipe breaks and Chrome exits with it — verified by a regression test that hard-kills the parent and asserts no Chrome survives. Set PLOTTY_PLOTLY_SERVER=0 to opt out of the persistent server (every render relaunches Chrome) if you'd rather not keep a browser resident.

How it works

Two cooperating pieces share state via the filesystem + OS signals:

  • Backend (module://plotty, runs in your REPL): on each figure it saves a PNG, atomically publishes it to ~/.cache/plotty/last.png, and signals the viewer.
  • Viewer (runs in the plot pane): redraws on a new figure (SIGUSR1) and on pane resize/zoom (SIGWINCH). It's event-driven via a self-pipe (zero CPU when idle), coalesces resize bursts into a single redraw, cleans up after itself, and always exits cleanly (no crash dialogs when a session is torn down).

Because only sixel bytes cross SSH and everything else is host-local, remote use is identical to local.

Inside tmux this state is keyed per window (~/.cache/plotty/win-<id>/), so a REPL in one window and a REPL in another each get their own plot pane and viewer instead of fighting over a shared one.

Display modes

  • Viewer mode (default in tmux) — a small viewer process lives in the target pane and redraws on new figures and on pane resize/zoom. Recommended; it's the mode that survives resizing. The plot pane also takes single keys: p/k step back through recent figures, n/j step forward, q quits. q or Ctrl+C in an auto-created plot pane closes that pane (the next figure splits a fresh one), so you never get stranded at a shell prompt with a stale plot; a pane you passed via target_pane is left open. Re-running enable(size=…, bg=…) updates a running viewer live; a new target_pane moves it.
  • Inline mode (default outside tmux, or enable(inline=True)) — the backend renders sixel itself, with no helper process, and writes it to the target pane's tty (in tmux) or to your stdout (no tmux). It does not auto-redraw on resize.
plotty.enable(inline=True)      # force inline even inside tmux

plotty never injects bytes into the console you are typing in: outside tmux, viewer-pane mode falls back to inline, and auto-selected inline first queries the terminal for graphics support (sixel or kitty) — if it has neither (e.g. an IDE console), plotty warns and skips display instead of printing escape garbage. An explicit enable(inline=True) is trusted and always writes.

Renderers

By default plotty is zero-dependency and protocol-aware: it detects what your terminal supports and picks the right built-in encoder automatically —

  • sixel-capable terminal (WezTerm, foot, Konsole, xterm, …) → built-in sixel encoder. It quantizes over the image's distinct colors — exact (lossless) when there are ≤256, fast count-weighted median-cut otherwise — and renders a typical plot in ~50 ms.
  • no sixel (ghostty, kitty) → built-in kitty-graphics encoder (see below).

Detection checks the terminal's identity first (ghostty/kitty always get the kitty-graphics encoder — they never render sixel, even when a tmux terminal-features override claims otherwise), then the terminal's own answers (a DA1/graphics query outside tmux; tmux's resolved client features inside), so plotty.enable() just works on both kinds of terminals. Override it any time:

plotty.enable(imgcat="builtin")     # force the built-in sixel encoder
plotty.enable(imgcat="kitty")       # force the kitty-graphics encoder
plotty.enable(imgcat="chafa")       # or "img2sixel", "magick": external sixel
                                    # tools - slightly faster, better resampling
# PLOTTY_IMGCAT=... works too; a full custom command string is also accepted

External encoders (chafa, img2sixel, ImageMagick) are worth it for photos/imshow and heavy downscaling; if the requested tool isn't installed, plotty warns and falls back to the built-in encoder. The bg background option applies to the built-in encoders only.

ghostty / kitty terminals (no sixel)

ghostty and kitty don't render sixel — for them plotty auto-selects its second built-in encoder, using the kitty graphics protocol with Unicode placeholders, the mechanism designed to make images robust inside tmux: the image data is sent once (passthrough-wrapped), while its placement is plain placeholder text that tmux tracks like any other text — so plots survive pane resize, zoom, and pane switches.

One requirement inside tmux:

# ~/.tmux.conf — let the image data through to the terminal (tmux >= 3.3)
set -g allow-passthrough on
  • Terminal must support kitty graphics with Unicode placeholders (kitty and ghostty do; most other terminals don't).
  • Works in a local tmux and over SSH into a single remote tmux. Nested tmux is not supported (passthrough doesn't survive two layers) — use a sixel terminal for that setup.

plotty is sixel-only by design — sixel is the only path that survives tmux and SSH. Non-sixel terminal-image protocols (kitty / iTerm) are not used. A custom non-sixel imgcat= may be passed but will warn that it may not display over SSH.

tmux configuration

plotty works with no config on a single tmux as long as tmux is ≥ 3.4 with sixel and your terminal supports sixel (i.e. Wezterm, iTerm2, xterm, xfce term, VSCode). Reference Are We Sixel Yet? for a complete list. If plots don't appear (or you see raw escape-sequence junk instead of an image), tmux hasn't recognized that your terminal can render sixel — its auto-detection isn't always reliable, especially over SSH. Tell it explicitly in ~/.tmux.conf:

set -as terminal-features ',*:sixel'

Nested tmux (local + remote)

A common remote setup is a tmux inside a tmux:

local terminal → local tmux → ssh → remote tmux → REPL + plot pane

For the image to flow all the way out, every tmux layer must render and forward the sixel — which means setting the feature on both the local and the remote tmux:

# add to ~/.tmux.conf on BOTH the local laptop and the remote machine
set -as terminal-features ',*:sixel'

Without this, the inner (remote) tmux doesn't know to forward sixel and the raw escape sequence leaks through as garbage characters. Verify a layer sees the feature with:

tmux display-message -p '#{client_termfeatures}'   # should contain "sixel"

Both tmux layers must be ≥ 3.4 and built with sixel.

Configuration reference

enable() arguments (each has an environment-variable default):

argument env var default meaning
target_pane PLOTTY_PANE auto auto = a dedicated split pane, reused across calls (recreated if closed); or an int pane index (negative from the end, -1 = last), or a name like sess:win.pane
size PLOTTY_SIZE 60 display width in terminal cells
dpi PLOTTY_DPI matplotlib default savefig DPI of the source image (raise it for sharper plots at large size)
imgcat PLOTTY_IMGCAT auto-detect picks built-in sixel or kitty-graphics by terminal; "builtin" forces sixel, "kitty" forces kitty graphics, "chafa"/"img2sixel"/"magick" use that tool, or a custom command
bg PLOTTY_BG white #rrggbb background composited under transparent figure regions (match your terminal for dark themes)
hist PLOTTY_HIST 10 recent figures kept for the viewer's history keys (0 disables)
inline PLOTTY_INLINE auto True/False to force inline vs viewer-pane mode
clear PLOTTY_CLEAR True clear the pane before each draw
close PLOTTY_CLOSE True close figures after display
tmux PLOTTY_TMUX tmux tmux binary to use
viewer True spawn the viewer process (tmux mode)
verbose 1 print startup health-check warnings
PLOTTY_PLOTLY_SCALE 2 kaleido resolution multiplier for plotly figures (the plotly analogue of dpi); only used with the [plotly] extra
PLOTTY_PLOTLY_SERVER 1 keep a persistent kaleido (Chrome) server warm across plotly renders (~20× faster after the first); 0 relaunches Chrome per figure
PLOTTY_CACHE ~/.cache/plotty base state directory; inside tmux each window gets its own win-<id>/ subdir (last.png, pidfile) so concurrent REPLs don't collide

size and dpi are independent: size is how wide the image is displayed, dpi is how many pixels the source has. For a crisp image at a large size, raise dpi so the source has enough pixels.

Troubleshooting

  • Garbage / +++ instead of an image: a tmux layer isn't forwarding sixel. Add set -as terminal-features ',*:sixel' to that layer (both layers if nested) and confirm tmux ≥ 3.4 with sixel.
  • Nothing appears: check tmux -V ≥ 3.4 and sixel support (strings $(command -v tmux) | grep -i sixel); confirm your terminal supports sixel; run plotty.enable(verbose=1) to print diagnostics.
  • "figures will not be displayed" warning: your terminal didn't advertise sixel support when queried (common in IDE consoles) — use a sixel-capable terminal or tmux, or force output with enable(inline=True).
  • ghostty/kitty shows nothing with imgcat="kitty": run tmux set -g allow-passthrough on (and add it to ~/.tmux.conf), and make sure you're not inside nested tmux — the kitty path supports one tmux layer.
  • Image too large / small: tune size. Blurry when enlarged? raise dpi.
  • Plot doesn't refresh when you resize the pane: use viewer mode (the default in tmux); inline mode doesn't auto-redraw on resize.

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

plotty-1.5.0.tar.gz (49.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

plotty-1.5.0-py3-none-any.whl (33.5 kB view details)

Uploaded Python 3

File details

Details for the file plotty-1.5.0.tar.gz.

File metadata

  • Download URL: plotty-1.5.0.tar.gz
  • Upload date:
  • Size: 49.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plotty-1.5.0.tar.gz
Algorithm Hash digest
SHA256 8c75f9f6f13134246ca7a55d12f8238951dd323bb83b93dd2aa815d16c42f59b
MD5 395a25bad3f02ca6c7e92525340674d6
BLAKE2b-256 185b508e4e6db1b192e8c2240785328b91ba4107d1525b8bcdcabab9ddcfa9a1

See more details on using hashes here.

File details

Details for the file plotty-1.5.0-py3-none-any.whl.

File metadata

  • Download URL: plotty-1.5.0-py3-none-any.whl
  • Upload date:
  • Size: 33.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.24 {"installer":{"name":"uv","version":"0.11.24","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for plotty-1.5.0-py3-none-any.whl
Algorithm Hash digest
SHA256 e4adc4a517d5ac39548bcb5ab81a7e1a0d6527ce4070cd6b6a309961afe164f3
MD5 74001427777db9ffa939171d1a0f1b51
BLAKE2b-256 f1d8d6ebfc26eeb99d1196273b360472353b3de0389527c560b4e872efe6addd

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page