libtmux-go

module
v0.0.1-alpha.8 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 19, 2026 License: MIT

README

libtmux for Go

Go Reference tests

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.26+, tmux 3.2a through 3.8-rc across the core, workspace, and MCP modules. The compatibility matrix checks ten builds spanning that range, the 3.8 release candidate included. The Go floor tracks upstream's support window, which covers the two most recent releases, so it rises whenever a Go release retires the oldest - roughly twice a year, and sooner than a toolchain pinned for longer.
  • Records never refresh behind you. A Session you hold is what tmux said when you asked, not a live handle that changes underneath.
  • Unix. tmux is a Unix program, so driving one needs a Unix host: Linux and macOS run the suites on every change, and the BSDs, illumos and Plan 9 build. A Windows build compiles and resolves a tmux executable by Windows PATH and PATHEXT rules, so a cross-platform program still builds; it has no tmux to reach.
$ go get github.com/libtmux/libtmux-go/tmux@latest

Modules are tagged per directory, so each consumer carries its own version: the tags are mcp/vN and workspace/vN beside the core's plain vN. Pin the exact ones you want in your own go.mod; the commands here fetch the newest.

Contents — Quick start · Querying · Choosing a mode · Watching tmux · Packages · For agents · Testing your code · Documentation

Quick start

Make a window, split it, type a command into the new pane, and read the reply back through an io.Reader:

// Given: ctx context.Context; server tmux.Server
session, err := server.NewSession(ctx, tmux.NewSessionRequest{
	Name: "libtmux-go-quickstart", WindowName: "start",
})
if err != nil {
	return fmt.Errorf("create session: %w", err)
}
defer func() {
	cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
	defer cleanupCancel()
	err = errors.Join(err, session.Kill(cleanupCtx))
}()

window, err := session.NewWindow(ctx, tmux.NewWindowRequest{Name: new("work")})
if err != nil {
	return fmt.Errorf("create window: %w", err)
}
pane, err := window.SplitPane(ctx, tmux.SplitPaneRequest{
	Direction: tmux.PaneDirectionRight, Command: "sh",
})
if err != nil {
	return fmt.Errorf("split window: %w", err)
}
output, err := pane.OpenObservation(ctx)
if err != nil {
	return fmt.Errorf("watch pane: %w", err)
}
defer func() { err = errors.Join(err, output.Close()) }()
if _, err := fmt.Fprintln(pane.Writer(ctx), "printf 'libtmux ready\\n'"); 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.

Running a command to completion

Session.Run is os/exec for a program that needs a terminal: the command runs in a window of its own with a tty, and what comes back is its exit status and the screen tmux rendered. A nonzero status is a result, not an error, and the wait is tmux's own rather than a poll:

// Given: ctx context.Context; session tmux.Session
result, err := session.Run(ctx, "tty; exit 3", tmux.RunOptions{})
if err != nil {
	return fmt.Errorf("run command: %w", err)
}
for _, line := range result.Lines {
	fmt.Println("screen:", line)
}
fmt.Println("exited", result.Status)

Runnable: examples/run-to-completion.

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:

// Given: ctx context.Context; server tmux.Server
live := tmux.TmuxFilter("#{==:#{session_name},libtmux-filter}")
sessions, err := server.SearchSessions(ctx, &live)
if err != nil {
	return err
}

Or read once and filter in Go, when you want several answers from one read:

// Given: ctx context.Context; server tmux.Server
snapshot, err := server.Snapshot(ctx)
if err != nil {
	return err
}
active, err := tmuxq.Matching(snapshot.Panes(), tmux.PaneActiveIs(true))
if err != nil {
	return err
}

Typed filters combine fields and captured relations in Go. They consume the snapshot above without another tmux command:

// Given: snapshot tmux.Snapshot
filter := tmux.PaneFilter{
	Active:  new(true),
	Session: &tmux.SessionFilter{Name: new("libtmux-filter")},
}
panes, err := tmuxq.Matching(snapshot.Panes(), filter)
if err != nil {
	return err
}

Runnable: examples/filter-query.

Choosing an execution path

A plain Server uses the executable, environment, working directory, and socket selection frozen by NewServer. Values derived from it retain that subprocess binding. Guards on materialized values assume stable, trusted tmux parser primitives and aliases. Establish a connection before socket replacement when exact-daemon ownership is required.

Path Construct it with Cost Reach for it
process NewServer a tmux process per command sent one-shot commands
connection Session.OpenControl one tmux client per lane repeated commands
concurrent ConnectionOptions{Lanes: N} N tmux clients parallel readers
chained NewPlan then Run fewer process starts builds and layouts
streaming Session.OpenNotifications(ctx, NotificationOptions{}) one tmux client watching what tmux does

