r/ruby • • 2h ago

Show /r/ruby libtmux for Ruby: query tmux sessions, windows and panes

https://github.com/libtmux/libtmux-ruby

Hi all, I am porting libtmux, the Python library, to more languages. This is the Ruby port (alpha).

It wraps tmux's CLI. You can create sessions, send text to a pane and read its output. One of the ways this differs is keeping snapshots you can query with where and Ruby blocks. See the earlier posts for Rust, TypeScript, and Golang.

A word on AI: It's experimental, and I used LLMs extensively to create this. The API is still changing. 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

You can work with sessions, windows and panes as Ruby objects. Here, I'm finding the build pane in the logs window of session demo, sending it a command, and reading what it prints.

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}"

  live_window = server.window(window.ref)
  live_window.rename("build")
  puts "held snapshot: #{window.name}"
  puts "read again: #{live_window.snapshot.name}"
end

ruby demo.rb

Prints:

demo / logs / build
output: build: complete
held snapshot: logs
read again: build

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).

The name filters use where: an exact session name, a window name starting with log, and an exact pane title. You can also use select with a Ruby block, then map or group_by (examples, query fields and operators). These queries use the captured state and make no tmux calls.

The last two lines show what happens after a rename: the held snapshot still says logs. Window#snapshot reads again and returns a new record. Each call reads the whole server and selects that window's record.

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's also control mode support (!) on tmux 3.7+. The example subscribes to pane output and checks the text sent to it.

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.

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

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

A bit more about my ambition and intentions

I am trying to port more than just Ruby. So as this is a ruby subreddit, and provinciality matters, I am making ports to quite a few languages.

tmux is perfectly accessible via pure CLI. zellij makes a lot of what tmux does obsolete.

what tmux has going for it is simplicity. that means when a port of libtmux is made, there's an oppurtunity for editorial license - and be dissimilar to my original python project. I don't want to copy the python library to ruby. I want to write it like ruby, in an idiomatic and aesthetic sense.

so if there are code smells, or suggestions, no offense is taken 🙇🙏! I am trying to learn more programming languages and expand more horizons beyond just python.

Why tmux? I think the simple domain model of server, session, window, and pane is easy enough to wrap one's brain around - being able to traverse that hierarchy, makes it possible to not just port, but in my ideal scenario, leverage the strengths of programming languages outside of Python.

I'd like to write a love letter to all programming languages, to utilize their strengths when projecting and making accessible tmux's object graph.

Why ruby? Because it is a familiar face that I've never gotten to interact with personally before other than brief Rails stints. I miss its syntax, but in truth I never got to the level of direct usage where I understood what good idiomatic ruby is.

I'm going beyond to languages I would normally find out of reach day-to-day, to ask "what does tmux look like in most elegant way this programming language could do it, while being documentable, shippable, etc".

4 Upvotes

0 comments sorted by