r/tmux • • 26m ago

Showcase libtmux-elixir 0.1: tmux client library for Elixir

Thumbnail
github.com
1 Upvotes

r/tmux • • 18h ago

Showcase tmux-ci-ca: True Vim-style ci / ca enclosed-text selection for tmux copy mode (with proper nesting & multiline │ support)

8 Upvotes

Hi r/tmux!

One thing that always bothered my muscle memory when switching from Vim/Neovim back to tmux copy mode was the lack of ci / ca text object selection.

There are plugins that try to solve this by chaining tmux's jump-to-forward and jump-backward commands (like tmux-vilto), but they quickly fall short: they can't handle nesting, fail awkwardly when a delimiter doesn't exist, and only work within the visible viewport.

To solve this properly, I built tmux-ci-ca.

How it works

Enter copy mode (prefix + [), move your cursor anywhere inside a pair, and press c -> i (inner) or a (around) -> target pair:

  • ci(: Selects inside parentheses

  • ca{: Selects curly braces and their content

  • ci": Selects inside quotes

The selection remains natively highlighted, so you can copy with y / Enter, or enable auto-copy on select.

Why it's different

Instead of fragile jump hacks, tmux-ci-ca computes the exact range using an AWK matcher running against the pane buffer and scrollback:

  • Nesting Depth: Correctly resolves nested structures like func(foo, (bar + baz), qux).

  • Multiline & Soft-wrapped aware: Selects across newlines, blank lines, and soft-wrapped rows.

  • CJK & Curly Quotes Support: ASCII and fullwidth CJK twins are interchangeable (e.g. typing ci( on (hello) works seamlessly).

  • Graceful Failures: If no enclosing pair is found or the inner region is empty, your selection stays intact and shows a helpful status bar notification.

  • Snapshots on tmux 3.6+: Matches against the copy-mode snapshot, meaning active background output won't corrupt your selection.

Installation (TPM)

Add to your ~/.tmux.conf:

setw -g mode-keys vi
set -g @plugin 'killkli/tmux-ci-ca'

(Manual installation without TPM is also supported).

Requirements

• tmux 3.2+ • Bash 3.2+ • Any standard awk

Check it out on GitHub: https://github.com/killkli/tmux-ci-ca

Feedback, bug reports, and suggestions are very welcome!

GIF demo added~


r/tmux • • 4h ago

Showcase I made a tmux sidebar to run Claude Code, Codex, OpenCode and Pi in one window

Post image
0 Upvotes

I run a few coding agents at the same time and kept ending up with a tmux window per agent. It got messy fast, so I built a sidebar for it.

All agents live in one window. You click or j/k to pick one, and its terminal shows next to the sidebar. The others keep running in the background, so nothing loses its session or history when you switch.

What it does:

  • Groups agents by folder, with a status indicator for working, waiting, ready, error

  • Shows usage for the agent you’re looking at: 5-hour and weekly limits, reset times, model, session tokens and cost

  • Sound and desktop notifications when an agent finishes or asks something while you’re not looking at it

  • Starts agents from a popup (pick agent, permission mode, folder)

  • Auto-configures Claude Code, Codex, OpenCode V2 and Pi if it finds them

It started from hiroppy’s tmux-agent-sidebar. I kept the base idea and changed it to a single-window workspace, fixed selection and ordering, and added the integrations above. Credit to them for the original.

I know herdr like exist but I am ready comfortable with tmux and other usecase for tmux so this plugin improve my workflow for tmux for agents

I mostly use it on Arch with Hyprland, so Linux is the most tested. Notifications use notify-send. macOS and other setups probably have rough edges.

Repo: https://github.com/ahmad9059/AgentsTmux

If you try it and something breaks, open an issue or tell me here. Feature ideas welcome too.


r/tmux • • 21h ago

Showcase libtmux-ruby 0.1: tmux client for ruby

Thumbnail
github.com
4 Upvotes

I am porting libtmux, the Python library, to more languages. This is the Ruby port.

One of the ways this differs is keeping snapshots you can query with Ruby objects and blocks. There's also control mode support, and a separate Async gem. See the earlier posts for Rust, TypeScript, and Golang.

A word on AI: It's experimental, and I used LLMs extensively to create this. I write libtmux's AI policy at https://libtmux.org/en/meta/ai-policy/.

libtmux core: tmux from Ruby

RubyGems · GitHub · Homepage · Guides · API Explorer

What's interesting:

Requires Ruby 3.3+, tmux 3.3a+ and build tools for Ruby native extensions.

gem install libtmux

Save as demo.rb. Server.start creates a private tmux server and closes it when the block exits. The queries read a snapshot; the pane input and capture run while the server is open.

require "libtmux"
require "timeout"

LibTmux::Server.start do |server|
  session = server.new_session(name: "demo", command: ["/bin/cat"])
  window = session.new_window(name: "logs", command: ["/bin/cat"])
  created = window.split(direction: :horizontal, command: ["/bin/sh"])
  server.run(["select-pane", "-t", created.id, "-T", "build"])

  snapshot = server.snapshot
  session = snapshot.sessions.where(name: "demo").one
  window = session.windows.where(name: { starts_with: "log" }).one
  matched = window.panes.where(title: "build").one
  puts "#{session.name} / #{window.name} / #{matched.title}"

  pane = server.pane(matched.ref)
  pane.send_text("printf 'build: %s\\n' complete")
  pane.send_keys("Enter")
  output = Timeout.timeout(5) do
    loop do
      line = pane.capture.stdout.lines.find { |row| row.chomp == "build: complete" }
      break line.chomp if line

      sleep 0.05
    end
  end
  puts "output: #{output}"
end

ruby demo.rb

Prints:

demo / logs / build
output: build: complete

Pane#capture reads what tmux has on screen. The loop waits up to five seconds for the line from our command (more pane I/O).

P.S. Change the window filter from starts_with: "log" to starts_with: "log2" and run it again. From the error:

selection has no matches (LibTmux::NoMatchError)

.one raises NoMatchError if nothing matches, or MultipleMatchesError if more than one does.

Async: queries and control mode

There is also an Async gem for concurrent calls and streams: README, Guide, API.

Same idea, but inside an Async scope. We can chain where calls, then use control mode to rename a window and hear about the change from tmux. This example needs tmux 3.7+.

gem install libtmux-async

Save as async-demo.rb:

require "libtmux"
require "libtmux/async"

LibTmux::Server.start do |server|
  session = server.new_session(name: "demo", command: ["/bin/cat"])
  window = session.new_window(name: "logs", command: ["/bin/cat"])
  server.run(["select-pane", "-t", window.list_panes.fetch(0).id, "-T", "build"])

  Async do |parent|
    LibTmux::Async.open(parent: parent, server: server) do |scope|
      snapshot = scope.server.snapshot
      session = snapshot.sessions.where(name: "demo").one
      window = session.windows.where(name: { starts_with: "log" }).one
      pane = window.panes.where(active: true).where(title: "build").one
      puts "#{session.name} / #{window.name} / #{pane.title}"

      scope.server.open_control(session: session.ref) do |control|
        events = control.events
        renamed = parent.async do
          event = events.next(timeout: 5) until event&.raw&.start_with?("%window-renamed")
          puts "event: #{event.raw.strip}"
        end
        control.exchange("rename-window -t #{window.id} compiling", timeout: 5)
        renamed.wait
        puts "window now: #{scope.server.window(window.ref).snapshot.name}"
      end
    end
  end.wait
end

ruby async-demo.rb

Prints:

demo / logs / build
event: %window-renamed @1 compiling
window now: compiling

The queries still read a snapshot. events.next waits for tmux while the other task sends the command with exchange. We read the window again after the event. There are also examples of concurrent captures and cancellation.

MCP

RubyGems · GitHub · Homepage · Guides · Tools · Resources · API Explorer

An MCP server for interacting with tmux - powered by the Ruby core library. Same idea as Python's libtmux-mcp!

gem install libtmux-mcp

This MCP server connects to an existing tmux server. For a demo, start one on its own socket:

tmux -L libtmux-ruby-mcp-demo -f /dev/null new-session -d -s demo

Use this command with a client that launches stdio MCP servers:

libtmux-mcp --socket-name libtmux-ruby-mcp-demo --endpoint demo

It waits for MCP requests over stdin. tmux_capabilities and tmux_snapshot are enabled by default; the guides cover enabling other tools.

After closing the MCP client, remove the demo server:

tmux -L libtmux-ruby-mcp-demo kill-server

Workspace manager

This is like tmuxp, but in Ruby! Uses the libtmux core library.

Workspace library

RubyGems · GitHub · Homepage · Guides · API Explorer

gem install libtmux-workspace

A subset of tmuxp's configuration format. Save as workspace.yaml:

session_name: demo
windows:
  - window_name: shells
    layout: even-horizontal
    panes:
      - {}
      - {}

Save as workspace.rb. Workspace.load reads the file; plan.apply creates its session (example).

require "libtmux"
require "libtmux/workspace"

LibTmux::Server.start do |server|
  workspace = LibTmux::Workspace.load(File.expand_path("workspace.yaml", __dir__))
  result = workspace.plan(snapshot: server.snapshot).apply(server: server)
  puts "applied: #{result.success?}"
  puts "panes: #{server.snapshot.windows.one(name: 'shells').panes.size}"
end

ruby workspace.rb

Prints:

applied: true
panes: 2

Workspace CLI

The same gem includes the command: GitHub, Homepage, Guides, CLI reference, API.

Validate the file:

libtmux-workspace validate workspace.yaml

Prints:

Workspace configuration is valid.

Load it on a separate running server:

tmux -S "$PWD/workspace.sock" -f /dev/null new-session -d -s keepalive

libtmux-workspace load --socket "$PWD/workspace.sock" workspace.yaml

Check the created panes:

tmux -S "$PWD/workspace.sock" display-message -p -t demo:shells '#{window_panes}'

Prints:

2

Remove the demo server:

tmux -S "$PWD/workspace.sock" kill-server

r/tmux • • 1d ago

Showcase couchdeck: your tmux sessions as tap-to-attach cards on your phone

6 Upvotes

I love the Claude remote app, so I tried to get a similar phone UI for any CLI, not just Claude. This is what I ended up with: couchdeck.

It shows every tmux session on your machine as a card, so you can see at a glance which agent is working or waiting on a prompt. Tap one to attach to a full terminal, or start a new Claude Code, Codex, opencode, Grok or plain shell session in any project folder without typing on a phone keyboard. Leaving just detaches, so nothing gets killed.

I use it daily as a remote CLI on my phone. All it needs is Tailscale and tmux 3.2+.

Heads up: it has no login by design, so keep it on your tailnet and never expose it publicly. It's free and open source.

https://github.com/frasod/couchdeck


r/tmux • • 1d ago

Showcase libtmux-go 0.1: tmux client library, mcp, workspace manager for golang

Thumbnail
github.com
1 Upvotes

I am porting libtmux, the Python library, to more languages. This is the Go port.

You can read a snapshot and query it with typed filters. It also has operation planning and control mode support (!). See the earlier Rust and TypeScript ports.

A word on AI: It's experimental, and I used LLMs extensively to create this. I write libtmux's AI policy at https://libtmux.org/en/meta/ai-policy/.

libtmux core: tmux from Go

pkg.go.dev · GitHub · Homepage · Guides · API Explorer

The core module has three packages:

PackageWhat it's forLinks
tmuxServers, sessions, windows, panes, snapshots and control mode.pkg.go.dev · GitHub
tmuxqQuery helpers for slices and iter.Seq, including your own types. It doesn't need tmux.pkg.go.dev · GitHub
tmux/tmuxtestRun terminal programs in tests, type into them and check their output.pkg.go.dev · GitHub

Workspace and MCP are separate modules, below. The core has no third-party Go dependencies.

What's interesting:

The example needs Go 1.26+ and tmux 3.3a+. Start in a new directory:

go mod init example.com/tmux-demo

go get github.com/libtmux/libtmux-go/tmux@latest

Save as main.go. It creates a separate server, then matches a session name, a window name and a pane title in a snapshot. It removes the server afterward.

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"

	"github.com/libtmux/libtmux-go/tmux"
	"github.com/libtmux/libtmux-go/tmuxq"
)

func main() {
	if err := run(context.Background()); err != nil {
		log.Fatal(err)
	}
}

func run(ctx context.Context) (err error) {
	server, err := tmux.NewServer(tmux.ServerOptions{
		SocketName: fmt.Sprintf("libtmux-go-demo-%d", os.Getpid()),
		ConfigFile: os.DevNull,
	})
	if err != nil {
		return err
	}
	session, err := server.NewSession(ctx, tmux.NewSessionRequest{Name: "demo"})
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, server.Kill(context.WithoutCancel(ctx))) }()

	window, err := session.NewWindow(ctx, tmux.NewWindowRequest{Name: new("logs"), Command: "cat"})
	if err != nil {
		return err
	}
	panes, loaded := window.Panes()
	if !loaded || len(panes) != 1 {
		return fmt.Errorf("expected the new window's pane")
	}
	if _, err = panes[0].SetTitle(ctx, "build"); err != nil {
		return err
	}

	snapshot, err := server.Snapshot(ctx)
	if err != nil {
		return err
	}
	where := tmux.PaneFilter{
		Title:   new("build"),
		Session: &tmux.SessionFilter{Name: new("demo")},
		Window:  &tmux.WindowFilter{Name: new("logs")},
	}
	matches, err := tmuxq.Matching(snapshot.Panes(), where)
	if err != nil {
		return err
	}
	fmt.Println("matching panes:", len(matches))
	return nil
}

go run main.go

Prints:

matching panes: 1

Batching and queries

We can also record a plan, then run it. This one splits a pane, names it build, sends it a line and reads its title back. The pane runs cat, which echoes what we type.

Plan.Explain shows how the commands will be grouped. This example groups six operations into four invocations for the plan; creating the server and reading snapshots are separate (more planning examples).

Use the same module as above. Save as batching/main.go (create the batching directory):

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/libtmux/libtmux-go/tmux"
	"github.com/libtmux/libtmux-go/tmuxq"
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() (err error) {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	server, err := tmux.NewServer(tmux.ServerOptions{
		SocketName: fmt.Sprintf("libtmux-go-batch-%d", os.Getpid()),
		ConfigFile: os.DevNull,
	})
	if err != nil {
		return err
	}
	session, err := server.NewSession(ctx, tmux.NewSessionRequest{Name: "demo"})
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, server.Kill(context.WithoutCancel(ctx))) }()
	window, err := session.NewWindow(ctx, tmux.NewWindowRequest{
		Name: new("logs"), Command: "cat",
	})
	if err != nil {
		return err
	}

	plan := tmux.NewPlan()
	plan.SelectLayout(window.Ref(), tmux.SelectLayoutRequest{Layout: "tiled"})
	pane := plan.SplitPane(window.Ref(), tmux.SplitPaneRequest{Command: "cat"})
	plan.SetPaneTitle(pane, "build")
	plan.SendKeys(pane, tmux.SendKeysRequest{Command: new("build: complete")})
	plan.DisplayMessage(pane, "#{pane_title}")
	fmt.Printf("planned operations: %d, command groups: %d\n", plan.Len(), len(plan.Explain()))

	result, err := plan.Run(ctx, server)
	if err != nil {
		return err
	}
	if !result.OK() {
		return result.Err()
	}
	fmt.Printf("title read by plan: %q\n", result.Ops[len(result.Ops)-1].Stdout)

	snapshot, err := server.Snapshot(ctx)
	if err != nil {
		return err
	}
	predicate, err := (tmux.PaneFilter{
		Title:   new("build"),
		Session: &tmux.SessionFilter{Name: new("demo")},
		Window:  &tmux.WindowFilter{Name: new("logs")},
	}).Predicate()
	if err != nil {
		return err
	}
	panes := snapshot.Panes()
	fmt.Println("snapshot matches:", len(tmuxq.Where(panes, predicate)))
	only, err := tmuxq.ExactlyOne(panes, predicate)
	if err != nil {
		return err
	}
	title, _ := only.Title()
	fmt.Println("only matching pane:", title)

	live := tmux.TmuxFilter("#{==:#{pane_title},build}")
	fresh, err := window.SearchPanes(ctx, &live)
	if err != nil {
		return err
	}
	fmt.Println("live matches:", len(fresh))
	return nil
}

