Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ $ uvx --from 'libtmux' --prerelease allow python
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

libtmux 0.57.0 broadens tmux support. It introduces
{class}`~libtmux.Client` as a first-class object, threads tmux's
C-side ``-f`` filter through the typed listing methods so callers can
push predicates into the tmux server, and adds typed access to many
more format tokens — all scope- and version-gated so they're safe on
every supported tmux version. Subcommand context now flows through
{exc}`~libtmux.exc.LibTmuxException`, making it easier for downstream
tools to dispatch on which tmux command produced an error.
### Documentation

#### Cleaner `from_env` examples (#719)
Expand Down Expand Up @@ -113,6 +121,52 @@ Blanket retry policies for {exc}`~libtmux.exc.LibTmuxException` should exclude
{exc}`~libtmux.exc.MultipleObjectsReturned`: they report deterministic missing
or ambiguous lookups, not transient tmux failures.

### Breaking changes

#### `LibTmuxException` string form gains a subcommand prefix (#670)

When {exc}`~libtmux.exc.LibTmuxException` is raised from one of the
typed command wrappers, ``str(exc)`` now begins with the originating
tmux subcommand name followed by ``": "``. For example, an error from
{meth}`~libtmux.Session.last_window` used to render as ``"can't find
window"`` and now renders as ``"last-window: can't find window"``.

The wrapped stderr is unchanged — ``exc.args[0]`` still holds the raw
tmux output, and the new {attr}`~libtmux.exc.LibTmuxException.subcommand`
attribute exposes the tmux subcommand name as a separate field.
{func}`~libtmux.common.raise_if_stderr` is the shared helper that
populates both.

This is a serialization-format change: code that pattern-matches on
``str(exc)`` exactly or anchors a regex with ``^`` against the old
shape will no longer match.

```python
# Before
try:
session.last_window()
except LibTmuxException as exc:
if str(exc) == "can't find window":
...

# After — dispatch on the typed attribute
try:
session.last_window()
except LibTmuxException as exc:
if exc.subcommand == "last-window":
...

# Or — match against the raw stderr without the prefix
try:
session.last_window()
except LibTmuxException as exc:
if exc.args and exc.args[0] == "can't find window":
...
```

Substring matches (``"can't find" in str(exc)``) and unanchored
``re.search`` patterns continue to work unchanged.

### What's new

#### Find where you are running (#714)
Expand Down Expand Up @@ -332,6 +386,17 @@ unaffected.

## libtmux 0.58.1 (2026-06-16)

New {class}`~libtmux.Client` dataclass and
{attr}`~libtmux.Server.clients` property bring typed-ORM ergonomics
to tmux's attached-client model. Reads like ``client.client_readonly``
and ``client.client_session`` work directly on the client instead of
forcing callers down to {meth}`~libtmux.Server.cmd`.

Note that ``client.session_id`` / ``client.window_id`` /
``client.pane_id`` reflect the client's currently attached view at
hydration time — {meth}`~libtmux.Client.refresh` re-reads them after
the client switches focus. ``client.client_name`` is the client's
stable identifier.
libtmux 0.58.1 restores compatibility with pytest 9.1. The bundled
pytest plugin no longer aborts at import time, so projects that rely on
libtmux's fixtures can move to the latest pytest without their test
Expand Down Expand Up @@ -552,6 +617,12 @@ Caveat: tmux silently expands a malformed filter expression to empty, which
it treats as false — a typo looks identical to "no matches". Verify filter
syntax against the FORMATS section of ``tmux(1)``.

Caveat: tmux silently expands a malformed predicate to empty, which
the format engine treats as false — a typo looks identical to "no
matches". Verify predicate syntax against the FORMATS section of
``tmux(1)``.

#### `Pane.send_keys(cmd=None, …)` flag-only invocation (#670)
#### `Pane.send_keys(cmd=None, …)` flag-only invocation (#672)

