v0.8.0 · stable · written in Rust
A Rust terminal multiplexer with a JSON-RPC API and a built-in pixel snapshotter. Spawn panes. Type into them. Get back a PNG of what they look like. Keep tmux for your shell; use shux wherever another process — an agent, a CI job, a test — needs to operate the terminal.
why this exists
tmux is great. It's also from before agents existed. Its job ends where the terminal emulator's job begins — and a human at a keyboard is always assumed. shux assumes nothing of the sort.
shux subcommand is a thin RPC call.pane.snapshot.xterm-256color probes so rich TUIs start fast and render correctly.state.apply batches. Optimistic concurrency on every entity.human-first where it counts
shux keeps the tmux-style copy mode for scrollback and search, but the everyday path is direct: drag visible text, release, paste. Right-click the selection when you want an explicit action.
Select pane output without entering a modal copy screen.
OSC 52 writes the selected text to the clipboard.
Use the inline Copy / Clear menu when you want confirmation.
agent-first, by construction
Spawn a session. Resize. Send keystrokes. Get a PNG back. Tear down. The same primitives a human uses are the same primitives an agent uses — no SDK, no screen-reading, no AppleScript bridge.
# 1 — declarative session spec (TOML).
# Same spec a human shux state apply's — same one an agent ships.
[session]
name = "review"
[[windows]]
title = "git"
[[windows.panes]]
command = ["lazygit"]
$ shux state apply review.toml
# → session created atomically. Pluck the first pane id:
$ PID=$(shux --format json session list \
| jq -r '.sessions[0].pane_id')
$ shux pane set-size -s review --cols 200 --rows 60
# → synchronous resize (VT + PTY).
$ shux pane send-keys -s review --text 'j'
# → navigate down one row.
$ shux --format json pane snapshot -s review \
| jq -r .png_base64 \
| base64 -d > out.png
# → 1800×1140 PNG of the current pane. No iTerm2 needed.
# Or drop to raw RPC for any registered method — no shell-escaping bait.
$ shux rpc call pane.send_keys --params @keys.json
$ echo '{"pane_id":"'$PID'","text":"j"}' | shux rpc call pane.send_keys --params -
The RPC surface covers session/window/pane lifecycle, synchronous resize, scripted input, sampled PTY watching, lossless PTY recording, and pixel snapshots. Each maps 1:1 to a CLI subcommand.
All methods accept JSON in, return JSON out, on stdin/stdout.
replaces these
shux collapses orchestration, scripted input, output capture, and pixel screenshots into one RPC surface. Same primitive, no extra tools.
| If you'd reach for | For this job | The shux call |
|---|---|---|
tmux · screen · byobu |
Multiplex sessions / windows / panes | shux state apply spec.toml · shux session attach |
| iTerm2 (Python SDK / AppleScript) | Drive a terminal app from outside | pane.send_keys + pane.snapshot |
expect · pexpect · sexpect |
Scripted CLI / REPL interaction | pane.send_keys → pane.wait_for → pane.capture |
iTerm2 wait_for_text / wait_for_absent |
Block until screen contains (or stops containing) a needle | pane.wait_for — text · regex · --absent |
asciinema rec · script(1) |
Record a terminal session | shux pane record --to FILE lossless PTY bytes |
vhs · agg · terminalizer |
Generate TUI demo GIFs / WebPs | window.snapshot loop → ffmpeg |
termshot · freezeframe |
Still PNG of a terminal frame | pane.snapshot or window.snapshot |
| iTerm2 broadcast input | Send keystrokes to many panes at once | pane.send_keys fan-out across pane IDs |
ttyrec · termsh |
Replay a recorded session | Feed captured bytes back into a pane — workflow |
GNU parallel --tmux mode |
Run N tasks in N panes, watch in one place | shux apply a multi-pane template — workflow |
| Custom Bubbletea / ratatui test harness | Visual regression for your TUI | window.snapshot + golden-image diff |
side by side
Both spawn PTYs and arrange them on screen. The contract beneath that is the part that diverges.
If you're a human at a keyboard and tmux works, keep using tmux.
xterm-256color compatibility for DA/DSR, DECRQM, XTGETTCAP, OSC palette queries, and synchronized output.If you're shipping agents, building TUIs, or want headless visual regression, this is for you.
in the wild
pane.snapshot outputs, no mockups.No mockups. No "rendered in After Effects". Pick a tab to see what shux's rasterizer produces when an agent drives a real TUI.
state.apply spawns claude · codex · opencode
into three vertical splits; pane.send_keys types
each prompt; pane.set_title pins the per-pane
label. The whole frame — borders, title overlays, status
bar — comes back as a single window.snapshot.
No terminal emulator, no screen scraping, no stitching.
shux plugin install watches the source by
default. A sed swap of demo· →
hot· in the running plugin triggers FSEvents
(~250ms debounce) → plugin respawn. The very
next window.create goes through the new code,
tagged hot·1. Same daemon, same session, no
reconnect.
pane.send_keys; each of the 7 frames
is a real pane.snapshot. Asciinema with pixels
instead of escape sequences.
state.apply spawns a 4-pane workspace.
bat · cargo nextest ·
shux --help · clock+git-log loop. Then 5 frames:
arrow-down via pane.send_keys, resize via
pane.set_size, swap quadrants via
pane.swap. Same verbs as
tmux send-keys / resize-pane /
swap-pane — typed, sync, JSON-shaped.
pane.snapshot of
one client running shux session attach against
a 2-pane session. Borders, the
demo · 11/11 · ? status bar, per-pane title
overlays — all RenderCompositor output.
Mouse-click moves focus, Alt+hjkl navigates,
Ctrl+Space is the prefix. This PNG was produced
without a terminal emulator.
bat crates/shux-raster/src/lib.rs in a
shux pane. Every TwoDark colour survives the round-trip
through shux-vt's SGR parser and
shux-raster's glyph emitter — no re-render, no
theme remap.
shux --help — paste-able agent reference.
Every CLI verb maps to its RPC method, a 3-call workflow,
and a replaces-these-tools table. Same rasterizer that
snapshotted everything above.
pane.send_keys types each statement,
pane.wait_for blocks on the response,
pane.capture pulls the result back. Replaces
expect / pexpect / iTerm2
wait_for_text. PyREPL 3.13 highlighting is free.
fontdue. No
terminal emulator in the loop.
three calls from zero
Install the binary. Spawn a session. Snapshot it. That's the loop.
One pre-built binary per platform. No daemons to register, no plugins to vendor. The daemon auto-starts on first use.
# macOS / Linux, arm64 or amd64
curl -sSf https://shux.pages.dev/install.sh | sh
One verb creates an atomic session + initial window + pane, with the command of your choice running in it.
shux --format json session create demo -d -- htop \
| jq -r .pane_id # save PID for next step
One verb returns a base64-encoded PNG of the live pane. Decode and pipe it anywhere — a CI golden, a vision LLM, a webhook.
shux --format json pane snapshot -s demo \
| jq -r .png_base64 \
| base64 -d > frame.png
frame.png — what came back. Real output, no mockup.for agents
One command installs the shux skill globally. The model then knows when to reach for shux instead of tmux, which RPC to call, and where the references live for deep dives.
$ npx skills add indrasvat/shux --global --yes
shux init scaffolds .shux/templates/, scripts/, goldens/, gitignored out/).shux state apply .shux/templates/spec.tomlshux pane send-keys -s NAME --text '...'shux pane snapshot -s NAME -o .shux/out/frame.png
extend, don't fork
A plugin is any executable that speaks shux's line-delimited
JSON-RPC dialect on stdin/stdout. It subscribes to bus
events, calls the same RPC methods you use from outside
(window.rename, pane.send_keys,
state.apply), publishes its own events via
event.publish (namespaced
plugin.<id>.<type>), and
persists state across hot reload via
plugin.state.get/set. Bash, python, node, anything.
# Install, list, kill — the whole CLI surface for v0.
$ shux plugin install ./my-plugin.sh
# → spawned, handshook (5s budget), registered under manifest name
$ shux plugin list
# name · version · pid · subscribes · status · uptime
$ shux plugin kill hello
# → plugin.shutdown sent, 2s graceful window, SIGKILL fallback
event.publish emits plugin.<id>.<type> events for other pluginsplugin.state.get/set — survives hot reload, 256 KiB cap, per-plugin
trust model
v0.19 ships a default-deny permission model on every plugin
RPC frame: per-method sensitivity tiers, ownership-based
auto-grant for entities the plugin created,
shux plugin grant <name> <method>
for the rest, NDJSON audit log per plugin. Identity is a
per-install UUID — reinstalling with the same name does
NOT inherit the predecessor's grants. Sandboxing of the
plugin's own filesystem / network access (the WASM
milestone) is still queued for later.