Core LibraryunreleasedGuides

Choose documentation 2

latest

Current version

latest
English

Unreleased libtmux has not been released. Its API may change during development. This site's structure and URLs may change too.

REPL tutorial

This tutorial uses an isolated existing tmux daemon. It creates two sessions, three physical windows, four links and four panes, then inspects them through ordinary Common Lisp data. It modifies only the daemon you start below. Complete the source installation first.

Run the checked walkthrough

Terminal window
$ mise run examples

The runner creates a mode-0700 temporary directory and explicit socket, clears inherited tmux variables, starts an empty-configuration daemon, runs the shipped Lisp examples, checks their results and terminates its own server. Assertions check object/link counts, false with presence, identity, ownership, literal names, refresh, byte buffers, optional regex/JSON queries and local access after client closure. A failure exits nonzero. It leaves the optional thread library unloaded.

Select another native tmux binary explicitly:

Terminal window
$ LIBTMUX_TEST_TMUX=/usr/bin/tmux mise run examples

Work at the REPL

For an interactive session, create a private daemon in a shell and keep that shell open. This command prints its socket path; use that path when prompted in Lisp. The automated runner and final cleanup helper need Python 3; the library itself does not.

Terminal window
$ export LIBTMUX_DEMO_DIR="$(mktemp -d /tmp/libtmux-lisp-demo-XXXXXXXX)" && \
env -u TMUX \
-u TMUX_PANE \
tmux \
-S "$LIBTMUX_DEMO_DIR/tmux.sock" \
-f /dev/null \
new-session \
-d \
-s work \
-n editor \
-x 120 \
-y 40 \
-- /bin/cat && \
env -u TMUX \
-u TMUX_PANE \
tmux \
-S "$LIBTMUX_DEMO_DIR/tmux.sock" \
display-message \
-p '#{pid}' \
> "$LIBTMUX_DEMO_DIR/server-witness.txt" && \
stat \
-Lc '%d %i' \
"$LIBTMUX_DEMO_DIR/tmux.sock" \
>> "$LIBTMUX_DEMO_DIR/server-witness.txt" && \
printf '%s\n' "$LIBTMUX_DEMO_DIR/tmux.sock"

Use the same terminal to start the pinned REPL:

Terminal window
$ mise run repl

Load the query system, which depends on the core. The development REPL registers the ASDF definition but does not implicitly load either system:

(asdf:load-system "libtmux/query")

Load the three optional systems used later in the checked walkthrough:

(asdf:load-system "libtmux/regex")
(asdf:load-system "libtmux/json")
(asdf:load-system "libtmux/workspace")

Load the recipe functions. Their package declares the SBCL package-local nicknames tm, q, j and ws for the core, query, JSON and workspace packages; the library claims no global short nicknames.

