libtmux for Go

Alpha software. 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.
Drive tmux from Go: sessions, windows, and panes as typed values, every tmux
option and hook as a typed accessor, and errors classified by what tmux actually
refused.
- No runtime dependencies. The core module imports only the standard library.
- Go 1.23+, tmux 3.2a through 3.7b, checked against every release in that
range on each change.
- Records never refresh behind you. A
Session you hold is what tmux said
when you asked, not a live handle that changes underneath.
$ go get github.com/libtmux/libtmux-go/tmux@v0.0.1-alpha.1
Modules are tagged per directory, so the consumers carry their own versions:
mcp/v0.0.1-alpha.2, workspace/v0.0.1-alpha.1.
Contents — Quick start · Querying
· Choosing a mode · Watching tmux ·
Packages · For agents ·
Testing your code · Documentation
Quick start
Make a window, split it, send a command into the new pane:
windowName := "work"
window, err := session.NewWindow(ctx, tmux.NewWindowRequest{Name: &windowName})
if err != nil {
return fmt.Errorf("create window: %w", err)
}
pane, err := window.SplitPane(ctx, tmux.SplitPaneRequest{
Direction: tmux.PaneDirectionRight,
})
if err != nil {
return fmt.Errorf("split window: %w", err)
}
command := "printf 'libtmux ready\\n'"
if err := pane.SendKeys(ctx, tmux.SendKeysRequest{Command: &command, Literal: true}); err != nil {
return fmt.Errorf("send command: %w", err)
}
Every Go block below marked this way is generated from a program in
examples/ that is compiled, linted, run against a real tmux, and
swept across every supported release — so none of it can drift from code that
works.
Runnable: examples/quickstart — go -C examples run ./quickstart.
What querying looks like
Two ways to ask, and they answer the same question at different costs.
Let tmux filter, which sends one command and gets back only matches:
live := tmux.TmuxFilter("#{==:#{session_name},libtmux-filter}")
sessions, err := server.SearchSessions(ctx, &live)
Or read once and filter in Go, when you want several answers from one read:
snapshot, err := server.Snapshot(ctx)
if err != nil {
return err
}
predicate, err := tmux.PaneActiveIs(true).Predicate()
if err != nil {
return err
}
active := tmuxq.Where(snapshot.Panes(), predicate)
Typed filters compose, and the generated ones push down into tmux's own -f
where tmux can evaluate them:
filter := tmux.PaneFilter{
Active: tmux.Ptr(true),
CurrentPath: tmux.Ptr("/home/you/project"),
}
panes, err := server.SearchPanes(ctx, &filter)
Runnable: examples/filter-query.
Choosing a mode
Every command starts a tmux process unless you turn something on. Each switch is
one line to turn on, one to take back, and independent of the others:
| Mode |
Turn it on |
Cost |
Reach for it |
| process |
nothing, the default |
a tmux process each |
one-shot commands |
| control |
OpenControlPool |
one tmux client |
more than a few commands |
| concurrent |
Connections: N |
N tmux clients |
parallel readers |
| chained |
NewPlan then Run |
no records back |
builds and layouts |
| streaming |
Notifications |
a connection |
watching what a pane does |
Each row changes how a command reaches tmux and none changes what it means,
which is the property the benchmark table gates on. One switch is deliberately
not a row, because it does change meaning: ServerOptions.Unsupported decides
whether a request naming a flag the running tmux does not have is refused —
the default — or carried out without it and reported to a warning handler.
A control connection carries commands without starting a process for each. It is
a tmux client while open — it appears in list-clients and counts toward
session_attached — which is why it is chosen rather than automatic:
_, connected, pool, err := server.OpenControlPool(ctx, session, tmux.ControlPoolRequest{})
if err != nil {
return fmt.Errorf("open control pool: %w", err)
}
defer func() { _ = pool.Close() }()
The pool returns the session bound to the connection. The one passed in still
starts a process per command, so the returned value is the one to keep.
A plan records commands instead of running them, sends the ones needing no
answer together, and hands back a reference to what a step will create — so a
build is written in one pass:
plan := tmux.NewPlan()
plan.SelectLayout(window.Ref(), tmux.SelectLayoutRequest{Layout: "tiled"})
editor := plan.SplitPane(window.Ref(), tmux.SplitPaneRequest{Attach: true})
plan.SetPaneTitle(editor, "editor")
plan.SendKeys(editor, tmux.SendKeysRequest{Command: tmux.Ptr("echo built")})
plan.DisplayMessage(editor, "#{pane_title}")
Runnable: examples/fast-path and
examples/planned-build.
BENCHMARKS.md is what each mode costs, measured on every
supported tmux.
Watching tmux
tmux pushes what happens down an open connection, so a change is heard once,
when it happens, rather than found by a poll that has to guess how often to ask:
for notification, err := range control.Notifications(ctx) {
if err != nil {
return fmt.Errorf("read notification: %w", err)
}
fmt.Printf("notification: %s\n", notification.Kind())
if notification.Kind() == tmux.ControlNotificationSessionRenamed {
fmt.Println("heard the rename")
return nil
}
}
Runnable: examples/control-mode-subscribe.
Packages
| Package |
Source |
Reference |
What it is |
tmux |
tmux/ |
pkg.go.dev |
The library. Sessions, windows, panes, options, hooks, formats, filters, snapshots, plans. |
tmuxtest |
tmux/tmuxtest/ |
pkg.go.dev |
Run your program in a real tmux and assert on what it drew. |
tmuxq |
tmuxq/ |
pkg.go.dev |
Model-free generic helpers for slices and iter.Seq. |
Three more ship as separate modules, so go get on the library pulls in
none of them:
| Module |
Source |
Reference |
What it is |
mcp |
mcp/ |
pkg.go.dev |
A tmux server for AI agents over the Model Context Protocol. Install it as a binary. |
workspace |
workspace/ |
pkg.go.dev |
Loads tmuxp-style YAML workspaces and builds them. |
benchmarks |
benchmarks/ |
— |
Prints what each way of reaching tmux costs. |
For agents
mcp/ is a standalone Model Context Protocol server that gives an agent
one tmux server: create panes, send keys, read output, wait for text.
$ go install github.com/libtmux/libtmux-go/mcp/cmd/libtmux-mcp@v0.0.1-alpha.2
See mcp/README.md for client configuration, and
mcp/TOOLS.md for the tool reference.
Testing your own code
tmux/tmuxtest runs your program inside a real tmux and lets
a test assert on what it drew, with no sleeps. Run it, wait for what it draws,
type at it:
pane := tmuxtest.RunInPane(ctx, t, "printf 'ready\\n'; cat")
tmuxtest.WaitForText(ctx, t, pane, "ready")
tmuxtest.Type(ctx, t, pane, "a line for the program")
tmuxtest.WaitForLine(ctx, t, pane, "a line for the program")
A wait that runs out fails with the screen the pane last held, rather than
sending you back to add a print statement:
tmuxtest: pane %1 never showed a line containing "ready"
the pane showed 3 line(s):
| tmuxtest$ ./mytui --watch
| loading widgets
| connecting
It works for a test whose subject is tmux itself too, giving a server on its own
socket that is killed when the test ends:
func TestSomething(t *testing.T) {
ctx := context.Background()
server := tmuxtest.NewServer(ctx, t)
session, err := server.NewSession(ctx, tmux.NewSessionRequest{Name: "under-test"})
// ...
}
For a machine with no tmux at all, ServerOptions.Runner replaces process
execution entirely.
Documentation
The package documentation is the reference, written to be read start to finish
rather than searched:
$ go doc github.com/libtmux/libtmux-go/tmux
It opens with a task index, then the rule mapping a tmux command to its Go
method — kill-pane is Pane.Kill, rename-session is Session.Rename — so a
command usually leads to its method without a lookup.
|
|
DESIGN.md |
The conventions this package holds itself to, and the bakeoffs behind them |
PARITY.md |
How the surface is checked against the Python libtmux |
BENCHMARKS.md |
What each way of reaching tmux costs |
CHANGELOG.md |
What each release changed |
CONTRIBUTING.md |
The gates a change has to pass |
SECURITY.md |
What this software executes, and how to report a hole in it |
AGENTS.md |
Working on this repository |
examples/ |
Runnable programs for each of the above |
License
MIT. See LICENSE.