Skip to content

Latest commit

 

History

959 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

libtmux for Java

libtmux for Java

Typed, blocking access to tmux from the JVM.

A sibling of the Python libtmux, targeting practical parity. It uses Java types: Optional for absence, AutoCloseable servers and typed query fields.

CI tmux matrix Maven Central License: MIT Status: alpha

Alpha. Releases carry an -alpha prerelease tag. The API is not settled, and any release may change or remove exported identifiers without a deprecation period. Pin an exact version. Not recommended for production.

Create sessions, windows and panes, find and filter what a Server holds, and run a command and read its exit status. It suits tools that drive a terminal program, test suites that need a real tmux, and agents that share a terminal.

Contents — Quick start · Find things · Create and run · Where am I · Round trips · Modules · Leaves out · Support · Documentation

Quick start

Add the library through libtmux-bom, which names one version for every BOM-managed coordinate. It needs JDK 25 or newer:

dependencies {
    implementation(platform("io.github.libtmux:libtmux-bom:0.0.1-alpha.18"))

    implementation("io.github.libtmux:libtmux")
    testImplementation("io.github.libtmux:libtmux-junit5")
}
Maven
<dependencyManagement>
  <dependencies>
    <dependency>
      <groupId>io.github.libtmux</groupId>
      <artifactId>libtmux-bom</artifactId>
      <version>0.0.1-alpha.18</version>
      <type>pom</type>
      <scope>import</scope>
    </dependency>
  </dependencies>
</dependencyManagement>

<dependency>
  <groupId>io.github.libtmux</groupId>
  <artifactId>libtmux</artifactId>
</dependency>

To build against an unreleased change instead, ./gradlew publishToMavenLocal installs every module into your local repository under the same coordinates.

A Server is a client for one tmux server. Server.open takes a ServerConfig that describes the server to reach, and Server.newSession(String) creates a Session. A session holds Windows, and a window splits into Panes:

// Given: Path socket
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");
}

That leaves a session behind on a tmux server of its own. Read it back:

// Given: Server server
Session session = server.newSession("demo");
Window window = session.newWindow("build");
Pane pane = window.split();

pane.sendLine("echo hello from libtmux");

session.name();                            // → demo
window.refresh().panes().size();           // → 2

Every value after a → is asserted against a real tmux: the documentation suite runs each Java block on this page. A block that starts with // Given: Server server reads a server the suite supplies.

Find sessions, windows, and panes

Server.sessions(): what tmux has now

One read hands you handles. Server.windows() and Server.panes() are its siblings. Walking the handles issues no further commands, so a traversal cannot see a half-changed server:

// Given: Server server
Window editor = server.newSession("demo").newWindow("editor");

List<String> names = server.windows().stream().map(Window::name).sorted().toList();

names.contains("editor");            // → true
editor.session().name();             // → demo

A handle holds what tmux said when you read it. Window.refresh() reads again.

Pane.window: walk back up

A handle reaches up as well as down, through Pane.window and Window.session:

// Given: Server server
Pane pane = server.sessions().get(0).windows().get(0).panes().get(0);

pane.window().session().name();      // → libtmux

Window_: filter without asking tmux again

Window_ has the typed fields of a Window. An expression built from one is a Predicate, so it drops into a stream:

// Given: Server server
server.newSession("build").newWindow("editor");

List<Window> editors = server.windows().stream()
        .filter(Window_.name().startsWith("edit"))
        .toList();

editors.size();                      // → 1
editors.get(0).name();               // → editor

An expression is a FilterExpr, which is a value, so describe() can say what it is — which a lambda cannot:

Window_.name().startsWith("edit").describe();   // → window_name starts-with edit

Server.session(String): one thing, or none

One read answers. Server.session(String) returns an Optional that is empty when no session has the name. Call orElseThrow where a missing session is a bug:

// Given: Server server
server.newSession("build");

server.session("build").orElseThrow().name();   // → build
server.session("absent").isPresent();           // → false

That is how "this session, or a new one" stays a single read:

// Given: Server server
Session work = server.session("work").orElseGet(() -> server.newSession("work"));

work.name();                         // → work

Selections.exactlyOne: say how many you expect

Selections.exactlyOne takes the matches and insists on one:

// Given: Server server
server.newSession("build");

Session build = Selections.exactlyOne(
        server.sessions().stream().filter(Session_.name().is("build")).toList());

build.name();                        // → build

It raises CardinalityException.NoMatch for none and CardinalityException.MultipleMatches for several, because those are different bugs in the calling code.

Create, type, and run

Session.newWindow: make things

Session.newWindow and Window.split each return the new handle:

// Given: Server server
Session session = server.newSession("demo");
Window editor = session.newWindow("editor");
Pane right = editor.split();

session.name();                      // → demo
editor.name();                       // → editor
editor.refresh().panes().size();     // → 2

Pane.sendLine: type, then read the screen

Pane.sendLine types a line into a pane, and Pane.capture reads what it shows:

// Given: Server server
Pane pane = server.sessions().get(0).windows().get(0).panes().get(0);

pane.sendLine("echo hello from libtmux");

pane.capture().isEmpty();            // → false

Pane.run: a command to its end

To get a command's exit status, call Pane.run instead of typing the command and reading the screen. It returns a PaneRun whose status is the shell's and whose output is the command's alone:

// Given: Pane pane
PaneRun ran = pane.run("printf 'built\\n'; exit 3", Duration.ofSeconds(30));

ran.exitStatus().getAsInt();   // → 3
ran.output();                  // → [built]

Server.cmd: any tmux command

Server.cmd sends any tmux command. A nonzero exit shows up in the CommandResult and throws nothing:

// Given: Server server
server.cmd("display-message", "-p", "#{version}").succeeded();   // → true
server.cmd("kill-session", "-t", "=nope").succeeded();           // → false

Know where you are running

TmuxEnvironment: which pane am I in

Code running inside a pane can ask where it is. tmux writes TMUX and TMUX_PANE into every pane it spawns, and TmuxEnvironment reads them back:

// Given: Path socket
Map<String, String> inside = Map.of("TMUX", socket + ",1,$0", "TMUX_PANE", "%0");

TmuxEnvironment here = TmuxEnvironment.of(inside).orElseThrow();

here.session().value();              // → $0
here.pane().orElseThrow().value();   // → %0

In a real pane those variables are already set, so TmuxEnvironment.current() takes nothing and returns empty when there is no pane to describe. This README is not running inside one, so the example supplies them.

Server.environment: what a later pane will see

tmux keeps an environment per server and per session, and gives it to every process it starts afterwards. Server.environment reaches the server's:

// Given: Server server
server.environment().set("LIBTMUX_TOKEN", "abc123");

server.environment().get("LIBTMUX_TOKEN").orElseThrow();   // → abc123

Fewer round trips

Server.chain(): dependent commands in one request

Server.batch() sends independent commands in one invocation. Server.chain() does the same for dependent ones, letting tmux carry the current target from one step to the next, with no round trip to learn an id:

// Given: Server server
server.chain()
        .newWindow("built")
        .splitLeftRight()
        .sendLine("echo chained")
        .run();

server.windows().stream().anyMatch(w -> w.name().equals("built"));   // → true

Both retain an outcome for every operation. A failure says how certain it is: "tmux never started" and "tmux timed out halfway" call for opposite recovery, so the transport reports which happened.

See every command tmux ran

The process transport and the control client log through the JDK's own System.Logger, so the library brings no logging dependency. Turn on DEBUG for io.github.libtmux and each command is one line:

tmux list-sessions exited 0 in 4 ms
tmux control display-message complete in 1 ms

Only the verb is written, never its arguments: those carry session names, pane contents and whatever was typed.

Kotlin and Scala

