Expand description
Type into a pane and read what it printed, wait for an answer instead of sleeping, find and filter what a server holds, and hear about changes as tmux makes them.
It suits tools that drive a terminal program, test suites that need a real tmux, and agents that share a terminal.
Releases are numbered
0.1.N, and each release incrementsN. The API is not settled: any release may change or remove exported identifiers, and the changelog marks each break. Pin an exact version to avoid one.
See the migration notes when upgrading from an earlier alpha.
libtmux-macros, tmux-workspace and
tmux-mcp are published beside this crate, under
Packages.
Contents — Quick start · Where to start · What tmux is made of · Find things · Type and wait · Watch tmux · Filtering · Failures · Choosing a transport · Cancellation · Raw commands · Bytes · Testing · Features · Python libtmux · Packages · Leaves out · Support · Documentation
§Quick start
Add the crate, and Tokio to drive it:
[dependencies]
libtmux = "0.1.0"
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }Create a session, type into its pane, and print what the pane showed:
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_line("echo hello").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", features = ["test-support"] }.
Pane::send_line types a line and presses Enter, and Pane::capture
reads what the pane shows. Every method 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.
Commands run without a shell, and results keep stdout and stderr as raw bytes, so decoding stays the caller’s decision.
It needs Rust 1.85 or newer and tmux 3.3a or newer, on a Unix host. Against an
older daemon, an operation returns Error::UnsupportedTmuxVersion and sends
nothing. To fix it, upgrade tmux and restart its server, or pin the previous
release. The contract covers unrecognized versions, failed
probes, and the calls that skip the check.
Optional operations have narrower daemon requirements:
| Operation | Qualified daemon |
|---|---|
| Ordinary subprocess operations and polling waits | 3.3a or newer |
| Named-buffer deletion or rename, including managed paste cleanup | 3.4 or newer |
Captured run-shell output | 3.5 or newer |
| Control acquisition, pane streams and subscriptions | A daemon ranking at or above 3.7 final |
Literal break-pane | Refused on exact 3.7; available again on 3.7a |
Literal split-window -p | Refused on 3.4; typed percentages use -l N% |
Literal select-layout with a layout operand | On 3.3a, a unique preset or standard serialized header; unsafe operands refuse |
Capture flags -T / -F | 3.4 / 3.7 or newer |
Literal refresh-client -l (clipboard query) | 3.6a or newer; earlier releases can stop the server |
Managed control acquisition accepts a daemon ranking at or above 3.7 final,
including 3.8-rc2 and next-3.8; next-3.7 and 3.7-rc* rank below it and
refuse.
Eligibility comes from the checked daemon and assumes a natively compatible
client/daemon pair. Direct literal argv commands and
flat batches are checked before dispatch; Command::requires_tmux carries a
managed staging requirement across the whole chain. Alias bodies are not
read, and shell programs keep native semantics.
Refused commands nested in another command. The guard reads the command
it is given and nothing nested in it. A refused command carried in the text of
another command reaches the daemon as written: inside if-shell,
run-shell -C, bind-key, set-hook, confirm-before, a menu, a prompt or
choose-* template, or a brace block. What happens then depends on the
command and the release:
| Nested command | Releases | Result |
|---|---|---|
break-pane without -n | exactly 3.7 | The server exits. Fixed in 3.7a. |
select-layout with an invalid string that has no checksum | 3.3 and 3.3a | The server exits. 3.2a and 3.4 answer with an error and stay up. |
refresh-client -l | below 3.6a | The server exits when an attached client leaves or does not answer the clipboard query. |
delete-buffer -b NAME, set-buffer -b NAME -n | below 3.4 | Silently acts on the most recent buffer when NAME does not exist. |
split-window -p | exactly 3.4 | tmux reports an error and nothing changes. |
: or . in a session or window name | exactly 3.7 | tmux reports an error and nothing changes. |
On exact tmux 3.7, name every break-pane you nest or bind with -n NAME,
including the one the prefix ! binding runs, or upgrade to 3.7a or later.
A direct literal break-pane is refused on exact 3.7 whatever its flags.
Upstream: the
break-pane fix in 3.7a,
its cause,
the prefix ! binding
and the clipboard query.
On the pinned Linux builds, 3.3a, 3.4 and 3.5 clients cannot register a control connection to an exact 3.7 daemon; 3.7d can. Those older clients exit without changing the daemon, including on a plain native attachment. Use a compatible client for control; this does not raise the 3.3a subprocess baseline.
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.
§Where to start
By what you came to do:
| I want to | Call | Shown in |
|---|---|---|
| Make a session and tear it down | Server::with_session | examples/scratch.rs |
| Reach a session someone left running | Server::session, Server::sessions | examples/inspect.rs |
| Find the pane my process is in | Pane::from_env | examples/inspect.rs |
| Add a window, split a pane | Session::new_window, Pane::split | examples/scratch.rs |
| Type into a pane | Pane::send_line, Pane::send_keys, Pane::send_key_names | examples/scratch.rs |
| Read what a pane printed | Pane::capture, Pane::capture_with | examples/scratch.rs |
| Wait for output rather than sleep | Pane::wait_for_text, Pane::wait_for_quiet | examples/scratch.rs |
| Follow a pane as it writes | Pane::stream_output | examples/watch.rs |
| Hear about changes as they happen | ControlMode::attach, ControlSender::subscribe | examples/watch.rs |
| Select panes by a typed expression | Filterable::filter_fields, QueryIteratorExt::matching | examples/find.rs |
| Send tmux something this crate has no method for | Server::cmd | below |
| Compare what each transport costs | plan::Plan, plan::Planner | examples/matrix.rs |
| Clean up fixtures a killed run left | test::reap_abandoned_servers | examples/sweep.rs |
| Tell a gone object from a gone link | Error::is_object_gone, Error::is_transient | examples/recover.rs |
Waiting needs no feature. Pane::wait_for_text and
Pane::wait_for_quiet are in the library, and docs/findings.md
records what they had to survive.
§What tmux is made of
Server ──┬── Session ──── Window ──── Pane
└── Client ─────┘| Type | Held by | Identified by | Notes |
|---|---|---|---|
Server | a socket | its socket path | one daemon; Server::new finds the default |
Session | the server | $0 | what a client attaches to |
Window | linked into sessions | @0 | one window can be linked into several sessions at once |
Pane | exactly one window | %0 | holds the process and the scrollback |
Client | the server | its tty | attached to one session at a time |
The third row decides API shape. A window has one identity and several
links, so a command that removes one link names the session too, as
session:@id. Window::unlink does exactly that.
It reports Error::LinkGone rather than
Error::ObjectGone when the link is already gone, because
the window itself may still be running under another session.
§Find sessions, windows, and panes
§Server::hierarchy: the whole tree in three commands
Server::hierarchy gathers sessions, windows, and panes in three tmux
commands, rather than one per object. Server::sessions and
Session::windows are its narrower siblings:
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(())
}The accessors in the loop are not async: the result is a snapshot, so
reading names from it costs no round trips.
Server::sessions returns Result<Vec<Session>>, so an unreachable tmux is
an error and a running tmux with no sessions is an empty list.
sessions().await.unwrap_or_default() suits a status line. A reconciler that
reads “no sessions” from an outage would delete everything, so it should match
on the error. The contract has more.
§Type into a pane and wait
§Server::wait_for_channel: wait for work that announces itself
If the work can announce itself, do not poll. End it with
tmux wait-for -S <channel>, which Server::signal_channel sends, and wait
on that channel:
use libtmux::ChannelWait;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = libtmux::test::TestServer::builder().start().await?;
let server = guard.server();
// Stands in for the pane running `make; tmux wait-for -S build-done`.
// Signalling first is safe: tmux keeps it for the next waiter.
server.signal_channel("build-done").await?;
match server.wait_for_channel("build-done", Duration::from_secs(60)).await? {
ChannelWait::Signalled => println!("the build ended"),
// Running out of time is an outcome, not an error, so this stays
// distinguishable from tmux being unreachable.
_ => println!("it is still going"),
}
guard.shutdown().await?;
Ok(())
}tmux keeps a signal nobody is waiting on, so the job finishing first does not lose the race, and nothing polls. A wait that runs out of time keeps its client on the channel, so the signal is still there for this process’s next wait.
The example prints the build ended. Running out of time is an outcome
(ChannelWait), and tmux being unreachable is an error.
examples/orchestrate.rs runs three jobs this way.
§Pane::wait_for_text: wait for text a pane prints
For a pane that was not written to announce itself, Pane::wait_for_text
does the same job without a channel:
use libtmux::{PaneWait, test::TestServer};
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = TestServer::new().await?;
let session = guard.server().new_session("waiting").await?;
let pane = session.panes().await?.remove(0);
pane.send_line("printf 'ready\n'").await?;
// Checked rather than discarded: a wait that reached its deadline still
// returns successfully, so the outcome is the answer.
assert_eq!(
pane.wait_for_text("ready", Duration::from_secs(10)).await?,
PaneWait::Arrived,
);
guard.shutdown().await?;
Ok(())
}It prints nothing, and the assertion is the check: a wait that reached its
deadline still returns successfully, so PaneWait is the answer.
- It looks before it sleeps, so text already present is an answer rather than a wait.
- It reads the scrollback with wrapped lines joined, so output that scrolled away is still found and a needle spanning a wrap still matches.
- It answers
PaneWait::Deadwhen the pane’s process ends, rather than holding the deadline. The answer carries no text, and a pane whose process has ended is destroyed unlessremain-on-exitis on, so there is usually nothing left to capture. Setremain-on-exiton the pane if you need its last output, then callPane::captureafter theDeadanswer.
Polling costs a tmux process per tick, and a tick is 120ms, so a wait stays
under ten dispatches a second. Every command is also bounded by the server’s
default_timeout: the deadline above is for the
condition, and the timeout is for the transport.
Pane::wait_for_quiet waits for a pane to stop writing. command_prompt and
display_menu wait for a person, so the dispatch timeout is what ends them.
§Watch tmux change
§ControlSender::subscribe: have tmux watch a value
With control-mode, a subscription costs one connection rather than a process
per tick. tmux evaluates a format for you and reports it when its value
changes. It watches a format rather than the pane’s text, so it suits “how many
windows are there now” better than “did this line appear”:
use libtmux::control::{ControlMode, Event, Subscription};
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("watching").await?;
let (commands, mut events) = ControlMode::attach(guard.server(), session.id())
.await?
.split();
commands
.subscribe("windows", &Subscription::Session, "#{session_windows}")
.await?;
// The first report arrives without anything having changed, so a
// subscription reads the value as well as watching it.
let mut reports = 0;
while let Some(event) = events.next_event().await {
if let Event::SubscriptionChanged { name, value, .. } = event? {
println!("{} = {}", name.to_string_lossy(), value.to_string_lossy());
reports += 1;
if reports == 1 {
break;
}
}
}
commands.unsubscribe("windows").await?;
events.shutdown().await?;
guard.shutdown().await?;
Ok(())
}The first report arrives without anything having changed, so a subscription reads the value as well as watching it. tmux coalesces reports to at most once a second: a subscription says what a value became, and skips the steps between.
To follow a pane as it writes, use Pane::stream_output.
examples/watch.rs runs both.
§Filtering
§Filterable::filter_fields: a comparison that means nothing does not compile
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 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();
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(())
}§SessionTree and WindowTree: ask what a session contains
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.
§Failures say what to do about them
§Error::kind: the decision a caller makes
Error has a variant per failure mode, and Error::kind reduces those to
the decision a caller makes:
Error::is_object_goneis the branch most programs write, because an object disappearing is an ordinary race and the request was fine.Error::is_transientmeans the same call can be repeated without duplicating an effect. A timeout isfalse, because tmux may have completed the command before its caller stopped waiting.Error::AfterEffect, classified asErrorKind::PartialEffect, means tmux accepted one effect of a composed call before a later step failed. Replay may repeat the accepted effect.
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(())
}The block shows the shape against a healthy server, so its interesting arms do
not run. examples/recover.rs makes each failure happen
instead, including the one that costs people: a link is gone and the window it
pointed at is still running. The contract has the details.
§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, and the API stays the async one | 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. 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? |
§Server::over_control_mode: send every typed call down one connection
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. Server::over_control_mode returns a handle whose
ordinary typed calls travel down the connection, and handles reached through
it inherit the route:
use libtmux::control::ControlMode;
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("routed").await?;
let (commands, events) = ControlMode::attach(guard.server(), session.id())
.await?
.split();
let routed = guard.server().over_control_mode(&commands).await?;
// One connection, no processes: the whole typed API, not a plan's worth of it.
for pane in routed.panes().await? {
pane.send_line("echo hello").await?;
}
events.shutdown().await?;
guard.shutdown().await?;
Ok(())
}Keep the original server for the two things a connection is wrong for: a
command that parks the client’s queue, such as wait-for or a foreground
run-shell, and arguments that are not valid UTF-8, which a text protocol
cannot carry.
§Cancellation and shutdown
§ServerBuilder::default_timeout: every command is bounded
Each subprocess command runs in an isolated process group with a supervised deadline, 30 seconds by default. Dropping the command future, reaching its timeout, or shutting the server down signals the group.
The signal reaches the tmux client, and a command the server has already received still runs. A dropped mutation may or may not have happened, and a retry can repeat it.
§Dropping a future: a deadline for one call
A caller that wants a shorter deadline for one command does not need an API for it. Dropping the future is what signals the group, so wrapping the call is a per-call deadline with the cleanup already attached:
use libtmux::Command;
use std::time::Duration;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = libtmux::test::TestServer::new().await?;
let server = guard.server();
// Ends at 400ms rather than the server's 30-second default, and the
// client's process group is signalled and reaped on the way out. The
// `sleep` runs under the tmux server, so it finishes regardless.
let bounded = tokio::time::timeout(
Duration::from_millis(400),
server.cmd(Command::new("run-shell").arg("sleep 3")),
)
.await;
assert!(bounded.is_err());
// The server is still usable: a bounded call is not a broken connection.
server.sessions().await?;
guard.shutdown().await?;
Ok(())
}§Server::shutdown(): end everything
It cancels active subprocess work and persistent control connections, rejects later commands and attaches, and is safe to call concurrently or repeatedly. Await it, or await your commands, before tearing the runtime down. The contract has the control-mode details.
§When the typed API does not cover it
§Server::cmd: any tmux command, through the same machinery
tmux has more commands than this crate has methods, and a method can be
narrower than the command behind it. Server::cmd runs anything, through
the same transport, timeout and error classification as the typed calls, so
reaching for it costs you the method and keeps the machinery:
use libtmux::{Command, test::TestServer};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let guard = TestServer::new().await?;
let server = guard.server();
let session = server.new_session("escape-hatch").await?;
let pane = session.panes().await?.remove(0);
// `Pane::clock_mode` has no typed way back out for every mode, so this is
// how you leave one the API does not model.
server
.cmd(
Command::new("copy-mode")
.arg("-q")
.arg("-t")
.arg(pane.id().to_string()),
)
.await?;
// The answer carries tmux's own bytes. A command tmux refuses is an `Err`
// classified the same way a typed call's would be.
let version = server
.cmd(Command::new("display-message").arg("-p").arg("#{version}"))
.await?;
assert!(!version.stdout().is_empty());
guard.shutdown().await?;
Ok(())
}A command you needed here is worth reporting: tmux-mcp and tmux-workspace
use this crate from outside it, and when either reaches for cmd that counts
as a gap in the API.
§Text from tmux is bytes
§TmuxText: names, titles, and contents as 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");§Testing against real tmux
§TestServer: a private tmux per test
test-support exports libtmux::test::TestServer, the same guard this
crate’s own suite uses:
[dev-dependencies]
libtmux = { version = "0.1.0", 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, and Drop cleans up
best-effort even after the runtime has ended.
There is no mock and no replay mode: a test here talks to tmux, because what tmux answers is the thing under test. CI needs tmux installed, which on a GitHub Actions Ubuntu runner is one step:
- run: sudo apt-get install -y --no-install-recommends tmuxLIBTMUX_TEST_TMUX points the guard at a specific
build when one release matters. The contract lists what
the guard isolates.
§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 |
schema | JSON Schema for serialized plans and portable filter expressions |
tracing | Sanitized command instrumentation: a tmux_command span per dispatch |
test-support | The real-tmux test guard, for your own tests |
full | Every capability above, but not test-support |
§If you know the Python libtmux
This is a port of it, and the object model is the same one: a server holding sessions, windows linked into them, panes inside those. Code you have written against the Python library will read as familiar here. Four things differ, and they are the ones worth knowing before you start.
Python libtmux | this crate | |
|---|---|---|
| Calling | synchronous throughout | async throughout; blocking::Runtime drives it from code that is not |
| Pane text | capture_pane returns list[str] | Pane::capture returns TmuxText, which keeps bytes no String would hold |
| Filtering | QueryList lives in _internal | query is public, and field handles are generated per type |
| Transport | one tmux process per command | the same by default, plus control mode and command chaining |
Waiting differs in this crate’s favour. The Python library keeps retry_until
in libtmux/test/retry.py, a test helper, so production code that waits on a
pane writes the loop itself. Pane::wait_for_text and
Pane::wait_for_quiet are here in the library and need no feature.
§Packages
Each crate published from this repository has a header linking to its directory, where its own README starts.
§libtmux
This crate: the async tmux client and object model. The examples on this page
are its own, and examples/ holds longer programs.
§libtmux-macros
#[derive(Filterable)], so your own structs take the same typed query grammar
as tmux objects. You do not depend on this crate: the derive feature
re-exports the macro.
use libtmux::query::{Filterable as _, QueryIteratorExt as _};
#[derive(libtmux::Filterable)]
#[filterable(target = "job")]
struct Job {
name: String,
attempts: u32,
}
let jobs = [
Job { name: "build".into(), attempts: 3 },
Job { name: "deploy".into(), attempts: 1 },
];
let fields = Job::filter_fields();
let retried = fields.name.starts_with("bui").and(fields.attempts.gt(2));
assert_eq!(jobs.iter().matching(&retried).count(), 1);§tmux-workspace
A native tmuxp: it loads tmuxp workspace files, freezes running sessions back into them, and imports tmuxinator and teamocil projects, as one binary with no Python.
Command reference · API reference · docs.rs · Guide
$ cargo install tmux-workspace --lockedBuild the session a workspace file describes, and attach to it:
$ tmux-workspace load ./dev.yamlSave a running session back to a file:
$ tmux-workspace freeze dev -o dev.yaml§tmux-mcp
A Model Context Protocol server over this crate. It is read-biased, and every tool that changes state names what it changes.
Tools · API reference · docs.rs · Connect a client · Guide
$ cargo install tmux-mcpRegister it with Claude Code. The guide has the same step for the other clients:
$ claude mcp add tmux -- tmux-mcpOffer only the read tools, on the tmux socket named work:
$ claude mcp add tmux \
--env LIBTMUX_TOOLSETS=inspect \
-- tmux-mcp --socket-name work§What it leaves out
- A mock or a replay mode. A test talks to a real tmux, because what tmux
answers is the thing under test.
TestServermakes that cheap. - A second, blocking API.
blocking::Runtimeis a runtime you drive the async API from. - Text decoding. Names, titles, and pane contents arrive as
TmuxTextbytes, and the caller decides how to decode them. - A default control-mode transport. Control mode is a connection you attach and route through, and ordinary calls stay one process each.
- Native Windows. tmux is unavailable there; WSL works.
§Support and status
Releases are numbered 0.1.N, and each release increments N. The API is not
settled: any release may change or remove exported identifiers, and the
changelog marks each break. Pin an exact version to avoid one.
- Rust 1.85+, the first compiler to ship Edition 2024. CI runs the whole test suite on it, and a raise is a minor version bump named in the release notes.
- tmux 3.3a or newer. CI builds 3.3a, 3.4, 3.5a, 3.6b, and 3.7b from source and runs the whole workspace against each. 3.4 and 3.5a wrap command output differently, which is why the format codec carries a second dialect.
- Linux, including WSL. CI runs the tests on Linux. macOS is supported on a best-effort basis; the release workflow builds binaries there, and CI does not run the tests.
- Unix only. Native Windows is unsupported because tmux is unavailable there.
§Documentation
| API documentation | The public surface, one page per item, with runnable examples |
| API reference | The same surface on libtmux.org, with guides and concepts beside it |
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/findings.md | Every tmux defect and platform surprise the crate works around, each with the tmux releases it holds for and what the crate does |
docs/parity.md | The capability ledger against Python libtmux, naming each Rust symbol and the test that exercises it |
docs/contracts.md | What the crate promises: the version check, deadlines, errors, and the test guard |
docs/migration.md | Moving from an earlier alpha |
examples/ | Programs that run and clean up after themselves |
§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.
matching and matching_owned are this trait’s own; exactly_one and
one_or_none deliberately overlap with itertools::Itertools, which has
exactly_one and at_most_one. The overlap is kept rather than renamed
around: the names are the obvious ones, and the shapes differ where it
matters. query::ExactlyOneError is a plain NoItems/MultipleItems
enum that is Eq and cheap to match, where itertools’ error owns the
iterator so it can replay it. With both traits imported a call is
ambiguous, which is a compile error naming both candidates rather than a
silent choice; universal function call syntax picks one:
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_line("cargo test").await?;
}
}
}A listing keeps the reason it failed. A caller that would rather show nothing than an error says so at the call site, where it reads as the choice it is:
let loud = server.sessions().await?; // Err on failure
let quiet = server.sessions().await.unwrap_or_default(); // empty on failure§Building something and cleaning up
Once polled, a scoped operation owns creation and cleanup. Cancellation can
let an in-flight creation finish, but an object whose creation yields a
handle is killed while the Tokio runtime remains active. Ordinary handle
Drop is deliberately non-destructive.
let id = server
.with_session("throwaway", async |session| {
session.new_window("build").await?;
Ok::<_, libtmux::Error>(session.id().to_string())
})
.await?;ScopeError distinguishes creation, operation and cleanup failures.
When both operation and cleanup fail, it retains both errors without
converting the operation’s error type. Cleanup errors carry
Error::AfterEffect because creation already succeeded.
§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.
A typed write is checked against the same table before it is sent;
OptionValue says how reads and writes fit together.
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(_))));
// A write takes the type a read returns, and a value outside what the
// table declares is refused before tmux sees it.
server.set_typed_global_option(option_names::HISTORY_LIMIT, 50_000).await?;§A name reaches tmux as text
tmux expands a name through its format machinery before it checks it, so
#{session_id} in a name would become the id and #(command) would run
command in a shell. That holds for a session and window name, a pane
title, an option name, and the -c start directory alike. tmux is
consistent here: whoever can run tmux new-session can already run
commands, so a name given on a command line is trusted by construction.
A library moves that boundary. tmux’s caller is a person at a shell; this
crate’s caller is a program, and the name it passes may have come from an
argument, a request field, or a configuration file. So every argument tmux
would expand is typed TmuxArg, and every conversion into it escapes:
what a caller passes is what tmux stores.
use libtmux::{NewSessionOptions, TmuxArg};
// Text is text, whatever is in it.
server.new_session("release#1").await?;
// Expansion is opt-in, and reads as such. Only for a template the
// program itself wrote.
server
.new_session(NewSessionOptions::new("build").start_directory(
TmuxArg::format("#{pane_current_path}"),
))
.await?;Expansion is not the only way the name you asked for is not the name you
get. tmux releases through 3.6b rewrite : and . in a session name to
_, because a target is split on those, and they do it silently: 3.7
refuses such a name outright, and 3.7a keeps it. So new_session("a:b")
succeeds on every supported release except 3.7 and hands back a session
called a_b on most of them. The handle reports what tmux stored, so
Session::name is always the truth; the request is what may differ from
it. Compare the two when the name has to round-trip.
§Examples
Runnable programs live in examples/. inspect reports what a server is
running and find selects panes with a typed expression, both of which
only read. scratch builds a throwaway session on its own socket and
cleans it up, which is the shortest complete tour: a window, a split, keys
sent, output waited for rather than slept on, and a scope that kills the
session whether the body succeeded or not. watch reacts to what a server
does over one control-mode connection while driving it down the same one.
matrix runs one workload six ways, so the cost of each execution mode is
visible side by side. sweep reaps servers that abandoned fixtures left
behind, which is maintenance rather than orchestration.
just examples runs every one of them against a server it owns and fails
if any leaves a socket behind.
§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);
}Use matching_owned to move selected items out of their collection:
use libtmux::query::QueryIteratorExt;
let values = vec![1, 2, 3];
let selected = values
.into_iter()
.matching_owned(|candidate: &i32| *candidate > 1)
.collect::<Vec<_>>();
assert_eq!(selected, [2, 3]);matching still needs a borrowed iterator; into_iter() does not satisfy
it:
use libtmux::query::QueryIteratorExt;
let values = vec![1, 2, 3];
let _ = values.into_iter().matching(|candidate: &i32| *candidate > 1);Borrowed results cannot outlive their collection:
use libtmux::query::QueryIteratorExt;
let selected = {
let values = vec![String::from("only")];
values.iter().exactly_one().unwrap()
};
println!("{selected}");Consuming a collection transfers ownership:
use libtmux::query::QueryIteratorExt;
let values = vec![String::from("only")];
let selected = values.into_iter().one_or_none().unwrap();
println!("{values:?} {selected:?}");§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 native 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
Client Limits control-mode - How many persistent control-mode clients one server may hold.
- 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.
- Join
Options - Where a pane lands when it is moved into another window.
- KeyBinding
- One key binding, as tmux holds it.
- Listing
Decode Error - Payload-free metadata describing why tmux output could not be decoded.
- Menu
Item - One line of a
Server::display_menumenu. - 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.
- TmuxArg
- Text bound for a tmux argument that tmux expands as a format.
- 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.
- Availability
- A snapshot field’s value, or the reason the snapshot holds none.
- Channel
Wait - What came of waiting on a
wait-forchannel. - 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 - A coarse failure category for reporting and routing.
- Layout
- One of the pane arrangements tmux knows by name.
- Layout
Spec - What to arrange a window’s panes as.
- 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, typed by what tmux’s own option table declares.
- Option
Value Refusal - Why a typed option write was refused before it reached tmux.
- 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.
- Pane
Wait - What came of waiting for a pane to do something.
- Principal
- Whether an
AccessRulenames an operating-system user or group. - Prompt
Kind - Which prompt tmux is remembering answers for.
- Resize
Direction - Which edge a resize moves.
- Respawn
- What to do about a process still running where one is being respawned.
- Rotation
- Where a split puts the new pane, relative to the one being divided.
- Scope
Error - A scoped resource’s creation, operation, or cleanup failure.
- 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§
- escape_
format - Escape text so tmux uses it as written rather than expanding it.
- option_
schema - Look up what tmux declares about one option.
Derive Macros§
- Filterable
derive - Derive a stable typed filter schema for a named struct.