(load "examples/workspace.lisp")
(in-package #:libtmux.examples)

Supply the printed socket path as data, without invoking the Lisp reader:

(defparameter *socket-path* (progn (write-line "Paste the demo socket path:")
(read-line)))

This acquires the graph and returns one session record named work, containing its editor window and one pane. Pane projections include identity, captured fields, provenance and relationship IDs:

(inspect-workspace *socket-path*)

Build a linked workspace

The complete recipe is copied from workspace.lisp. It creates a logs window and review session, splits editor, shares that physical window through index 7 of review, and renames logs literally. Run it once on the fresh demo; a repeated invocation encounters existing names.

(defun create-workspace (client)
"Extend the demo's WORK session; return a fresh captured graph.
Creates a logs window, a second editor pane, a review session and a shared link.
The caller owns the isolated daemon; repeating this recipe there is an error."
(let* ((before (tm:fetch-snapshot client))
(work (find "work" (tm:sessions before) :key #'tm:name :test #'string=)))
(unless work (error "Start the demo's WORK session first"))
(let* ((editor-link (aref (tm:window-links work) 0))
(editor (tm:window editor-link))
(pane (aref (tm:panes editor) 0))
(logs (observed-value
(tm:new-window client work :name "logs" :command '("/bin/cat"))))
(review (observed-value
(tm:new-session client :name "review" :window-name "notes"
:command '("/bin/cat")))))
(tm:split-pane client pane :direction :horizontal :command '("/bin/cat"))
(tm:link-window client editor-link review 7)
(tm:rename-window client logs "logs #{literal}")
(tm:fetch-snapshot client))))

Keep a captured graph after closing the client. These globals are interactive scratch values, not a global connection convention in the library:

(defparameter *snapshot*
(tm:with-client (client :socket-path *socket-path*)
(create-workspace client)))
(list (length (tm:sessions *snapshot*))
(length (tm:windows *snapshot*))
(length (tm:window-links *snapshot*))
(length (tm:panes *snapshot*)))

The result is (2 3 4 4). The two editor links have different session/index locators and the same physical window. windows on a snapshot deduplicates physical windows; windows on a session retains one entry per link. The graph contract explains all traversal directions.

Select and project locally

(defun wide-active-panes (snapshot)
"Select captured active panes at least 40 cells wide; no tmux commands."
(loop for pane across (tm:panes snapshot)
when (and (tm:active-p pane) (>= (tm:width pane) 40))
collect (tm:entity-data pane)))
(wide-active-panes *snapshot*)

This returns a nonempty list of plists from the retained graph. You can instead use map, find-if, count-if, reduce or remove-if with a native function. These calls do not acquire state.

Compile a reusable scalar filter when the selection should be validated, inspected or shared. The example package’s local nickname q refers to libtmux.query; the library claims no global nickname.

(defparameter *wide-active-filter*
(q:compile-filter
:pane
'(:and (:= :pane/active t)
(:>= :pane/width 40))))
(defun wide-active-pane-data (snapshot)
"Return owned data for captured active panes at least 40 cells wide; no I/O."
(map 'vector #'tm:entity-data
(q:matching *wide-active-filter* (tm:panes snapshot))))
(wide-active-pane-data *snapshot*)

matching returns a fresh simple vector in captured order. Compilation copies the query form and checks field types, cycles and resource limits. The returned predicate is ordinary Lisp code over retained observations; neither compilation nor matching contacts tmux.

Traverse registered relationships without reacquiring state, and keep the eager selection in a QuerySet with explicit root-model metadata:

(defparameter *active-session-filter*
(q:compile-filter
:session
'(:any :session/window-links
(:is :link/window
(:any :window/panes
(:= :pane/active t))))))
(defun active-session-data (snapshot)
"Return owned data for sessions with an active pane; no I/O."
(map 'vector #'tm:entity-data
(q:items
(q:where (q:from (tm:sessions snapshot) :model :session)
*active-session-filter*))))

See the native query guide for quantifier identities, stable ordering, ownership and strict cardinality.

Match and serialize an optional query

libtmux/regex adds CL-PPCRE-backed operators without adding that dependency to core or libtmux/query. The scanner is compiled once with the filter; this full-match example also proves that a match beginning at position zero works:

(defparameter *cat-command-filter*
(q:compile-filter
:pane
'(:regex-full-match :pane/current-command
(:cl-ppcre "CAT" :case-insensitive-mode t))))
(defun cat-pane-data (snapshot)
"Return owned data for captured panes whose command is exactly cat; no I/O."
(map 'vector #'tm:entity-data
(q:matching *cat-command-filter* (tm:panes snapshot))))
(cat-pane-data *snapshot*)

libtmux/json writes the native schema libtmux.lisp.filter/1. Reading regex is an explicit local opt-in because parser limits do not interrupt CL-PPCRE scanner execution:

(defun round-trip-filter-document (snapshot)
"Round-trip the checked regex filter and apply it to one retained snapshot."
(let* ((document (j:write-filter *cat-command-filter*))
(restored (j:read-filter document :allow-regex t)))
(values document
(map 'vector #'tm:entity-data
(q:matching restored (tm:panes snapshot))))))
(round-trip-filter-document *snapshot*)

Both operations are local. The interchange guide documents the closed envelope, limits and false/empty/null distinctions.

Use both values when false and absence matter. This field is always present; an inactive pane returns nil and t:

(let ((pane (find-if-not #'tm:active-p (tm:panes *snapshot*))))
(multiple-value-bind (value present-p) (tm:field-value pane :pane/active)
(list value present-p)))

Returned strings, plists and relation vectors are caller-owned copies. entity-data is finite and contains relationship identifiers rather than a recursive graph. A copied plist is local data; editing it does not mutate tmux. See the field reference for types and optional values.

Handle command and observation outcomes

Creation and named mutations return command-outcome. A successful command can have no replacement observation because the target disappeared afterward. Check the observation separately; this helper never retries the mutation:

(defun observed-value (outcome)
"Return an operation's observed value, or signal its observation failure."
(when (tm:outcome-observation-error outcome)
(error (tm:outcome-observation-error outcome)))
(multiple-value-bind (value present-p) (tm:outcome-value outcome)
(unless present-p
(error "This operation did not return an observation"))
value))

Rename a captured physical window and inspect old and new observations:

(tm:with-client (client :socket-path *socket-path*)
(let* ((snapshot (tm:fetch-snapshot client))
(window (find "editor" (tm:windows snapshot) :key #'tm:name :test #'string=))
(renamed (observed-value (tm:rename-window client window "api #{literal}"))))
(list (tm:name window) (tm:name renamed) (tm:same-entity-p window renamed))))

The result is ("editor" "api #{literal}" t). The literal format punctuation survives. Refresh uses the same client context and returns another captured observation. Observations from a different connection are not interchangeable operational targets, even when their numeric IDs match.

External failures signal typed conditions. A handler can inspect error-outcome, outcome-status, outcome-phase, outcome-exit-code and owned diagnostic bytes. Missing completion evidence can mean the mutation already happened; an automatic retry can duplicate effects. The operation guide defines these cases.

Preserve binary data

(defun round-trip-bytes (client)
"Store and retrieve a named binary buffer, including NUL and invalid UTF-8."
(let ((bytes (make-array 5 :element-type '(unsigned-byte 8)
:initial-contents '(0 10 92 195 255))))
(tm:set-buffer client bytes :name "example bytes")
(multiple-value-bind (actual outcome)
(tm:show-buffer client :name "example bytes")
(assert (eq :succeeded (tm:outcome-status outcome)))
(assert (equalp bytes actual))
actual)))
(tm:with-client (client :socket-path *socket-path*) (round-trip-bytes client))

This returns five octets, including NUL and invalid UTF-8, unchanged. For pane contents, capture-pane returns UTF-8 text and outcome; use capture-pane-bytes for octets. Captures and buffer reads are bounded complete results, not subscriptions or streams.

Successful input dispatch does not prove application readiness or consumption. Coordinate with an application signal; do not add a sleep or treat printed text as proof. pipe-pane configures a daemon-owned shell pipe and has a separate ownership contract.

Drive an application without polling

The shipped worker signals initialization, reads one request, writes its receipt file, then signals consumption. The Lisp controller uses a fresh wait-for channel for each signal. The file receipt proves what the application consumed; a successful send or printed marker would not. These are blocking calls with deadlines, not a control-mode subscription API.

(defun drive-worker (client session socket-path program worker-path receipt-path)
"Launch a worker through argv, wait for readiness and verify input consumption.
WORKER-PATH names the shipped worker.sh; RECEIPT-PATH is in the owned demo directory.
Signals operational conditions without retrying a mutation. Calls block."
(let* ((ready (symbol-name (gensym "example-ready-")))
(consumed (symbol-name (gensym "example-consumed-")))
(window (observed-value
(tm:new-window client session :name "worker"
:command (list "/bin/sh" worker-path program
socket-path ready consumed receipt-path))))
(pane (aref (tm:panes window) 0))
(message "release #{literal}; λ"))
(tm:run-command client (list "-N" "wait-for" ready)
:effect :read :timeout 1 :output-limit 4096)
(tm:send-text client pane message :enter t)
(tm:run-command client (list "-N" "wait-for" consumed)
:effect :read :timeout 1 :output-limit 4096)
(let ((receipt (uiop:read-file-string receipt-path :external-format :utf-8)))
(assert (string= message receipt))
(list :consumed receipt :pane (tm:id pane)))))
(tm:with-client (client :socket-path *socket-path*)
(let ((session (find "work" (tm:fetch-sessions client) :key #'tm:name :test #'string=)))
(drive-worker client session *socket-path* "tmux"
(namestring (truename "examples/worker.sh"))
(namestring (merge-pathnames "worker-receipt.txt"
(uiop:pathname-directory-pathname *socket-path*))))))

The result contains :consumed "release #{literal}; λ" and a pane ID. The request travels as literal text; worker arguments travel through argv. The worker remains in its pane until the owned daemon is stopped. Receipt paths are local to this example’s daemon host; this is not a remote-file API.

Inspect a typed failure

This cancels a read before it acquires data, then handles command-cancelled and returns the retained dispatch evidence:

(defun cancelled-read (client)
"Demonstrate typed failure evidence using a read cancelled before acquisition."
(let ((token (tm:make-cancellation-token)))
(tm:cancel token)
(handler-case
(progn (tm:fetch-snapshot client :cancel-token token)
(error "A cancelled read unexpectedly completed"))
(tm:command-cancelled (condition)
(let ((outcome (tm:error-outcome condition)))
(list :condition (type-of condition)
:status (and outcome (tm:outcome-status outcome))
:phase (and outcome (tm:outcome-phase outcome))))))))
(tm:with-client (client :socket-path *socket-path*) (cancelled-read client))

This handler inspects evidence and returns; it never retries a command. Post-dispatch cancellation of a mutation can have a different status or phase. See the condition reference.

Terminate the owned server

Closing a client leaves its daemon alive. This checked helper uses the separate kill-server operation only for the explicit socket passed by the caller. It accepts success only after the operation proves both dispatch and daemon exit:

(defun terminate-owned-server (socket-path &key (program "tmux"))
"Terminate the explicitly selected owned server and return its proven outcome."
(tm:with-client (client :program program :socket-path socket-path)
(let ((outcome (tm:kill-server client)))
(assert (eq :succeeded (tm:outcome-status outcome)))
(assert (eq :server-terminated (tm:outcome-phase outcome)))
outcome)))

Run it only for the private tutorial endpoint:

(terminate-owned-server *socket-path*)

Clean up

Exit the REPL:

(uiop:quit)

In the original shell, terminate only the daemon created for this tutorial:

Terminal window
$ python3 tools/stop-example.py --already-terminated "$LIBTMUX_DEMO_DIR"

The helper proves the retained daemon PID exited, rejects a replaced socket, then removes the owned witness, receipt and stale socket file. Tmux 3.3a can leave its socket behind after exit. The automated runner executes the same Lisp termination function before this file cleanup. If an earlier example fails, it uses the retained witness to terminate and reap only this tutorial daemon.

Esc

Type to search.

Reference previewOpen reference