libtmux 0.1.0-alpha.3

Async typed tmux client and object model
Documentation

libtmux

Drive tmux from Rust: typed, async control over servers, sessions, windows, and panes.

use libtmux::Server;

# async fn walk() -> Result<(), libtmux::Error> {
let server = Server::new()?;
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("cargo test").await?;
pane.send_key_names(["Enter"]).await?;

for line in pane.capture().await? {
    println!("{}", line.to_string_lossy());
}
# Ok(())
# }

Every accessor that reaches tmux is async; everything that reads an already-taken snapshot is not. Commands run without a shell, and results keep stdout and stderr as raw bytes, so decoding stays the caller's decision.

Requirements and installation

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.

[dependencies]
libtmux = "0.1.0-alpha.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }

Async operations need an entered Tokio runtime. The default executable is tmux, resolved through the PATH captured when the Server is built; use ServerBuilder::tmux_executable to select another.

Minimum supported Rust version. 1.85, the first compiler to ship Edition 2024. It is checked in CI against the whole test suite, not just a build. A raise is a minor version bump and is called out in the release notes, so a patch release never moves it.

Features

None are on by default.

Feature What it adds
control-mode One persistent tmux connection, so the server reports changes as they happen rather than being polled
derive #[derive(Filterable)], for filtering your own structs with the same expressions
serde Versioned serialization for FilterExpr<T>, for sending expressions over a wire
blocking A runtime for calling from code that is not async
tracing Sanitized command instrumentation
test-support The real-tmux test guard, for your own tests

Walking the hierarchy

Server::hierarchy gathers the whole tree in three tmux commands, rather than one per object:

# async fn tree(server: &libtmux::Server) -> Result<(), libtmux::Error> {
for branch in server.hierarchy().await? {
    println!("{}", branch.session.name().to_string_lossy());

    for window in &branch.windows {
        println!("  {}", window.window.name().to_string_lossy());

        for pane in &window.panes {
            println!("    {pane}");
        }
    }
}
# Ok(())
# }

Listings come in pairs. The plain form returns an empty Vec when the underlying tmux command fails, which suits a status line; the try_ form keeps the reason, which suits anything that must not guess.

Filtering

Typed field handles build an expression without accepting an untyped field name or value, so a comparison that has no meaning for a field does not compile:

use libtmux::query::{Filterable as _, QueryIteratorExt as _};

# async fn find(server: &libtmux::Server) -> Result<(), libtmux::Error> {
let panes = server.try_panes().await?;
let fields = libtmux::Pane::filter_fields();

let editors = fields
    .pane_current_command
    .starts_with("nvim")
    .and(fields.pane_active.eq(true));

for pane in panes.iter().matching(&editors) {
    println!("{pane}");
}
# Ok(())
# }

A question about what a session contains needs the shape that holds its windows, so SessionTree and WindowTree carry relations:

use libtmux::query::{Filterable as _, QueryIteratorExt as _};
use libtmux::{SessionTree, WindowTree};

# async fn contained(server: &libtmux::Server) -> Result<(), libtmux::Error> {
let sessions = SessionTree::filter_fields();
let windows = WindowTree::filter_fields();

let building = sessions.windows.any(windows.window.window_name.eq("build"));

for branch in server.hierarchy().await?.iter().matching(&building) {
    println!("{}", branch.session);
}
# Ok(())
# }

With serde, an expression lowers to a versioned JSON envelope, so a CLI, an MCP server, or a config file can carry one.

Text from tmux is bytes

tmux permits names, titles, and pane contents that are not valid UTF-8, so they arrive as TmuxText rather than String. There is no implicit conversion:

let text = libtmux::TmuxText::from("editor");

assert_eq!(text.as_bytes(), b"editor");
assert_eq!(text.as_str().expect("valid UTF-8"), "editor");
assert_eq!(text.to_string_lossy(), "editor");

Failures say what to do about them

Error has a variant per failure mode; Error::kind reduces those to the decision a caller makes. Error::is_object_gone is the branch most programs write, because an object disappearing is an ordinary race rather than a failed request.

# async fn resilient(session: &libtmux::Session) -> Result<(), libtmux::Error> {
match session.try_windows().await {
    Ok(windows) => println!("{} windows", windows.len()),
    Err(error) if error.is_object_gone() => println!("gone"),
    Err(error) if error.is_transient() => println!("retry: {error}"),
    Err(error) => return Err(error),
}
# Ok(())
# }

Cancellation and shutdown

Each command runs in an isolated process group with a supervised deadline, 30 seconds by default and configurable through ServerBuilder::default_timeout. Dropping the command future, reaching its timeout, or shutting the server down signals the group and waits for the direct child while the runtime is alive.

Server::shutdown() is shared by all clones: it cancels active work, rejects later commands, and is safe to call concurrently or repeatedly. Await it, or await your commands, before tearing the runtime down — runtime destruction signals best-effort but cannot promise that child reaping finished.

Testing against real tmux

test-support exports libtmux::test::TestServer, the same guard this crate's own suite uses:

[dev-dependencies]
libtmux = { version = "0.1.0-alpha.3", features = ["test-support"] }

Each guard owns a tmux child on a private socket with an empty config, so tests cannot reach your real server or each other. shutdown().await closes escaped clients, waits the daemon, and reports cleanup failures; Drop forces best-effort cleanup even after the runtime has ended. On Linux, cleanup also sweeps processes by an exact environment marker through pidfds, so PID reuse cannot redirect a signal.

The guarantees and their limits are set out in docs/design.md, which ships with the crate.

Compatibility

Every supported tmux release is built from source in CI and runs the whole workspace: 3.2a, 3.4, 3.5a, 3.6, and 3.7b. The floor and the ceiling matter equally — 3.4 and 3.5a are the releases that wrap command output differently, which is why the format codec carries a second dialect.

Documentation

API documentation covers the public surface. Two longer documents ship inside the crate, next to the source:

  • docs/design.md — why the crate is shaped the way it is: the transport, the snapshot and format boundary, the query grammar, the test guard, and the compatibility lanes.
  • docs/parity.md — the capability ledger against Python libtmux, naming each Rust symbol and the test that exercises it.

License

MIT.