Every snippet here is executed by ExamplesTest. If one stops working the build
fails, rather than the page quietly going stale.
Every Java block in this guide runs against a real tmux server when the build
runs, and every value shown after a → is asserted.
A Server is a client, not the tmux process. Closing one closes your connection;
it never ends anybody's sessions.
ServerConfig config = ServerConfig.builder()
.endpoint(ServerEndpoint.socketPath(socket))
.build();
try (Server server = Server.open(config)) {
Session session = server.newSession("demo");
Window window = session.newWindow("build");
Pane pane = window.split();
pane.sendLine("echo hello from libtmux");
}And what it leaves behind:
Session session = server.newSession("demo");
Window window = session.newWindow("build");
session.name(); // → demo
window.name(); // → buildBy default each command is one tmux process. One switch makes them share a persistent client instead — see execution modes, which has the measured difference and says what else is worth reading.
Server.open owns the transport it creates and closes it. Server.using borrows
one you own and never closes it, so several servers can share a transport.
To end the tmux server itself, ask plainly:
server.killServer();Sessions, windows and panes are all made the same way: call it plainly, describe it with a lambda, or hand it a description you built earlier.
Session build = server.newSession(s -> s.named("build").firstWindowNamed("editor"));
Window logs = build.newWindow(w -> w.named("logs").running("sleep", "30"));
build.name(); // → build
build.refresh().windows().get(0).name(); // → editor
logs.name(); // → logsA new window or pane is selected, because that is what new-window and
split-window do. Say detached() to leave the current one where it is. A
session is the exception and is always detached: new-session attaches unless
told not to, and attaching needs a terminal that a build, a service or an agent
does not have.
split() takes tmux's defaults. Anything else is described by a lambda over a
builder:
Pane side = pane.split(s -> s.toRight().percent(30));
Pane app = pane.split(s -> s.running("sleep", "30").in(directory));
side.edges().right(); // → true
pane.window().refresh().panes().size(); // → 3A description is also a value, so one can be named and applied wherever it fits:
SplitSpec sidebar = SplitSpec.builder().toRight().percent(25).build();
Pane leftSide = session.newWindow("left").split(sidebar);
Pane rightSide = session.newWindow("right").split(sidebar);
leftSide.edges().right(); // → true
rightSide.edges().right(); // → trueA size is one thing with two spellings — cells(5) or percent(30) — so there
is no size-and-percentage pair to hold consistent. What runs in the pane is one
choice too: a shell, a command, or nothing at all. tmux rejects a command on an
empty pane, so no spec can carry both.
Options that arrived in tmux 3.7 — an empty pane, keeping a pane after its
command exits, per-pane styles — raise UnsupportedTmuxVersion on an older
server rather than being quietly dropped:
if (!server.version().atLeast(new TmuxVersion(3, 7, ""))) {
assertThrows(UnsupportedTmuxVersion.class, () -> pane.split(s -> s.empty()));
}Being told is the point. A split that silently ignored empty() would hand back
a pane with a shell in it, and nothing downstream could tell that apart from the
pane that was asked for.
Accessors read tmux once and hand back handles over what they saw. Walking the hierarchy afterwards issues no commands at all:
for (Session session : server.sessions()) {
for (Window window : session.windows()) {
for (Pane pane : window.panes()) {
// The window a pane reports is the one it was reached through.
pane.window().id().equals(window.id()); // → true
}
}
}
// One read. Walking it asked tmux nothing further.
server.sessions().get(0).windows().size(); // → 1That is deliberate. tmux offers no transaction across separate listings, so a
traversal that re-queried could observe a hierarchy that never existed. To see
newer state, take a new capture with refresh().
server.snapshot() is the strict form: it raises when a listing fails. The list
accessors are lenient and answer with an empty list, which is the long-standing
libtmux contract. Use isAlive() or raiseIfDead() when you need to tell an
empty server from an absent one.
A handle's identity is what a user cannot change. A session is its server and its
id, so renaming it does not produce a different session. A window is its
winlink — session, index and window together — because a window linked into two
sessions is one window at two positions, and tmux orders and addresses those
separately. Window.id() compares the underlying window across links.
A scope is chosen when you take the view, so you cannot read one scope and write another:
server.globalOptions().set("base-index", "1");
session.options().get("base-index").orElseThrow(); // → 1get reports the value tmux will act on, inherited when the scope does not set
it. all() answers the narrower question — what this scope sets itself.
A batch is one tmux invocation, and every operation gets its own outcome:
BatchResult result = server.batch()
.add("new-window", "-d", "-n", "one")
.add("new-window", "-d", "-n", "two")
.run();
result.succeeded(); // → true
result.operations().get(0).outcome(); // → COMPLETE
result.operations().get(1).outcome(); // → COMPLETEtmux discards a group after its first failure, so a single exit status cannot say
which command failed or which never ran. Each operation is reported as
COMPLETE, FAILED, SKIPPED or UNKNOWN.
A chain is the same machinery where each step acts on what the last one made, using tmux's own current-target following:
server.chain()
.newWindow("built")
.splitLeftRight()
.sendLine("echo chained")
.run();
// One request made the window and split it, with no round trip to learn its id.
server.windows().stream().anyMatch(w -> w.name().equals("built")); // → trueNo step names a target, and no round trip is needed to learn the id of something just created.
A control client stays attached and pushes terminal output as it happens:
try (ControlClient client = ControlClient.attach(server.config(), session.id())) {
List<PaneOutput> seen = new CopyOnWriteArrayList<>();
client.onOutput(seen::add);
client.send("send-keys", "-t", session.name(), "echo streamed", "Enter");
}Control-mode requests are independent: a failure discards nothing behind it, and every reply carries the request that produced it. Attaching is what makes tmux push output at all.
A run that reads the developer's own .tmux.conf is a run whose behaviour nobody
can predict. Pin one:
Path tmuxConf = Files.writeString(directory.resolve("tmux.conf"), "");
ServerConfig pinned = ServerConfig.builder()
.endpoint(ServerEndpoint.socketPath(directory.resolve("s")))
.configFile(tmuxConf)
.build();
pinned.configFile().isPresent(); // → true| you want to | read |
|---|---|
| make every command cost less | execution modes |
| select things without lambdas | filtering |
| read and write tmux's settings | options and hooks |
| send several commands at once | batching and chaining |
| understand what a handle is | snapshots and handles |
| watch output as it happens | streaming |
| test your own code against tmux | testing |