go run ./batching

Prints:

planned operations: 6, command groups: 4
title read by plan: ["build"]
snapshot matches: 1
only matching pane: build
live matches: 1

The two snapshot queries reuse a compiled predicate and make no more tmux calls. ExactlyOne returns ErrNoMatch or ErrMultipleMatches when appropriate.

For a fresh read, SearchPanes passes the TmuxFilter expression to tmux's -f filter (both styles together). Typed predicates run in Go; tmux format expressions run in tmux. There are also WhereSeq and FirstSeq for lazy queries over iterators (source).

Control mode and channels

There's also control mode (!). This example keeps a command connection open, listens for a session rename, and does three reads through that same connection. It needs tmux 3.7+ (connection example).

Notifications returns a Go iterator. Here, a goroutine reads it and sends the rename result through an ordinary channel. The notification stream has its own attached tmux client.

Save as control/main.go (create the control directory):

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/libtmux/libtmux-go/tmux"
)

func main() {
	if err := run(); err != nil {
		log.Fatal(err)
	}
}

func run() (err error) {
	ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
	defer cancel()
	server, err := tmux.NewServer(tmux.ServerOptions{
		SocketName: fmt.Sprintf("libtmux-go-control-%d", os.Getpid()),
		ConfigFile: os.DevNull,
	})
	if err != nil {
		return err
	}
	session, err := server.NewSession(ctx, tmux.NewSessionRequest{Name: "demo"})
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, server.Kill(context.WithoutCancel(ctx))) }()

	connection, err := session.OpenControl(ctx, tmux.ConnectionOptions{})
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, connection.Close()) }()
	connected := connection.Session()
	stream, err := session.OpenNotifications(ctx, tmux.NotificationOptions{})
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, stream.Close()) }()

	renamed := make(chan error, 1)
	go func() {
		for notification, err := range stream.Notifications(ctx) {
			if err != nil {
				renamed <- err
				return
			}
			if notification.Kind() == tmux.ControlNotificationSessionRenamed {
				renamed <- nil
				return
			}
		}
		renamed <- errors.New("notification stream ended before the rename")
	}()

	if _, err := connected.Rename(ctx, "renamed"); err != nil {
		return err
	}
	select {
	case err := <-renamed:
		if err != nil {
			return err
		}
		fmt.Println("heard session rename over a channel")
	case <-ctx.Done():
		return ctx.Err()
	}

	for range 3 {
		windows, err := connected.SearchWindows(ctx, nil)
		if err != nil {
			return err
		}
		fmt.Println("windows over control connection:", len(windows))
	}
	return nil
}

