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- Examples — six programs that run and clean up after themselves, from reading a server to watching one over control mode
- 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.9Cargo.toml
[dependencies]
libtmux = "0.1.0-alpha.9"
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("the new window has a pane");
pane.send_line("echo built").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.9", 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 std::time::Duration;
use libtmux::query::{Filterable as _, QueryIteratorExt as _};
use libtmux::test::{TestServer, retry_until};
#[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 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));
// tmux hands back a pane the moment it forks, before the shell in it has
// started, so what a pane is running is worth waiting for rather than
// assuming. A wait must assert the outcome it got: this one fails if the
// deadline passes without the expression ever matching.
retry_until(Duration::from_secs(5), async || {
server
.panes()
.await
.is_ok_and(|panes| panes.iter().matching(&active).count() == 1)
})
.await?;
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, printed by cargo run --example matrix --all-features.
Every column but wall is exact and checked against this block by
just example-tables; the timings are one run on one developer machine:
mode feature dispatches processes wall attribution query output
----------------------------------------------------------------------------------------------------------------------
blocking/sequential plan 6 6 16ms per-command 2 panes, 2 windows, 2 active
async/sequential plan 6 6 16ms per-command 2 panes, 2 windows, 2 active
async/folded plan 3 3 9ms merged 2 panes, 2 windows, 2 active
async/marked-fold plan 3 3 10ms merged 2 panes, 2 windows, 2 active
control-mode/streaming plan,control-mode 6 1 6ms per-command 2 panes, 2 windows, 2 active
every mode built the same thing: true
dispatches ranged 3..6, processes ranged 1..6
the same failure, dispatched two ways:
Sequential 1 dispatch(es) -> [Failed, Skipped]
Folding 1 dispatch(es) -> [Unknown, Unknown]
Sequential names the failing operation; Folding cannot, because tmux
reports one status for the group whichever member failed.
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.10See 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.