Expand description
§libtmux
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.1requirement does not pick this up: depend on the exact version below, and expect to edit it.
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.
[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.
| Feature | What it adds |
|---|---|
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
| Switch | Off (the default) | On |
|---|---|---|
| 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:
| Mode | Processes | Attribution on failure |
|---|---|---|
| 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
| Switch | Cargo feature | In code |
|---|---|---|
| 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:
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:
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:
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:
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.
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:
[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 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 or MIT license at your option.
§Query iterators
Listings hand back an ordered Vec<T> that the caller owns. Borrow it with
.iter(), use Iterator::filter for an inline closure, and
query::QueryIteratorExt::matching for a portable expression or a named
query::Matcher. Exact cardinality inspects at most two items.
If another iterator extension trait, such as itertools::Itertools, adds
the same method name, use universal function call syntax to select this
crate’s method:
use libtmux::query::QueryIteratorExt;
let values = vec![1];
let item = QueryIteratorExt::exactly_one(values.iter());
assert_eq!(item, Ok(&1));§Finding what is already there
Naming an object is cheaper than listing and scanning for it:
let server = libtmux::Server::new()?;
// Find one object rather than listing and scanning.
if let Some(session) = server.session("work").await? {
if let Some(window) = session.window("editor").await? {
if let Some(pane) = window.active_pane().await? {
pane.send_keys("cargo test").await?;
pane.send_key_names(["Enter"]).await?;
}
}
}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:
let quiet = server.sessions_or_empty().await; // empty on failure
let loud = server.sessions().await?; // Err on failure§Building something and cleaning up
Scoped operations kill what they create, whether the body succeeded or
failed. Drop is deliberately not destructive, so nothing disappears
because a handle went out of scope.
let id = server
.with_session("throwaway", async |session| {
session.new_window("build").await?;
Ok::<_, libtmux::Error>(session.id().to_string())
})
.await?;Setup and teardown failures convert into the operation’s own error type,
so there is one ? rather than two. If both the operation and the cleanup
fail, the operation’s error is returned: that is the work you were doing.
§Options carry types
tmux reports no type over the command line, so the crate generates the
schema from tmux’s own table. That matters more than it sounds: status
holds "on" but is a choice, because tmux also accepts 2 through 5.
use libtmux::{OptionValue, option_names};
// Names are constants, so a typo does not compile.
let mouse = server.typed_global_option(option_names::MOUSE).await?;
assert!(matches!(mouse, Some(OptionValue::Flag(_))));§Examples
Runnable programs live in examples/: inspect reports what a server is
running, find selects panes with a typed expression, and scratch
builds a throwaway session on its own socket and cleans it up.
§Filtering the hierarchy
Session, Window, Pane, and Client carry generated field
handles, so an expression names the same type a listing returns:
use libtmux::query::Filterable as _;
let fields = libtmux::Session::filter_fields();
let expression = fields.session_name.starts_with("build");
let sessions: Vec<libtmux::Session> = Vec::new();
assert_eq!(sessions.iter().count(), 0);Field types decide which operations exist, so a mismatched comparison is a compile error rather than a predicate that is always false:
use libtmux::query::Filterable as _;
let fields = libtmux::Session::filter_fields();
// `session_name` is text, so it has no integer comparison.
let _ = fields.session_name.eq(3_u32);use libtmux::query::Filterable as _;
let fields = libtmux::Session::filter_fields();
// `session_windows` is an integer, so it has no substring operation.
let _ = fields.session_windows.contains("3");A question about what a session contains needs a value that holds its
windows. Server::hierarchy returns one, and SessionTree and
WindowTree carry relations for it:
use libtmux::query::{Filterable as _, QueryIteratorExt as _};
use libtmux::{SessionTree, WindowTree};
let sessions = SessionTree::filter_fields();
let windows = WindowTree::filter_fields();
// The session's own fields sit beside the relation, not behind it.
let building = sessions
.session
.session_name
.starts_with("build")
.and(sessions.windows.any(windows.window.window_name.eq("editor")));
for branch in server.hierarchy().await?.iter().matching(&building) {
println!("{}", branch.session);
}Query extensions intentionally apply only to borrowed iterators:
use libtmux::query::QueryIteratorExt;
let values = vec![1, 2, 3];
let _ = values.into_iter().matching(|candidate: &i32| *candidate > 1);§Being told instead of asking
Everything above runs a tmux command and reads the answer. The control-mode
feature opens one connection and keeps it, so tmux reports what happens as it
happens – no polling interval, and nothing missed between two polls.
Pane::stream_output is the narrow version: what one pane writes, as it
writes it, where Pane::capture gives only what is on screen now.
let mut output = pane.stream_output().await?;
while let Some(chunk) = output.next_chunk().await {
println!("{} bytes", chunk.len());
}
output.shutdown().awaitcontrol::ControlMode is the whole connection: every notification the server
sends, plus commands that travel down the connection rather than spawning a
process. Sending and watching are separate handles, so a task can act on what
it sees. See the control module.
§Calling from code that is not async
The blocking feature adds a blocking::Runtime that drives this crate’s
futures to completion. It is deliberately a runtime rather than a mirrored
blocking API: one type to learn, and no second surface to keep in step.
let runtime = libtmux::blocking::Runtime::new()?;
let server = libtmux::Server::new()?;
let sessions = runtime.run(server.sessions())?;
println!("{} sessions", sessions.len());Re-exports§
pub use hooks::IndexedHooks;pub use hooks::ReplaceMode;pub use hooks::SparseValues;
Modules§
- blocking
blocking - Running the async API without writing an async program.
- control
control-mode - Watching a tmux server over control mode.
- hooks
- Reading the hooks tmux is holding.
- option_
names - Option names, so a typo is a compile error rather than a runtime one.
- plan
plan - Describe tmux work before doing any of it.
- query
query - Predicates and cardinality helpers for borrowed iterators.
- since
- The tmux release each version-gated capability arrived in.
- test
test-support - Isolated real-tmux support for downstream tests.
Structs§
- Access
Rule - One entry of the server’s access list.
- Capture
Options - How far back a capture reaches, and in what form.
- Captured
Line - One line of a capture, with what tmux knows about it.
- Client
- One client attached to the tmux server.
- Client
Fields query - Typed filter field handles for a
client. - Command
- A logical tmux command with classified diagnostic arguments.
- Command
Chain - Several tmux commands dispatched as one
tmux a \; binvocation. - Command
Result - The exact status and output captured for one tmux command.
- Command
Summary - A rendering of a dispatched command that is safe to log.
- Control
Limits control-mode - What one control-mode connection may accumulate before it gives up.
- Dispatch
Limits - How much work one server may have in flight at once.
- Engine
Capabilities - Immutable capability state detected for one configured tmux executable.
- IdParse
Error - An invalid scope-specific tmux object ID.
- Listing
Decode Error - Payload-free metadata describing why tmux output could not be decoded.
- NewSession
Options - Options for creating a session.
- NewWindow
Options - Options for creating a window in a session.
- Option
Schema - What tmux declares about one option.
- Output
Limits - How many bytes one dispatch may read from each stream.
- Pane
- One tmux pane, as reached through one window link.
- Pane
Fields query - Typed filter field handles for a
pane. - PaneId
- A native tmux pane ID such as
%1. - Release
Suffix - The suffix of a numbered tmux release.
- Release
Version - A numbered tmux release.
- Server
- A cloneable handle to one captured tmux server endpoint.
- Server
Builder - A consuming builder for one inert
Serverhandle. - Server
Generation - Which tmux daemon is answering on an endpoint.
- Server
Identity - The structural identity of one tmux server endpoint.
- Session
- One tmux session, together with the snapshot it was discovered with.
- Session
Fields query - Typed filter field handles for a
session. - Session
Id - A native tmux session ID such as
$1. - Session
Name - A session name tmux can address.
- Session
Tree - One session and everything under it, from
Server::hierarchy. - Session
Tree Fields query - Typed filter handles for
SessionTree. - Split
Options - Options for splitting a window or pane into a new pane.
- Tmux
Text - Immutable text bytes returned by tmux.
- Tmux
Version - A parsed raw tmux version.
- Window
- One tmux window, as reached through one session that links it.
- Window
Fields query - Typed filter field handles for a
window. - Window
Id - A native tmux window ID such as
@1. - Window
Tree - One window and its panes, from
Server::hierarchy. - Window
Tree Fields query - Typed filter handles for
WindowTree.
Enums§
- Access
Mode - How much a user on the server’s access list may do.
- Chooser
- One of tmux’s interactive choosers.
- Control
Mode Error Kind control-mode - Why a control-mode connection failed.
- Environment
Entry - What a session’s environment holds for one name.
- Error
- An error returned by libtmux.
- Error
Kind - What a failure means for the caller.
- Object
Kind - The kind of tmux object a failure refers to.
- Option
Error Kind - Which way tmux would not accept an option.
- Option
Kind - What kind of value an option holds.
- Option
Scope - Which table an option primarily lives in.
- Option
Value - One option’s value, decoded according to what tmux declares about it.
- Pane
Direction - Which way to move focus among a window’s panes.
- Pane
Progress State - Closed progress-state vocabulary tmux emits for a pane’s progress bar.
- Pane
Size - How much space a new pane gets.
- Pane
Target - A target accepted by pane-scoped operations.
- Prompt
Kind - Which prompt tmux is remembering answers for.
- Resize
Direction - Which edge a resize moves.
- Rotation
- Where a split puts the new pane, relative to the one being divided.
- Server
Configuration Error Kind - The category of an invalid
crate::ServerBuilderconfiguration. - Server
Gone Kind - Which way a tmux server was not there.
- Session
Name Error - Why a session name is one tmux could not address.
- Session
Target - A target accepted by session-scoped operations.
- Split
Direction - Where a split puts the pane it makes.
- Window
Placement - Where a new window goes, relative to the index it is given.
- Window
Target - A target accepted by window-scoped operations.
Functions§
- option_
schema - Look up what tmux declares about one option.
Derive Macros§
- Filterable
derive - Derive a stable typed filter schema for a named struct.