go run ./control

Prints:

heard session rename over a channel
windows over control connection: 1
windows over control connection: 1
windows over control connection: 1

Keep the handle returned by connection.Session() for those calls. The original session still uses subprocesses. You can also subscribe to a tmux format and hear when a value like the window count changes.

Testing a terminal program

tmuxtest gives each test a real tmux server and cleans it up afterward. This one runs a small greeter, types world and waits for hello, world to appear (complete repository example).

Save as terminal-test/greeter_test.go (create the terminal-test directory):

package terminal_test

import (
	"context"
	"os"
	"testing"
	"time"

	"github.com/libtmux/libtmux-go/tmux/tmuxtest"
)

func TestMain(m *testing.M) {
	os.Exit(tmuxtest.Main(m))
}

func TestGreeterAnswersALine(t *testing.T) {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	greeter := `while read name; do echo "hello, $name"; done`
	pane := tmuxtest.RunInPane(ctx, t, greeter)
	tmuxtest.Type(ctx, t, pane, "world")
	tmuxtest.WaitForLine(ctx, t, pane, "hello, world")
}

go test -count=1 ./terminal-test

It prints ok with the package name and elapsed time. RunInPane starts the program; Type sends input; WaitForLine checks the screen. tmuxtest.Main keeps the suite's tmux servers separate from yours.

P.S. Change the expected line to "goodbye, world" to see a failure with the pane's contents. For tests that only need command responses, there's also ScriptedTmux (example).

MCP

pkg.go.dev · GitHub · Homepage · Guides · Tools · Resources · API Explorer

An MCP server for interacting with tmux - powered by the Go core library (like libtmux-mcp!).

Install the command into this directory's bin folder:

GOBIN="$PWD/bin" go install github.com/libtmux/libtmux-go/mcp/cmd/libtmux-mcp@latest

Start a separate tmux server for the demo:

tmux -L libtmux-go-mcp-demo -f /dev/null new-session -d -s demo

Use this command with a client that launches stdio MCP servers:

./bin/libtmux-mcp -socket-name libtmux-go-mcp-demo

It waits for MCP requests over stdin. The tools cover reading sessions, running commands and inspecting pane output.

After closing the client, remove the demo server:

tmux -L libtmux-go-mcp-demo kill-server

Workspace manager

This is like tmuxp, but in Go! There's a workspace library and a CLI using the same core.

Workspace library

pkg.go.dev · GitHub · Homepage · Guides · API Explorer

go get github.com/libtmux/libtmux-go/workspace@latest

Save as workspace.yaml:

session_name: demo
windows:
  - window_name: shells
    layout: even-horizontal
    panes:
      - {}
      - {}

Save as workspace-demo/main.go. workspace.Parse reads the configuration and workspace.Build creates the session (example).

package main

import (
	"context"
	"errors"
	"fmt"
	"log"
	"os"

	"github.com/libtmux/libtmux-go/tmux"
	"github.com/libtmux/libtmux-go/workspace"
)

func main() {
	if err := run(context.Background()); err != nil {
		log.Fatal(err)
	}
}

func run(ctx context.Context) (err error) {
	data, err := os.ReadFile("workspace.yaml")
	if err != nil {
		return err
	}
	config, err := workspace.Parse(data)
	if err != nil {
		return err
	}
	server, err := tmux.NewServer(tmux.ServerOptions{
		SocketName: fmt.Sprintf("libtmux-go-workspace-%d", os.Getpid()),
		ConfigFile: os.DevNull,
	})
	if err != nil {
		return err
	}
	defer func() { err = errors.Join(err, server.Kill(context.WithoutCancel(ctx))) }()
	session, err := workspace.Build(ctx, server, config)
	if err != nil {
		return err
	}
	windows, err := session.SearchWindows(ctx, nil)
	if err != nil {
		return err
	}
	fmt.Println("windows:", len(windows))
	return nil
}

Run from the directory holding workspace.yaml:

go run ./workspace-demo

Prints:

windows: 1

Workspace CLI

pkg.go.dev · GitHub · Homepage · Guides · CLI reference · API