Plans run over either a plain server or a connection-bound server. Unsupported capability policy is separate: ServerOptions.Unsupported decides whether a request naming an unavailable tmux flag is refused — the default — or carried out without it and reported to a warning handler.

A connection carries commands without starting a process for each. It appears in list-clients and counts toward session_attached, which is why opening one is explicit:

// Given: ctx context.Context; session tmux.Session
connection, err := session.OpenControl(ctx, tmux.ConnectionOptions{})
if err != nil {
	return fmt.Errorf("open control connection: %w", err)
}
defer func() { _ = connection.Close() }()
connected := connection.Session()

Once established, connection.Server() and connection.Session() are bound to that exact daemon. Values derived from them retain that owner. The binding is terminal: closing the connection makes later operations return ErrControlClosed, and an operation that needs a separate process returns ErrConnectionRequiresProcess. It never falls back or rebinds. The original session remains on its frozen subprocess binding.

Server.NewSessionConnection creates a session and retains its creating control process as the first lane. It returns the ordinary created session and an owned connection; use connection.Session() for connected operations.

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:

// Given: window tmux.Window
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: new("echo built")})
plan.DisplayMessage(editor, "#{pane_title}")

Runnable: examples/fast-path and examples/planned-build. BENCHMARKS.md is what each path costs, measured on every supported tmux.

Watching tmux

Session.OpenNotifications and Server.OpenNotifications return owned streams. Zero options retain tmux changes but suppress pane output; set IncludePaneOutput when watching pane content. tmux pushes each change when it happens rather than making a poll guess how often to ask. Before tmux 3.6, destroying the attached session follows its detach-on-destroy policy and may end the stream:

// Given: ctx context.Context; session tmux.Session
stream, err := session.OpenNotifications(ctx, tmux.NotificationOptions{})
if err != nil {
	return fmt.Errorf("open notification stream: %w", err)
}
defer func() { err = errors.Join(err, stream.Close()) }()

// Rename after subscribing; notifications do not include earlier changes.
if _, err := session.Rename(ctx, "control-example"); err != nil {
	return fmt.Errorf("rename session: %w", err)
}

for {
	notification, err := stream.Next(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")
		break
	}
}

A subscription asks tmux to evaluate a format and report it when its value changes, so a program hears about a state it cares about — a window count, a pane's current command — without asking again:

// Given: ctx context.Context; session tmux.Session;
// stream *tmux.NotificationStream
// A subscription is a format tmux evaluates for you: it reports the value
// when it first looks, about a second later, and then each time it changes.
if err := stream.Subscribe(ctx, tmux.SubscriptionRequest{
	Name: "windows", Format: "#{session_windows}",
}); err != nil {
	return fmt.Errorf("subscribe: %w", err)
}
if _, err := session.NewWindow(ctx, tmux.NewWindowRequest{}); err != nil {
	return fmt.Errorf("open window: %w", err)
}
for {
	notification, err := stream.Next(ctx)
	if err != nil {
		return fmt.Errorf("read notification: %w", err)
	}
	if change, ok := notification.Subscription(); ok && change.Value == "2" {
		fmt.Println("session has", change.Value, "windows")
		return nil
	}
}

Runnable: examples/control-mode-subscribe and, for a pane as an io.Writer and io.Reader, examples/pane-io.

Moving bytes

Keys and command arguments are the wrong way to move a payload: a shell reads what it is sent, and tmux caps a whole command at 16 KiB and cannot carry a NUL through one at all. A buffer loaded from an io.Reader has neither limit, and a capture written to an io.Writer never holds a scrollback in memory:

// Given: ctx context.Context; server tmux.Server; pane tmux.Pane;
// payload *strings.Reader; archive string; marker string
// Pasting hands the bytes to the pane's pty; the program reading it echoes
// them back on its own schedule. Watch before pasting, so the wait cannot
// start after the bytes it is waiting for already arrived, and capture only
// once they are on the screen -- a capture taken straight after the paste
// archives whatever the pane happened to be showing, which on a loaded
// machine is still an empty screen.
observation, err := pane.OpenObservation(ctx)
if err != nil {
	return fmt.Errorf("observe pane: %w", err)
}
defer func() { err = errors.Join(err, observation.Close()) }()