{meth}`~libtmux.Pane.send_keys` accepts ``cmd=None`` together with
Expand All @@ -562,6 +633,10 @@ key argument.
#### `Server.list_buffers(format_string=, filter=)` (#672)

{meth}`~libtmux.Server.list_buffers` gains ``format_string`` (``-F``)
and ``filter`` (``-f``) kwargs. Callers can project a chosen template
(e.g. ``"#{buffer_name}"``) or push a buffer-name match expression
into tmux's format engine — same bad-filter caveat as the
``search_*`` methods.
and ``filter`` (``-f``) kwargs. Callers can ask tmux to return selected
fields (e.g. ``"#{buffer_name}"``) or only buffers matching an expression —
same bad-filter caveat as the ``search_*`` methods.
Expand All @@ -574,13 +649,48 @@ merge the command's stderr into the captured output. Both kwargs are
version-gated; older tmux warns and ignores the flag instead of
erroring.

#### `Pane.capture_pane(pending=True)` (#670)
#### `Pane.capture_pane(pending=True)` (#672)

{meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg that
returns bytes tmux has read from the pane but not yet committed to
the terminal — useful for diagnosing programs whose output stalls
mid-sequence.

#### Subcommand-tagged exceptions (#670)

{exc}`~libtmux.exc.LibTmuxException` takes an optional ``subcommand``
attribute. When set, ``str(exc)`` prefixes the originating tmux
command name (e.g. ``"last-window: no such window"``), giving
downstream consumers a stable way to dispatch on which tmux command
produced the error. {func}`~libtmux.common.raise_if_stderr` is the
shared helper most commands use to populate it.

#### Scope-aware format-token retrieval (#670)

The ``-F`` template libtmux sends to each ``list-*`` subcommand is now
scope- and version-aware. tmux's format engine cascades context
downward from client → session → current window → active pane, so a
``Session`` row hydrates active-window and active-pane fields via that
cascade, and a ``Client`` row likewise hydrates the client's attached
session, window, and active pane. ``client_*`` tokens resolve only
under ``list-clients`` because tmux has no reverse cascade. Tokens
introduced after tmux 3.2a are gated through ``FIELD_VERSION`` so the
format string stays compatible with the project's minimum supported
tmux. Tokens the running tmux doesn't recognize stay ``None`` on the
typed surface — no crash, no warning.

{class}`~libtmux.Pane`, {class}`~libtmux.Window`,
{class}`~libtmux.Session`, and {class}`~libtmux.Client` declare typed
dataclass fields for the scope-relevant tokens that ship in tmux 3.2a,
including pane state (``pane_dead``, ``pane_in_mode``, ``pane_marked``,
``pane_synchronized``, ``pane_path``, ``pane_pipe`` …), window state
(``window_zoomed_flag``, ``window_silence_flag``, ``window_flags`` …),
session state (``session_marked`` …), and the client view
(``client_session``, ``client_readonly``, ``client_termtype`` …). Typed
fields for tokens tmux added in 3.4 / 3.5 / 3.6 and the forward-looking
set from tmux master will land in a follow-up shipment once those
releases can be validated end-to-end.
#### More format-token fields on tmux objects (#672)

libtmux now asks each ``list-*`` subcommand for the format tokens that make
Expand All @@ -603,10 +713,29 @@ pane." See {ref}`format-tokens` for details.
- {meth}`~libtmux.Pane.reset` now clears pane scrollback. In 0.56.0
the history clear silently no-op'd, leaving the scrollback intact
(#650).
- {meth}`~libtmux.Server.display_message`,
{meth}`~libtmux.Window.display_message`, and
{meth}`~libtmux.Pane.display_message` surface tmux stderr via
:func:`warnings.warn` instead of silently returning ``[]``. tmux uses
stderr for both genuine errors and informational messages on some
versions, so the wrappers warn rather than raise; callers that want
to escalate can wrap the call in :func:`warnings.catch_warnings` with
``filterwarnings("error")`` (#672).
- {attr}`~libtmux.Server.clients` and
{meth}`~libtmux.Server.search_sessions` propagate tmux errors
rather than silently returning an empty
{class}`~libtmux._internal.query_list.QueryList`. A genuine
list-clients or list-sessions failure now surfaces instead of
looking identical to "no clients" or "filter matched nothing"
(#672).

### Documentation

- New API page: {doc}`api/libtmux.client`.
- {class}`~libtmux.neo.Obj`'s class docstring documents the
downward-cascade resolution target so readers know that, for
example, ``session.pane_id`` is the session's *current window's
active* pane — not "the session's pane" (#672).
- New {ref}`format-tokens` topic explains why some fields describe an active
child object, such as ``session.pane_id`` reflecting the active pane in the
session's current window (#672).
Expand Down
112 changes: 112 additions & 0 deletions MIGRATION
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,118 @@ with warnings.catch_warnings():
result = pane.display_message("#{pane_id}", get_text=True)
```

## libtmux 0.57.0: Subcommand-tagged exceptions (#670)

### `LibTmuxException` `str()` gains a subcommand prefix

When {exc}`~libtmux.exc.LibTmuxException` is raised from one of the
typed command wrappers, ``str(exc)`` now starts with the originating
tmux subcommand name followed by ``": "``. The wrapped stderr is
unchanged — ``exc.args[0]`` still holds the raw tmux output, and the
new {attr}`~libtmux.exc.LibTmuxException.subcommand` attribute exposes
the tmux subcommand name on its own.

**Who is affected:** code that pattern-matches `str(exc)` exactly,
anchors a regex with `^` against the previous shape, or hashes the
stringified exception. Substring containment (`"can't find" in
str(exc)`) and unanchored `re.search` patterns continue to match
unchanged.

**Before (0.56.x and earlier):**

```python
try:
session.last_window()
except LibTmuxException as exc:
if str(exc) == "can't find window":
handle_missing_last_window()
```

**After (0.57.0+) — dispatch on the typed attribute:**

```python
try:
session.last_window()
except LibTmuxException as exc:
if exc.subcommand == "last-window":
handle_missing_last_window()
```

**Or — match against the raw stderr in `exc.args[0]`:**

```python
try:
session.last_window()
except LibTmuxException as exc:
if exc.args and exc.args[0] == "can't find window":
handle_missing_last_window()
```

### `Client.session_id` / `window_id` / `pane_id` are snapshots, not identity

{class}`~libtmux.Client` is new in 0.57.0, so this isn't a behavior
change — but new users of {attr}`~libtmux.Server.clients` should know
that ``client.session_id``, ``client.window_id``, and
``client.pane_id`` are hydrated from tmux's downward format cascade
(``c->session`` → ``s->curw`` → ``wl->window->active``) at the moment
the {class}`~libtmux.Client` was built. They go stale as soon as the
client switches sessions, changes window, or detaches.

For typed access that reflects the client's *live* attachment, use
{attr}`~libtmux.Client.attached_session`,
{attr}`~libtmux.Client.attached_window`, and
{attr}`~libtmux.Client.attached_pane`:

```python
# Snapshot (cheap, may be stale)
client = server.clients.get(client_name=ctl.client_name)
session_id = client.session_id # str captured at hydration time

# Live (re-reads list-clients; returns None if tmux no longer reports the client)
attached = client.attached_session # libtmux.Session | None
window = client.attached_window # libtmux.Window | None
pane = client.attached_pane # libtmux.Pane | None
```

The ``client.client_name`` field (typically the tty path on Unix) is
the client's *stable* identifier and does not have this caveat. The
``attached_*`` properties translate a missing ``list-clients`` row into
``None`` for convenience; direct {meth}`~libtmux.Client.refresh` and
{meth}`~libtmux.Client.from_client_name` calls still raise when that
client row is gone.

### `Pane.reset` now dispatches through `self.server.cmd`

{meth}`~libtmux.Pane.reset` now bundles ``send-keys -R`` and
``clear-history`` into a single tmux IPC routed through
``self.server.cmd`` (with an explicit ``-t <pane_id>`` on both
subcommands) rather than calling ``self.cmd`` twice. This closes a race
where output written to the pane between the two IPCs could land in the
scrollback that the second call then cleared.

**Who is affected:** test fixtures and downstream code that intercepts
``Pane.cmd`` (for example with ``unittest.mock.patch.object(pane,
"cmd")``) will no longer observe ``reset()``'s tmux invocation. Patch
``Server.cmd`` instead, or assert on the resulting pane state directly.

### `Server.display_message` / `Window.display_message` / `Pane.display_message` warn instead of raise

The three ``display_message`` wrappers now report tmux stderr via
:func:`warnings.warn` rather than raising
{exc}`~libtmux.exc.LibTmuxException`. tmux uses stderr for both genuine
errors and informational messages, and the right escalation depends on
tmux version and call shape; the wrappers default to warning so callers
can decide. To escalate to an exception, wrap the call in
:func:`warnings.catch_warnings` with ``filterwarnings("error")``:

```python
import warnings

with warnings.catch_warnings():
warnings.filterwarnings("error", category=UserWarning)
result = pane.display_message("#{pane_id}", get_text=True)
```

## libtmux 0.50.0: Unified Options and Hooks API (#516)

### New unified options API
Expand Down
35 changes: 35 additions & 0 deletions tests/test_pane.py
Original file line number Diff line number Diff line change
Expand Up @@ -613,13 +613,15 @@ def test_send_keys_flag_only_requires_a_flag(session: Session) -> None:
"pane_format",
"pane_in_mode",
"pane_input_off",
"pane_key_mode",
"pane_last",
"pane_marked",
"pane_marked_set",
"pane_mode",
"pane_path",
"pane_pipe",
"pane_synchronized",
"pane_unseen_changes",
)


Expand All @@ -646,6 +648,39 @@ def test_pane_format_field_declared_and_hydrated(
assert value is None or isinstance(value, str)


NEW_TMUX_FORMAT_FIELDS = (
# Pane-scope tokens registered in tmux master post-3.6a.
"pane_zoomed_flag",
"pane_floating_flag",
"pane_flags",
"pane_pb_state",
"pane_pb_progress",
"pane_pipe_pid",
# Server-scope tokens.
"synchronized_output_flag",
"bracket_paste_flag",
)


@pytest.mark.parametrize("field_name", NEW_TMUX_FORMAT_FIELDS)
def test_obj_declares_post_3_6a_field(field_name: str, session: Session) -> None:
"""Tmux's post-3.6a format tokens have typed slots on ``Obj``.

Older tmux releases that don't recognize these tokens expand them to
the empty string, so the wrapper hydrates the rest of the dataclass
normally and the new fields stay ``None``. When the user upgrades
tmux, ``refresh()`` populates the fields automatically — no library
update required.
"""
pane = session.active_window.active_pane
assert pane is not None
assert field_name in pane.__dataclass_fields__

pane.refresh()
value = getattr(pane, field_name)
assert value is None or isinstance(value, str)


def test_pane_synchronized_reflects_window_state(session: Session) -> None:
"""``pane.pane_synchronized`` flips when synchronize-panes toggles."""
window = session.active_window
Expand Down
5 changes: 5 additions & 0 deletions tests/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -709,12 +709,17 @@ def test_session_search_panes_filter_by_id(session: Session) -> None:


SESSION_FORMAT_FIELDS = (
"session_active",
"session_activity_flag",
"session_alert",
"session_bell_flag",
"session_format",
"session_group_attached_list",
"session_group_many_attached",
"session_grouped",
"session_many_attached",
"session_marked",
"session_silence_flag",
)


Expand Down
Loading