ver = _tmux('-V')
ver'tmux 3.7b'
Everything fastmux does happens by shelling out to the tmux binary, which is the one true API to a tmux server. The foundation is a small runner that executes one tmux command and either returns its stdout or raises.
Raised when a tmux command fails or a target cannot be resolved.
_tmux takes the command as separate arguments (no shell involved), passes optional input on stdin (used later for loading paste buffers), and strips the trailing newline tmux prints. Any failure becomes a TmuxError carrying tmux’s own message. tmux -V needs no running server, so it makes a good first check:
tmux answers questions through format strings: every list-* and display-message command takes -F with #{variable} placeholders. So fastmux describes each concept once, as a mapping of field name to format variable, and parses the tab-separated answer into a typed dict.
Sessions, windows, and panes all have server-unique ids ($1, @1, %1) which never change or get reused. Names and indexes are ambiguous as -t targets (a session auto-named 5 can resolve as a window index!), so fastmux keys every object on its id and only ever targets ids internally.
A field spec is used twice: _fmt renders it as the -F argument, and _parse types the reply. The pane title comes last since it is the one field that may itself contain tabs. scroll_position is blank except in copy-mode, so scroll_pos types as int-or-None rather than int. Some tmux versions report an unknown -t target as success with every field empty rather than as an error, so _get turns that reply into the TmuxError it should have been.
A session is tmux’s top-level unit: a named group of windows with its own lifecycle, which clients attach to. All fastmux objects follow the same pattern: an AttrDict holding the fields queried from tmux, a fetch classmethod that builds one from any target, and refresh to re-query in place. The repr matches what tmux ls prints.
def new_session(
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
name:NoneType=None, # Session name; default: tmux auto-numbers
width:NoneType=None, # Terminal width in columns
height:NoneType=None, # Terminal height in rows
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
remain:bool=False, # Keep dead panes around (inspectable, with exit status) instead of destroying them
):Start a detached tmux session and return its Session
A tmux session; fields from _sess_f, keyed on the immutable session id
Sessions are created detached (-d), so making one never steals the terminal you are working in. We’ll create one now running a small script that prints numbered lines then waits. It will be the playground for the rest of this notebook.
Shell command that attaches a terminal to this session
Rename this session, returning it
Kill this session and everything in it
Destructive operations raise a sys.audit event, so audit-hook based sandboxes can gate them. In-place operations return self for chaining.
A pane is one terminal: a pty running a command, with a visible screen and a scrollback transcript. Everything you can read or type happens in a pane. Because a pane knows its geometry and cursor, we can treat its whole transcript (history plus visible screen) as a list of lines, addressed absolutely from line 0.
A tmux pane; fields from _pane_f, keyed on the immutable pane id
'fastmux-demo:1.1'
target is the pane’s address in standard tmux syntax (session:window.pane), so it can be pasted into any tmux -t command.
in_mode and scroll_pos report copy-mode state — the one thing capture and screen cannot show, since tmux always captures the underlying pane, never the copy-mode overlay. A driver that sends a wheel gesture asserts arrival with in_mode, and reads how far up the view sits with scroll_pos (lines above the live position; None outside copy-mode). For queries not worth a curated field, fmt evaluates any tmux format string on the pane.
assert not p.in_mode and p.scroll_pos is None
_tmux('copy-mode','-t',p.id)
assert p.refresh().in_mode and p.scroll_pos == 0
_tmux('send-keys','-t',p.id,'-X','scroll-up')
test_eq(p.refresh().scroll_pos, 1)
_tmux('send-keys','-t',p.id,'-X','cancel')
assert not p.refresh().in_mode
test_eq(p.fmt('#{pane_id}'), p.id)A Capture of transcript lines start:end (absolute; end=None means up to the cursor)
Captured transcript lines plus their absolute range and source pane
tmux numbers capture lines relative to the top of the visible screen (scrollback is negative), so capture converts from absolute transcript lines. The Capture repr is the text itself with a source footer:
Return repr(self).
The visible screen with ANSI escape sequences
The visible screen as plain text
A pane’s repr is its current screen, so displaying one is like glancing at that terminal:
Capture the last lines transcript lines, recording the pane’s last-seen state
Transcript line i as a str, a Capture for a slice; str keys keep normal dict access
Return len(self).
Indexing and slicing read the transcript like a list of lines, in the same coordinates tmux itself uses, so p[-200:] is the last 200 lines including scrollback. Since Pane is a dict underneath, string keys still do dict lookup (p['id']), while ints and slices read the transcript.
capture and display read the transcript, which ends at the cursor — right for shell work, wrong for full-screen apps, which park the cursor mid-screen and paint below it (completion menus, status rows). screen captures the visible viewport regardless of cursor position, with geometry and cursor in its footer, and the cursor cell overlaid as ▮ in the repr (the text/lines fields stay unmarked). styles=True keeps the SGR text attributes that carry UI meaning — dim ghost text, reversed selections — while stripping color codes, which in a syntax-highlighting app outnumber everything else.
A Screen of the visible viewport (rows below the cursor included), recording the pane’s last-seen state
The visible screen plus geometry, cursor position, and source pane
tui = new_session([sys.executable,'-u','-c',
"import time\nprint('\x1b[32mdoc\x1b[0m')\nprint('\x1b[2mmenu\x1b[0m\x1b[F', end='', flush=True)\ntime.sleep(60)"],
width=30, height=6)
time.sleep(0.5)
tp = Pane.fetch(tui.id)
assert 'menu' not in tp.display().text # the transcript stops at the cursor: the menu row is below it
tp.screen(styles=True)fastmux writes to a pane two ways: send pastes literal text through a tmux buffer (no key-name interpretation, so any characters are safe), and send_keys sends tmux key names like Enter or C-c. Both then poll, waiting up to wait_ms for the pane to differ from its last-seen state and returning the latest Capture, so the common send-then-read round trip is one call.
Wait up to wait_ms for the pane to differ from its last-seen state (returning at once if it already does) – or, with until, for the capture to match that regex instead. With settle_ms, then keep sampling until the pane has stopped changing for that long (running at most settle_ms past the deadline). On timeout the latest capture is returned regardless. Capture is the last lines of transcript, or the viewport with screen=True.
Let’s drive a stdin-echo process end to end, sending text and getting the acknowledgement back in the same call:
Wait for this pane’s command to exit, returning its status (None on timeout)
Send Ctrl-C to this pane, then poll
Send tmux key names to this pane, then poll
Paste chars into this pane literally, then poll
When the process exits, the pane dies but (with remain-on-exit set) stays inspectable, and wait returns the exit status:
poll doesn’t just wait for the pane to change after the call: it waits for the pane to differ from the last state a capture showed you. Each display (and so each send, send_keys, interrupt, and poll, which all return one) records the pane’s last-seen state, and poll returns as soon as the pane differs from it. So output that arrived while you weren’t watching satisfies the next poll immediately, rather than making it wait for yet another change:
Two refinements cover the patterns that plain change-detection gets wrong. until= waits for the capture to match a regex rather than merely differ — the way to wait out a program’s startup before sending it input. settle_ms= waits for the pane to stop changing: poll returns at the first observed change, so a capture during a burst of output shows a half-painted frame; settling returns the frame the burst ended on.
staged = new_session([sys.executable,'-u','-c','import time\n'
'time.sleep(1)\n'
'print("READY")\n'
'for i in range(5): print(f"burst {i}"); time.sleep(0.15)\n'
'time.sleep(600)'], width=60, height=12)
sp = Pane.fetch(staged.id)
t0 = time.monotonic(); c = sp.poll(wait_ms=5000, until='READY'); dt = time.monotonic()-t0
assert 'READY' in c.text and dt < 3 # returned on the match, not the deadline
c = sp.poll(wait_ms=5000, settle_ms=500) # the burst is mid-flight now
staged.kill()
assert 'burst 4' in c.text # settled past the whole burst, not the first changeTerminal apps that enable SGR mouse reporting receive clicks as escape sequences on stdin, so a synthetic click is just bytes — but composing them by hand means counting screen rows and remembering that a real click is a press and a release (apps see the release; sending only the press masks bugs). click does both halves correctly, and can find its own coordinates: pass a 1-based (col,row), or a string (literal) or compiled regex located on the current screen. wheel sends scroll events (SGR buttons 64/65).
Send n SGR wheel events (up for positive n, down for negative), then poll with poll_kw
Send an SGR mouse click (press+release) to this pane’s application, then poll with poll_kw. target is a 1-based (col,row), or a str (literal) or compiled regex located on the current screen – the click lands on the bottom-most match’s first cell (screens grow downward: the newest occurrence is the live one). Raises ValueError if it is not on screen.
peek = new_session([sys.executable,'-u','-c',
"import sys, tty; tty.setcbreak(0)\n"
"print('alpha [beta] gamma')\n"
"while True: print(repr(sys.stdin.buffer.read1(64)))"],
width=60, height=12, remain=True)
pk = Pane.fetch(peek.id)
r = pk.click((5, 2), wait_ms=2000)
assert r"\x1b[<0;5;2M\x1b[<0;5;2m" in r.text # press and release, at the given cell
r = pk.click('[beta]', wait_ms=2000) # a literal str: regex chars are no trap
assert r"\x1b[<0;7;1M" in r.text # found on row 1, col 7
r = pk.click(re.compile(r'gam+a'), wait_ms=2000)
assert r"\x1b[<0;14;1M" in r.text
r = pk.wheel(2, wait_ms=2000)
assert r.text.count(r"\x1b[<64;1;1M") >= 1 and '65;' not in r.text
with expect_fail(ValueError, contains='not on screen'): pk.click('nope')
peek.kill()This session’s active pane
All panes in this session
Panes shown as tmux list-panes-style summary lines
A Panes collection reprs as summary lines rather than screens (a dozen full screens would be unreadable). The line format mirrors tmux list-panes, with the pane title appended when it carries information (tmux defaults it to the hostname, which doesn’t):
A window groups panes into one screenful, like tabs in a terminal emulator. Its repr is its tmux list-windows-style summary line followed by its panes.
def new_window(
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
name:NoneType=None, # Window name; default: tmux auto-names from the command
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
remain:bool=False, # Keep dead panes around instead of destroying them
focus:bool=False, # Make it the session's current window
):Create a window in this session, returning the new Window
This session’s windows
Windows shown with their pane listings
A tmux window; fields from _win_f, keyed on the immutable window id
Like sessions, windows are created without stealing focus unless you pass focus=True, since a script composing a layout shouldn’t yank the user’s cursor around. (remain is a per-window setting; panes made by splitting inherit it from their window.)
Apply a tmux layout (even-horizontal, tiled, …), returning this window
Make this the session’s current window, returning it
Rename this window, returning it
Resize this window (and so its panes) to absolute width/height, returning it (tmux >= 2.9)
Kill this window and its panes
Window.resize is the resize that works in a detached single-pane session. Pane.resize is clamped by the window geometry there, so it silently no-ops; resizing the window reaches the pane and delivers its SIGWINCH, which is what you want when driving a full-screen app headlessly and testing its resize behavior. Panes also carry their process pid, the handle for inspecting a stuck session’s process from outside.
Splitting divides a pane in two, and is how layouts get built. tmux’s own flags are famously backwards (-h puts panes side by side; -v stacks them), so fastmux speaks in directions instead: where the new pane goes, right/below/left/above, with a one-word verb for each. Like window creation, splits never steal focus unless asked.
def asplit(
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
focus:bool=False, # Make the new pane active
):Split this pane, returning the new Pane
def lsplit(
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
focus:bool=False, # Make the new pane active
):Split this pane, returning the new Pane
def bsplit(
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
focus:bool=False, # Make the new pane active
):Split this pane, returning the new Pane
def rsplit(
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
focus:bool=False, # Make the new pane active
):Split this pane, returning the new Pane
def split(
where:str='right', # Where the new pane goes: `right`, `below`, `left`, or `above`
cmd:NoneType=None, # Command to run (str or argv list); default: the user's shell
size:NoneType=None, # Size of the new pane: rows/columns (int) or `'30%'`
cwd:NoneType=None, # Working directory
env:NoneType=None, # Extra environment vars as a dict
focus:bool=False, # Make the new pane active
):Split this pane, returning the new Pane
Make this the active pane, returning it
Toggle this pane fullscreen within its window, returning it
Resize this pane to absolute width/height, returning it
Kill this pane (killing its window/session if it is the last one)
Any scope can be grepped: one pane’s transcript, every pane in a window or session, or every pane on the server. Matches display rg-style, and each match’s target is a real tmux target, so you can paste it back into tmux() to get the pane that said it.
Search every pane in this session, rg-style
Search every pane in this window, rg-style
Search this pane’s recent transcript, rg-style
Search matches, one rg-style row per line
One matching transcript line, shown rg-style
fastmux-demo:1.1:2: ln 2
fastmux-demo:1.1:20: ln 20
fastmux-demo:1.1:21: ln 21
fastmux-demo:1.1:22: ln 22
fastmux-demo:1.1:23: ln 23
fastmux-demo:1.1:24: ln 24
fastmux-demo:1.1:25: ln 25
fastmux-demo:1.1:26: ln 26
fastmux-demo:1.1:27: ln 27
fastmux-demo:1.1:28: ln 28
fastmux-demo:1.1:29: ln 29
tmux() with no arguments returns every session as an indented session/window/pane tree, empty if no server is running at all. With a target it returns the right live handle: a session name or $id gives a Session, sess:win or @id a Window, sess:win.pane or %id a Pane.
All sessions as a tree, or a live handle for target (session name, sess:win, sess:win.pane, or a $/@/% id)
Sessions shown as an indented session/window/pane tree
fastmux-demo: 2 windows
1: nbs (1 panes) @0
1.1: [80x10] %0 python (active)
2: jobs* (1 panes) @2
2.1: [80x10] %2 python (active)
Search for a line, then jump to the pane that printed it:
One more query: current_pane answers “which pane is this?”. Inside tmux it is the caller’s own pane; outside, tmux resolves it to the active pane of the most recently used session. fastmux.bg uses it to give sid=None its meaning.
The pane tmux considers current: the caller’s own inside tmux, else the active pane of the most recent session
Finally, clean up the playground: