Skip to content

Repository files navigation

libtmux for Lua

libtmux for Lua

Script tmux from Lua: create sessions and panes, capture terminal output, query server state, and watch pane output. Use standalone Lua with luv or Neovim's event loop.

CI libtmux libtmux.org license

Needs Lua 5.1 to 5.5 or LuaJIT, and tmux 3.3a or newer. Before 1.0, any release may change an API: pin an exact version and read the changelog.

Contents — Quick start · Read tmux · Create · Type and read · Resize · Run together · Watch · Neovim · Packages · Leaves out · Support · Guides · CI · MIT license

Quick start

Standalone scripts run on luv, the event loop. It builds with a C compiler, make and CMake:

  • Debian and Ubuntu: build-essential and cmake.
  • Fedora: gcc, make and cmake.
  • macOS: the Xcode command line tools and cmake.

If the build fails, install lua-luv on Debian or Ubuntu instead. It serves only the distribution's own Lua.

Install the rock, then luv (skip it if you installed lua-luv):

$ luarocks --local install libtmux
$ luarocks --local install luv 1.52.1-0

Add the local rocks tree to Lua's module paths:

$ eval "$(luarocks --local path)"

Create a session and read it back. This is the whole program:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(tostring(err), 0)
    end
    return value
end

local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")

must(adapter.run(function(runtime)
    local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
    local created = must(server
        :new_session({
            name = "demo",
            window_name = "main",
            argv = { "/bin/cat" },
        })
        :await())
    assert(created.session and created.window and created.pane, "creation handles are missing")
    local snapshot = must(created.session:snapshot():await())
    assert(snapshot.name == "demo", "created session has the wrong name")
    print("session: " .. snapshot.name)

    must(server:close():await())
    return true
end))

Runtime:connect binds the socket you name and never starts a server. Server:new_session creates the session and Session:snapshot reads it back.

Live calls return Requests. :await() yields inside the runtime body and returns value, err; must raises the error.

The program needs TMUX_BIN and TMUX_SOCKET, absolute paths to tmux and a socket. The repository's launcher sets both on a private server, runs the program, then stops the server:

$ git clone https://github.com/libtmux/libtmux-lua.git && cd libtmux-lua

The launcher needs perl to confirm the stop:

$ sh examples/api/run.sh examples/api/new_session.lua
session: demo

Every Lua block here comes from a program under examples/. Excerpts continue inside the same runtime body, with server and must in hand.

Read what tmux has now

Server:snapshot: what tmux has now

One call reads every session, window and pane. snapshot.sessions, snapshot.windows and snapshot.panes list them. This prints each pane and its window:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(err, 0)
    end
    return value
end

must(adapter.run(function(runtime)
    local server = must(runtime
        :connect({
            binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable"),
            socket_path = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to an explicit socket"),
        })
        :await())
    local snapshot = must(server:snapshot({ strict = true }):await())
    for _, pane in ipairs(snapshot.panes) do
        io.stdout:write(pane.id, "\t", pane.window_id, "\n")
    end
    must(server:close():await())
    return true
end))

The launcher's server has one pane, so it prints that pane's ID and its window's ID:

$ sh examples/api/run.sh examples/snapshot.lua
%0	@0

A snapshot is what tmux held when it was read, across several tmux commands: not live and not atomic. strict = true adds a verification pass. Call Server:snapshot again to refresh.

More: Snapshots.

Selection:where: narrow what you read

Selection:where narrows a collection by structured criteria, and Selection:filter by a Lua function. Both work on snapshot.panes and on any list you give query.select, and neither calls tmux. This program needs only Lua:

local query = require("libtmux.query")

