# libtmux
[![crates.io]][crate] [![docs.rs]][docs] [![MSRV]][rust-1.85]
[crates.io]: https://img.shields.io/crates/v/libtmux.svg
[crate]: https://crates.io/crates/libtmux
[docs.rs]: https://img.shields.io/docsrs/libtmux
[docs]: https://docs.rs/libtmux
[MSRV]: https://img.shields.io/badge/rustc-1.85+-lightgray.svg
[rust-1.85]: https://blog.rust-lang.org/2025/02/20/Rust-1.85.0/
**Drive tmux from Rust: typed, async control over servers, sessions, windows,
and panes.**
> **Alpha.** The API changes between releases, including in ways that will not
> be called out as breaking, because nothing here is stable yet. Cargo will not
> resolve a prerelease unless the requirement names one, so a plain `0.1`
> requirement does not pick this up: depend on the exact version below, and
> expect to edit it.
```rust
use libtmux::test::TestServer;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Runs for real. `TestServer` is an isolated tmux on its own socket under
// `/tmp/libtmux-rs-test/`, torn down at the end. Your own code says
// `let server = libtmux::Server::new()?;` instead; nothing else changes.
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 hello").await?;
pane.send_key_names(["Enter"]).await?;
for line in pane.capture().await? {
println!("{}", line.to_string_lossy());
}
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"] }`.
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.
```toml
[dependencies]
libtmux = "0.1.0-alpha.8"
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
`query` is the only one on by default. `full` turns on every capability
below, so a caller who wants them all does not have to list them.
| `query` | Typed filter expressions over listings. On by default |
| `plan` | Recording tmux work before running it, and choosing what it costs |
| `control-mode` | One persistent tmux connection, so the server reports changes as they happen rather than being polled |
| `blocking` | A runtime for calling from code that is not async |
| `derive` | `#[derive(Filterable)]`, for filtering your own structs with the same expressions |
| `serde` | Versioned serialization for `FilterExpr<T>`, for sending expressions over a wire |
| `tracing` | Sanitized command instrumentation |
| `test-support` | The real-tmux test guard, for your own tests |
| `full` | Every capability above, but not `test-support` |
## Choosing how commands reach tmux
Three switches decide what a run costs and what it can prove. They compose:
async is the API, control mode is the transport, and chaining is what a
subprocess transport does instead of having one.
### What each one does
| **Async** | Nothing: every method is already `async`. `blocking::Runtime` is a runtime you drive them from, not a second API to keep in step | `blocking::Runtime::new()?.run(future)` for scripts and tests |
| **Control mode** | One tmux process per command | One connection for every command, and tmux reports changes as they happen |
| **Chaining** | One tmux process per command | Neighbouring commands share one process, trading the ability to say which one failed |
Measured on one workload of six operations, all leaving identical tmux state:
| One command per invocation | 6 | names the failing command |
| Folded into shared invocations | 3 | `Unknown` -- tmux reports one status for the group |
| Control mode | 1 | names the failing command |
Control mode is the only one that buys back the processes without giving up
the answer, because its `%begin`/`%end` blocks are per command. Folding is
what a subprocess transport offers instead of having that. Run
`cargo run --example matrix --features full,test-support` to reproduce the
table on your own machine.
### How to turn each one on
| Async | none | already the default; every method is `async` |
| Blocking runtime | `blocking` | `libtmux::blocking::Runtime::new()?` |
| Control mode | `control-mode` | `ControlMode::attach(&server, session).await?` |
| Chaining, by hand | none | `CommandChain::new(a).then(b)`, then `server.chain(chain).await?` |
| Chaining, by planner | `plan` | `plan.run(&server, Planner::Folding).await?` |
| Folding a pane creation in too | `plan` | `Planner::Marked` |
| Never folding across your own work | `plan` | `Planner::steps_bounded(&plan, &boundaries)` |
| Per-command answers over one connection | `plan`, `control-mode` | `plan.run_over_control_mode(&sender).await?` |
Control mode is never the default transport, and turning the feature on does
not make it one: normal commands stay one process per command until you attach
a connection and use it.
## Walking the hierarchy
`Server::hierarchy` gathers the whole tree in three tmux commands, rather than
one per object:
```rust
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?;
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}");
}
}
}
guard.shutdown().await?;
Ok(())
}
```
Listings come in pairs, and the short name is the honest one. `sessions()`
returns `Result<Vec<Session>>`, so an unreachable tmux is an error rather than
an empty list. `sessions_or_empty()` collapses failure into no rows, which
suits a status line and nothing that reconciles state -- a reconciler reading
"no sessions" from an outage will happily delete everything.
## 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:
```rust
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();
let active_shell = fields
.pane_current_command
.starts_with("sh")
.and(fields.pane_active.eq(true));
assert_eq!(panes.iter().matching(&active_shell).count(), 1);
guard.shutdown().await?;
Ok(())
}
```
A question about what a session *contains* needs the shape that holds its
windows, so `SessionTree` and `WindowTree` carry relations:
```rust
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 ci = server.new_session("ci").await?;
ci.new_window("build").await?;
server.new_session("idle").await?;
let sessions = SessionTree::filter_fields();
let windows = WindowTree::filter_fields();
let building = sessions.windows.any(windows.window.window_name.eq("build"));
let names: Vec<_> = server
.hierarchy()
.await?
.iter()
.matching(&building)
.map(|branch| branch.session.name().to_string_lossy().into_owned())
.collect();
assert_eq!(names, ["ci"]);
guard.shutdown().await?;
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:
```rust
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.
```rust
use libtmux::test::TestServer;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = TestServer::new().await?;
let session = guard.server().new_session("work").await?;
match session.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.into()),
}
guard.shutdown().await?;
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:
```toml
[dev-dependencies]
libtmux = { version = "0.1.0-alpha.8", 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.6b, 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](https://docs.rs/libtmux) 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
Licensed under either of [Apache License, Version 2.0](LICENSE-APACHE)
or [MIT license](LICENSE-MIT) at your option.