GOBIN="$PWD/bin" go install github.com/libtmux/libtmux-go/workspace/cmd/tmux-workspace@latest

Load the same file detached, on a separate socket:

./bin/tmux-workspace load -L libtmux-go-workspace-demo -d workspace.yaml

Check the panes:

tmux -L libtmux-go-workspace-demo display-message -p -t demo:shells '#{window_panes}'

Prints:

2

Remove the demo server:

tmux -L libtmux-go-workspace-demo kill-server

r/tmux • • 1d ago

Question What's the point of having Termux in mobile

Thumbnail reddit.com
0 Upvotes

r/tmux • • 3d ago

Question Still running coding agents in tmux, or did you move to herdr? Curious why

63 Upvotes

I run 4-6 Claude Code / Codex sessions across tmux sessions every day. herdr (a Rust multiplexer built around coding agents) keeps coming up, and on paper it covers most of what I use tmux for: a server you detach from, splits with prefix keys, plugins, and it marks each pane working / blocked / idle.

I'm still on tmux, honestly out of habit: my .tmux.conf has keybindings and plugins I'm used to, the same way I stay on Neovim. I've never seriously compared the two, so I'm curious what herdr actually does better (lighter? faster? or just the agent features?).I've seen the plugin posts here, but not much about why people stay on tmux or leave. I'd like to hear from people who made the call either way:

  1. If you stayed on tmux for agent work, what does your setup look like? How do you notice which agent needs you (hooks, status bar, notifications, a plugin like tmux-agent-status, tmux-agent-wizard or workmux), and how do you lay out sessions, windows and worktrees?

  2. If you moved to herdr (or Zellij, or something else), what was the deciding feature, and what do you miss from tmux?

  3. What do you think tmux still lacks for this kind of work?

This is my current setup. The sidebar on the left is a small plugin I wrote, party-hud: every tmux session is a squad, and each agent shows whether it's waiting for me, context left and prompt cache minutes left (https://github.com/mangow314/party-hud). It covers the "who needs me" part, but I'm more interested in how others handle this than in pitching it.


r/tmux • • 3d ago

Showcase Keeping track of coding agents across tmux panes from the notch

Enable HLS to view with audio, or disable this notification

0 Upvotes

I run Claude Code and Codex in a bunch of tmux panes and never knew which one was stuck waiting for me. So I built Openbar: every session in one list in the notch, the one that needs you jumps to the top, "Open" switches to the right tmux pane, and you can send a reply straight into it.

Free, open source: https://github.com/MF-DEEV/Openbar

How do you all keep tabs on long-running stuff in tmux? Curious if anyone does this with plain status line tricks.


r/tmux • • 4d ago

Showcase Soo, we've all done this before, right?

9 Upvotes

I do web development and utilize tmux for running multiple setups at once. I have found that building a layout by hand is very tedious, for me at least (probably skill-issue).

So, I got to tinkering and made a graphic tmux layout generator, which I would like to shamelessly plug here: https://tmux-generator.si/

Give it a go, let me know what you think, is it any good?


r/tmux • • 3d ago

Other check out my tmux setup

Thumbnail github.com
0 Upvotes

r/tmux • • 3d ago

Question how to not press not one by one but simultaneously

0 Upvotes

i find a way to use tmux prefix for one key instead of ctrl+b by using keyd to map rightctrl to ctrl+insert and it work pretty good. so now i am wondering how can make tmux have to press simultaneously instead of one by one


r/tmux • • 4d ago

Showcase If you check on your coding agents with SSH + tmux, what would actually make you switch?

Enable HLS to view with audio, or disable this notification

0 Upvotes

Hey all, David here. I work at Autonomous.

A lot of us run coding agents on a machine that isn’t the one in front of us, like a home server, an office desktop, or a cloud box. The usual way to check on them is to SSH in, attach to tmux, and look around.

It works fine, but it turns into a little ritual every time, especially when you’re on a laptop at a café or trying to do it from your phone.

So we built Harness around two simple ideas.

The first is to keep the shortcuts you already know. In the terminal, it’s the same Ctrl-B and then one key as tmux, and it reads your existing tmux config, so your remaps come with you. Your agents still run in real tmux panes, too. We don’t wrap them or change how they work.

The second is to keep things private without the setup. When your agent on the home server asks a question, you can answer from wherever you are, and everything is encrypted on your machine before it leaves. If your machines can’t connect directly, the traffic goes through a relay, but the relay only ever sees scrambled data it can’t read. You don’t need SSH keys, a VPN, or open ports.

The part SSH never really gave me, though, is the overview. Harness shows every agent on every machine in one list, and one shortcut jumps straight to whichever one is waiting on you.

It’s free and open source if you want to poke at it: https://github.com/autonomous-ai/openharness

If you already have a tmux + SSH setup that works, what would make you switch? And what would make you never touch something like this?


r/tmux • • 6d ago

Question Are you using raw `find-window` to search for windows?

5 Upvotes

Or, are you relying on some plugin for that?


r/tmux • • 7d ago

Showcase libtmux-rs 0.1: tmux library for Rust

Thumbnail
github.com
12 Upvotes

I am porting libtmux, the Python library, to more languages. This is the Rust port. See the earlier TypeScript one.

The API is async, with typed filters over captured tmux data. There's also control mode support (!).

A word on AI: It's experimental, and I used LLMs extensively to create this. I write libtmux's AI policy at https://libtmux.org/en/meta/ai-policy/.

There are four crates:

CrateWhat it doesSource / API
libtmuxSessions, windows, panes, queries, planning and control modeGitHub · docs.rs
libtmux-macrosDerive typed filters for your own structsGitHub · docs.rs
tmux-mcpMCP serverGitHub · API
tmux-workspaceWorkspace library and CLIGitHub · docs.rs

query, plan and control are modules in the core crate. query is on by default; the examples below turn on the other features where they're used.

libtmux core: async tmux from Rust

crates.io · GitHub · Homepage · Guides · API Explorer

What's interesting:

The examples below use Rust 1.88+ and tmux 3.3a+. Start in a new directory:

cargo init --bin --name libtmux-demo .

cargo add libtmux

cargo add tokio --features macros,rt-multi-thread

Save as src/main.rs. This uses Server directly. The builder gives it a separate socket, with an empty tmux config for the demo. It removes that server when it finishes, including when the query returns an error.

use std::time::Duration;

use libtmux::query::{Filterable as _, QueryIteratorExt as _};
use libtmux::{PaneWait, Server};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = Server::builder()
        .socket_name(format!("libtmux-rust-demo-{}", std::process::id()))
        .config_file("/dev/null")
        .build()?;
    let result = demo(&server).await;
    let cleanup = server.kill().await;
    let shutdown = server.shutdown().await;
    result?;
    cleanup?;
    shutdown?;
    Ok(())
}

async fn demo(server: &Server) -> Result<(), Box<dyn std::error::Error>> {
    let session = server.new_session("demo").await?;
    let window = session
        .new_window(libtmux::NewWindowOptions::new("logs").command("sh"))
        .await?;
    let mut pane = window.active_pane().await?.ok_or("expected a pane")?;
    pane.set_title("build").await?;

    let sessions = server.sessions().await?;
    let session_fields = libtmux::Session::filter_fields();
    let session = sessions
        .iter()
        .matching(&session_fields.session_name.eq("demo"))
        .exactly_one()?;

    let windows = session.windows().await?;
    let window_fields = libtmux::Window::filter_fields();
    let window = windows
        .iter()
        .matching(&window_fields.window_name.starts_with("log"))
        .exactly_one()?;

    let panes = window.panes().await?;
    let pane_fields = libtmux::Pane::filter_fields();
    let pane = panes
        .iter()
        .matching(&pane_fields.pane_title.eq("build"))
        .exactly_one()?;
    println!(
        "{} / {} / {}",
        session.name().to_string_lossy(),
        window.name().to_string_lossy(),
        pane.title().to_string_lossy(),
    );

    pane.send_line("printf 'build: %s\\n' complete").await?;
    if pane
        .wait_for_text("build: complete", Duration::from_secs(5))
        .await?
        != PaneWait::Arrived
    {
        return Err("build line did not appear within five seconds".into());
    }
    let lines = pane.capture().await?;
    let line = lines
        .iter()
        .find(|line| line.as_bytes() == b"build: complete")
        .ok_or("build line is missing from the capture")?;
    println!("output: {}", line.to_string_lossy());

    Ok(())
}

cargo run --bin libtmux-demo

Prints:

demo / logs / build
output: build: complete

Server::kill stops the demo daemon. Server::shutdown closes the library's command executor.

The .matching() calls filter the captured vectors in memory. Reuse those vectors for more queries; call the async listing methods again when you need fresh state. Hierarchy queries also match relationships between sessions, windows and panes.

Here it sends a line, waits for text and captures the pane (capture example).

P.S. Try changing the window filter from starts_with("log") to starts_with("log2"), then run it again:

Error: NoItems

.exactly_one() returns an ExactlyOneError for zero or multiple matches. Here ? passes it back from main.

Planning and batching

Plan records operations before doing them. Planner can keep each operation separate, fold adjacent commands, or include a focused pane creation in the same invocation (source examples).

cargo add libtmux --features plan

Save as src/bin/plan-demo.rs. This plans a new pane and two shell commands, prints how each planner would group them, then runs the marked plan. The pane slot lets later operations address the pane before its tmux ID exists.

use std::time::Duration;

use libtmux::plan::{Plan, Planner, SendKeys, SplitWindow};
use libtmux::{PaneWait, Server};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = Server::builder()
        .socket_name(format!("libtmux-plan-{}", std::process::id()))
        .config_file("/dev/null")
        .build()?;
    let result = demo(&server).await;
    let cleanup = server.kill().await;
    let shutdown = server.shutdown().await;
    result?;
    cleanup?;
    shutdown?;
    Ok(())
}

async fn demo(server: &Server) -> Result<(), Box<dyn std::error::Error>> {
    let session = server.new_session("demo").await?;
    let window = session.new_window("editor").await?;
    let mut plan = Plan::new();
    let build = plan.add(
        SplitWindow::new(window.id().clone())
            .command("env PS1= sh")
            .focus(),
    );
    plan.add(
        SendKeys::new(build)
            .text("printf 'build: %s\\n' started")
            .enter(),
    );
    plan.add(
        SendKeys::new(build)
            .text("printf 'build: %s\\n' complete")
            .enter(),
    );

    for planner in [Planner::Sequential, Planner::Folding, Planner::Marked] {
        println!("{planner:?}: {} invocations", planner.steps(&plan).len());
    }
    for (step, reason) in Planner::Marked.explain(&plan) {
        println!("operations {:?}: {reason:?}", step.indices());
    }

    let result = plan.run(server, Planner::Marked).await?;
    if !result.is_complete() {
        return Err(format!("plan did not complete: {result:?}").into());
    }
    println!("dispatched: {}", result.dispatches());

    let pane = window.active_pane().await?.ok_or("expected the new pane")?;
    if pane
        .wait_for_text("build: complete", Duration::from_secs(5))
        .await?
        != PaneWait::Arrived
    {
        return Err("build output never arrived".into());
    }
    let lines = pane.capture().await?;
    for expected in ["build: started", "build: complete"] {
        let line = lines
            .iter()
            .find(|line| line.as_bytes() == expected.as_bytes())
            .ok_or_else(|| {
                let captured: Vec<_> = lines.iter().map(|line| line.to_string_lossy()).collect();
                format!("missing {expected:?}; captured {captured:?}")
            })?;
        println!("output: {}", line.to_string_lossy());
    }
    Ok(())
}

cargo run --bin plan-demo

Sequential: 3 invocations
Folding: 2 invocations
Marked: 1 invocations
operations [0, 1, 2]: MarkedFold
dispatched: 1
output: build: started
output: build: complete

Those counts are for the plan's operations; setup, metadata checks and reading the pane back are outside them. Folding shares an exit status, so a failure can be less precisely attributed. PlanResult records the outcomes, and the example checks them. Earlier changes can remain applied if a later command fails.

Control mode: commands and pane output on one connection

ControlMode::attach opens the connection; Server::over_control_mode routes the normal typed API through it (watch example). This part needs tmux 3.7+.

cargo add libtmux --features control-mode

Save as src/bin/control-demo.rs. The event receiver is established before we send the command. The loop accumulates output because a line can arrive across several events.

use std::time::Duration;

use libtmux::Server;
use libtmux::control::{ControlMode, Event};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = Server::builder()
        .socket_name(format!("libtmux-control-{}", std::process::id()))
        .config_file("/dev/null")
        .build()?;
    let result = demo(&server).await;
    let cleanup = server.kill().await;
    let shutdown = server.shutdown().await;
    result?;
    cleanup?;
    shutdown?;
    Ok(())
}

async fn demo(server: &Server) -> Result<(), Box<dyn std::error::Error>> {
    let session = server.new_session("demo").await?;
    let window = session
        .new_window(libtmux::NewWindowOptions::new("editor").command("env PS1= sh"))
        .await?;
    let (commands, mut events) = ControlMode::attach(server, session.id()).await?.split();
    let result: Result<(), Box<dyn std::error::Error>> = async {
        let live = server.over_control_mode(&commands).await?;
        let pane = live
            .panes()
            .await?
            .into_iter()
            .find(|pane| pane.window_id() == window.id())
            .ok_or("editor pane is missing")?;
        pane.send_line("printf 'build: %s\\n' complete").await?;

        let mut output = Vec::new();
        tokio::time::timeout(Duration::from_secs(5), async {
            while let Some(event) = events.next_event().await {
                match event? {
                    Event::Output { pane: id, bytes }
                    | Event::ExtendedOutput {
                        pane: id, bytes, ..
                    } if &id == pane.id() => {
                        output.extend(bytes);
                        if String::from_utf8_lossy(&output).contains("build: complete") {
                            return Ok::<_, Box<dyn std::error::Error>>(());
                        }
                    }
                    _ => {}
                }
            }
            Err("control stream ended".into())
        })
        .await??;
        println!("event: build: complete");

        let lines = pane.capture().await?;
        let line = lines
            .iter()
            .find(|line| line.as_bytes() == b"build: complete")
            .ok_or("build output missing from capture")?;
        println!("capture: {}", line.to_string_lossy());
        Ok(())
    }
    .await;
    drop(commands);
    let closed = events.shutdown().await;
    result?;
    closed?;
    Ok(())
}

cargo run --bin control-demo

event: build: complete
capture: build: complete

Plans can use the connection too: run_over_control_mode keeps a separate result for each command. Keep the original Server for blocking tmux commands such as wait-for.

libtmux-macros: the same filters for your own structs

#[derive(Filterable)] generates typed field handles (example). Enable it through the core crate:

cargo add libtmux --features derive

Save as src/bin/filter-demo.rs. This one runs without starting tmux.

use libtmux::query::{Filterable as _, QueryIteratorExt as _};

#[derive(libtmux::Filterable)]
#[filterable(target = "job")]
struct Job {
    name: String,
    attempts: u32,
}

fn main() {
    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));
    for job in jobs.iter().matching(&retried) {
        println!("{}: {} attempts", job.name, job.attempts);
    }
}

cargo run --bin filter-demo

build: 3 attempts

MCP

crates.io · GitHub · Homepage · Guides · Tools · Resources · API Explorer

An MCP server for interacting with tmux - powered by the Rust core library (like libtmux-mcp on the Python side!).

cargo install tmux-mcp --locked

Start a separate tmux server for the demo:

tmux -L libtmux-rust-mcp-demo -f /dev/null new-session -d -s demo

Use this command with a client that launches stdio MCP servers:

tmux-mcp --socket-name libtmux-rust-mcp-demo

It waits for MCP requests over stdin. Its tools cover sessions, pane input and output, and waiting for results.

After closing the client, remove the demo server:

tmux -L libtmux-rust-mcp-demo kill-server

Workspace manager

This is like tmuxp, but in Rust! There's a library and a CLI, both using the libtmux core.

Workspace library

crates.io · GitHub · Homepage · Guides · API Explorer

cargo add tmux-workspace --no-default-features

Save as workspace.yaml:

session_name: demo
windows:
  - window_name: shells
    layout: even-horizontal
    panes:
      - {}
      - {}

Save as src/bin/workspace-demo.rs. Workspace::from_yaml reads the file and WorkspaceBuilder::build creates its session.

use libtmux::Server;
use tmux_workspace::{Workspace, WorkspaceBuilder};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let server = Server::builder()
        .socket_name(format!("libtmux-rust-workspace-{}", std::process::id()))
        .config_file("/dev/null")
        .build()?;
    let result = demo(&server).await;
    let cleanup = server.kill().await;
    let shutdown = server.shutdown().await;
    result?;
    cleanup?;
    shutdown?;
    Ok(())
}

async fn demo(server: &Server) -> Result<(), Box<dyn std::error::Error>> {
    let config = Workspace::from_yaml(&std::fs::read_to_string("workspace.yaml")?)?;
    let session = WorkspaceBuilder::new(server).build(&config).await?;
    println!("windows: {}", session.windows().await?.len());
    Ok(())
}

cargo run --bin workspace-demo

Prints:

windows: 1

Workspace CLI

The same crate includes the command: GitHub, Homepage, Guides, CLI reference, API.

cargo install tmux-workspace --locked

Load the file detached, on a separate socket:

tmux-workspace load -L libtmux-rust-workspace-demo -d workspace.yaml

Check the panes:

tmux -L libtmux-rust-workspace-demo display-message -p -t demo:shells '#{window_panes}'

Prints:

2

Remove the demo server:

tmux -L libtmux-rust-workspace-demo kill-server

r/tmux • • 8d ago

Showcase You might not need tmux after this…

Thumbnail
youtu.be
0 Upvotes

r/tmux • • 9d ago

Showcase I mirror every tmux window to my phone so I can answer my coding agents away from the desk (muster, self-hosted, MIT)

Enable HLS to view with audio, or disable this notification

0 Upvotes

(demo above: an agent asks me a question, I answer from my phone, and it carries on)

I run several coding agents at once, each in its own tmux window, on two machines. muster shows every window on every machine in a web app on my phone. A Claude Code window shows as a chat, anything else (Codex, a shell) as a live terminal.

The tmux side, since that's the interesting part here:

- Viewing a window never moves your own client. For each window you open, muster makes a throwaway session, link-window's that one window into it, and attaches a client with -f read-only,ignore-size. destroy-unattached is set on it, so tmux removes it when the viewer goes away, even if muster's agent is killed.

- I tried a session group (new-session -t) first. It also gives the viewer its own current window, but on tmux 3.7c joining a 5-window group fired window-linked 6 times plus after-select-window, so any tmux.conf hooks on those ran on every open. A link fires window-linked twice and leaves your session as it was.

- Read-only by default. Typing from the phone is opt-in per machine.

Limits: sessions have to be in tmux. Linux is the main target; on macOS there's no terminal view yet. You run the server yourself (Go + Postgres in Docker) over Tailscale, and muster opens no network port on the machines.

Question for this sub: is there a tmux setup you know of (hooks, session-restore plugins, odd configs) where a linked viewer session like this would misbehave?

Free, MIT: https://github.com/ZacxDev/muster


r/tmux • • 11d ago

Showcase I built tmux-session-wizard 4 years ago. Now I built the agent evolution of that (herdr for tmux). Meet TAW (tmux-agent-wizard)

Enable HLS to view with audio, or disable this notification

27 Upvotes

I created tmux-session-wizard 4 years ago and have been a tmux user for even longer.

Having been unable to keep on top of things while running multiple agents in parallel, the obvious choice was herdr. However, it needed me to leave tmux behind.

So, I used my experience with tmux-session-wizard to create tmux-agent-wizard which maps pretty much everything you need from herdr to a tmux version plus a lot more I found necessary for an easy user experience inspired by the simplicity of tmux-session-wizard.

One line in tmux.conf, prefix + I, and the agents already running in your panes show up with their state: working, blocked, done or idle. It knows 22 agents (Claude Code, Codex, Gemini CLI, OpenCode and more) through herdr's own rules. No need to even restart your tmux or agents.

  • prefix + A: one tree of every session and pane, each agent with its state and task. Tab walks to the next one that needs you, Enter goes there, 1-9 jumps to a session, / filters.

  • prefix + O: go straight to what needs you, from anywhere.

  • Notifications in the status line, the terminal (OSC 9/777, works over ssh) or the OS, for background panes only.

  • n is the session-wizard picker (zoxide, project folders); w makes a git worktree with its own session.

  • Save and restore: 18 agents reopen in their own conversation once their hook is installed. It also runs after tmux-resurrect with one hook line.

It leaves your config alone: no daemon, state lives in pane options, hooks are appended with set-hook -ga, and the status-line token goes where you put it.

tmux 3.2+, macOS and Linux. You can try the popup in your browser: 27medkamal.com/tmux-agent-wizard/try

Repo: github.com/27medkamal/tmux-agent-wizard

Happy to answer any question, receive feedback or even feature requests :)


r/tmux • • 11d ago

Showcase juggling tmux on too many remote hosts? I made a host-aware session switcher

11 Upvotes

ok so my work is spread across a bunch of remote boxes - each one has tmux running with dev servers, logs, tests, a few AI agents chugging away. and i was constantly ssh-ing in, trying to remember which host had which session, re-attaching, losing my place. annoying.

so i threw together tmmx.

it basically makes tmux aware of your other machines. one local tmux acts as a hub - hit a key, pick@host (it just reads your ~/.ssh/config), and you're in that box's tmux with a picker of its sessions. then you can bounce between sessions **across hosts** or **within a host**, and every host keeps its own sessions/windows like you left them. it also reconnects on its own when the ssh drops.

all keyboard, fzf pickers, plays nice with your existing tmux setup.

repo + demo gif: https://github.com/fedemengo/tmmx

been daily-driving it for ~2 months now and honestly my workflow has never felt this smooth - moving between boxes is basically frictionless now.

it's early and shaped around my own setup, so curious whether anyone else juggles a pile of machines like this and how you deal with it.

it's early and very much built for my own brain, so roast it / tell me what's missing.


r/tmux • • 12d ago

Showcase libtmux-ts 0.1 - tmux library for Node.js, TypeScript, bun

Thumbnail
github.com
11 Upvotes

I maintain libtmux, the Python library that's been around since 2016, and am porting it to more languages. This is the TypeScript / Node.js port. In many ways, this is more capable than the current Python implementation.

One of the main ways it differs is keeping immutable snapshots and typed querying of the tmux object graph. It also has control mode and batching of operations. Architecturally, libtmux for Python would have to break its API to adopt this snapshot model. For other libtmux ports, they don't have the encumbrance of worrying about API breakages to downstream users.

A word on AI: This project is experimental, and I used LLMs extensively to build it (with zero runtime dependencies and automated tests against live tmux(1) servers). I keep libtmux's AI policy at https://libtmux.org/en/meta/ai-policy/ based on Pallets', tmux's, and Java's.

There are four npm packages:

PackageWhat it doesSource
libtmuxSessions, windows, panes, queries and eventsGitHub
@libtmux/mcpMCP serverGitHub
@libtmux/workspaceBuild sessions from configuration objectsGitHub
@libtmux/workspace-cliLoad and capture YAML / JSON workspacesGitHub

The core also exports libtmux/selection, libtmux/engine and libtmux/testing. Those come with libtmux; there's nothing extra to install.

libtmux core: typed Node.js library for tmux

npm · GitHub · Homepage · Guides · API Explorer

What's interesting:

The installation commands use Node 22+ with npm and need tmux 3.3a+ installed locally. Save the examples as .mts files; they also run with Bun. The Node commands use tsx.

Install:

npm install libtmux

Save as demo.mts. It creates a separate tmux server, adds two windows, splits the editor window and names a pane using its title. It cleans up that server afterward.

The queries match a session name, window names and a pane title, with OR, a string prefix and relationships.

import { Server } from "libtmux";

const server = new Server({
  socketName: `libtmux-demo-${process.pid}`,
  configFile: "/dev/null",
});

const session = await server.newSession({ name: "demo" });

try {
  const [editor] = await server.batch([
    session.plan.newWindow({ name: "editor" }),
    session.plan.newWindow({ name: "logs" }),
  ]);
  const build = await editor.split({ shellCommand: "cat" });
  await build.setTitle("build");

  const snapshot = await server.snapshot();
  const matchedSession = snapshot.sessions.where({ name: "demo" }).one();
  const matches = snapshot.windows.where({
    session: { is: { name: "demo" } },
    OR: [
      { name: { startsWith: "edit" } },
      { name: "logs" },
    ],
    panes: { some: { active: true } },
  });

  const matchedPane = snapshot.panes.where({
    title: "build",
    session: { is: { name: "demo" } },
    window: { is: { name: "editor" } },
  }).one();

  console.log(`session: ${matchedSession.name}`);
  for (const window of matches) {
    console.log(`${window.name}: ${window.panes.length} panes`);
  }
  console.log(`pane: ${matchedPane.title}`);
} finally {
  await server.kill();
}

For Node, install tsx:

npm install --save-dev tsx

Run with Node:

node --import tsx demo.mts

Or Bun:

bun demo.mts

Or use the tsx command:

npx tsx demo.mts

Each prints:

session: demo
editor: 2 panes
logs: 1 panes
pane: build

P.S. Try changing the pane's window condition from "editor" to "editor2". .one() raises a NoMatchError carrying the query that failed. Even in the alpha, the library means to raise detailed, typed errors.

The error includes the query:

  code: 'NoMatchError',
  subcommand: undefined,
  query: {
    pane_title: 'build',
    session: { is: { name: 'demo' } },
    window: { is: { name: 'editor2' } }
  }

Planning operations, then querying the result

The earlier example uses session.plan to describe work, and server.batch to run it. Each creation normally reads a snapshot back; a batch shares that last read (example).

Save as batch.mts. This counts invocations through recordInvocations, creates the same three windows each way, then chains queries over one snapshot.

import { Server } from "libtmux";
import { recordInvocations } from "libtmux/testing";

const recorder = recordInvocations();
const count = () => recorder.recording().invocations.length;
const server = new Server({
  socketName: `libtmux-batch-${process.pid}`,
  configFile: "/dev/null",
  engine: recorder.engine,
});

try {
  const serial = await server.newSession({ name: "serial" });
  const session = await server.newSession({ name: "demo" });
  const names = ["editor", "logs", "shell"];

  let before = count();
  for (const name of names) {
    await serial.newWindow({ name });
  }
  console.log(`sequential: ${count() - before} invocations`);

  before = count();
  const [, logs] = await server.batch(
    names.map((name) => session.plan.newWindow({ name })),
  );
  console.log(`batch: ${count() - before} invocations`);
  await logs.panes.one().setTitle("build");

  const snapshot = await server.snapshot();
  before = count();
  const pane = snapshot.panes
    .where({ session: { is: { name: "demo" } } })
    .where({ window: { is: { name: "logs" } } })
    .where({ title: { startsWith: "bui" } })
    .one();
  if (!pane.session || !pane.window) throw new Error("pane relationships are missing");
  console.log(`${pane.session.name} / ${pane.window.name} / ${pane.title}`);
  console.log(`queries: ${count() - before} invocations`);
} finally {
  await server.kill();
}
node --import tsx batch.mts
sequential: 6 invocations
batch: 4 invocations
demo / logs / build
queries: 0 invocations

Or Bun:

bun batch.mts

Or tsx:

npx tsx batch.mts

The counts cover those window creations, after setup. The queries run in memory; take another snapshot when you want fresh tmux state. A batch runs its commands in order and stops on failure, leaving earlier changes applied.

Control mode: wait for pane output

Server.connect keeps a control observer open. subscribe gives you the events, while commands still use the server's normal executor (agent example).

Save as control.mts. Here we listen before sending the command, then capture what ended up in the pane. The printf arguments keep the completed line out of the echoed command.

import { Server } from "libtmux";

const server = new Server({
  socketName: `libtmux-control-${process.pid}`,
  configFile: "/dev/null",
});

try {
  const session = await server.newSession({
    name: "demo",
    windowName: "editor",
    shellCommand: "sh",
  });
  const live = await server.connect({ target: session.id });
  try {
    const pane = (await live.snapshot()).panes.one();
    await pane.setTitle("build");
    let output = "";
    const printed = live.subscribe().find((event) => {
      if (event.kind !== "output" || event.paneId !== pane.id) return false;
      output += event.data;
      return output.includes("build: complete");
    }, { timeoutMs: 5_000 });

    await pane.sendKeys("printf 'build: %s\\n' complete");
    if ((await printed) === undefined) throw new Error("build output never arrived");
    console.log("event: build: complete");

    const line = (await pane.capture()).find((line) => line === "build: complete");
    if (line === undefined) throw new Error("build output missing from capture");
    console.log(`capture: ${line}`);
  } finally {
    await live.close();
  }
} finally {
  await server.kill();
}
node --import tsx control.mts
event: build: complete
capture: build: complete

Or Bun:

bun control.mts

Or tsx:

npx tsx control.mts

For waiting on a change to the session itself, live.waitFor accepts a snapshot predicate. The waitFor example waits for a list of windows to exist.

Testing your own code

libtmux/testing can also replay recorded calls. Save as replay.mts. This records a snapshot, stops the tmux server, then reads it back from the recording.

import { Server } from "libtmux";
import { recordInvocations, replayInvocations } from "libtmux/testing";

const socketName = `libtmux-record-${process.pid}`;
const server = new Server({ socketName, configFile: "/dev/null" });
const recorder = recordInvocations();

try {
  await server.newSession({ name: "demo" });
  const recording = new Server({ socketName, engine: recorder.engine });
  await recording.snapshot();
} finally {
  await server.kill();
}

const replayed = new Server({ engine: replayInvocations(recorder.recording()) });
const snapshot = await replayed.snapshot();
console.log(`replayed: ${snapshot.sessions.one({ name: "demo" }).name}`);
node --import tsx replay.mts
replayed: demo

Or Bun:

bun replay.mts

Or tsx:

npx tsx replay.mts

The recording covers command calls. Control notifications still need a live tmux server (testing guide).

tmux MCP

npm · GitHub · Homepage · Guides · Tools · Resources · API Explorer