local schema = {
    fields = {
        id = { type = "string" },
        active = { type = "boolean" },
        current_command = { type = "string" },
    },
}
local rows = {
    { id = "%1", active = true, current_command = "nvim" },
    { id = "%2", active = false, current_command = "sh" },
    { id = "%3", active = false, current_command = "vim" },
}
local panes = query.select(rows, schema)
local editors = panes:where({ current_command = { one_of = { "nvim", "vim" } } })
assert(#editors == 2 and editors[1] == rows[1] and editors[2] == rows[3])

local inactive = panes:filter(function(pane)
    return not pane.active
end)
assert(#inactive == 2 and inactive[1].id == "%2")
for _, pane in ipairs(editors) do
    print(pane.id, pane.current_command)
end

It prints the panes whose command is nvim or vim:

$ lua examples/native_query.lua
%1	nvim
%3	vim

More: criteria and relationships, the field reference.

Server:query: read and filter in one call

Server:query captures a snapshot and applies where to it. kind picks the record type. The rows come back as a Selection beside the snapshot behind them:

local result = must(server
    :query({
        kind = "session",
        where = { name = "demo" },
        snapshot = { strict = true },
    })
    :await())
assert(result.complete, "query observed a topology change")
assert(#result.rows == 1 and result.rows[1].name == "demo", "query selected the wrong session")
print("matched: " .. result.rows[1].name)

The program creates sessions named demo and worker, so the filter matches one:

$ sh examples/api/run.sh examples/api/query.lua
matched: demo

complete reports no known inconsistency. The read is still not atomic.

More: live queries.

Server:explain_panes: see what runs in tmux

Server:explain_panes and Server:explain return the plan for a query without contacting tmux: its commands, the criteria sent to tmux as a filter, and the ones kept local. pushdown picks the mode: never, auto or require:

    local where = { active = true, current_command = "cat" }
    for _, mode in ipairs({ "never", "auto" }) do
        -- Explaining sends nothing to tmux.
        local plan = must(server:explain_panes({ where = where, pushdown = mode }):await())
        local filter = plan.filter or "none"
        print(mode .. ": " .. #plan.commands .. " tmux commands, filter " .. filter)
        for _, left in ipairs(plan.residual) do
            print("  local: " .. left.path .. " (" .. left.reason .. ")")
        end
    end

never keeps every criterion local. auto sends active to tmux and keeps current_command local:

$ sh examples/api/run.sh examples/api/explain.lua
never: 6 tmux commands, filter none
  local: $ (pushdown_disabled)
auto: 7 tmux commands, filter #{==:#{pane_active},1}
  local: $.current_command.eq (nullable_field_remains_local)

auto adds a tmux-side listing; it does not make the query faster.

More: live queries.

Window:snapshot: read one object again

A record is what tmux said when it was read. Lua has no refresh that updates one in place, as Python libtmux's .refresh() does. Call snapshot on the handle instead: Window:snapshot returns a new record and leaves the one you hold as it was.

Session:snapshot and Pane:snapshot work the same way.

This reads a window, renames it with Window:rename, and reads it again:

local window = created.window
local held = must(window:snapshot():await())
must(window:rename("build"):await())
local fresh = must(window:snapshot():await())
assert(held.name == "editor" and fresh.name == "build", "refresh did not read the rename")
print(held.name .. " " .. fresh.name)
$ sh examples/api/run.sh examples/api/refresh.lua
editor build

Create sessions, windows and panes

Session:new_window: add a window

Session:new_window adds a window to a session. Like Server:new_session, it returns a Creation holding session, window, pane and window_link handles:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(tostring(err), 0)
    end
    return value
end

local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")

must(adapter.run(function(runtime)
    local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
    local created = must(server
        :new_session({
            name = "demo",
            window_name = "main",
            argv = { "/bin/cat" },
        })
        :await())
    local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await())
    local snapshot = must(logs.window:snapshot():await())
    assert(snapshot.name == "logs", "created window has the wrong name")
    print("window: " .. snapshot.name)

    must(server:close():await())
    return true
end))

argv is the literal command the pane runs. Leave it out for tmux's default shell.

$ sh examples/api/run.sh examples/api/new_window.lua
window: logs

Creation does not wait for the program to start or report whether it succeeds. Closing the server handle leaves what you created running.

More: Creation.

Pane:split: divide a window

Pane:split splits a pane to the left, right, up or down, sized by percent or size. The new pane is split.pane:

local logs = must(created.session:new_window({ name = "logs", argv = { "/bin/cat" } }):await())
local split =
    must(logs.pane:split({ direction = "right", percent = 40, argv = { "/bin/cat" } }):await())

quickstart.lua creates a session, a window and this split, then prints their IDs. New windows and splits leave the selection alone unless you pass select = true:

$ sh examples/api/run.sh examples/quickstart.lua
$1	@2	%3

Type into a pane and read the answer

Pane:send_text: type a command

Pane:send_text types literal text and adds no Enter. Pane:send_keys presses named keys such as Enter.

Success means tmux accepted the input while the command may still be running. The example waits on a tmux wait-for channel with Server:command:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(tostring(err), 0)
    end
    return value
end

local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")

must(adapter.run(function(runtime)
    local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
    local created = must(server:new_session({ name = "demo", argv = { "/bin/sh" } }):await())
    local pane = created.pane
    local function quote(text)
        return "'" .. text:gsub("'", "'\\''") .. "'"
    end

    -- Signal completion on this socket; do not guess when the shell has printed.
    local command = "printf '\\nlua input ready\\n'; "
        .. quote(binary)
        .. " -S "
        .. quote(socket)
        .. " wait-for -S example-ready"
    must(pane:send_text(command):await())
    must(pane:send_keys({ "Enter" }):await())
    must(server:command({ "wait-for", "example-ready" }, { timeout = 1000 }):await())
    local capture = must(pane:capture({ history_lines = 20 }):await())
    local found = false
    for line in must(capture:text()):gmatch("[^\r\n]+") do
        if line == "lua input ready" then
            found = true
        end
    end
    assert(found, "completed command did not print the expected line")
    print("lua input ready")

    must(server:close():await())
    return true
end))

quote shell-quotes the tmux path and socket for the pane's shell. The program captures the pane and prints the line the command wrote:

$ sh examples/api/run.sh examples/api/send_keys.lua
lua input ready

Server:command runs any tmux command from literal arguments. A nonzero exit is part of the result.

More: Pane operations.

Pane:capture: read the screen

Pane:capture returns the screen tmux drew. history_lines = 20 adds up to 20 rows of history. capture:text() returns the bytes as UTF-8, or an invalid_utf8 error:

local capture = must(pane:capture({ history_lines = 20 }):await())
local found = false
for line in must(capture:text()):gmatch("[^\r\n]+") do
    if line == "lua capture ready" then
        found = true
    end
end
assert(found, "completed command did not print the expected line")
print("lua capture ready")
$ sh examples/api/run.sh examples/api/capture.lua
lua capture ready

A capture holds the cells tmux rendered, so it cannot say whether a command finished. Pane operations lists the capture options, from joined lines to escape sequences.

Pane:wait_for_text: wait for text on screen

Pane:wait_for_text polls the visible screen until it holds the literal text. The result has the matching screen and matched_at_entry, true when the first read already matched. Pass timeout or deadline to bound the wait:

-- The shell echoes what is typed, so print the needle from two pieces.
must(pane:send_text("printf '%s%s\\n' 'lua wait ' 'ready'"):await())
must(pane:send_keys({ "Enter" }):await())
local found = must(pane:wait_for_text("lua wait ready", { timeout = 5000 }):await())
assert(found.text:find("lua wait ready", 1, true))
print("lua wait ready")
$ sh examples/api/run.sh examples/api/wait_for_text.lua
lua wait ready

If the pane exits first, the wait ends with pane_died and the text seen so far. More: waiting for pane text.

Resize, remove and configure

Pane:resize: set a size

Pane:resize sets a pane's width and height, or adjusts it by a direction and an amount. Window:resize takes width and height. tmux can clamp both to the layout, so read the sizes back:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(tostring(err), 0)
    end
    return value
end

local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")

must(adapter.run(function(runtime)
    local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
    local created = must(server:new_session({ name = "demo", argv = { "/bin/cat" } }):await())
    local split = must(created.pane:split({ direction = "right", argv = { "/bin/cat" } }):await())

    must(created.window:resize({ width = 100, height = 30 }):await())
    must(split.pane:resize({ width = 30 }):await())

    -- A resize can clamp to the layout, so read the sizes tmux settled on.
    local window = must(created.window:snapshot():await())
    local pane = must(split.pane:snapshot():await())
    assert(window.width == 100 and window.height == 30, "the window kept its old size")
    assert(pane.width == 30, "the pane kept its old width")
    print(("window %dx%d, pane %d wide"):format(window.width, window.height, pane.width))

    must(server:close():await())
    return true
end))
$ sh examples/api/run.sh examples/api/resize.lua
window 100x30, pane 30 wide

More: pane operations, window size.

Pane:kill: remove what you made

Pane:kill, Window:kill and Session:kill remove the object they are called on. Removing a window's last pane removes the window too. More: removal.

Window:set_option: change a setting

Window:set_option and Window:get_option write and read one option. Server, session and pane handles have them too, and Window:set_hook stores a command for a hook. More: options and hooks.

Run several commands together

Server:group: several commands, one tmux call

Server:group runs commands in order in one tmux call. Server:batch runs independent commands and reports each outcome. This group creates a session and two windows, then lists them:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(tostring(err), 0)
    end
    return value
end

local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")

must(adapter.run(function(runtime)
    local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
    -- One tmux process runs all four commands, in order.
    local done = must(server
        :group({
            { "new-session", "-d", "-s", "demo", "-n", "main", "/bin/cat" },
            { "new-window", "-d", "-t", "demo:", "-n", "build", "/bin/cat" },
            { "new-window", "-d", "-t", "demo:", "-n", "test", "/bin/cat" },
            { "list-windows", "-t", "demo", "-F", "#{window_name}" },
        })
        :await())
    assert(done.exit_code == 0, "the group failed")
    local names = {}
    for name in done.stdout:gmatch("[^\n]+") do
        names[#names + 1] = name
    end
    assert(#names == 3, "the group did not create three windows")
    print("windows: " .. table.concat(names, ", "))

    must(server:close():await())
    return true
end))
$ sh examples/api/run.sh examples/api/group.lua
windows: main, build, test

A group is not a transaction: a failing command skips the rest, and commands that already ran stay done.

More: commands and batches.

Watch a pane as it prints

Server:observe: keep a control-mode client open

Server:observe holds one control-mode client for a session. Observation:watch_pane streams a pane's output, and Observation:watch_notifications streams tmux's own events. watch:next returns the next event. This needs tmux 3.7 or newer:

local adapter = require("libtmux.runtime.luv")

local function must(value, err)
    if err ~= nil then
        error(tostring(err), 0)
    end
    return value
end

local binary = assert(os.getenv("TMUX_BIN"), "set TMUX_BIN to an absolute tmux executable")
local socket = assert(os.getenv("TMUX_SOCKET"), "set TMUX_SOCKET to the private socket")

must(adapter.run(function(runtime)
    local server = must(runtime:connect({ binary = binary, socket_path = socket }):await())
    local created = must(
        server:new_session({ name = "demo", window_name = "main", argv = { "/bin/cat" } }):await()
    )
    -- Observation holds one control-mode client open; it needs tmux 3.7 or newer.
    local observation = must(server:observe(created.session):await())
    local watch = must(observation:watch_pane(created.pane):await())

    must(created.pane:send_text("watched"):await())
    local output = ""
    while not output:find("watched", 1, true) do
        output = output .. must(watch:next({ timeout = 5000 }):await()).data
    end
    print("output: watched")

    must(observation:close():await())
    must(server:close():await())
    return true
end))
$ sh examples/api/run.sh examples/api/watch_pane.lua
output: watched

Each event carries the pane's bytes and a sequence number. A slow reader loses events: the watch ends with observation_gap instead of dropping them silently.

More: observing a session.

Use it from Neovim

runtime.nvim.start: borrow the editor's loop

start returns at once, runs the body on Neovim's loop and calls on_done(value, err) after the work it owns has finished. Handles it did not create stay open:

local adapter = require("libtmux.runtime.nvim")

adapter.start(function(runtime)
    local server, err = runtime
        :connect({
            binary = vim.fn.exepath("tmux"),
            socket_path = assert(os.getenv("TMUX_SOCKET")),
        })
        :await()
    if not server then
        return nil, err
    end
    return server:snapshot({ strict = true }):await()
end, function(snapshot, err)
    if err then
        vim.notify(tostring(err), vim.log.levels.ERROR)
        return
    end
    vim.notify(("Panes: %d"):format(#snapshot.panes))
end)

From the checkout, start Neovim with the library on its runtimepath and set TMUX_SOCKET to the absolute path of a server's socket:

$ TMUX_SOCKET=/path/to/tmux.sock nvim \
    --cmd 'set runtimepath+=.' \
    -c 'luafile examples/nvim_snapshot.lua'

Neovim shows the server's pane count. It has its own libuv binding, so the luv rock is not needed.

More: runtime ownership and cancellation.

Packages

libtmux: the library

Sessions, windows, panes, queries and pane watches on luv or Neovim's event loop. Importing a module starts neither tmux nor an event loop, and local queries need only Lua.

Reference: LuaRocks, libtmux.org, libtmux.Server, libtmux.runtime.luv, libtmux.runtime.nvim, libtmux.query. Changelog: CHANGES.md. Module by module: the core README.

To install from a checkout instead of LuaRocks:

$ luarocks --local make rockspecs/libtmux-scm-1.rockspec

What it leaves out

  • Starting a server. Runtime:connect never starts one. Without a socket_path, LIBTMUX_SOCKET_PATH or LIBTMUX_SOCKET names the server, and otherwise tmux picks it.
  • Live records. A snapshot holds what tmux said when it was read. Capture again to see a change.
  • Proof that a command finished. Pane:send_keys succeeds when tmux accepts the input. Wait on a wait-for channel, or print a token and wait for it.
  • An interactive terminal. The luv and Neovim adapters own no terminal, so Session:attach returns unsupported_tty.
  • Your tmux configuration. The library does not change a borrowed server's configuration, and your aliases and hooks still apply to its commands.

Support and status

  • Before 1.0. Any release may change or remove an exported name. Pin an exact version; the changelog marks each break.
  • Lua. 5.1 to 5.5, and LuaJIT.
  • tmux. 3.3a or newer. An older server is refused with unsupported_version. Compatibility matrix.
  • Optional features. Some commands and options need a newer tmux. The table and capabilities.json list each.
  • Control mode. observe needs tmux 3.7 or newer and returns unsupported below it. Control guide.
  • Polling. Pane:wait_for_text works on every release. It reads the visible screen, never scrollback, and each read runs the after-capture-pane hook.
  • Nested commands are not checked. if-shell -F 1 'break-pane -d' reaches tmux unchecked and can stop a 3.7 server. Details.
  • No release candidates. An X.Y-rcN build ranks below X.Y. Policy.
  • Neovim. 0.10 or newer.
  • Linux, WSL included. macOS is not tested.
  • Changelog. CHANGES.md records each release; release steps.

Guides

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-lua in scientific discourse:

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

License

MIT. See LICENSE.

Releases

Packages

Contributors

Languages