Drive tmux from Rust: typed, async control over servers, sessions, windows, and panes — and a query layer that makes "which pane is running the tests?" one expression instead of a parsing problem.
Alpha. Releases carry an
-alphaprerelease tag. The API is not settled, and any release may change or remove exported identifiers without a deprecation period. Pin an exact version. Not recommended for production.
You may be looking for:
- API documentation — every public item, with a runnable example
- The
libtmuxguide — features, the three transport switches, testing tmux-mcp— the MCP server, a separate package, if you want an agent to drive tmux- Design notes — why it is shaped this way
- Parity ledger — capability-by-capability against Python libtmux
| You want to | Use |
|---|---|
| Script tmux from Rust, async | libtmux |
| Ask "which pane/window/session matches X?" | libtmux query layer, below |
| Let an AI agent read and drive tmux | tmux-mcp |
| Filter your own structs with the same grammar | libtmux-macros |
| Build a workspace from a tmuxp-style YAML file | tmux-workspace |
Not for you if you need Windows without WSL — tmux does not run there — or a synchronous-first API. Blocking callers get a runtime, not a mirrored API.
The version has to be written out in full: Cargo does not resolve a prerelease
unless the requirement names one, so a plain 0.1 requirement selects nothing.
$ cargo add libtmux@0.1.0-alpha.8Cargo.toml
[dependencies]
libtmux = "0.1.0-alpha.8"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }use libtmux::test::TestServer;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// This example runs. `TestServer` is an isolated tmux on its own socket
// under `/tmp/libtmux-rs-test/`, torn down at the end, so it cannot touch
// sessions you are using. In your own code, that line is
// `let server = libtmux::Server::new()?;` and the rest is unchanged.
let guard = TestServer::new().await?;
let server = guard.server();
let session = server.new_session("work").await?;
let window = session.new_window("editor").await?;
let pane = window.active_pane().await?.expect("a window has a pane");
pane.send_keys("echo built").await?;
pane.send_key_names(["Enter"]).await?;
assert_eq!(server.sessions().await?.len(), 1);
guard.shutdown().await?;
Ok(())
}The examples on this page run as written, against a throwaway tmux. To run
them yourself, enable the test-support feature as a dev-dependency:
libtmux = { version = "0.1.0-alpha.8", features = ["test-support"] }.
Everything that reaches tmux is async. Everything that reads an
already-taken snapshot is not, so walking a tree you already have costs no
round trips.
Typed field handles build the expression, so a comparison that has no meaning for a field is a compile error rather than an empty result:
use libtmux::query::{Filterable as _, QueryIteratorExt as _};
use libtmux::test::TestServer;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = TestServer::new().await?;
let server = guard.server();
server.new_session("work").await?;
let panes = server.panes().await?;
let fields = libtmux::Pane::filter_fields();
// `pane_active` is a flag, so `.eq(true)` compiles; `.gt(..)` would not.
let active = fields
.pane_current_command
.starts_with("sh")
.and(fields.pane_active.eq(true));
let found = panes.iter().matching(&active).count();
assert_eq!(found, 1, "the session's one pane is active and running a shell");
guard.shutdown().await?;
Ok(())
}Ask what a session contains, and the relation is part of the expression:
use libtmux::query::{Filterable as _, QueryIteratorExt as _};
use libtmux::test::TestServer;
use libtmux::{SessionTree, WindowTree};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = TestServer::new().await?;
let server = guard.server();
let building = server.new_session("ci").await?;
building.new_window("build").await?;
server.new_session("idle").await?;
let sessions = SessionTree::filter_fields();
let windows = WindowTree::filter_fields();
let has_build = sessions.windows.any(windows.window.window_name.eq("build"));
// `hierarchy` gathers the whole tree in three tmux commands, not one
// per object.
let matched: Vec<_> = server
.hierarchy()
.await?
.iter()
.matching(&has_build)
.map(|branch| branch.session.name().to_string_lossy().into_owned())
.collect();
assert_eq!(matched, ["ci"], "only the session holding a `build` window");
guard.shutdown().await?;
Ok(())
}With the serde feature an expression lowers to a versioned JSON envelope, so
a CLI, a config file, or an MCP tool call can carry one.
Three switches, each a Cargo feature, none of them the default. The same
workload under each — this is cargo run --example matrix --all-features,
verbatim:
mode feature dispatches processes wall attribution
blocking/sequential plan 6 6 168ms per-command
async/sequential plan 6 6 189ms per-command
async/folded plan 3 3 59ms merged
async/marked-fold plan 3 3 57ms merged
control-mode/streaming plan,control-mode 6 1 8ms per-command
every mode built the same thing: true
Same result, different cost — and different evidence. Folding a chain into
one tmux invocation halves the dispatches, but tmux reports one status for the
group, so a failure cannot be blamed on a member: attribution degrades to
unknown by design rather than guessing. Control mode keeps per-command
evidence over a single process.
The libtmux guide
has the behavior table, the how-to-turn-it-on table, and the named presets.
tmux-mcp is a separate package: a Model Context Protocol
server over this API, read-biased, where every tool that changes state names
exactly what it changes.
$ cargo install tmux-mcp --version 0.1.0-alpha.9See its README for the tool list and for wiring it into an MCP client.
Rust 1.85 or newer, tmux 3.2a or newer, and a Unix target. Native Windows is unsupported because tmux is unavailable there; WSL works.
Every supported tmux release — 3.2a, 3.4, 3.5a, 3.6b, and 3.7b — is built from
source in CI and runs the whole workspace, because tmux's own output and error
wording change between releases. Where a release is wrong rather than merely
old, the crate says so: run_shell refuses on 3.3 through 3.4, which drop the
command's output instead of returning it.
| Crate | Version | What it is |
|---|---|---|
libtmux |
The async tmux client and object model | |
libtmux-macros |
#[derive(Filterable)], for your own structs |
|
tmux-mcp |
A Model Context Protocol server over tmux | |
tmux-workspace |
A tmuxp-style YAML builder |
libtmux does not require proc macros: its own Filterable implementations
are hand-written, and the derive exists for structs outside this workspace.
The last two crates are also how the API gets used from outside it. When
either needs a workaround, that is treated as a finding about libtmux rather
than about them.
just lists every recipe. Cargo is authoritative; the justfile only groups
Cargo commands.
$ just checkThat runs what CI runs: formatting, Clippy, tests, doctests, docs, the feature
powerset, cargo deny, both MSRV builds, and a packaging check.
Tests run against real tmux rather than a mock. libtmux::test::TestServer
gives each test an isolated socket under /tmp/libtmux-rs-test/ and
deterministic cleanup.
Every Rust example on this page is compiled by cargo test --doc, including
the ones in this file. See
.github/CONTRIBUTING.md for the gates and the
things that will bite, and .github/WRITING.md for how
the prose reads.
Licensed under either of Apache License, Version 2.0 or MIT license at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this workspace by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.