Documentation
¶
Overview ¶
Package gotmucks drives tmux from Go.
tmux presents two interfaces and this package covers both.
Commands ¶
Client issues one-shot tmux invocations: create a session, list what exists, send keys, capture a pane, set an option or a hook. Arguments are typed, results are parsed from tmux format strings into typed rows, and no call goes through a shell.
c := gotmucks.New(gotmucks.WithSocketName("myapp"))
s, err := c.NewSession(ctx, gotmucks.NewSessionOptions{
Name: "build",
Command: []string{"go", "test", "./..."},
})
Control mode ¶
ControlClient holds a persistent "tmux -C" connection: commands go down one pipe, and replies, live pane output and asynchronous notifications come back up another, interleaved. This is the half no other Go package covers and the reason this one exists. (The -CC form documented for applications wants a terminal on standard input and exits at once against a pipe; see WithDoubleControlMode.)
cc, err := gotmucks.Connect(ctx, gotmucks.WithSocketName("myapp"))
defer cc.Close()
for ev := range cc.Events() {
switch e := ev.(type) {
case gotmucks.PaneOutput:
os.Stdout.Write(e.Data)
case gotmucks.Exited:
return
}
}
Addressing ¶
tmux objects are addressed by identifier — SessionID "$0", WindowID "@1", PaneID "%2" — and never by name or index. Names change and indexes renumber; identifiers do not. The three are distinct Go types so that a pane cannot be passed where a window is wanted.
They are string types, so the compiler cannot also stop SessionID("work") being written, and tmux would resolve that as a name. Every call that acts on an identifier therefore checks its shape first and reports ErrInvalidID rather than proceeding: the ones that build a -t, and ControlClient.Output, which builds none but would otherwise register a tap that matches nothing. Identifiers come back from tmux — Session.ID, Row.PaneID — or from ParseSessionID and its siblings; a name is not an address.
The exceptions are the four calls that take a tmux command line rather than an identifier — ControlClient.Do, ControlClient.DoArgs, Client.QueryArgs and WithControlArgs. A command line is passed to tmux as written, -t included, so a name in one resolves as a name. DoArgs quotes each argument, which is about how tmux splits the line and not about what it addresses: a quoted -t still takes a name.
Names ¶
A name is not an address, but it is data, and tmux does not hand a value back the way it was given. It expands a name as a format before storing it, so "v#{host}" would name a window after the host; and it stores names and prints option values escaped with vis(3), so a session called "$HOME" reads back as "\$HOME". Both are undone here: Client.RenameWindow, Client.RenameSession and Client.NewSession escape what they send, and Window.Name, Session.Name, Client.ShowOption and Client.ShowOptions decode what they read, so what was set is what comes back.
tmux hands the same stored name back a second way, in a notification, and the control half decodes it in the same place: WindowRenamed, SessionRenamed, SessionChanged and ClientSessionChanged carry the name that was set, so a caller that lists once and then follows the events holds one name for an object rather than two. SubscriptionChanged is not one of these — its value is the caller's own format, expanded, and there is nothing to undo.
The expansion is not confined to names. NewSessionOptions.StartDir is expanded too and is escaped for the same reason: an unescaped "#H" in a path put the pane in the home directory, silently and with a nil error.
One alteration is not an encoding and so cannot be undone by reading: tmux rewrites a ':' or a '.' in a *session* name to '_' before storing it, because both are its own target separators. It exits 0 and says nothing, so "web.example.com" would be a session called "web_example_com". Client.RenameSession and Client.NewSession refuse those two bytes rather than report a name nobody asked for; window names take both, since tmux does this to sessions alone.
The cost is that a caller who wants tmux to expand a format into a name cannot get one through these calls — a '#' is a '#' — and should send rename-window itself. The other edge is a window named by another program through "new-window -n", which is the one path tmux stores unescaped: a plain name is unaffected, but a backslash or a tab in one is not. It applies to the events as well as to the rows, since both read what tmux stored.
Arguments ¶
Commands are argument vectors and never shell strings, so a space, a quote or a metacharacter in a caller's value is inert. One byte is not: tmux's own argv parser takes a trailing ';' off an element and ends the command there, so "a;" arrived as "a" and an argument of exactly ";" vanished, changing what the command meant. Client escapes that one byte on the way out and tmux stores the semicolon, so the vector really is a boundary. A control connection quotes its arguments instead and never needed it.
The boundary reaches as far as tmux's argv parser and no further. An argument tmux parses a *second* time is a command line in its own right, and the only one of those here is Client.SetHook's command: the escape hands set-hook the ';' intact and set-hook then reads it as the separator it is, so a hook body still cannot end in one. That argument is also lexed when the hook is set rather than when it fires, so a '~' or a '$HOME' in double quotes is expanded at that moment.
Option and hook names ¶
A name on the way *out* is the other half. show-options and show-hooks print "name value" separated by a space, escape the value and print the name exactly as it was stored — and a user option's name is whatever the caller passed, since tmux validates only the leading '@'. So a name containing a space comes back indistinguishable from a shorter name and a longer value, and one containing a newline arrives as two lines of which the second is an option nobody set. Client.SetOption, Client.UnsetOption, Client.ShowOption and the hook calls refuse those bytes rather than answer wrongly; Client.ShowOptions can only report what another program left, and says so.
Which of tmux's option tables a hook lands in follows the hook's *name*, not the target it is set on: on 3.2a every pane-* and window-* name is a window hook wherever it is addressed. Client.ShowHooks reads all three tables for that reason, since Client.SetHook has no say in which one is used.
A plain option is filed the same way, and the consequence is sharper because there is no merging it away: tmux ignores the scope flag for a name it knows and so does the named form of show-options, while the *listing* form obeys it. So Client.SetOption and Client.ShowOptions are not inverses — an option set at ScopeSession that tmux files under window is found by Client.ShowOption and is absent from Client.ShowOptions at that scope. Unlike a hook name, an option name legitimately exists in several tables at once with different values, so the tables cannot be merged; OptionScope says which of the two readers to believe about what.
Row is the raw view and decodes nothing: it is what tmux wrote.
No server running ¶
tmux exits non-zero when no server is listening, but for a read that is an answer rather than a failure. Client.ListSessions returns an empty slice, Client.HasSession and Client.ServerRunning return false, and none of them return an error. Only writes that cannot start a server report ErrNoServer.
Backpressure ¶
A control connection has one goroutine reading the pipe, and it never blocks on a consumer. If a caller stops reading ControlClient.Events, the events are dropped and the loss is reported through EventsDropped — stalling the reader would stall command replies and every other pane too. A per-pane tap from ControlClient.Output has a buffer of its own and so a loss of its own: reported as OutputDropped, and counted by ControlClient.DroppedOutput for a pane that fell quiet before any report could be attached to it.
Separately, tmux itself offers flow control: ControlClient.PauseAfter makes tmux pause a pane whose output is not being consumed and say so with PanePaused, so a slow consumer can never block a pane's process indefinitely. ControlClient.Resume restarts it.
Requirements ¶
tmux 3.2 or newer, for "new-session -e". Go 1.22 or newer. No dependencies outside the standard library. Builds with CGO_ENABLED=0.
tmux does not run on Windows, so neither does this package's subject; the code compiles anywhere but only functions where tmux does.
Index ¶
- Constants
- Variables
- func Line(n int) *int
- func SubscribePane(id PaneID) string
- func SubscribeWindow(id WindowID) string
- type CaptureOptions
- type Client
- func (c *Client) Binary() string
- func (c *Client) CapturePane(ctx context.Context, id PaneID, opts CaptureOptions) ([]byte, error)
- func (c *Client) CheckVersion(ctx context.Context) error
- func (c *Client) HasSession(ctx context.Context, id SessionID) (bool, error)
- func (c *Client) KillServer(ctx context.Context) error
- func (c *Client) KillSession(ctx context.Context, id SessionID) error
- func (c *Client) ListPanes(ctx context.Context) ([]Pane, error)
- func (c *Client) ListSessionPanes(ctx context.Context, id SessionID) ([]Pane, error)
- func (c *Client) ListSessionWindows(ctx context.Context, id SessionID) ([]Window, error)
- func (c *Client) ListSessions(ctx context.Context) ([]Session, error)
- func (c *Client) ListWindowPanes(ctx context.Context, id WindowID) ([]Pane, error)
- func (c *Client) ListWindows(ctx context.Context) ([]Window, error)
- func (c *Client) NewSession(ctx context.Context, opts NewSessionOptions) (*Session, error)
- func (c *Client) Pane(ctx context.Context, id PaneID) (*Pane, error)
- func (c *Client) Query(ctx context.Context, cmd string, spec FormatSpec) ([]Row, error)
- func (c *Client) QueryArgs(ctx context.Context, spec FormatSpec, args ...string) ([]Row, error)
- func (c *Client) RenameSession(ctx context.Context, id SessionID, name string) error
- func (c *Client) RenameWindow(ctx context.Context, id WindowID, name string) error
- func (c *Client) SendKeys(ctx context.Context, id PaneID, keys ...Key) error
- func (c *Client) SendLine(ctx context.Context, id PaneID, s string) error
- func (c *Client) SendText(ctx context.Context, id PaneID, s string) error
- func (c *Client) ServerRunning(ctx context.Context) (bool, error)
- func (c *Client) Session(ctx context.Context, id SessionID) (*Session, error)
- func (c *Client) SetGlobalHook(ctx context.Context, name, command string) error
- func (c *Client) SetHook(ctx context.Context, t Target, name, command string) error
- func (c *Client) SetOption(ctx context.Context, t Target, name, value string) error
- func (c *Client) SetOptionScoped(ctx context.Context, t Target, scope OptionScope, name, value string) error
- func (c *Client) SetRemainOnExit(ctx context.Context, t Target, on bool) error
- func (c *Client) ShowGlobalHooks(ctx context.Context) (map[string]string, error)
- func (c *Client) ShowHooks(ctx context.Context, t Target) (map[string]string, error)
- func (c *Client) ShowOption(ctx context.Context, t Target, scope OptionScope, name string) (string, bool, error)
- func (c *Client) ShowOptions(ctx context.Context, t Target, scope OptionScope) (map[string]string, error)
- func (c *Client) SocketArgs() []string
- func (c *Client) UnsetGlobalHook(ctx context.Context, name string) error
- func (c *Client) UnsetHook(ctx context.Context, t Target, name string) error
- func (c *Client) UnsetOption(ctx context.Context, t Target, scope OptionScope, name string) error
- func (c *Client) Version(ctx context.Context) (Version, error)
- func (c *Client) Window(ctx context.Context, id WindowID) (*Window, error)
- type ClientDetached
- type ClientSessionChanged
- type ConfigError
- type ControlClient
- func (cc *ControlClient) AttachedSession() SessionID
- func (cc *ControlClient) Close() error
- func (cc *ControlClient) Do(ctx context.Context, cmd string) (Reply, error)
- func (cc *ControlClient) DoArgs(ctx context.Context, args ...string) (Reply, error)
- func (cc *ControlClient) Done() <-chan struct{}
- func (cc *ControlClient) Dropped() uint64
- func (cc *ControlClient) DroppedOutput(pane PaneID) uint64
- func (cc *ControlClient) Err() error
- func (cc *ControlClient) Events() <-chan Event
- func (cc *ControlClient) Output(pane PaneID) (<-chan []byte, error)
- func (cc *ControlClient) Pause(ctx context.Context, pane PaneID) error
- func (cc *ControlClient) PauseAfter(ctx context.Context, d time.Duration) error
- func (cc *ControlClient) Resume(ctx context.Context, pane PaneID) error
- func (cc *ControlClient) SetSize(ctx context.Context, cols, rows int) error
- func (cc *ControlClient) Stderr() string
- func (cc *ControlClient) Subscribe(ctx context.Context, name, target, format string) error
- func (cc *ControlClient) Unsubscribe(ctx context.Context, name string) error
- func (cc *ControlClient) Untap(pane PaneID)
- func (cc *ControlClient) Version() Version
- func (cc *ControlClient) Wait(ctx context.Context) error
- type ControlError
- type Event
- type EventsDropped
- type ExitError
- type Exited
- type FormatSpec
- type GlobalTarget
- type Key
- type LayoutChanged
- type Message
- type NewSessionOptions
- type Option
- func WithAttach(id SessionID) Option
- func WithBinary(path string) Option
- func WithCloseTimeout(d time.Duration) Option
- func WithControlArgs(args ...string) Option
- func WithDoubleControlMode() Option
- func WithEnv(env ...string) Option
- func WithEventBuffer(n int) Option
- func WithOutputBuffer(n int) Option
- func WithSocketName(name string) Option
- func WithSocketPath(path string) Option
- func WithoutParentEnv() Option
- func WithoutVersionCheck() Option
- type OptionScope
- type OutputDropped
- type Pane
- type PaneContinued
- type PaneID
- type PaneModeChanged
- type PaneOutput
- type PanePaused
- type PasteBufferChanged
- type PasteBufferDeleted
- type ProtocolError
- type Reply
- type Row
- func (r Row) At(i int) string
- func (r Row) Bool(name string) (bool, error)
- func (r Row) Get(name string) string
- func (r Row) Int(name string) (int, error)
- func (r Row) Len() int
- func (r Row) Lookup(name string) (string, bool)
- func (r Row) Map() map[string]string
- func (r Row) PaneID(name string) (PaneID, error)
- func (r Row) SessionID(name string) (SessionID, error)
- func (r Row) Time(name string) (time.Time, error)
- func (r Row) WindowID(name string) (WindowID, error)
- type Session
- type SessionChanged
- type SessionID
- type SessionRenamed
- type SessionWindowChanged
- type SessionsChanged
- type SubscriptionChanged
- type Target
- type UnknownNotification
- type Version
- type Window
- type WindowAdded
- type WindowClosed
- type WindowID
- type WindowPaneChanged
- type WindowRenamed
Examples ¶
Constants ¶
const ( // SubscribeSession expands the format once for the attached session. SubscribeSession = "" // SubscribeAllPanes expands the format once per pane. SubscribeAllPanes = "%*" // SubscribeAllWindows expands the format once per window. SubscribeAllWindows = "@*" )
Subscription target selectors, the middle field of tmux's "refresh-client -B name:target:format".
Variables ¶
var ErrClosed = fmt.Errorf("gotmucks: control client closed: %w", ErrServerExited)
ErrClosed reports use of a ControlClient after ControlClient.Close.
It wraps ErrServerExited, because to a caller asking whether the connection is still usable the answer is the same and testing for both would be a trap. Test for this one to tell a connection this program ended from one tmux ended: a command sent after Close reports it, and a genuine failure that happened first is reported instead, since that is the more useful news.
ControlClient.Wait and ControlClient.Err still report nil after Close. Being asked to end is not a fault.
var ErrInvalidID = errors.New("gotmucks: not a tmux identifier")
ErrInvalidID reports a SessionID, WindowID or PaneID that is not an identifier: a name, an index, or anything else that is not the object's sigil followed by digits.
The three are Go string types, so nothing stops a caller building one out of a session name — and tmux would then resolve it as a name, which is the failure addressing by identifier exists to prevent. Every exported call that acts on one checks it first and reports this rather than acting on whichever object the name happened to reach.
var ErrNoPane = errors.New("gotmucks: pane not found")
ErrNoPane reports that the named pane does not exist.
var ErrNoServer = errors.New("gotmucks: no tmux server running")
ErrNoServer reports that no tmux server is listening on the configured socket.
tmux exits 1 for this, the same as for a genuine failure, so this package classifies it from stderr and treats it as a fact rather than a fault: read paths (Client.ListSessions, Client.HasSession, Client.ServerRunning and friends) report emptiness instead of failing. Write paths that cannot start a server return an error wrapping ErrNoServer.
var ErrNoSession = errors.New("gotmucks: session not found")
ErrNoSession reports that the named session does not exist.
var ErrNoWindow = errors.New("gotmucks: window not found")
ErrNoWindow reports that the named window does not exist.
var ErrServerExited = errors.New("gotmucks: control connection exited")
ErrServerExited reports that the control-mode connection ended, either because tmux sent %exit or because the process died. It is terminal: the caller reconnects if it wants to, because only the caller knows whether that is wanted.
var ErrUnsupportedVersion = errors.New("gotmucks: tmux version too old")
ErrUnsupportedVersion reports that the tmux binary predates the minimum this package supports.
var Global = GlobalTarget{}
Global is the zero-object target. Passing it to a command omits -t.
Functions ¶
func Line ¶
Line is a convenience for building the Start and End fields of CaptureOptions.
func SubscribePane ¶
SubscribePane is the subscription target for one pane.
func SubscribeWindow ¶
SubscribeWindow is the subscription target for one window.
Types ¶
type CaptureOptions ¶
type CaptureOptions struct {
// Escapes includes the escape sequences for text and background
// attributes in the output, tmux's capture-pane -e. Without it the
// capture is plain text.
Escapes bool
// Join preserves trailing spaces and joins wrapped lines, tmux's -J.
// Use it when the capture is going to be diffed or parsed, since it makes
// a wrapped line indistinguishable from a hard-wrapped one.
Join bool
// PreserveTrailingSpaces keeps trailing spaces on each line, tmux's -N.
PreserveTrailingSpaces bool
// Start and End bound the captured region in lines. Negative values index
// into the scrollback: -1 is the line immediately above the visible area.
// Both are inclusive.
//
// Leave both nil to capture the visible pane only.
Start, End *int
// FullHistory captures the entire scrollback plus the visible area. It
// overrides Start.
FullHistory bool
}
CaptureOptions configures Client.CapturePane.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client issues one-shot tmux commands. Each call starts a tmux process, waits for it and parses its output; there is no persistent connection. For live pane output and asynchronous notifications use Connect and ControlClient.
A Client holds no state beyond its configuration and one cached answer to "tmux -V", and is safe for concurrent use. The version is remembered because the readers consult it per value to undo a fault in one tmux release, and the binary a client runs is fixed when it is made.
func New ¶
New returns a Client configured by opts.
With no options it runs "tmux" from PATH against the default socket, which is the same server an interactive tmux would use. Programs that must not disturb a user's own sessions should pass WithSocketName or WithSocketPath.
func (*Client) CapturePane ¶
CapturePane returns the contents of a pane.
The result is raw bytes rather than a string because with CaptureOptions.Escapes set it contains terminal escape sequences, and because pane contents are not guaranteed to be valid UTF-8.
func (*Client) CheckVersion ¶
CheckVersion reports an error wrapping ErrUnsupportedVersion if the tmux binary is older than MinimumVersion.
func (*Client) HasSession ¶
HasSession reports whether a session exists.
Neither a missing session nor a missing server is an error; both are false. An id that is not an identifier is: an absence is an answer, whereas asking the wrong question is a caller mistake. See ErrInvalidID.
func (*Client) KillServer ¶
KillServer terminates the tmux server on the configured socket.
A server that was not running is success, not failure.
func (*Client) KillSession ¶
KillSession destroys a session.
It is idempotent: a session that is already gone, or a server that is not running, is success. An id that is not an identifier is an error, because tmux would otherwise read it as a name and kill whichever session answers to it. See ErrInvalidID.
func (*Client) ListPanes ¶
ListPanes returns every pane on the server.
No server running is not an error: the result is an empty slice.
func (*Client) ListSessionPanes ¶
ListSessionPanes returns every pane in one session, across all its windows.
func (*Client) ListSessionWindows ¶
ListSessionWindows returns the windows of one session.
A session that does not exist yields an empty slice rather than an error, matching the other list calls.
func (*Client) ListSessions ¶
ListSessions returns every session on the server, in no promised order.
tmux prints them ordered by name, which is not the order of their identifiers and not the order they were created in — verified on 3.2a, where sessions created as zulu, mike, alpha come back as $2, $1, $0. That is tmux's business rather than a promise made here, so sort the result if an order matters. Sort on SessionID.Ordinal rather than on the identifier as a string: the number is the part tmux never reuses or renumbers, and "$10" sorts before "$9" as text.
No server running is not an error: the result is an empty slice.
Example ¶
A server that is not running is not an error for a read. There is no need to check for it separately, and no error to distinguish from a real failure.
package main
import (
"context"
"fmt"
"log"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
c := gotmucks.New(gotmucks.WithSocketName("example"))
sessions, err := c.ListSessions(ctx)
if err != nil {
log.Fatal(err) // a genuine failure, not "nothing is running"
}
for _, s := range sessions {
fmt.Printf("%s %q %d windows\n", s.ID, s.Name, s.Windows)
}
}
Output:
func (*Client) ListWindowPanes ¶
ListWindowPanes returns the panes of one window.
func (*Client) ListWindows ¶
ListWindows returns every window on the server.
No server running is not an error: the result is an empty slice.
func (*Client) NewSession ¶
NewSession creates a detached session and returns it.
tmux starts a server if one is not already running, so this is the one read path that legitimately fails when there is no server: it is a write.
Example ¶
Create a detached session running a command, and read back what it printed.
The command is an argument vector, not a shell string, so the semicolon in it is inert.
package main
import (
"context"
"log"
"os"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
c := gotmucks.New(gotmucks.WithSocketName("example"))
s, err := c.NewSession(ctx, gotmucks.NewSessionOptions{
Name: "build",
Env: map[string]string{"CI": "1"},
Command: []string{"sh", "-c", "make; sleep 60"},
})
if err != nil {
log.Fatal(err)
}
defer c.KillSession(ctx, s.ID)
panes, err := c.ListSessionPanes(ctx, s.ID)
if err != nil {
log.Fatal(err)
}
out, err := c.CapturePane(ctx, panes[0].ID, gotmucks.CaptureOptions{})
if err != nil {
log.Fatal(err)
}
os.Stdout.Write(out)
}
Output:
func (*Client) Query ¶
Query runs a tmux command with a format specification and returns its output as typed rows. Callers never write -F by hand and never parse tmux's human-readable output.
cmd is a bare subcommand name such as "list-sessions". Use Client.QueryArgs when the command needs arguments of its own.
No server running is not an error: the result is an empty slice.
func (*Client) QueryArgs ¶
QueryArgs is Client.Query for commands that take arguments. The -F flag built from spec is appended after args.
func (*Client) RenameSession ¶
RenameSession gives a session a new name. The session's identifier is unchanged, which is why identifiers rather than names are the addressing scheme here.
The name goes after "--" for the reason Client.RenameWindow gives: it is positional, so without the separator a name beginning with a dash is read as a flag and the session keeps its old name. A '#' in it is doubled for the other reason that call gives: tmux expands the name as a format first.
A ':' or a '.' is refused rather than sent, because tmux rewrites either to '_' and says nothing — see [checkSessionName]. A window name may contain both.
func (*Client) RenameWindow ¶
RenameWindow gives a window a new name.
The name is positional, so it goes after "--": tmux's option parser reaches it otherwise, and a name beginning with a dash is then read as a flag rather than a name. Verified on 3.2a: without the separator, "-a" is refused as an unknown option, "-tother" is consumed as a second -t and leaves the command with no name at all, and "--" is eaten as the separator; with it, all three become the window's name.
The separator is not enough on its own, because tmux expands the name as a format before storing it. A '#' in the name is doubled so that it stays a '#' — see [escapeFormat] for what happens otherwise — which means a caller that wants tmux to expand a format into the name cannot get it through here and should send rename-window itself.
func (*Client) SendKeys ¶
SendKeys sends keys to a pane.
tmux selects literal and hex interpretation with a flag on send-keys, not per key, so a mixed sequence is issued as one send-keys per run of same-kind keys. The runs are sent in order and a failure part-way through leaves the earlier runs delivered — tmux has no transaction for this.
c.SendKeys(ctx, pane, gotmucks.Literal("echo hi"), gotmucks.Enter())
Example ¶
Send a command to a pane and wait for its output to appear.
package main
import (
"context"
"log"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
c := gotmucks.New(gotmucks.WithSocketName("example"))
pane := gotmucks.PaneID("%0")
// Literal text and named keys cannot share one tmux invocation, so the
// library issues one per run. The caller does not have to care.
err := c.SendKeys(ctx, pane,
gotmucks.Literal("go test ./..."),
gotmucks.Enter(),
)
if err != nil {
log.Fatal(err)
}
// Ctrl-C as a raw byte.
if err := c.SendKeys(ctx, pane, gotmucks.Hex(0x03)); err != nil {
log.Fatal(err)
}
}
Output:
func (*Client) SendLine ¶
SendLine sends a string to a pane literally followed by Enter, which is what running a shell command amounts to.
The text is not interpreted by this package, but it is by whatever is reading the pane. Sending untrusted input to a shell is the caller's risk to manage.
func (*Client) ServerRunning ¶
ServerRunning reports whether a tmux server is listening on the configured socket.
No server is not an error: this returns false, nil.
func (*Client) Session ¶
Session returns one session by identifier.
A session that does not exist, or a server that is not running, yields an error wrapping ErrNoSession or ErrNoServer respectively. This differs from the list calls deliberately: asking for a specific object and not getting it is a failure, whereas listing an empty server is not. The filter is applied here rather than with tmux's list-sessions -f, which would tie this call to a flag whose availability varies across the supported version range. Session counts are small enough that filtering in process costs nothing.
func (*Client) SetGlobalHook ¶
SetGlobalHook installs a hook on the server rather than on one object, tmux's set-hook -g. The name and the command are checked as Client.SetHook checks them.
func (*Client) SetHook ¶
SetHook installs a hook on a target.
name is a tmux hook name such as "session-created", "pane-exited" or "alert-bell". command is a tmux command line, run by tmux when the hook fires — it is tmux syntax, not a shell command, though it may invoke one with run-shell.
Being a command line rather than a value is what makes two of its bytes behave unlike the same bytes in an option value, both measured on 3.2a.
A trailing ';' does not survive. The package's own escape puts the byte in front of set-hook intact — it is [escapeTrailingSemicolon]'s whole job, and it is what makes an option value ending in ';' work — but set-hook then parses what it was handed a second time, and there the ';' is the command separator it always is: "display-message hi;" is stored as "display-message hi". So the guarantee that an argv element is a boundary stops at an argument tmux parses again, and this is the argument it parses again.
And the command line is lexed when the hook is *set*, not when it fires, so anything tmux's lexer expands is expanded at that moment: a '~' inside double quotes is the one that catches a path, with `display-message "~/bin"` stored as "display-message /home/you/bin". Single quotes stop it, as they stop "$HOME". Client.ShowHooks shows what was stored, so it is visible there.
Which option table the hook lands in is tmux's choice and follows the name rather than the target: on 3.2a "pane-exited" is a window hook whether it is set on a session, a window or a pane, and it is set on the window the target resolves to. Client.ShowHooks reads all three tables for that reason.
A hook name tmux does not know is an error ("invalid option: nosuchhook"), with one exception: a name beginning with '@' is a user option, which tmux stores and never fires. Nothing reads it back as a hook.
An empty command is refused. tmux accepts one and then prints the hook exactly as it prints a hook that is not set at all — the bare name, with no index and no command — so a hook set to nothing cannot be told from an absent one and Client.ShowHooks does not report it. Use Client.UnsetHook to remove a hook.
func (*Client) SetOption ¶
SetOption sets an option on a target, addressed at tmux's session scope.
The scope is not what decides where the option goes, so this is not limited to session options: tmux files a name it knows in that name's own table whatever flag it is given, and measured on 3.2a this sets remain-on-exit (a window option) and escape-time (a server option) just as successfully as status. See OptionScope.
What the scope decides is a user option, which has no table of its own, and which *listing* the option turns up in afterwards. An option set here that tmux does not file under session is found by Client.ShowOption and is not in Client.ShowOptions at ScopeSession. Use Client.SetOptionScoped with the option's own table when the two have to agree.
func (*Client) SetOptionScoped ¶
func (c *Client) SetOptionScoped(ctx context.Context, t Target, scope OptionScope, name, value string) error
SetOptionScoped sets an option in a named scope.
The scope reaches the write only for a user option and for the '-p' of a window-or-pane name; tmux files every other known name by the name. What it always reaches is the read, since Client.ShowOptions lists one table — so this is the call to use when an option has to appear in a listing of the scope it was set through. See OptionScope.
The name is checked by [checkOptionName]: tmux would store one containing a space perfectly well, and neither reader here could tell it from a name and a value.
func (*Client) SetRemainOnExit ¶
SetRemainOnExit controls whether a pane stays after its process exits.
This is the option that makes a pane's final output readable instead of vanishing, so it gets a named helper. The scope follows the target: pane options for a PaneID, window options otherwise, which is where tmux keeps remain-on-exit for each.
It is also the one place in the package where the scope is doing real work on a built-in name. tmux files remain-on-exit under window *and* pane, and '-p' is the flag that picks between them — measured on 3.2a, "set-option -p" puts it in the pane table and "set-option" with no flag in the window table, while every other built-in name ignores the flag entirely. See OptionScope.
func (*Client) ShowGlobalHooks ¶
ShowGlobalHooks returns the hooks set on the server, tmux's show-hooks -g. The map is keyed as Client.ShowHooks describes, and the same three option tables are read: "set-hook -g -- pane-exited" goes to the global window table, where "show-hooks -g" alone does not look.
The global tables are the ones tmux prints in full — every hook name it knows appears, with no command against the ones that are not set. Those are dropped, so the map holds the hooks that are set and nothing else; without that it held sixty-odd names of which one was real. See [Client.hooks].
func (*Client) ShowHooks ¶
ShowHooks returns the hooks set on a target, keyed by hook name.
tmux prints one "name command" pair per line and puts an array index on every name, not only on a hook set at an index: on 3.2a a plain "set-hook alert-bell ..." prints back as "alert-bell[0]". The index is taken off here, so the name a hook was set under is the name it is found under — which is the whole use of the call, and did not work before.
A hook with more than one command keeps its bracketed names, since a map cannot hold two values under one key: "alert-bell[0]" and "alert-bell[1]" both appear, and "alert-bell" does not. Only tmux's set-hook -a and an explicit index produce that; this package's Client.SetHook always writes element zero.
Which is also what the index costs when a name has only one element: it is taken off without being recorded, so a hook set elsewhere as "alert-bell[3]" is reported under "alert-bell" and cannot be told from one at element zero. Handing that entry back to Client.SetHook relocates it there. Nothing is lost or duplicated — tmux clears the array on a set without -a, so the hook still fires and the map still holds one entry, verified on 3.2a — but the hook has moved. Only set-hook -a and an explicit index can reach this.
The command is the tmux command line as tmux printed it, which is what Client.SetHook takes: tmux re-serialises the parsed command list, quoting what needs it, so what comes back can be handed straight back. It is not an option value and is deliberately not decoded as one — on 3.2a a hook whose argument contains a tab prints as "display-message a\tb", and turning that back into a raw tab would split the argument in two the next time it was set.
What comes back is what would fire for that target, and a hook's table decides what "for that target" means. A session hook is read off the session the target belongs to, so a window and a pane of that session report it as their own. A window hook is read off the window the target resolves to, which for a session target is its *active* window — so two windows of one session report different hooks, and which ones a session reports moves when the active window changes. Verified on 3.2a.
All three tables are asked, because Client.SetHook cannot choose between them: tmux picks by the hook's name. Reading only the session table — which is what plain "show-hooks -t" does — reported nothing at all for "pane-exited", "window-renamed" and every other window hook, however successfully it had been set. Global hooks are not included; Client.ShowGlobalHooks reports those.
func (*Client) ShowOption ¶
func (c *Client) ShowOption(ctx context.Context, t Target, scope OptionScope, name string) (string, bool, error)
ShowOption returns the value of a single option and whether it is set on the target rather than inherited.
That is what the bool means, and it is not "set in the table you asked for". tmux resolves a name it knows to that name's own table and reads it from there, so the scope is honoured only for a user option and for the '-p' of a window-or-pane name — see OptionScope. What the bool does distinguish is the distinction this call exists for: measured on 3.2a with status set globally to off and nothing set on the session, ShowOption at ScopeSession reports ("", false, nil) rather than the inherited value.
A name tmux does not know, a server that is not running and a target that does not exist are all absences rather than failures: the value is empty, the bool is false, and the error is nil.
It asks show-options for one name rather than using show-options -v, even though -v would print the value with no quoting to undo. Two reasons, and neither is the one this comment used to give. The named form is what lets this refuse an array rather than answer with its first element, and it shares its quoting and vis decode with Client.ShowOptions instead of being a second path to keep right. The reason it used to give — that -v cannot tell an unset option from one set to the empty string — is false on 3.2a, measured with od: unset prints no bytes at all and empty prints one newline, which is exactly the difference [splitLines] already preserves.
An array option — status-format, command-alias — has no single value and is an error here rather than a quiet answer of its first element. Read one with Client.ShowOptions, which reports every element under its own indexed name.
A name containing a space or a control byte is refused rather than answered: tmux prints the name back unescaped and this splits at the first space, so such a name is indistinguishable from a shorter name and a longer value. See [checkOptionName].
func (*Client) ShowOptions ¶
func (c *Client) ShowOptions(ctx context.Context, t Target, scope OptionScope) (map[string]string, error)
ShowOptions returns every option in a scope.
One scope, and this is the one call in the package where the scope is the whole answer: tmux's listing form takes no name to follow, so it reads the table the flag names and nothing else. That makes this and Client.SetOption not inverses. An option appears here only if the scope given to this call is the table tmux filed the *name* under, which for a built-in name has nothing to do with the scope it was set with — measured on 3.2a, SetOption(t, "remain-on-exit", "on") succeeds, ShowOption finds it at ScopeSession, ScopeWindow and ScopeServer alike, and ShowOptions at ScopeSession does not list it. See OptionScope. There is no call here for "everything set on this object"; ask each scope in turn.
tmux prints one "name value" pair per line, quoting values that need it and escaping the characters that would otherwise break the line; both are undone here, so a value containing a tab or a newline comes back intact.
An array option appears as one entry per element, keyed by the name tmux printed: "status-format[0]", "status-format[1]". That is what makes this the call for reading one — Client.ShowOption refuses an array rather than answering with its first element.
The name is the one field on a line that tmux does not escape, and a space is all that separates it from the value, so a user option another program set with a space in its name is read wrong here and cannot be read any other way: "@a b V" is a name of "@a b" and a value of "V", or a name of "@a" and a value of "b V", and nothing on the wire says which. This package's own writers cannot create one — [checkOptionName] refuses the byte — so the ambiguity is reachable only from outside.
func (*Client) SocketArgs ¶
SocketArgs reports the global socket flags this client prepends to every command, as ["-L", name] or ["-S", path], or nil for the default socket.
func (*Client) UnsetGlobalHook ¶
UnsetGlobalHook removes a global hook.
func (*Client) UnsetOption ¶
UnsetOption removes an option, restoring the inherited value. This is tmux's set-option -u.
set-option -u follows a name to the same table set-option wrote it to, so this and Client.SetOption are inverses whatever scope either is given — measured on 3.2a, unsetting remain-on-exit through a session target empties the window table entry a session-scoped set put there. Only the listing in Client.ShowOptions is bound by the flag.
type ClientDetached ¶
type ClientDetached struct{ Client string }
ClientDetached reports that a client detached from the server.
type ClientSessionChanged ¶
type ClientSessionChanged struct {
Client string
Session SessionID
// Name is the session name as it was set, decoded from the form tmux
// stores.
Name string
}
ClientSessionChanged reports that another client switched session.
type ConfigError ¶
type ConfigError struct{ Text string }
ConfigError reports an error tmux hit while reading its configuration.
type ControlClient ¶
type ControlClient struct {
// contains filtered or unexported fields
}
ControlClient is a persistent connection to a tmux server in control mode.
Control mode is a two-way protocol on one pair of pipes: commands go down, and command replies, live pane output and asynchronous notifications come back on the same stream. A single goroutine owns the read side; nothing else touches the pipe. It dispatches by line prefix only between command blocks — inside one, every line is that command's output, however much it may look like a notification. See handleLine.
The connection uses tmux's -C. The -CC form documented for applications additionally puts the terminal out of canonical mode, which makes tmux call tcgetattr on its standard input; against a pipe that fails and tmux exits at once. See WithDoubleControlMode.
A ControlClient is safe for concurrent use. ControlClient.Do may be called from many goroutines at once: more than one command may be outstanding, and each reply is bound to the command that earned it by queue order rather than by tmux's command number, which cannot be predicted. See beginBlock.
func Connect ¶
func Connect(ctx context.Context, opts ...Option) (*ControlClient, error)
Connect opens a control-mode connection.
ctx bounds the connection setup only. The connection itself lives until ControlClient.Close, tmux exits, or the process dies — cancelling ctx afterwards does not close it, because a connection whose lifetime was tied to a setup context would be surprising to hand to another goroutine.
By default the connection creates a new session. Use WithAttach to attach to an existing one, or WithControlArgs for anything else.
func (*ControlClient) AttachedSession ¶
func (cc *ControlClient) AttachedSession() SessionID
AttachedSession reports the session tmux said this client is attached to, learned from the %session-changed notification sent at startup. It is empty if tmux has not said.
func (*ControlClient) Close ¶
func (cc *ControlClient) Close() error
Close ends the connection.
It writes the empty line that detaches a control client, then waits for tmux to exit, killing it if it outstays WithCloseTimeout. Close is idempotent and safe to call concurrently with anything else.
The bound is two of those timeouts, and it holds whatever tmux does: one for the detach, including the write, and one more for the kill to take effect. The second has never been reached — killing the client makes the reader see end of file at once, and with the tmux client stopped outright Close still returned in 1.09s under a one-second timeout — but a guarantee that rests on the kill working is not a guarantee.
It returns an error if tmux did not end the way it was asked to — an exit status of its own, a signal this package did not send, or the process outstaying the kill.
Calling it is good manners rather than a requirement for tidiness: the reader reaps the process whenever the connection ends, so a caller that watches ControlClient.Done instead leaks nothing. What Close adds is the clean detach and the report of how tmux went.
Closing detaches the control client; it does not kill the session or the server. Use Client.KillSession for that.
func (*ControlClient) Do ¶
Do sends a command and waits for its reply.
cmd is one tmux command line, parsed by tmux itself; use ControlClient.DoArgs to have arguments quoted for you. It must be a single line: a newline would be a second command, and an empty line detaches the control client. It must also be a single command: tmux answers each command of a ";"-separated list with a block of its own, so a list would leave blocks over for the commands after it, and an unquoted ";" is rejected for that reason. Send the commands one at a time instead — Do may be called concurrently.
A '{' that begins a token is rejected for the same reason. It is tmux's other quoting form, and where the command it belongs to takes a command — if-shell, bind-key — what is inside the braces is run and answered with a block of its own. Verified on 3.2a: "if-shell 'true' { list-sessions }" produces two blocks, both marked as this client's. Nothing on the line says which of the two a brace-quoted token will be, so both are refused; single-quote the argument instead, or let ControlClient.DoArgs quote it.
It must also be at least one command, which is the same requirement counted the other way and the more dangerous half. A line whose first non-blank byte is a '#' is a comment: it parses to an empty command list and tmux answers it with nothing at all, so this command would be handed the *next* one's reply — with a nil error — and every command after that would wait for a block that is never coming. Measured on 3.2a, all ninety-five printable bytes swept as the first of the line, '#' is the only one that does it; see scripts/probe-blocks.sh. A '#' anywhere else is harmless, either data ("a#b") or a truncation of a command that still earns its block ("list-sessions #tail"), and a quoted one is a command name tmux does not know, which is what ControlClient.DoArgs relies on.
What cannot be detected is the same hazard written as an ordinary string. Verified on 3.2a: "if-shell 'true' 'list-sessions'" also produces two blocks, and the inner command is indistinguishable from any other argument. source-file is the one a caller actually reaches for, and it belongs in that list beside if-shell and bind-key: measured on 3.2a, a file of two commands earns three blocks, one for source-file and one for each command in it. It needs a file on the server's own filesystem, so replaying a configuration line by line is the obvious alternative — and that is the same trap from the other side, since a configuration is full of the comment lines above. Do not send a command that makes tmux run further commands on this client.
It is safe to call Do concurrently: several commands may be outstanding at once, and each reply is bound to the command that earned it by the order the commands were written in.
A command tmux answers with %error yields a *ControlError; the reply is still returned with the error body in its output.
The reply body is whatever the command printed, line for line, including a line that looks like part of the protocol: capture-pane on a pane containing "%exit forged" puts that line in Reply.Output and nowhere else. Nothing in a reply is interpreted as a notification. If the connection ends while this command is outstanding the error wraps ErrServerExited and the partial body is returned with it.
One error is neither of those: a *ProtocolError means a block header arrived that carried no command number, so the reply this command was owed can no longer be told from the next one's. The command is abandoned rather than answered from a body that might belong to another, and the connection stays up.
Example ¶
Run an arbitrary command over the connection and parse its output with the same format machinery the one-shot client uses.
package main
import (
"context"
"errors"
"fmt"
"log"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
cc, err := gotmucks.Connect(ctx, gotmucks.WithSocketName("example"))
if err != nil {
log.Fatal(err)
}
defer cc.Close()
spec := gotmucks.FormatSpec{"pane_id", "pane_current_command"}
// DoArgs quotes each argument for tmux's parser. It matters: unquoted, the
// '#' in a format starts a comment and the command loses its argument.
reply, err := cc.DoArgs(ctx, "list-panes", "-a", "-F", spec.Arg())
if err != nil {
var cerr *gotmucks.ControlError
if errors.As(err, &cerr) {
log.Fatalf("tmux refused the command: %s", cerr.Message)
}
log.Fatal(err)
}
rows, err := reply.Rows(spec)
if err != nil {
log.Fatal(err)
}
for _, r := range rows {
fmt.Printf("%s %s\n", r.Get("pane_id"), r.Get("pane_current_command"))
}
}
Output:
func (*ControlClient) DoArgs ¶
DoArgs sends a command built from separate arguments, each quoted for tmux's parser. This is the safer form when any argument is caller data.
Safer is about splitting, not about addressing. Quoting stops an argument containing a space, a ";" or a "{" from becoming a second command whose reply block would be delivered to whatever this connection sends next; it does nothing about what the command addresses. A quoted -t is still a -t, so DoArgs("kill-session", "-t", "work") kills whichever session answered to that name — this is one of the four exceptions to the identifier rule described in the package documentation, not an escape from it. Build the -t from a SessionID, WindowID or PaneID that came from tmux.
Nor does quoting stop a command expanding its own argument. rename-window, rename-session, new-session's -s and -n and new-session's -c run what they are given through tmux's format expansion before using it, so "v#{host}" names an object after the host and no quoting here prevents it: the expansion happens after the lexer, inside the command. Double the '#' — see [escapeFormat], which is what Client.RenameWindow and the other four do.
On this path that is not a cosmetic difference. A "#(...)" reaches tmux's job machinery, and a job belongs to the client that asked for the expansion: a one-shot tmux exits before its own job can run, but a control connection stays alive, so the job runs. Measured on 3.2a, the same rename-window line that left a one-shot client's window merely misnamed executed the shell command when sent down a control connection, twice. Through DoArgs, an unescaped '#' in caller data is arbitrary command execution rather than a wrong name.
One further thing changed with the ability to send a newline. Every argument is spliced as tmux's own "\n" escape, so it reaches tmux intact rather than ending the command — which is what lets a FormatSpec.Arg template down this pipe, and what removed ControlClient.Do's newline check as a guarantee that nothing a caller sends can become a second protocol line. DoArgs cannot restore that centrally: it cannot tell a newline that is a substitution pattern, which is safe and necessary, from one in a value tmux will store and write back into a notification, which is not. So the guarantee now belongs to whichever call hands tmux such a value, and ControlClient.Subscribe is the one this package offers — see [checkSubscribeName] for the shape a new one should copy.
func (*ControlClient) Done ¶
func (cc *ControlClient) Done() <-chan struct{}
Done returns a channel closed when the connection ends.
func (*ControlClient) Dropped ¶
func (cc *ControlClient) Dropped() uint64
Dropped reports how many events have been discarded because the event channel was full.
It counts the event stream only. A tap registered by ControlClient.Output has a buffer of its own and overflows on its own terms, which is a separate number: ControlClient.DroppedOutput.
func (*ControlClient) DroppedOutput ¶
func (cc *ControlClient) DroppedOutput(pane PaneID) uint64
DroppedOutput reports how many of one pane's output messages have been discarded because that pane's tap channel was full.
This is the number to ask for rather than watching for OutputDropped. A drop is reported as that event when delivery to the pane next succeeds, and at teardown for anything still owed, but both are events on a stream that is itself lossy, and a pane that overflows and then falls quiet has nothing to attach a report to until the connection ends. The count is a lifetime total for the tap and outlives the connection.
A pane with no tap reports zero, as does an identifier that is not one, since ControlClient.Output refuses to register a tap under one. ControlClient.Untap forgets the tap, and the count with it.
func (*ControlClient) Err ¶
func (cc *ControlClient) Err() error
Err reports why the connection ended, or nil while it is still open or if it ended cleanly.
func (*ControlClient) Events ¶
func (cc *ControlClient) Events() <-chan Event
Events returns the notification stream.
The channel is closed when the connection ends. It is buffered, and the reader never blocks on it: a consumer that stops reading loses events rather than stalling the connection — which would stall command replies and every other pane too — and is told what it lost through EventsDropped and ControlClient.Dropped.
Every notification appears here, including pane output. Calling ControlClient.Output adds a per-pane tap; it does not divert anything from this stream.
Example ¶
Handle the full notification stream. Exited is terminal and the channel closes after it; this library does not reconnect on its own, because only the caller knows whether that is wanted.
package main
import (
"context"
"fmt"
"log"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
cc, err := gotmucks.Connect(ctx, gotmucks.WithSocketName("example"))
if err != nil {
log.Fatal(err)
}
defer cc.Close()
if err := cc.SetSize(ctx, 132, 43); err != nil {
log.Fatal(err)
}
for ev := range cc.Events() {
switch e := ev.(type) {
case gotmucks.PaneOutput:
fmt.Printf("%s: %d bytes\n", e.Pane, len(e.Data))
case gotmucks.WindowAdded:
fmt.Printf("window %s appeared\n", e.Window)
case gotmucks.PanePaused:
// tmux stopped this pane because we fell behind. Nothing is lost
// until we say we are ready.
if err := cc.Resume(ctx, e.Pane); err != nil {
log.Print(err)
}
case gotmucks.EventsDropped:
log.Printf("fell behind; %d events discarded", e.Count)
case gotmucks.Exited:
log.Printf("connection ended: %v", e)
return
}
}
}
Output:
func (*ControlClient) Output ¶
func (cc *ControlClient) Output(pane PaneID) (<-chan []byte, error)
Output returns a channel carrying one pane's unescaped output bytes.
The first call for a pane registers the tap; later calls for the same pane return the same channel. The channel is closed when the connection ends. Like ControlClient.Events it is buffered and lossy rather than blocking.
A drop is reported as OutputDropped on the event stream when delivery to this pane next succeeds, and at teardown for anything still owed then. That leaves no silent loss, but it does leave a report a caller has to be listening for, on a stream that is itself lossy, and possibly not until the connection ends. ControlClient.DroppedOutput answers the same question directly at any time.
Bytes are delivered as tmux framed them, which is not line-oriented: one receive is one %output notification, not one line.
A tap lasts until ControlClient.Untap removes it or the connection ends.
An identifier that is not one is refused with ErrInvalidID. This call builds no -t, so nothing would reach tmux, but a tap registered under a name matches no %output notification and is silent for the life of the connection — which is the failure addressing by identifier exists to remove. It returns an error rather than a closed channel so that the mistake is distinguishable from a connection that has ended.
Note what the check does not buy. A well-formed identifier for a pane that does not exist is accepted and is equally silent, deliberately: a tap may be registered before the pane it names is created.
Example ¶
Stream one pane's output live.
package main
import (
"context"
"log"
"os"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
cc, err := gotmucks.Connect(ctx,
gotmucks.WithSocketName("example"),
gotmucks.WithAttach("$0"),
)
if err != nil {
log.Fatal(err)
}
defer cc.Close()
// The channel carries unescaped bytes as tmux framed them, which is not
// line-oriented: one receive is one notification, not one line.
pane, err := cc.Output("%0")
if err != nil {
log.Fatal(err)
}
for chunk := range pane {
os.Stdout.Write(chunk)
}
}
Output:
func (*ControlClient) Pause ¶
func (cc *ControlClient) Pause(ctx context.Context, pane PaneID) error
Pause stops output for a pane without waiting for it to fall behind.
func (*ControlClient) PauseAfter ¶
PauseAfter enables flow control: tmux stops sending a pane's output once the client is more than d behind, and says so with a PanePaused event. Call ControlClient.Resume to restart that pane.
With flow control on, pane output arrives as %extended-output instead of %output, so PaneOutput values gain a meaningful PaneOutput.Age and report Extended.
This is what stops a slow consumer from making tmux block a pane's process indefinitely: backpressure is a feature of the protocol rather than something the caller has to build.
A duration of zero or less clears the flag and disables flow control. tmux's resolution here is whole seconds; a shorter non-zero duration is rounded up to one second.
Example ¶
Let tmux apply backpressure rather than building it yourself. With flow control on, a pane whose output nobody is consuming is paused instead of blocking its process indefinitely.
package main
import (
"context"
"log"
"time"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
cc, err := gotmucks.Connect(ctx, gotmucks.WithSocketName("example"))
if err != nil {
log.Fatal(err)
}
defer cc.Close()
if err := cc.PauseAfter(ctx, 2*time.Second); err != nil {
log.Fatal(err)
}
// Output now arrives as %extended-output, carrying how far behind it is.
for ev := range cc.Events() {
if o, ok := ev.(gotmucks.PaneOutput); ok && o.Age > time.Second {
log.Printf("%s is %s behind", o.Pane, o.Age)
}
}
}
Output:
func (*ControlClient) Resume ¶
func (cc *ControlClient) Resume(ctx context.Context, pane PaneID) error
Resume restarts output for a pane that flow control paused.
func (*ControlClient) SetSize ¶
func (cc *ControlClient) SetSize(ctx context.Context, cols, rows int) error
SetSize sets the size of the control client, which bounds the size of the windows it is attached to.
A control client has no terminal, so tmux would otherwise use a default; setting this is what makes pane geometry predictable.
func (*ControlClient) Stderr ¶
func (cc *ControlClient) Stderr() string
Stderr returns whatever tmux wrote to standard error. It is normally empty and is worth reading when Connect or a command fails unexpectedly.
func (*ControlClient) Subscribe ¶
func (cc *ControlClient) Subscribe(ctx context.Context, name, target, format string) error
Subscribe asks tmux to report a format's value as it changes, delivered as SubscriptionChanged events.
target selects what the format is expanded for: SubscribeSession, SubscribeAllPanes, SubscribeAllWindows, or a single object via SubscribePane or SubscribeWindow. tmux expands the format once per matching object and reports a change at most once a second. Anything else is refused with ErrInvalidID: a name here would subscribe to whichever object tmux resolved it to.
Subscriptions are how a caller tracks tmux state without polling list-sessions. Re-subscribing under an existing name replaces it; use ControlClient.Unsubscribe to remove one.
The name is checked, because tmux does not check it and writes it back into every %subscription-changed line verbatim — see [checkSubscribeName].
The value is the other half of that line and cannot be checked here, because it is tmux's rather than the caller's. tmux writes it last with nothing to delimit it, so a format expanding to a value with a raw newline in it splits the notification. Measured on 3.2a, a subscription on an option holding "v1\n%exit forged" arrived as
%subscription-changed sv $0 - - - : v1 %exit forged
and the second line is outside any block, so the reader acts on it as tmux's.
The format is where that is fixed, by the same substitution FormatSpec.Arg uses on a row: on the same tmux "#{s/<newline>/ /:@opt}" kept "v1 %exit forged" on one line, and so did the nested "#{s/<newline>/ /:#{@opt}}". A format whose value can carry a newline — a path, a title, a user option — wants one of those. A raw newline in format itself is not the hazard: it is sent as tmux's own "\n" escape, which is what makes the substitution expressible here at all. scripts/probe-notify.sh asserts both.
Example ¶
Track tmux state without polling. tmux expands the format once per matching object and reports a change at most once a second.
package main
import (
"context"
"fmt"
"log"
"github.com/counterflow/gotmucks"
)
func main() {
ctx := context.Background()
cc, err := gotmucks.Connect(ctx, gotmucks.WithSocketName("example"))
if err != nil {
log.Fatal(err)
}
defer cc.Close()
err = cc.Subscribe(ctx, "cmd", gotmucks.SubscribeAllPanes, "#{pane_current_command}")
if err != nil {
log.Fatal(err)
}
for ev := range cc.Events() {
if s, ok := ev.(gotmucks.SubscriptionChanged); ok {
fmt.Printf("%s is now running %s\n", s.Pane, s.Value)
}
}
}
Output:
func (*ControlClient) Unsubscribe ¶
func (cc *ControlClient) Unsubscribe(ctx context.Context, name string) error
Unsubscribe removes a subscription. tmux reads a -B argument with no colons as a removal.
The name is checked exactly as ControlClient.Subscribe checks it, so that the two agree about what a name is: a name this refused but that one accepted could not be removed.
func (*ControlClient) Untap ¶
func (cc *ControlClient) Untap(pane PaneID)
Untap removes the tap ControlClient.Output registered for a pane and closes its channel. A later Output for the same pane registers a fresh one.
Taps are otherwise permanent, so a long-lived connection to a session that churns through panes would accumulate a channel and a buffer for every pane it ever saw. Untapping a pane that has none does nothing, and so does untapping an identifier that is not one — ControlClient.Output refuses to register a tap under one, so there can be nothing there to remove. Untap says so itself rather than leaving it to be inferred from Output.
func (*ControlClient) Version ¶
func (cc *ControlClient) Version() Version
Version reports the tmux version this connection checked at Connect time. It is the zero Version when WithoutVersionCheck was used.
func (*ControlClient) Wait ¶
func (cc *ControlClient) Wait(ctx context.Context) error
Wait blocks until the connection ends and reports why.
It returns nil for a clean exit — tmux sent %exit, or ControlClient.Close was called — and an error wrapping ErrServerExited otherwise. It reports the same thing as the Exited event, for a caller that is not reading the event stream.
type ControlError ¶
type ControlError struct {
// Command is the command line as sent.
Command string
// Number is the tmux command number the error was reported against.
Number int
// Message is the error body, with block lines joined by newlines.
Message string
}
ControlError is a control-mode command that tmux answered with %error instead of %end.
func (*ControlError) Error ¶
func (e *ControlError) Error() string
type Event ¶
type Event interface {
// contains filtered or unexported methods
}
Event is an asynchronous notification from a control-mode connection.
The interface is closed — implementations all live in this package — so a type switch over the concrete types below is exhaustive for a given release of this library. New tmux notifications appear as UnknownNotification rather than as new types, so a type switch does not silently start missing things when tmux gains a notification this library predates.
type EventsDropped ¶
type EventsDropped struct{ Count uint64 }
EventsDropped reports that the event channel was full and events were discarded.
The reader never blocks on a slow consumer — a stalled reader would stall the whole connection, including command replies and every other pane — so it drops instead, and says so here. Count is the number discarded since the last EventsDropped.
type ExitError ¶
type ExitError struct {
// Args is the argument vector as executed, excluding the binary itself.
Args []string
// Code is the process exit status.
Code int
// Stderr is the trimmed standard error of the process.
Stderr string
// Err is the underlying error from os/exec, if any.
Err error
}
ExitError is a tmux invocation that exited non-zero.
func (*ExitError) Error ¶
Error renders the exit status, then whichever of Stderr and Err has anything to say.
Err is included because a process that never started has neither a real exit status nor a stderr to explain itself: a missing binary, a permissions failure, or an argument Go will not put in an argv — a NUL in a name is the reachable one — all report "exit status -1" with an empty Stderr, and without this the only route to the reason was errors.Unwrap. A cancelled or expired context arrives the same way and renders for the same reason.
It is left out when it would only restate the line, which is what [ExitError.restatesStderr] decides. ErrNoServer and the missing-target sentinels are classified *from* Stderr, so rendering one appends this package's words for what tmux has just said in its own — on the two most common failures a caller will ever see. Nothing is lost by leaving it out: errors.Is reaches the sentinel through Unwrap either way, which is how a caller is meant to ask.
type Exited ¶
type Exited struct {
// Reason is the text tmux gave with %exit, if any.
Reason string
// Err is why the connection ended when tmux did not say. It is nil for a
// clean %exit.
Err error
}
Exited reports that the control connection has ended.
It is terminal and is the last event on the channel before it closes. A slot in the channel is held back for it alone, so a consumer that ranges over ControlClient.Events and waits for this receives it even if it fell far enough behind to lose ordinary events; ControlClient.Wait reports the same thing for a caller that is not reading the stream.
This library does not reconnect on its own: only the caller knows whether a reconnect is wanted, and re-establishing state after one is the caller's business.
type FormatSpec ¶
type FormatSpec []string
FormatSpec is an ordered list of tmux format variables to request.
Entries are normally bare variable names ("session_id", "pane_active"). An entry containing a '#' is already a format expression and is used verbatim, which allows conditionals, modifiers and the single-character forms:
FormatSpec{"pane_id", "#{?pane_dead,dead,live}", "#H"}
The name a value is looked up by in a Row is the entry as written.
Order matters for more than presentation. tmux hands some values back with a raw tab in them, and a raw tab is an extra field as far as ParseRows is concerned, so FormatSpec.Arg requests every entry but the last through a substitution that replaces one with a space. The last entry keeps its tabs, because an extra field there can be folded back into it: put the field whose value must come back byte for byte at the end, and expect a tab anywhere else to arrive as a space.
A raw newline is not an ordering question, because no position survives one: it ends the line rather than adding a field, so the row is already two rows by the time ParseRows sees it. Every entry is therefore requested through a substitution that takes a newline out, the last one included, and no column can come back carrying one.
func (FormatSpec) Arg ¶
func (s FormatSpec) Arg() string
Arg renders the spec as the value of tmux's -F flag.
Every entry is wrapped in tmux's substitution modifier so that a raw newline in the value cannot split the row, and every entry but the last so that a raw tab cannot split the column. Only a plain variable name is wrapped. Anything else the caller wrote — a format expression, a prefixed expansion such as "T:status-left" — is rendered as it stands and is the caller's own business: a substitution's operand is itself expanded, so "#{...}" nests inside one, but "#{s/<tab>/ /:#H}" expands to nothing at all on 3.2a, and turning a working column into an empty one is the worse trade.
type GlobalTarget ¶
type GlobalTarget struct{}
GlobalTarget addresses no particular object. Commands that accept it omit -t entirely, which tmux reads as "the server" or "the global option set" depending on the command.
func (GlobalTarget) TargetArg ¶
func (GlobalTarget) TargetArg() string
TargetArg implements Target. It is always empty; callers building argv must check for the empty string and omit -t rather than passing it blank.
type Key ¶
type Key struct {
// contains filtered or unexported fields
}
Key is one thing to send to a pane.
Build keys with Named, Literal or Hex. The zero Key is an empty named key and is rejected by Client.SendKeys.
func Enter ¶
func Enter() Key
Enter is the return key, the most common thing to send after text.
It is a function rather than a variable so that nothing else in the process can redefine what this package's callers mean by Enter.
func Hex ¶
Hex sends raw bytes, tmux's send-keys -H. Use it for input that is not text: control bytes, or a specific encoding this package should not guess at.
func Literal ¶
Literal is text sent exactly as written, with no key-name lookup. This is tmux's send-keys -l.
func Named ¶
Named is a tmux key name, looked up in tmux's key table: "Enter", "Escape", "C-c", "M-x", "Up", "F1". A single printable character is also a valid key name.
Use Literal for text that should be sent as-is; a string like "C-c" sent as a named key is an interrupt, whereas as literal text it is three characters.
type LayoutChanged ¶
type LayoutChanged struct {
Window WindowID
// Layout is the layout string.
Layout string
// Visible is the visible layout, when tmux sends one.
Visible string
// Flags is the window flags field, when tmux sends one.
Flags string
}
LayoutChanged reports a window's new layout.
type Message ¶
type Message struct{ Text string }
Message is a message tmux would have shown in the status line.
type NewSessionOptions ¶
type NewSessionOptions struct {
// Name is the session name. Empty lets tmux pick the next number.
//
// A name is a convenience for humans reading tmux output; it is not how
// this package addresses the session afterwards.
//
// tmux expands it as a format before storing it, so a '#' in it is
// doubled to keep it a '#' — see [escapeFormat]. [Session.Name] reads
// back what was given here.
//
// A ':' or a '.' is refused: tmux rewrites either to '_' in a session
// name and reports nothing, so there is no name to read back. See
// [checkSessionName]. [NewSessionOptions.WindowName] takes both, since
// tmux only does this to sessions.
Name string
// StartDir is the working directory for the session's first window.
//
// tmux expands it as a format too — the fifth argument that does, beside
// the four names — so a '#' in it is doubled here as well, see
// [escapeFormat]. Without that a path containing "#H" silently became a
// different path: verified on 3.2a, where tmux expands it, finds no such
// directory, falls back to the home directory, exits 0 and says nothing on
// stderr. The session is created and the pane is somewhere else.
//
// A working directory is the kind of value a program takes from a config
// file, a checkout path or a request, which is what makes this reachable
// by data rather than only by a caller writing a format on purpose.
StartDir string
// Env is set in the session environment, tmux's new-session -e. It
// requires tmux 3.2 or newer, which is this package's floor.
Env map[string]string
// Command is the program to run in the first window, as an argument
// vector. It is passed to tmux after a literal "--" so the elements
// arrive as separate arguments.
//
// A vector of two or more elements is executed directly, so shell
// metacharacters anywhere in it are inert. A vector of exactly one
// element is not: tmux hands a lone argument to the shell, so
// {"rm -rf /tmp/x; reboot"} would run both commands.
//
// Rather than let that promise quietly fail, a single-element vector is
// required to be a bare command word — no whitespace and no shell
// metacharacters. To run a shell fragment, say so:
//
// Command: []string{"sh", "-c", "make && ./run"}
Command []string
// WindowName names the session's first window.
//
// It is escaped twice on the way out, where [NewSessionOptions.Name] is
// escaped once. tmux expands "-n" as a format like the others, but it is
// the one name argument it then stores without applying vis(3) — verified
// on 3.2a — so the escaping tmux would have done is done here instead.
// That is what makes [Window.Name] read back what was given, whichever
// call named the window.
WindowName string
// Width and Height set the size of the detached session, as "new-session
// -x" and "-y". tmux defaults to 80x24 for a session with no attached
// client.
//
// They are a request rather than a guarantee, and the difference is worth
// knowing before relying on one. They size the *session*; a window is
// sized by the "window-size" option, whose default "latest" means the size
// of the most recently attached client. Where tmux considers that it has
// never seen a client it falls back to the session size and the request is
// honoured; where it considers that it has — which includes environments
// with no terminal at all, such as a CI runner, where every window comes
// back at 80x24 less the status line — the client's size wins and this is
// ignored. Nothing is reported when that happens.
//
// Read the size back from the window if it matters. Forcing it is possible
// with "window-size manual" but is not done here: it pins the window for
// good, and a control client that attaches later and calls
// [ControlClient.SetSize] then cannot resize it, which is a worse trade
// than an unhonoured request. scripts/probe-size.sh measures all of this.
Width, Height int
}
NewSessionOptions configures Client.NewSession.
Sessions are always created detached. Attaching a session requires a terminal, which a library driving tmux programmatically does not have, and is out of this package's scope.
type Option ¶
type Option func(*config)
Option configures a Client or a ControlClient. The two share a configuration type so that a socket chosen for one-shot commands can be reused verbatim for a control connection.
Options that only make sense for one of the two are documented as such and are ignored by the other.
func WithAttach ¶
WithAttach makes a control connection attach to an existing session rather than create one. Equivalent to WithControlArgs("attach-session", "-t", id).
Being equivalent to one, it loses to one: a WithControlArgs anywhere in the same option list replaces the whole startup command, and the session given here is then neither attached to nor checked.
Control-mode only.
func WithBinary ¶
WithBinary sets the tmux executable to run. It may be a bare name resolved through PATH or an absolute path. Defaults to "tmux".
The test suite uses this to point the client at a stand-in binary.
func WithCloseTimeout ¶
WithCloseTimeout bounds how long ControlClient.Close waits for tmux to exit after the connection is detached before the process is killed.
Control-mode only.
func WithControlArgs ¶
WithControlArgs replaces the tmux command a control connection issues on startup. The default is "new-session", which creates a session and attaches the control client to it.
It replaces the whole command, so it also replaces the one WithAttach would have built: given both, this one wins and the attach is not made.
These arguments are passed to tmux as written. That includes a -t, which makes this the one Option through which an object may be addressed by name: WithAttach("work") is refused with ErrInvalidID, while WithControlArgs("attach-session", "-t", "work") attaches to whichever session that name reached. It is allowed for the same reason the three calls that take a command line are — ControlClient.Do, ControlClient.DoArgs and Client.QueryArgs — a command line assembled by the caller is the caller's own, and parsing it here to disagree with it would be guesswork; but the addressing scheme is not enforced through it. Prefer WithAttach, or build the -t from an identifier.
"As written" includes the trailing ';' that tmux's argv parser reads as a command terminator, which Client escapes on the one-shot path and this does not: an element ending in one ends the command there, and an element that is exactly ";" separates two. Write "\;" to mean the byte.
Control-mode only.
func WithDoubleControlMode ¶
func WithDoubleControlMode() Option
WithDoubleControlMode starts the control connection with tmux's -CC rather than -C.
-CC additionally turns off canonical mode on the terminal, which means tmux calls tcgetattr on its standard input. With a pipe there — which is how a library drives tmux — that call fails and tmux exits immediately:
tcgetattr failed: Inappropriate ioctl for device
So -C is the default here, despite -CC being the flag documented for applications: -CC is for an application that has given tmux a terminal, such as a terminal emulator embedding a tmux session. Pass this option only if you have arranged a pty for tmux's standard input yourself.
Control-mode only.
func WithEnv ¶
WithEnv adds "KEY=VALUE" entries to the environment of every tmux process this client starts. By default the parent environment is inherited and these are appended to it; see WithoutParentEnv.
func WithEventBuffer ¶
WithEventBuffer sets the capacity of the channel returned by ControlClient.Events. A larger buffer tolerates a slower consumer before events start being dropped. Values below 1 are ignored.
Control-mode only.
func WithOutputBuffer ¶
WithOutputBuffer sets the capacity of each per-pane channel returned by ControlClient.Output. Values below 1 are ignored.
Control-mode only.
func WithSocketName ¶
WithSocketName sets the server socket name, tmux's -L flag. The socket is created in tmux's own directory.
Tests and any program that must not disturb a developer's own sessions should always set this or WithSocketPath.
func WithSocketPath ¶
WithSocketPath sets the server socket path, tmux's -S flag. It takes precedence over WithSocketName.
func WithoutParentEnv ¶
func WithoutParentEnv() Option
WithoutParentEnv runs tmux with only the entries given to WithEnv, rather than inheriting this process's environment. On its own, with no WithEnv beside it, that means an empty environment — see [config.environ], where the difference between an empty environment and an inherited one is the difference between an empty slice and a nil one.
func WithoutVersionCheck ¶
func WithoutVersionCheck() Option
WithoutVersionCheck skips the tmux version check performed when a control connection is opened. Intended for tests against stand-in binaries, and for the one real build the check is deliberately strict about: a "next-" tmux sorts before the release it is heading for, so next-3.2 does not satisfy the 3.2 floor even though it may well have what the floor is there for. See Version.Compare.
type OptionScope ¶
type OptionScope int
OptionScope selects which of tmux's option tables a command addresses.
It selects less than the name suggests, and the difference is the reason Client.SetOption and Client.ShowOptions are not inverses. tmux does keep separate tables for server, session, window and pane options — but for a name it knows it picks between them by the *name*, not by the flag, exactly as it does for a hook name. Measured on 3.2a:
set-option -t <session> -- remain-on-exit on // a window option show-options -t <session> -- remain-on-exit // remain-on-exit on show-options -t <session> // does not list it show-options -w -t <session> // remain-on-exit on
Swept rather than sampled: over all eighty-seven names in this binary's two global tables, "show-options -g -- name" and "show-options -g -w -- name" answer identically, and over all seventeen server names so do "show-options -s -- name" and "show-options -- name". The flag is ignored.
Three things do obey it, and they are why this type exists:
- A user option, the ones beginning with '@'. It has no entry in tmux's table, so there is no name to follow and the flag is all there is: "set-option -w -t <session> @a v" is invisible to "show-options -t <session> -- @a".
- The listing form with no name, which is what Client.ShowOptions uses. It reads the one table the flag names, whatever is in the others.
- '-p', for the few names tmux files under window *and* pane, of which remain-on-exit is one — see Client.SetRemainOnExit. That is the single case where the flag alters a known name's table rather than being ignored by it.
'-g' is a fourth thing and a different kind, because it does not choose a table so much as choose the global counterpart of whichever table the name already chose: measured on 3.2a, "set-option -g -- remain-on-exit on" is listed by "show-options -g -w" and not by "show-options -g".
So a wrong scope on a built-in name does not, as this comment used to claim, make a set-option succeed and change nothing. It sets the option, in the table tmux chose; what it changes is which listing can find it afterwards.
const ( // ScopeSession passes no scope flag, which is tmux's default for // set-option and show-options and reaches session options. ScopeSession OptionScope = iota // ScopeServer is tmux's -s, which reaches server options. ScopeServer // ScopeGlobal is tmux's -g, the global table for the option's own type. ScopeGlobal // ScopeWindow is tmux's -w, which reaches window options. ScopeWindow // ScopePane is tmux's -p, which reaches pane options. ScopePane // ScopeGlobalWindow is tmux's -g and -w together, the global window // option table. ScopeGlobalWindow )
func (OptionScope) String ¶
func (s OptionScope) String() string
String names the scope for diagnostics.
type OutputDropped ¶
OutputDropped reports that a per-pane channel from ControlClient.Output was full and pane bytes were discarded. The same reasoning as EventsDropped applies.
type Pane ¶
type Pane struct {
// ID is the stable identifier, "%0".
ID PaneID
// Window and Session are the pane's containers.
Window WindowID
Session SessionID
// Index is the pane's position in its window. Indexes renumber.
Index int
// Active reports whether this is the window's current pane.
Active bool
// Dead reports that the pane's process has exited and the pane is being
// kept by the remain-on-exit option.
Dead bool
// PID is the process id of the pane's immediate child.
PID int
// Width and Height are the pane's dimensions in cells.
Width, Height int
// CurrentCommand is the name of the running foreground command. tmux takes
// it from the operating system, so a binary whose file name contains a tab
// puts one here; it arrives as a space, for the reason [FormatSpec] gives.
CurrentCommand string
// CurrentPath is the working directory of the running command, when tmux
// can determine it. It is the one field here that keeps a raw tab, since
// it is last in the spec and an overflowing final column folds back.
//
// A newline in it arrives as a space. Nothing can be last enough to
// survive one — it ends the format line rather than adding a field — so
// [FormatSpec.Arg] asks tmux to substitute it out of this column too. It
// used to be left in, and one pane started in a directory whose name
// contained a newline failed [Client.ListPanes] for every pane on the
// server.
CurrentPath string
// Title is the pane title.
Title string
}
Pane is a tmux pane.
type PaneContinued ¶
type PaneContinued struct{ Pane PaneID }
PaneContinued reports that a paused pane's output has resumed.
type PaneID ¶
type PaneID string
PaneID is a tmux pane identifier, of the form "%0".
func ParsePaneID ¶
ParsePaneID validates s and returns it as a PaneID.
type PaneModeChanged ¶
type PaneModeChanged struct{ Pane PaneID }
PaneModeChanged reports that a pane entered or left a mode, such as copy mode.
type PaneOutput ¶
type PaneOutput struct {
Pane PaneID
Data []byte
// Extended reports that this arrived as %extended-output, which replaces
// %output once flow control is enabled with
// [ControlClient.PauseAfter].
Extended bool
// Age is how far behind the data is. Meaningful only when Extended is
// set; zero otherwise.
Age time.Duration
}
PaneOutput carries bytes a pane produced.
Data is unescaped and owned by the receiver: the reader allocates a fresh slice per delivery, so it is safe to retain.
Every pane's output appears here. ControlClient.Output additionally taps one pane's bytes into a dedicated channel; it does not remove them from this stream.
func (PaneOutput) String ¶
func (e PaneOutput) String() string
String renders the event for logs and test failures.
type PanePaused ¶
type PanePaused struct{ Pane PaneID }
PanePaused reports that tmux has stopped sending a pane's output because the consumer fell behind. Call ControlClient.Resume to restart it.
This is tmux applying backpressure on the caller's behalf: a slow consumer cannot make tmux block the pane's process indefinitely.
type PasteBufferChanged ¶
type PasteBufferChanged struct{ Buffer string }
PasteBufferChanged reports a change to a paste buffer.
type PasteBufferDeleted ¶
type PasteBufferDeleted struct{ Buffer string }
PasteBufferDeleted reports a deleted paste buffer.
type ProtocolError ¶
type ProtocolError struct {
// Line is the offending input, verbatim.
Line string
// Reason describes what was wrong with it.
Reason string
}
ProtocolError is a control-mode line the parser could not make sense of. It is reported as an event rather than tearing the connection down, because an unrecognised line is far more likely to be a newer tmux than a broken stream.
func (*ProtocolError) Error ¶
func (e *ProtocolError) Error() string
type Reply ¶
type Reply struct {
// Number is the command number tmux assigned. It identifies the block on
// the wire; it is not what the reply was matched by, since the numbers
// are neither predictable nor contiguous.
Number int
// Time is the timestamp tmux reported on the closing %end or %error.
Time time.Time
// Flags is the flags word from the block terminator.
Flags int
// Output is the block body, one entry per line, with no trailing blank.
Output []string
}
Reply is the result of a control-mode command.
func (Reply) Rows ¶
func (r Reply) Rows(spec FormatSpec) ([]Row, error)
Rows parses the reply's output against a format spec, so that a control-mode query is typed the same way a one-shot Client.Query is.
type Row ¶
type Row struct {
// contains filtered or unexported fields
}
Row is one line of format output, addressed by the spec entries that produced it.
Accessors that convert return an error rather than panicking or yielding a zero value silently, because a conversion failure means tmux returned something this package did not predict, and that is worth surfacing.
func ParseRows ¶
func ParseRows(spec FormatSpec, lines []string) ([]Row, error)
ParseRows splits raw format output into rows against a spec.
It is exported so that output captured elsewhere — a control-mode reply, for instance — can be parsed with the same rules as a one-shot command.
Too few fields is an error: the row cannot be aligned with the spec at all, and guessing which column is missing would be worse than saying so. Too many are folded into the last field, because tmux does not escape the tab in every value it expands — verified on 3.2a, where a pane whose working directory contains one puts a raw tab in pane_current_path. Folding is only correct if no earlier column can overflow, which is what FormatSpec.Arg arranges; a caller who builds the -F template some other way and hands the output here owes itself the same discipline, or an earlier tab will shift every column after it without saying so.
A short row is that caller's other way of arriving here. tmux writes a raw newline in a value out as it stands, which ends the line and leaves the remainder of the value as a row of its own with too few fields in it; the substitution FormatSpec.Arg wraps every column in is what keeps that from happening, and a template built by hand has to do the same.
An empty spec is a caller error rather than an unusual line: there is nothing for a row to be aligned against.
A blank line is skipped for a spec of two columns or more, where it cannot be a row: any real row carries its separators, so a row of nothing but empty values is a line of tabs rather than an empty line. For a single-column spec the same line is a row whose one value is empty — a pane with no title is an ordinary thing — and skipping it would hand back fewer rows than there are objects with no way to tell which one went missing, which is the count callers align everything else against. The cost is that a trailing blank line becomes a row for a one-column spec; tmux does not write one, and splitLines drops the trailing newline that would otherwise look like one.
func (Row) Bool ¶
Bool returns a field parsed as a tmux flag. tmux writes "1" and "0" for boolean formats; "on"/"off"/"yes"/"no" are also accepted because option values use those spellings. A missing or empty field is false.
func (Row) Int ¶
Int returns a field parsed as a decimal integer. A missing or empty field is zero without error, because tmux writes an empty string for a variable that does not apply to the object being listed.
type Session ¶
type Session struct {
// ID is the stable identifier, "$0". Address sessions by this, never by
// Name: names are neither unique nor stable.
ID SessionID
// Name is the current session name, as it was set. tmux stores it escaped
// with vis(3) and "#{session_name}" expands to the escaped form, so it is
// decoded here for the reason [Window.Name] gives; unlike a window name
// there is no path that skips the escaping, so this one is always exact.
//
// It can still differ from a name this package did not set, because tmux
// rewrites a ':' or a '.' in a session name to '_' before storing it and
// no decoding undoes that. The calls here refuse those two bytes rather
// than hand back a name nobody asked for — see [checkSessionName] — but a
// session another program named is reported as tmux holds it.
Name string
// Windows is the number of windows in the session.
Windows int
// Created is when the session was created.
Created time.Time
// Activity is the time of the last activity in the session.
Activity time.Time
// Attached is the number of clients attached to the session.
Attached int
}
Session is a tmux session.
func (Session) IsAttached ¶
IsAttached reports whether any client is attached to the session.
type SessionChanged ¶
type SessionChanged struct {
Session SessionID
// Name is the session name as it was set, decoded from the form tmux
// stores — see the Names section of the package documentation.
Name string
}
SessionChanged reports that the control client's attached session changed.
type SessionID ¶
type SessionID string
SessionID is a tmux session identifier, of the form "$0".
func ParseSessionID ¶
ParseSessionID validates s and returns it as a SessionID.
func (SessionID) Ordinal ¶
Ordinal returns the numeric part of the identifier. It reports an error if the identifier is malformed.
type SessionRenamed ¶
type SessionRenamed struct {
Session SessionID
// Name is the new name as it was set, decoded from the form tmux stores.
Name string
}
SessionRenamed reports that the attached session was renamed.
type SessionWindowChanged ¶
SessionWindowChanged reports that a session's current window changed.
type SessionsChanged ¶
type SessionsChanged struct{}
SessionsChanged reports that a session was created or destroyed. It carries no detail; re-read the session list if the detail matters.
type SubscriptionChanged ¶
type SubscriptionChanged struct {
// Name is the subscription name given to Subscribe.
Name string
// Session, Window and Pane identify the object the value is for.
// Whichever do not apply are empty.
Session SessionID
Window WindowID
Pane PaneID
// WindowIndex is the window index tmux includes for window
// subscriptions, or -1 when absent.
WindowIndex int
// Value is the expanded format, exactly as tmux wrote it. Unlike the
// names on the other events it is not decoded: it is the caller's own
// format, expanded, rather than something tmux stored escaped.
//
// tmux writes it as the last field of the notification with nothing to
// delimit it, so a format whose value can contain a raw newline splits the
// line — see [ControlClient.Subscribe], which says how to take one out.
Value string
}
SubscriptionChanged reports a new value for a format subscription created with ControlClient.Subscribe.
Subscriptions are how a caller tracks tmux state without polling. tmux expands the format once per matching object and sends this at most once a second per subscription.
type Target ¶
type Target interface {
// TargetArg is the literal value passed to tmux's -t flag.
TargetArg() string
// contains filtered or unexported methods
}
Target is anything that can be named in a tmux -t argument. The three ID types implement it, as does GlobalTarget for server- and global-scoped commands.
The interface is closed: it has unexported methods so that only this package can add targets, which keeps -t construction total.
type UnknownNotification ¶
type UnknownNotification struct {
// Name is the notification name without its leading '%'.
Name string
// Args is everything after the name.
Args string
}
UnknownNotification is a notification this library does not recognise, reported rather than discarded so that a newer tmux is visible instead of silent.
type Version ¶
type Version struct {
// Major and Minor are the numeric components.
Major, Minor int
// Suffix is the trailing letter revision, "a" in "3.2a". Empty if absent.
Suffix string
// Next reports a development build, the "next-" prefix in "next-3.4". It
// is the tree heading towards that release rather than the release, so it
// sorts before it; see [Version.Compare].
Next bool
// Raw is the string this was parsed from.
Raw string
// Unknown reports that no version number could be found, as for builds
// that report "master".
Unknown bool
}
Version is a tmux version.
tmux versions are a major and minor number with an optional letter suffix ("3.2a"), and development builds carry a "next-" prefix ("next-3.4"). Unnumbered builds ("master") parse as Version.Unknown.
func MinimumVersion ¶
func MinimumVersion() Version
MinimumVersion is the oldest tmux this package supports.
The floor is set by "new-session -e", which arrived in tmux 3.2. Everything else used here is older, so 3.2 is the single constraint.
It is a function rather than a variable because it is what Connect and Client.CheckVersion gate on, and a variable could be moved by anything else in the process — including a dependency — with the version check then quietly passing something it was written to refuse.
func ParseVersion ¶
ParseVersion reads the output of "tmux -V", with or without the leading "tmux " word.
func (Version) Compare ¶
Compare orders two versions: -1 if v is older than w, 0 if equal, +1 if newer. The letter suffix is compared lexically, so 3.2 < 3.2a < 3.2b, and a hyphenated suffix is a pre-release of the version it hangs off rather than a revision of it, so 3.4-rc1 < 3.4 < 3.4a.
A "next-" build is the development tree heading towards a release, not that release, so next-3.2 < 3.2 for the same reason 3.2-rc1 does. This is what the prefix is for: a next-3.2 predates 3.2 and so may predate anything 3.2 added, and reading it as 3.2 would let it through a check for a feature it does not have. Where that is not wanted — a tip-of-tree build known to be new enough — WithoutVersionCheck is the way past it.
An unknown version is treated as newer than any known one: builds that do not report a number are development builds of the tip, and refusing to run against them would be the wrong default. That is not in tension with the rule above; "master" says nothing about which release it is heading for, while "next-3.2" says exactly that.
func (Version) EscapesDollarOnWrite ¶
EscapesDollarOnWrite reports a tmux that adds a backslash before a '$' when it stores a value, rather than only when it prints one.
This is a bug in 3.4 alone: 3.2a and 3.5a both store the bytes they were given. On 3.4 "set-option @x 'a$b'" is held as "a\$b", and the two readers that escape least agree it is in storage rather than in the printing — "show-options -v" and a "#{@x}" expansion both return the backslash, where on the other two releases both return the value. A '$' at the very end is untouched, since nothing could follow it to be read as a variable, and a backslash already present is doubled: "a\$b" is held as "a\\$b".
That doubling is what makes it correctable rather than fatal. The mapping is exactly one vis(3) pass, so it is injective and [visDecode] inverts it, and the readers therefore decode one extra time when talking to 3.4. Without that a caller reading a value, editing it and writing it back gains a backslash every cycle, and a name is worse: tmux expands the "$HOME" that the lost backslash was protecting.
Measured rather than read: scripts/probe-dollar.sh asks a binary the same question in the four positions round ten established are not interchangeable, and [TestUndoDollarEscape] pins the inverse against what it recorded from a real 3.4.
type Window ¶
type Window struct {
// ID is the stable identifier, "@0".
ID WindowID
// Session is the session the window belongs to.
Session SessionID
// Index is the window's position in its session. Indexes renumber; do not
// address windows by them.
Index int
// Name is the current window name, as it was set.
//
// tmux stores a name escaped with vis(3) and "#{window_name}" expands to
// the escaped form, so the raw field is not the name: a window renamed to
// "$HOME" reports "\$HOME" and one renamed to a tab reports "\t". It is
// decoded here, which is exact in both directions — a window genuinely
// named backslash-t-b is stored as "\\tb" and decodes back to itself.
//
// The one name that does not survive is one set by another program
// through new-window -n or new-session -n, which is the single path tmux
// does not escape — verified on 3.2a. A plain name is unaffected; one
// containing a backslash or a raw tab is not, the tab because
// [FormatSpec] has to replace it with a space before it splits the
// column. This package escapes the name it passes to -n itself, so a
// window it created is not affected either.
Name string
// Active reports whether this is the session's current window.
Active bool
// Panes is the number of panes in the window.
Panes int
// Layout is tmux's layout string for the window.
Layout string
// Width and Height are the window's dimensions in cells.
Width, Height int
}
Window is a tmux window.
Windows are exposed for reading and for addressing other commands. Creating windows and manipulating layouts are deliberately absent: layout management is out of scope, and adding those calls later is additive whereas removing them would not be.
type WindowClosed ¶
type WindowClosed struct{ Window WindowID }
WindowClosed reports a window that has gone.
type WindowID ¶
type WindowID string
WindowID is a tmux window identifier, of the form "@0".
func ParseWindowID ¶
ParseWindowID validates s and returns it as a WindowID.
type WindowPaneChanged ¶
WindowPaneChanged reports that a window's active pane changed.
type WindowRenamed ¶
type WindowRenamed struct {
Window WindowID
// Name is the new name as it was set, decoded from the form tmux stores.
// It agrees with [Window.Name] for the same window, which is what lets a
// caller list once and then follow these events.
Name string
}
WindowRenamed reports a window's new name.
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
internal
|
|
|
ctlparse
Package ctlparse classifies and decodes the lines of a tmux control-mode stream.
|
Package ctlparse classifies and decodes the lines of a tmux control-mode stream. |
|
escape
Package escape implements the octal escaping tmux applies to pane output in control mode.
|
Package escape implements the octal escaping tmux applies to pane output in control mode. |
|
faketmux
Package faketmux is a stand-in for the tmux binary, used by the test suite.
|
Package faketmux is a stand-in for the tmux binary, used by the test suite. |