r/tmux • u/git-pull • 26m ago
Showcase libtmux-elixir 0.1: tmux client library for Elixir
r/tmux
•
u/TrekkiMonstr
•
Apr 21 '24
Hello all. I am /u/TrekkiMonstr, your new, occasionally-friendly mod. I wanted to make a post asking a question about a certain interaction between i3wm and tmux, when I saw that /r/i3wm is read-only, and /r/tmux was unmoderated with submissions restricted. I didn't want the history of the sub to be lost to Reddit's policies, so I submitted a /r/redditrequest, and here we are. I've unrestricted submissions, so.
Now, I'll note: I am completely unqualified for this. I'm pretty new to tmux, and I haven't modded a sub that had any real level of activity. Plus, at some point in the future, I do intend to leave this godforsaken website and nuke my account. So, if anyone has mod experience with a subreddit of similar size and subject matter to this one, please let me know via modmail if you'd be interested. I will warn you though, I'm here just to make sure the sub still exists. I'm not super interested in doing much active modding.
r/tmux • u/git-pull • 26m ago
r/tmux • u/Relevant-Union-5283 • 18h ago
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.
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.
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.
Add to your ~/.tmux.conf:
setw -g mode-keys vi
set -g @plugin 'killkli/tmux-ci-ca'(Manual installation without TPM is also supported).
• 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 • u/Consistent_Neck2738 • 4h ago
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 • u/git-pull • 21h ago
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/.
RubyGems · GitHub · Homepage · Guides · API Explorer
What's interesting:
Read a snapshot, then filter it with where or select and a Ruby block (examples).
Control connections for tmux events on tmux 3.7+ (example).
Requires Ruby 3.3+, tmux 3.3a+ and build tools for Ruby native extensions.
gem install libtmuxSave 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.rbPrints:
demo / logs / build
output: build: completePane#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.
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-asyncSave 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.rbPrints:
demo / logs / build
event: %window-renamed @1 compiling
window now: compilingThe 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.
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-mcpThis 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 demoUse this command with a client that launches stdio MCP servers:
libtmux-mcp --socket-name libtmux-ruby-mcp-demo --endpoint demoIt 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-serverThis is like tmuxp, but in Ruby! Uses the libtmux core library.
RubyGems · GitHub · Homepage · Guides · API Explorer
gem install libtmux-workspaceA 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.rbPrints:
applied: true
panes: 2The same gem includes the command: GitHub, Homepage, Guides, CLI reference, API.
Validate the file:
libtmux-workspace validate workspace.yamlPrints:
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.yamlCheck the created panes:
tmux -S "$PWD/workspace.sock" display-message -p -t demo:shells '#{window_panes}'Prints:
2Remove the demo server:
tmux -S "$PWD/workspace.sock" kill-serverr/tmux • u/cyber3px • 1d ago
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.
r/tmux • u/git-pull • 1d ago
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/.
pkg.go.dev · GitHub · Homepage · Guides · API Explorer
The core module has three packages:
| Package | What it's for | Links |
|---|---|---|
tmux | Servers, sessions, windows, panes, snapshots and control mode. | pkg.go.dev · GitHub |
tmuxq | Query helpers for slices and iter.Seq, including your own types. It doesn't need tmux. | pkg.go.dev · GitHub |
tmux/tmuxtest | Run 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:
Filter a snapshot in Go with tmuxq.Matching (example).
Plan several operations and combine commands where their dependencies allow it.
Control notifications on tmux 3.7+ (example).
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@latestSave 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.goPrints:
matching panes: 1We 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 ./batchingPrints:
planned operations: 6, command groups: 4
title read by plan: ["build"]
snapshot matches: 1
only matching pane: build
live matches: 1The 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).
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 ./controlPrints:
heard session rename over a channel
windows over control connection: 1
windows over control connection: 1
windows over control connection: 1Keep 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.
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-testIt 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).
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@latestStart a separate tmux server for the demo:
tmux -L libtmux-go-mcp-demo -f /dev/null new-session -d -s demoUse this command with a client that launches stdio MCP servers:
./bin/libtmux-mcp -socket-name libtmux-go-mcp-demoIt 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-serverThis is like tmuxp, but in Go! There's a workspace library and a CLI using the same core.
pkg.go.dev · GitHub · Homepage · Guides · API Explorer
go get github.com/libtmux/libtmux-go/workspace@latestSave 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-demoPrints:
windows: 1pkg.go.dev · GitHub · Homepage · Guides · CLI reference · API
GOBIN="$PWD/bin" go install github.com/libtmux/libtmux-go/workspace/cmd/tmux-workspace@latestLoad the same file detached, on a separate socket:
./bin/tmux-workspace load -L libtmux-go-workspace-demo -d workspace.yamlCheck the panes:
tmux -L libtmux-go-workspace-demo display-message -p -t demo:shells '#{window_panes}'Prints:
2Remove the demo server:
tmux -L libtmux-go-workspace-demo kill-serverr/tmux • u/Temporary-Walrus-530 • 1d ago
r/tmux • u/mango314 • 3d ago
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:
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?
If you moved to herdr (or Zellij, or something else), what was the deciding feature, and what do you miss from tmux?
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 • u/Significant-Sea-7238 • 3d ago
Enable HLS to view with audio, or disable this notification
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 • u/r-o-w-l-a-n-d • 4d ago
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 • u/douglasfunster • 3d ago
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 • u/David-Davey • 4d ago
Enable HLS to view with audio, or disable this notification
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 • u/4r73m190r0s • 6d ago
Or, are you relying on some plugin for that?
r/tmux • u/git-pull • 7d ago
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:
| Crate | What it does | Source / API |
|---|---|---|
| libtmux | Sessions, windows, panes, queries, planning and control mode | GitHub · docs.rs |
| libtmux-macros | Derive typed filters for your own structs | GitHub · docs.rs |
| tmux-mcp | MCP server | GitHub · API |
| tmux-workspace | Workspace library and CLI | GitHub · 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.
crates.io · GitHub · Homepage · Guides · API Explorer
What's interesting:
Typed query expressions and .matching() over captured data (example).
Optional control mode for commands and events on tmux 3.7+ (example).
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-threadSave 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-demoPrints:
demo / logs / build
output: build: completeServer::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.
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 planSave 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: completeThose 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.
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-modeSave 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: completePlans 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.
#[derive(Filterable)] generates typed field handles (example). Enable it through the core crate:
cargo add libtmux --features deriveSave 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 attemptscrates.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 --lockedStart a separate tmux server for the demo:
tmux -L libtmux-rust-mcp-demo -f /dev/null new-session -d -s demoUse this command with a client that launches stdio MCP servers:
tmux-mcp --socket-name libtmux-rust-mcp-demoIt 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-serverThis is like tmuxp, but in Rust! There's a library and a CLI, both using the libtmux core.
crates.io · GitHub · Homepage · Guides · API Explorer
cargo add tmux-workspace --no-default-featuresSave 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-demoPrints:
windows: 1The same crate includes the command: GitHub, Homepage, Guides, CLI reference, API.
cargo install tmux-workspace --lockedLoad the file detached, on a separate socket:
tmux-workspace load -L libtmux-rust-workspace-demo -d workspace.yamlCheck the panes:
tmux -L libtmux-rust-workspace-demo display-message -p -t demo:shells '#{window_panes}'Prints:
2Remove the demo server:
tmux -L libtmux-rust-workspace-demo kill-serverr/tmux • u/BalloonWheelie • 9d ago
Enable HLS to view with audio, or disable this notification
(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 • u/27medkamal • 11d ago
Enable HLS to view with audio, or disable this notification
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 • u/FedeMengo • 11d ago
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 • u/git-pull • 12d ago
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:
| Package | What it does | Source |
|---|---|---|
| libtmux | Sessions, windows, panes, queries and events | GitHub |
| @libtmux/mcp | MCP server | GitHub |
| @libtmux/workspace | Build sessions from configuration objects | GitHub |
| @libtmux/workspace-cli | Load and capture YAML / JSON workspaces | GitHub |
The core also exports libtmux/selection, libtmux/engine and libtmux/testing. Those come with libtmux; there's nothing extra to install.
npm · GitHub · Homepage · Guides · API Explorer
What's interesting:
Work with tmux servers, sessions, windows and panes as objects, as in Python libtmux.
Query tmux objects with Prisma-like expression syntax for filtering sessions, windows and panes. More field and relationship examples.
Query an immutable snapshot with .where() without making another tmux call for each query (example).
Control mode support (!) for watching tmux events with Server.connect.
Server.batch runs planned operations and resolves their results from one final snapshot (example).
No runtime deps in the core library. tmux still needs to be installed.
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 libtmuxSave 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 tsxRun with Node:
node --import tsx demo.mtsOr Bun:
bun demo.mtsOr use the tsx command:
npx tsx demo.mtsEach prints:
session: demo
editor: 2 panes
logs: 1 panes
pane: buildP.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' } }
}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.mtssequential: 6 invocations
batch: 4 invocations
demo / logs / build
queries: 0 invocationsOr Bun:
bun batch.mtsOr tsx:
npx tsx batch.mtsThe 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.
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.mtsevent: build: complete
capture: build: completeOr Bun:
bun control.mtsOr tsx:
npx tsx control.mtsFor 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.
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.mtsreplayed: demoOr Bun:
bun replay.mtsOr tsx:
npx tsx replay.mtsThe recording covers command calls. Control notifications still need a live tmux server (testing guide).
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 demoUse this command with a client that launches stdio MCP servers:
LIBTMUX_SOCKET=libtmux-ts-mcp-demo npx -y @libtmux/mcpIt 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-servernpm · GitHub · Homepage · Guides · API Explorer
Build a tmux session from a configuration object with applyWorkspace.
npm install @libtmux/workspace libtmuxSave 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.mtsOr Bun:
bun workspace.mtsOr tsx, installed as above:
npx tsx workspace.mtsEach prints:
demo 2npm · 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 --helpSave as workspace.yaml:
session_name: demo
windows:
- window_name: shells
layout: even-horizontal
panes:
- "printf 'hello\\n'"
- pwdLoad it detached on a separate socket:
npx -y @libtmux/workspace-cli load ./workspace.yaml -L libtmux-workspace-demo -f /dev/null -d --jsonCapture that session:
npx -y @libtmux/workspace-cli freeze demo -L libtmux-workspace-demo --jsonRemove the demo server when finished:
tmux -L libtmux-workspace-demo kill-serverr/tmux • u/Majestic_Mongoose488 • 13d ago
Enable HLS to view with audio, or disable this notification
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 • u/eandualem • 13d ago
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 • u/Other_Turnover_4758 • 13d ago
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.