Both use the Java API directly, because the core is annotated with JSpecify and carries no Scala version suffix.

  • Kotlin reads the JSpecify annotations (since Kotlin 1.5.20), so a value is String or String? and never a platform type. Server is AutoCloseable, so use {} works, and the Consumer<Builder> overloads take trailing lambdas.
  • libtmux-kotlin adds what Java cannot express: absence as null where Java has Optional, and !expr on a filter.
  • Scala can consume the Java artifacts directly; the direct Java guide shows how.
  • libtmux-scala is a Scala 3 facade with its own _3 artifacts. The suffix applies only to the facade, never to the Java artifact.

Modules

Group io.github.libtmux. Each listed published directory is an artifact with its own README. They are on Maven Central, the Scala artifacts suffixed _3.

  • libtmux — the library itself. Transport, snapshots, entities, options, hooks, batching, control mode, query model. No runtime dependencies, and a real Java module, io.github.libtmux.

  • libtmux-bom — name one BOM version, and every BOM-managed coordinate below follows it.

  • libtmux-mcp — give a model a tmux server, over the Model Context Protocol. Finds its way around, reads what a pane shows, runs a command and waits for its exit status, and has bounded waits for text, a channel or new output in place of pushed notifications.

  • libtmux-junit5 — test your code against real tmux. One server per test. A JVM killed outright leaves its server for the next run sharing the fixture root to reap.

  • libtmux-kotlin — Kotlin ergonomics. Optional: the core is already null-safe from Kotlin without it.

  • libtmux-scala — Scala 3 collections, opaque handles over the Java ones, and the typed query DSL.

  • libtmux-scala-cats — optional Cats Effect resources and FS2 observations.

  • libtmux-scala-ox — an optional Ox Flow over subscriptions and live views.

  • libtmux-jackson — a filter expression as a versioned JSON document, so it can be stored, sent, or written by something that is not a Java program.

  • libtmux-workspace — build a session from a tmuxp-shaped YAML file.

  • libtmux-workspace-cli — the tmux-workspace command: load, capture, convert and import workspaces, with tmuxp's command names and JSON or NDJSON output. A separate implementation from libtmux-workspace, whose package documentation states how their document shapes and building behaviour diverge.

Not published, and part of how the library is built: examples/ · integration-tests/ · module-tests/ · benchmarks/ · docs/ · tools/ · build-logic/

A directory is a published artifact exactly when it appears in the list above, and platformCoversEveryPublishedModule fails the build when the published artifacts stop matching the BOM.

Each heading below links to the module's directory on GitHub, where its README lives. Only libtmux is required. The other modules are optional and build on it, and libtmux-bom only names their versions.

libtmux: the library

Transport, snapshots, entities, options, hooks, batching, control mode, and the query model, with no runtime dependencies, as the named module io.github.libtmux.

Reference: libtmux.org and javadoc.io. Its README maps the packages.

Server.batch sends independent commands in one invocation, and BatchResult reports an outcome for each:

// Given: Server server
BatchResult result = server.batch()
        .add("new-window", "-d", "-n", "one")
        .add("new-window", "-d", "-n", "two")
        .run();

result.succeeded();                         // → true
result.operations().size();                 // → 2
result.operations().get(0).outcome();       // → COMPLETE

libtmux-bom: one version for every module

Names one version for every BOM-managed coordinate, so two releases of modules built against each other cannot be mixed. It has no API of its own; see Maven Central.

Import the platform, and leave the version off the module:

dependencies {
    implementation(platform("io.github.libtmux:libtmux-bom:0.0.1-alpha.18"))
    implementation("io.github.libtmux:libtmux-jackson")   // no version
}

libtmux-mcp: a tmux server for agents

Gives a model a tmux server over the Model Context Protocol. It finds its way around, reads what a pane shows, and runs a command and waits for its exit status.

Reference: libtmux.org, the tool pages, and javadoc.io.

An MCP client starts the launcher itself and speaks to it over stdin and stdout. Build it from this repository:

$ ./gradlew :libtmux-mcp:installDist

With libtmux-mcp/build/install/libtmux-mcp/bin on PATH, register it with Claude Code on the tmux socket named work, offering only the tools that read tmux. That is the socket the tmux-workspace load command below builds on:

$ claude mcp add tmux \
    -e LIBTMUX_TOOLSETS=inspect \
    -- libtmux-mcp --socket-name work

libtmux-junit5: tests against real tmux

Test your code against real tmux. @ExtendWith(TmuxExtension.class) gives each test a server of its own and removes it afterwards, and FakeTmux stands in where a test has no tmux at all.

Reference: TmuxExtension on libtmux.org and javadoc.io.

FakeTmux answers the way tmux answers, so a snapshot of it is a real snapshot and every handle works:

FakeTmux tmux = new FakeTmux();
PaneId editor = tmux.addSession("work");
tmux.show(editor, "$ make", "make: Nothing to be done.");

try (Server server = tmux.server()) {
    Pane pane = server.pane(editor).orElseThrow();
    pane.capture().get(1);            // → make: Nothing to be done.
}

libtmux-kotlin: Kotlin ergonomics

Coroutine wrappers, a session, window, and split DSL, and Flow bridges over the Java API. Optional: the core is already null-safe from Kotlin without it.

Reference: libtmux.org and javadoc.io.

withServer opens a server and closes it however the block ends, and newSession declares a session, its windows, and their splits:

// Given: config: ServerConfig
withServer(config) { server ->
    val session = server.newSession {
        name = "editors"
        window { name = "left" }
        window {
            name = "right"
            split { toRight(); percent(30) }
        }
    }

    session.windows.size                  // → 2
}

libtmux-scala: Scala 3 collections over the handles

Scala collections, opaque handles over the Java ones, and the typed query DSL.

Reference: libtmux.org and javadoc.io.

Server.panes captures once, and the Pane fields filter the captured Vector without asking tmux again:

import io.github.libtmux.scaladsl.{config => _, *}
import io.github.libtmux.scaladsl.query.*
import scala.util.Using

Using.resource(Server.open(config)) { server =>
  val panes = server.panes()
  val wide = panes.matching(Pane.width.atLeast(1) && Pane.active.isTrue)
  assert(wide == panes.filter(p => p.info.size().width() >= 1 && p.info.active()))
  assert(wide.exactlyOne.isRight)
}

libtmux-scala-cats: Cats Effect and FS2

Optional Cats Effect resources and FS2 observations for libtmux-scala.

Reference: libtmux.org and javadoc.io.

Server.resource owns the client for the length of use, and the capture it returns is an ordinary vector:

import _root_.cats.effect.IO
import io.github.libtmux.scaladsl.cats.{config => _, *}

Server.resource[IO](config).use { server =>
  server.panes().flatMap { panes =>
    val active = panes.filter(_.info.active())
    IO(assert(active.size == 1))
  }
}

An optional Ox Flow over subscriptions and live views, for structured concurrency on virtual threads.

Reference: Flows on libtmux.org and javadoc.io.

Flows.liveView emits the current view of a server when the flow starts, then every newer one:

import _root_.ox.{supervised, useCloseableInScope}
import io.github.libtmux.scaladsl.{config => _, *}
import io.github.libtmux.scaladsl.live.LiveView
import io.github.libtmux.scaladsl.ox.Flows

supervised {
  val server = useCloseableInScope(Server.open(config))
  val live = useCloseableInScope(LiveView.attach(server.sessions().head))
  val first = Flows.liveView(live).take(1).runToList()
  assert(first.head.snapshot.sessions().size() == 1)
}

libtmux-jackson: filters as JSON

A filter expression as a versioned JSON document, so it can be stored, sent, or written by something that is not a Java program.

Reference: FilterJson on libtmux.org and javadoc.io.

FilterJson writes an expression and reads it back, validated against a model:

String json = FilterJson.writeString(
        Pane_.command().startsWith("nvim"), LibTmuxModels.pane());

FilterExpr<Pane> restored = FilterJson.readString(json, LibTmuxModels.pane());

restored.describe();                      // → pane_current_command starts-with nvim