name := "payload"
if err := server.LoadBufferFrom(ctx, payload, tmux.LoadBufferFromOptions{
	Name: &name,
}); err != nil {
	return fmt.Errorf("load payload: %w", err)
}
if err := pane.PasteBuffer(ctx, tmux.PasteBufferRequest{
	BufferName: &name, DeleteAfter: true,
}); err != nil {
	return fmt.Errorf("paste payload: %w", err)
}
// The pane announces the bytes as it echoes them, so nothing here has to
// guess how long that takes or re-capture on a timer.
seen := strings.Builder{}
for _, line := range observation.Baseline() {
	seen.WriteString(line)
}
for !strings.Contains(seen.String(), marker) {
	notification, err := observation.NextNotification(ctx)
	if err != nil {
		return fmt.Errorf("follow pane output: %w", err)
	}
	if _, output, ok := notification.Output(); ok {
		seen.Write(output)
	}
}

file, err := os.Create(archive)
if err != nil {
	return fmt.Errorf("create archive: %w", err)
}
defer func() { err = errors.Join(err, file.Close()) }()
compressor := gzip.NewWriter(file)
if err := pane.CaptureTo(ctx, compressor, tmux.CapturePaneRequest{
	Start: tmux.CaptureBoundary, End: tmux.CaptureBoundary,
}); err != nil {
	return fmt.Errorf("capture scrollback: %w", err)
}
if err := compressor.Close(); err != nil {
	return fmt.Errorf("finish archive: %w", err)
}

Server.LoadBufferFrom, Server.SaveBufferTo and Pane.CaptureTo use tmux's own stdin and stdout, which tmux offers no control client, so they need a process; Server.LoadBuffer, Server.SaveBuffer and Pane.CaptureToFile take a path and work over a connection. Runnable: examples/byte-streams.

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@latest

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:

// Given: ctx context.Context; t *testing.T
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"})
	// ...
}

Where the behavior under test is your own code rather than tmux's, script a tmux instead of starting one. tmuxtest.ScriptedTmux writes an executable that answers the invocations you name, so the test needs no tmux installed:

server, err := tmux.NewServer(tmux.ServerOptions{
	Binary: tmuxtest.ScriptedTmux(t,
		tmuxtest.ScriptedCommand{Contains: []string{"-V"}, Stdout: "tmux 3.7\n"},
		tmuxtest.ScriptedCommand{
			Contains: []string{"kill-pane"},
			Stderr:   "can't find pane: %7\n",
			ExitCode: 1,
		},
	),
})

It answers commands only. Control connections and notification streams speak tmux's own protocol, so code that opens one needs NewServer and a real tmux.

NewServer snapshots its effective environment and working directory, resolves one absolute executable, and returns an error before starting tmux when configuration or resolution fails. Later environment and directory changes do not retarget the handle, and the zero Server is invalid. Tests of process behavior can point ServerOptions.Binary at an executable fixture, which is what tmuxtest.ScriptedTmux above writes for you; construction still resolves and freezes it. Use tmuxtest when the behavior belongs to a real tmux daemon.

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, and carries 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
WRITING.md How this repository writes: docs, the changelog, commits
SECURITY.md What this software executes, and how to report a hole in it
AGENTS.md Which of the above applies to what you are changing
examples/ Runnable programs for each of the above

License

MIT. See LICENSE.

Directories

Path Synopsis
mcp module
Package tmux provides a typed, context-aware API for tmux 3.2a through tmux 3.8-rc.
Package tmux provides a typed, context-aware API for tmux 3.2a through tmux 3.8-rc.
internal/generate/docs command
Command docs replaces marked Markdown blocks with matching Go source regions.
Command docs replaces marked Markdown blocks with matching Go source regions.
internal/generate/filters command
Command filters generates the typed tmux snapshot filters.
Command filters generates the typed tmux snapshot filters.
internal/generate/formats command
Command formats generates tmux format metadata and typed accessors.
Command formats generates tmux format metadata and typed accessors.
internal/generate/options command
Command options generates tmux option and hook metadata and typed value surfaces.
Command options generates tmux option and hook metadata and typed value surfaces.
internal/goname
Package goname defines the exported Go spelling shared by generators and parity checks for tmux and Python lower_snake_case names.
Package goname defines the exported Go spelling shared by generators and parity checks for tmux and Python lower_snake_case names.
internal/termtext
Package termtext turns the bytes a terminal program writes into text a match can read.
Package termtext turns the bytes a terminal program writes into text a match can read.
internal/tmuxcmd
Package tmuxcmd contains the private tmux process boundary.
Package tmuxcmd contains the private tmux process boundary.
tmuxtest
Package tmuxtest runs your program inside a real tmux and lets a test assert on what it drew.
Package tmuxtest runs your program inside a real tmux and lets a test assert on what it drew.
Package tmuxq queries materialized values without representing or invoking tmux.
Package tmuxq queries materialized values without representing or invoking tmux.
workspace module

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL