Skip to content

Repository files navigation

libtmux for Ruby

libtmux for Ruby

Create tmux sessions, split windows, send input, and capture pane output from Ruby. Read server state into a snapshot, then query it with where, select, and the rest of Enumerable.

Quick start · Find things · Packages · Guide · API reference · Recipes · API on libtmux.org

compatibility libtmux libtmux.org license

It suits scripts, tests that need a real tmux, and tools that drive a terminal program. An MCP server, an Async adapter and a workspace loader ship beside the core: see Packages.

Contents — Quick start · Drive a pane · Find things · Several commands · Watch tmux · Your own server · Packages · Build from source · Leaves out · Support · Documentation

Quick start

Install the core gem. It needs Ruby 3.3 or newer and tmux 3.3a or newer on PATH:

$ gem install libtmux

This program creates a session, adds a window, splits it, and prints each window with its panes:

require "libtmux"

snapshot = LibTmux::Server.start do |server|
  session = server.new_session(name: "work", window_name: "main", command: ["/bin/cat"])
  window = session.new_window(name: "logs", command: ["/bin/cat"])
  window.split(direction: :horizontal, size: "40%", command: ["/bin/cat"])

  server.snapshot
end

snapshot.windows.each do |window|
  puts "#{window.name}: #{window.panes.map(&:id).join(', ')}"
end

Save it as quickstart.rb and run it:

$ ruby quickstart.rb

It prints:

main: %0
logs: %1, %2

Server.start gives the program a tmux server of its own, on its own socket with an empty configuration, and closes it when the block exits. It never touches a tmux you already use. Pane commands take argument arrays.

Drive a pane

Pane#split: split a pane, type into it, resize it

send_text types literal text, send_keys sends key names such as Enter, and capture reads the screen. Pane#resize and Window#resize set sizes in cells:

require "libtmux"

LibTmux::Server.start do |server|
  created = server.new_session(name: "work", command: ["/bin/sh"], receipt: true)
  pane = created.pane
  side = pane.split(direction: :horizontal, size: "40%", command: ["/bin/cat"])

  pane.send_text("echo ready")
  pane.send_keys("Enter")
  line = nil
  50.times do
    line = pane.capture.stdout.lines(chomp: true).find { |text| text == "ready" }
    break if line
    sleep 0.1
  end
  puts "captured: #{line}"

  created.window.resize(width: 100, height: 30)
  side.resize(width: 20)
  puts "side pane: #{side.snapshot.width} columns"
end

It prints captured: ready and side pane: 20 columns. Entity#kill removes a pane, window or session, and Server#kill stops a daemon. More: pane input and capture.

Find things in a snapshot

Server#snapshot: one read, many queries

where takes criteria as data and select takes a Ruby block. Both filter the snapshot locally and make no tmux calls:

require "libtmux"

snapshot = LibTmux::Server.start do |server|
  session = server.new_session(name: "work", window_name: "main", command: ["/bin/cat"])
  window = session.new_window(name: "logs", command: ["/bin/cat"])
  window.split(direction: :horizontal, size: "40%", command: ["/bin/cat"])

  server.snapshot
end

panes = snapshot.panes
active_ids = panes.where(active: true).map(&:id)
wide_panes = panes.select { |pane| pane.width >= 40 }
panes_by_window = panes.group_by { |pane| pane.window.name }

logs = snapshot.windows.one(name: "logs")
missing = snapshot.windows.one_or_nil(name: "missing")

puts "active: #{active_ids.join(', ')}"
puts "wide: #{wide_panes.map(&:id).join(', ')}"
puts "logs panes: #{logs.panes.length}"
puts "missing: #{missing.inspect}"

It prints:

active: %0, %1
wide: %0, %1
logs panes: 2
missing: nil

More: the field catalog and the list and filter recipe.

Server#explain_panes: what runs in tmux, what runs in Ruby

explain_panes returns the plan for a pane query and sends nothing to tmux. search_panes runs it. Today tmux returns the whole server and Ruby applies every criterion:

require "libtmux"

LibTmux::Server.start do |server|
  server.new_session(name: "work", command: ["/bin/cat"])
  criteria = {active: true}
  plan = server.explain_panes(where: criteria)
  puts "tmux reads: #{plan.fetch(:capture)}"
  puts "tmux filters: #{plan.fetch(:pushed) || 'nothing'}"
  puts "Ruby filters: #{plan.fetch(:residual).fetch('where').keys.join(', ')}"
  puts "matching panes: #{server.search_panes(where: criteria).length}"
end

It prints:

tmux reads: full_graph
tmux filters: nothing
Ruby filters: active
matching panes: 1

More: execution modes.

Window#snapshot: read one object again

A record is what tmux said when it was read. Ruby has no refresh that updates a record in place, as Python libtmux's .refresh() does: read the object again.

Window#snapshot returns a new record and leaves the one you hold as it was. Session#snapshot and Pane#snapshot do the same:

require "libtmux"

LibTmux::Server.start do |server|
  session = server.new_session(name: "work", window_name: "editor", command: ["/bin/cat"])
  window = session.list_windows.fetch(0)

  held = window.snapshot
  window.rename("build")

  puts held.name
  puts window.snapshot.name
end

It prints:

editor
build

Each call reads the whole server, as server.snapshot does, and returns the one record you asked for.

Run several commands in order

Server#run_group: several commands through one client

A group sends several tmux commands, in order, through one client. The GroupResult holds one aggregate status, and each step reports unknown:

require "libtmux"

LibTmux::Server.start do |server|
  server.new_session(name: "work", command: ["/bin/cat"])
  group = server.run_group([
    ["set-option", "-g", "@before", "kept"],
    ["select-pane", "-t", "%4294967294"],
    ["set-option", "-g", "@after", "never run"]
  ])
  puts "success: #{group.success?}"
  puts "steps: #{group.steps.map { |step| step.fetch(:outcome) }.join(', ')}"
  puts "@before: #{server.options(scope: :session).get('@before').as(:string)}"
end

It prints:

success: false
steps: unknown, unknown, unknown
@before: kept

The first command keeps its effect when the second fails, and the third never runs. More: the group recipe.

Watch tmux change

Server#open_control: events as they happen

A ControlConnection runs a command with exchange and streams what tmux reports through subscribe. The subscription's next waits for the next ControlEvent. It needs tmux 3.7 or newer:

require "libtmux"

LibTmux::Server.start do |server|
  session = server.new_session(name: "work", command: ["/bin/cat"])
  window = session.list_windows.fetch(0)
  server.open_control(session: session.ref) do |control|
    reply = control.exchange("display-message -p ready", timeout: 5)
    puts "reply: #{reply.blocks.last.body.strip}"
    events = control.subscribe(max_events: 64, max_bytes: 65_536)
    window.rename("renamed")
    event = events.next(timeout: 5) until event&.raw&.start_with?("%window-renamed")
    puts "event: #{event.raw.strip}"
  end
end

It prints:

reply: ready
event: %window-renamed @0 renamed

More: the control overflow recipe.

Use the tmux server you already run

Server.new: borrow a daemon

Server.open and Server.new bind an endpoint that already exists, such as Server.open(socket_path: ...). Closing the binding leaves the daemon running. The outer Server.start block here only supplies a daemon to borrow:

require "libtmux"

LibTmux::Server.start do |owner|
  owner.new_session(name: "work", command: ["/bin/cat"])
  server = LibTmux::Server.new(endpoint: owner.endpoint)
  begin
    puts "connected sessions: #{server.list_sessions.length}"
  ensure
    server.close
  end
  puts "sessions after client close: #{owner.list_sessions.length}"
end

It prints:

connected sessions: 1
sessions after client close: 1

More: ownership and errors.

Packages

Start with libtmux. Add the companion for your caller:

Gem Require Use it for
libtmux libtmux Blocking scripts, snapshots, and control connections
libtmux-async libtmux/async Concurrent commands and bounded streams in Async tasks libtmux-async
libtmux-mcp libtmux/mcp An MCP server with explicit endpoints and tool policy libtmux-mcp
libtmux-workspace libtmux/workspace YAML/JSON workspace plans and a CLI to apply them libtmux-workspace

Requiring a gem starts no tmux process, scheduler, or protocol server. Execution modes compares blocking calls, Async tasks, and control subscriptions.

libtmux: the core

Blocking server, session, window and pane handles, immutable snapshots, and control connections. Require libtmux.

API reference · Guides · RubyGems

$ gem install libtmux

More tasks, in the core README:

libtmux-async: concurrent commands

Concurrent commands and bounded control streams inside an Async task that your application owns. Require libtmux/async, then open a scope with LibTmux::Async.open.

API reference · Guide · RubyGems

$ gem install libtmux-async

Capture every pane concurrently with Scope#map:

require "libtmux"
require "libtmux/async"

LibTmux::Server.start do |server|
  server.new_session(name: "work", command: ["/bin/cat"])
  server.new_session(name: "logs", command: ["/bin/cat"])
  Async do |parent|
    LibTmux::Async.open(parent: parent, server: server) do |scope|
      refs = scope.server.list_panes.map(&:ref)
      screens = scope.map(refs, concurrency: 2) { |ref| scope.server.pane(ref).capture }
      puts "captured panes: #{screens.length}"
      puts "all succeeded: #{screens.all?(&:success?)}"
    end
  end.wait
end

It prints:

captured panes: 2
all succeeded: true

libtmux-mcp: a tmux server for MCP clients

An MCP server over stdio for one tmux server, with an explicit tool policy. Installs libtmux-mcp; require libtmux/mcp to embed it through LibTmux::MCP::Application.

API reference · Tools · Guides · RubyGems

$ gem install libtmux-mcp

The server borrows a tmux server that is already running. Start one on a socket of its own:

$ tmux -L agent new-session -d -s work

Add the server to Claude Code. Until you enable more tools, it serves tmux_capabilities and tmux_snapshot:

$ claude mcp add tmux -- libtmux-mcp --socket-name agent --endpoint dev

More: the MCP README covers other clients, every tool and authored shell commands.

libtmux-workspace: YAML workspaces

YAML or JSON workspaces, validated, planned offline and applied to an open server. Installs libtmux-workspace; require libtmux/workspace to use it from Ruby through LibTmux::Workspace.

API reference · Command reference · Guides · RubyGems

$ gem install libtmux-workspace

Save this workspace as workspace.yaml:

session_name: work
environment:
  PROJECT_MODE: development
windows:
  - window_name: editor
    window_index: 1
    layout: tiled
    panes:
      - shell_command: printf 'editor ready\n'
      - {}

Check it without contacting tmux:

$ libtmux-workspace validate workspace.yaml
Workspace configuration is valid.

More: the workspace README loads it into a running server and lists every option.

Build from source

From a checkout, install the Ruby pinned in .tool-versions, then the development bundle:

$ mise install
$ mise exec -- bundle config set --local path vendor/bundle
$ mise exec -- bundle install

To use the core outside the checkout, build the artifacts:

$ mise exec -- bundle exec rake build

Install the core into the Ruby that will run your application. Its runtime dependencies must already be installed:

$ gem install \
    --local \
    --no-document \
    pkg/libtmux-0.1.2.gem

Companion gems need their runtime dependencies too. See Contributing for the checks.

What it leaves out

  • Live objects. A snapshot record is what tmux said when you read it, and a selection keeps the membership it captured. Read again to see a change.
  • A terminal emulator. Pane#capture returns what tmux's capture-pane prints for the pane.
  • A server it starts unasked. Server.new and Server.open bind a running server; only Server.start starts one. With no socket named, Endpoint.new picks the socket tmux would, so a program run inside tmux reaches that tmux. See Endpoints.
  • Whole-program typing. RBS declarations ship with each gem. See the public methods.
  • Throughput claims. The benchmarks list their workloads and limits.

Support and status

  • Unstable API. Releases are numbered 0.1.N, and any release may change or remove exported identifiers. Pin an exact version. See the changelog, which links each gem's own.
  • Ruby 3.3 or newer and tmux 3.3a or newer. A server handle refuses an older daemon before it runs a command. Release candidates are not supported. See Release candidates.
  • Control connections need tmux 3.7 or newer. On an older daemon open_control raises UnsupportedFeatureError, and Server#supports_control? returns false. The optional-feature table and capabilities.json list the other releases.
  • Linux, including WSL, and macOS. CI runs on Linux; macOS is best effort. See the compatibility workflow and the CI matrix.

Documentation

See Contributing for setup and checks.

AI policy

Use these packages with AI tools as you like. When contributing, check what you submit and wait for a maintainer's invitation before preparing a pull request. See the AI policy, also kept as AI_POLICY.md.

Attribution

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

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

License

MIT. See LICENSE.

Contributors

Languages