libtmux-workspace: sessions from YAML

Builds a session from a tmuxp-shaped YAML file.

Reference: libtmux.org and javadoc.io.

WorkspaceBuilder.parse reads a description into a value while it is still text, and build is what touches tmux:

// Given: Server server
Workspace workspace = WorkspaceBuilder.parse("""
        session_name: built
        windows:
          - window_name: editor
            layout: even-horizontal
            panes:
              - shell_command: echo one
              - shell_command: echo two
        """);

Session session = WorkspaceBuilder.build(server, workspace);

session.windows().get(0).panes().size();          // → 2

libtmux-workspace-cli: the tmux-workspace command

Loads, captures, converts, and imports workspaces with tmuxp's command names and JSON or NDJSON output.

It is a separate implementation from libtmux-workspace, whose package documentation states how their document shapes and building behaviour diverge.

Reference: the command manual, with pages for load and freeze.

Build the launcher from this repository:

$ ./gradlew :libtmux-workspace-cli:installDist

With libtmux-workspace-cli/build/install/tmux-workspace/bin on PATH, and a workspace file saved as workspace.yaml, build it on the tmux socket named work without attaching to it:

$ tmux-workspace load \
    -L work \
    -d \
    ./workspace.yaml

What it leaves out

  • Live objects. A Session or a Pane is what tmux said when it was read, and does not change when tmux does. Read again with Window.refresh() to see a change.
  • A logging dependency. The library logs through the JDK's System.Logger, and writes only a command's verb, never its arguments.
  • Runtime dependencies. libtmux has none. The adapters carry their own, and libtmux-bom names their versions.
  • Backports. Only the newest release is supported.

Support and status

  • JDK 25 or newer, on the module path or the classpath: libtmux is the named module io.github.libtmux, and the jars carry no OSGi bundle headers.

  • Linux, including WSL. CI runs on Ubuntu. macOS is supported on a best-effort basis, and CI coverage for it is highly limited.

  • tmux 3.3a through 3.7c. The whole real-tmux suite runs against every one of those releases, and each lane checks it ran the tmux it is named after:

    $ ./gradlew testTmuxMatrix -PlibtmuxMatrix=/path/to/tmux/builds
  • Any locale. Text the JVM cannot encode reaches tmux over standard input, so names and typed text arrive intact under LANG=C (details). Only Pane.currentPath() needs a UTF-8 locale.

  • Alpha. Any release may change or remove exported identifiers without a deprecation period, including in ways that do not compile. Pin an exact version. Not recommended for production.

  • Tested against real tmux. The real-tmux suite runs against every supported release on every push.

Changes are recorded in CHANGELOG.md; how a release is cut is in RELEASING.md.

Documentation

Every public type and member is in the API reference, built from the current trunk; each released version's Javadoc is on javadoc.io.

Getting started The first program, compiled and run
Filtering An expression is a value
Options and hooks Reading and setting both
Batching and chaining Several commands, one invocation
Snapshots and handles What a capture freezes
Streaming Watching pane output as it happens
Failures, telemetry, and pane input Driving tmux from a service
Threads and cancellation What runs at once
Driving tmux from a model The libtmux-mcp server
Testing with real tmux libtmux-junit5
tmux-workspace The rules load, convert and import follow
Kotlin and Scala The facades
examples/ Whole programs, each run against a real tmux
docs/decisions/ Short ADRs, each citing the tmux behaviour that forced it
MIGRATION.md What to change when upgrading
docs/README.md Every guide, reference and decision, one line each

Attribution

Please use the following BibTeX template to cite libtmux-java in scientific discourse:

@misc{libtmux-java,
   author = {Tony Narlock},
   year = {2026},
   note = {https://libtmux.org/en/java/},
   title = {libtmux-java: Java wrapper for tmux}
}

Contributing

.github/CONTRIBUTING.md covers the gate, the tmux matrix, and why every server this suite starts lives under a path naming this port.

License

MIT. See LICENSE.

Used by

Contributors

Languages