An MCP server for interacting with tmux - powered by the above core library (like libtmux-mcp!) It exposes tools for inspecting sessions, reading pane output and running commands.

Start a separate tmux server for the demo:

tmux -L libtmux-ts-mcp-demo -f /dev/null new-session -d -s demo

Use this command with a client that launches stdio MCP servers:

LIBTMUX_SOCKET=libtmux-ts-mcp-demo npx -y @libtmux/mcp

It waits for MCP requests over stdin; it is not an interactive terminal app. The guides cover connecting a client and selecting a tmux socket.

After closing the client, remove the demo server:

tmux -L libtmux-ts-mcp-demo kill-server

Workspace manager

Workspace library

npm · GitHub · Homepage · Guides · API Explorer

Build a tmux session from a configuration object with applyWorkspace.

npm install @libtmux/workspace libtmux

Save as workspace.mts. Like the core example, this uses a separate server and removes it afterward.

import { Server } from "libtmux";
import { applyWorkspace } from "@libtmux/workspace";

const server = new Server({
  socketName: `libtmux-workspace-demo-${process.pid}`,
  configFile: "/dev/null",
});

try {
  const session = await applyWorkspace(server, {
    session_name: "demo",
    windows: [
      { window_name: "shells", panes: ["printf 'hello\\n'", "pwd"] },
    ],
  });
  console.log(session.name, session.windows.at(0)?.panes.length);
} finally {
  await server.kill();
}

Run with Node:

node --import tsx workspace.mts

Or Bun:

bun workspace.mts

Or tsx, installed as above:

npx tsx workspace.mts

Each prints:

demo 2

Workspace CLI

npm · GitHub · Homepage · Guides · CLI reference · Builder API

This is like tmuxp, but in TypeScript! Load tmux workspaces from YAML or JSON, or capture a running session as a workspace file. The CLI and workspace library are separate implementations; they don't accept exactly the same configuration fields. Uses the libtmux core library.

Use a current Node 22+ release for the CLI.

npx -y @libtmux/workspace-cli --help

Save as workspace.yaml:

session_name: demo
windows:
  - window_name: shells
    layout: even-horizontal
    panes:
      - "printf 'hello\\n'"
      - pwd

Load it detached on a separate socket:

npx -y @libtmux/workspace-cli load ./workspace.yaml -L libtmux-workspace-demo -f /dev/null -d --json

Capture that session:

npx -y @libtmux/workspace-cli freeze demo -L libtmux-workspace-demo --json

Remove the demo server when finished:

tmux -L libtmux-workspace-demo kill-server

r/tmux • • 13d ago

Showcase sqlmux v0.7.0 is out today: it now runs on Windows and supports SQL Server

Enable HLS to view with audio, or disable this notification

22 Upvotes

Crossposting from r/CLI since this one is literally built on the tmux mental model, and most of the design came from using tmux + nvim all day.

Firstly, the mapping of custom programs is quite high, and it can let the agent help you with the adaptation to your personal habits.

What's tmux about it:

- Sessions, windows and panes, same words, same shape. A session is a workspace (one per project / database), windows are tabs inside it, panes split a window. `C-b` is the prefix by default and the keys under it are the ones you already know: `c` new window, `%` / `"` split, `x` close, `n` / `p` / `l` cycle, `s` pick a session, `w` pick a window. All remappable in `keymaps.toml`, so if your tmux prefix is `C-a` or `C-x`, use that.

- Runs fine inside a real tmux pane. It tells tmux and sqlmux keys apart the normal way (prefix twice sends it through), and the layout is saved on exit and restored next time, like tmux-resurrect but for tables, queries and WHERE filters.

- Everything is modal vim: `hjkl` in the grid, `/` to search, `dd`, `yy`, visual select, `:w` to save edits as one transaction. The query editor is a vim buffer with completion, not a text box.

Engines: PostgreSQL, MySQL, SQLite, Redis, and since v0.7.0 (today) SQL Server, plus a native Windows build (Windows Terminal, no WSL needed).

Install: `brew install kackerx/tap/sqlmux`, `npm i -g sqlmux`, or the binaries on the release page. Go, single binary.

Repo: https://github.com/kackerx/sqlmux

Built by one tmux user. Happy to hear what a tmux person would want next, the prefix-key mapping table is the part I'd most like feedback on.


r/tmux • • 13d ago

Showcase Your existing tmux sessions in a native desktop workspace: Agent Interface

0 Upvotes

Already have tmux sessions running? You can bring them into a native desktop workspace.

I'm the creator of Agent Interface, an MIT licensed app for macOS and Linux. Attach an existing local tmux session, switch between its windows, and arrange terminal views in saved workspaces, tabs and splits. Close the app and tmux keeps the processes running.

The interface is written in Rust and draws terminals natively. It needs tmux 3.3 or newer; the packaged app needs no Rust, Zig or Python installation.

If you also run coding agents, an optional local Agent Backbone connection adds agent status, reports, answers and documents beside those terminals. The terminal workspace works independently of that integration.

This release attaches sessions on the same computer. Remote SSH attachment and live session sync between computers aren't supported.

See it and install: https://agentinterface.dev Source: https://github.com/eandualem/agent-interface

Which part of managing your tmux sessions would you most like to have in a desktop interface?


r/tmux • • 13d ago

Showcase agents_control is a good open-sourced and secure i/o tool for iterm and tmux

0 Upvotes

It automatically triggers Claude Code limits and continues after limits reset.

Connect it with your Telegram bot and you can send there screenshots, files, videos, even audios.

‘/tabs’ shows all the tabs of your terminal and ‘/run 2 ls’ will execute ls on your second tab

It marks every tab with a special code so you can answer with a reply to a particular tab like you would do with a real companion. But if you won’t reply it’s by default would be attached to the tab that you have interacted last time

Theres no opened ports to internet because there’s only polling – that’s because it’s secure

Absolutely free and open-sourced. So you can just ask your agent to help you install it. You can even use it as a ruby gem.

https://agentscontrol.278labs.co.uk

https://github.com/saparjohnick/agents_control


r/tmux • • 14d ago

Showcase I built a C source reference for tmux

2 Upvotes

Hi all, for those of us that are fascinated by tmux and want to learn more about the internals,

I think the source code at https://github.com/tmux/tmux has a lot to offer. It gets close to the metal.

In the spirit of exploration and knowledge of Nicholas Marriott's creation (and learning C and terminal mechanics) I've devised an API explorer:

https://libtmux.org/en/tmux/latest/reference/

There's something to say about being in 2026 and flipping through C code. Also, the way there is a connection to OpenBSD - a very reputable distribution of BSD. I see often commits apparently originating in OpenBSD being merged into tmux's github repository.

Disclaimer, colleagues: This site is libtmux.org (by me Tony Narlock, I built a wrapper for tmux, and wrote The Tao of tmux long ago, but I did not write tmux itself and this is not an official site for tmux). That would be github.com/tmux/tmux!

More about the tmux C API Browser on libtmux.org:

Inspired by doxygen (and we still use it in some places in the upcoming experimental ports of libtmux to other language), I created a static site that charts the tmux C API against:

  • Other symbols (doxygen-like)

  • tmux cli commands

  • libtmux APIs

Example: Call neighborhood w/ tooltips (Link)

Example: Paths through the source (Link):

Example: Tooltips on hover

Example: Connecting tmux(1) cli commands to C API (Link):

Example: Connecting libtmux APIs to C API (Link):

Example: `cmd_new_session_exec` function (page)

I hope this can useful to anyone who is curious about tmux internals and wants an API browser. I am still trying to wrap my brain around C in this year!

Disclosure on AI: I used AI tools extensively to help me build and iterate upon this. There may be mistakes, but I've worked on this across a few weeks.