From e234dc3a7ff61819a3478405c1513e6f174f0cba Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:25:05 -0500 Subject: [PATCH 01/65] Server(feat[display_message]): Add Server.display_message wrapper why: tmux's display-message entry uses CMD_FIND_CANFAIL so -t is optional, but libtmux only wrapped Pane.display_message. Server-scoped reads like #{version} / #{socket_path} had to drop to server.cmd("display-message", "-p", "#{...}") with no wrapper path. libtmux-mcp carries three workaround sites. what: - Add Server.display_message mirroring Pane.display_message's signature minus -t injection (Server.cmd never auto-injects -t). - Cover -p/-a/-v/-l/-N/-c/-d/-F flags; gate -l on tmux 3.4+. - Doctests demonstrate #{version} and all_formats=True usage. - Tests in tests/test_server.py use control_mode() so display-message has a client to dispatch -p output through (target/pane is unneeded but a client is needed for stdout to materialize). --- src/libtmux/server.py | 159 ++++++++++++++++++++++++++++++++++++++++++ tests/test_server.py | 104 +++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c0ad1dc50..7882f6b98 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1395,6 +1395,165 @@ def show_messages( return proc.stdout + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[True], + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> list[str]: ... + + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[False] = ..., + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> None: ... + + def display_message( + self, + cmd: str, + get_text: bool = False, + *, + format_string: str | None = None, + all_formats: bool | None = None, + verbose: bool | None = None, + no_expand: bool | None = None, + target_client: str | None = None, + delay: int | None = None, + notify: bool | None = None, + ) -> list[str] | None: + """Display message at server scope via ``$ tmux display-message``. + + Like :meth:`Pane.display_message` but without ``-t `` injection. + tmux's ``cmd-display-message`` entry uses ``CMD_FIND_CANFAIL`` so the + target is optional; server-scoped format reads (``#{version}``, + ``#{socket_path}``, ``#{pid}``) resolve without a specific pane handle. + + With no client attached and ``target_client`` omitted, the status-line + path (``get_text=False``) raises ``no current client``. Use + ``get_text=True`` for headless reads, or pair with + :class:`~libtmux._internal.control_mode.ControlMode`. + + Parameters + ---------- + cmd : str + Format string to display or evaluate (e.g. ``"#{version}"``). + Pass ``""`` together with ``all_formats=True`` to dump every + variable. + + .. versionadded:: 0.57 + get_text : bool, optional + Return tmux's stdout instead of rendering to the status line + (``-p`` flag). + + .. versionadded:: 0.57 + format_string : str, optional + Alternative format template (``-F`` flag). + + .. versionadded:: 0.57 + all_formats : bool, optional + List all format variables (``-a`` flag). + + .. versionadded:: 0.57 + verbose : bool, optional + Show format variable types (``-v`` flag). + + .. versionadded:: 0.57 + no_expand : bool, optional + Output the literal string without format expansion (``-l`` flag). + Requires tmux 3.4+. + + .. versionadded:: 0.57 + target_client : str, optional + Target client (``-c`` flag). + + .. versionadded:: 0.57 + delay : int, optional + Display time in milliseconds (``-d`` flag). + + .. versionadded:: 0.57 + notify : bool, optional + Do not wait for input (``-N`` flag). + + .. versionadded:: 0.57 + + Returns + ------- + list[str] | None + Message output if ``get_text`` is True, otherwise ``None``. + + Examples + -------- + Read tmux version without needing a pane handle: + + >>> result = server.display_message("#{version}", get_text=True) + >>> isinstance(result, list) and len(result) == 1 + True + + Dump every format variable: + + >>> result = server.display_message("", get_text=True, all_formats=True) + >>> any("session_name=" in line for line in result) + True + """ + tmux_args: tuple[str, ...] = () + + if get_text: + tmux_args += ("-p",) + + if all_formats: + tmux_args += ("-a",) + + if verbose: + tmux_args += ("-v",) + + if no_expand: + if has_gte_version("3.4", tmux_bin=self.tmux_bin): + tmux_args += ("-l",) + else: + warnings.warn( + "no_expand requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if notify: + tmux_args += ("-N",) + + if target_client is not None: + tmux_args += ("-c", target_client) + + if delay is not None: + tmux_args += ("-d", str(delay)) + + if format_string is not None: + tmux_args += ("-F", format_string) + + if cmd: + tmux_args += (cmd,) + + proc = self.cmd("display-message", *tmux_args) + + if get_text: + return proc.stdout + + return None + def show_prompt_history( self, *, diff --git a/tests/test_server.py b/tests/test_server.py index 8e9976f18..c0026e060 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1292,3 +1292,107 @@ def test_new_session_client_flags( client_flags="no-output", ) assert session.session_name == "flags_test" + + +class ServerDisplayMessageCase(t.NamedTuple): + """Test case for Server.display_message() flag variations.""" + + test_id: str + cmd: str + kwargs: dict[str, t.Any] + expected_in_output: str | None + min_tmux_version: str | None + + +SERVER_DISPLAY_MESSAGE_CASES: list[ServerDisplayMessageCase] = [ + ServerDisplayMessageCase( + test_id="version", + cmd="#{version}", + kwargs={"get_text": True}, + expected_in_output=".", + min_tmux_version=None, + ), + ServerDisplayMessageCase( + test_id="socket_path_format_string", + cmd="", + kwargs={"get_text": True, "format_string": "#{socket_path}"}, + expected_in_output="/", + min_tmux_version=None, + ), + ServerDisplayMessageCase( + test_id="all_formats", + cmd="", + kwargs={"get_text": True, "all_formats": True}, + expected_in_output="session_name", + min_tmux_version=None, + ), + ServerDisplayMessageCase( + test_id="no_expand_literal", + cmd="#{version}", + kwargs={"get_text": True, "no_expand": True}, + expected_in_output="#{version}", + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(ServerDisplayMessageCase._fields), + SERVER_DISPLAY_MESSAGE_CASES, + ids=[c.test_id for c in SERVER_DISPLAY_MESSAGE_CASES], +) +def test_server_display_message_flags( + test_id: str, + cmd: str, + kwargs: dict[str, t.Any], + expected_in_output: str | None, + min_tmux_version: str | None, + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Server.display_message() resolves server-scoped formats without a pane. + + tmux dispatches ``display-message -p`` output through a client; the wrapper + omits ``-t `` but still needs a client to receive stdout. The + headless test environment provides one via :class:`ControlMode`. + """ + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + with control_mode() as ctl: + call_kwargs = dict(kwargs) + call_kwargs.setdefault("target_client", ctl.client_name) + result = server.display_message(cmd, **call_kwargs) + + if expected_in_output is not None: + assert result is not None + output = "\n".join(result) + assert expected_in_output in output + + +def test_server_display_message_no_text_returns_none( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """Without ``get_text=True`` the call renders to status line and returns None.""" + with control_mode() as ctl: + result = server.display_message( + "hi from libtmux", target_client=ctl.client_name + ) + assert result is None + + +def test_server_display_message_target_client( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``target_client`` is plumbed through as ``-c``; get_text=True returns stdout.""" + with control_mode() as ctl: + result = server.display_message( + "#{version}", get_text=True, target_client=ctl.client_name + ) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].strip() != "" From 899441f88dcd9fc2c280fe6f7dc4bd9407add0b9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:29:50 -0500 Subject: [PATCH 02/65] Window(feat[display_message]): Add Window.display_message wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Pane.display_message exists but Window doesn't, forcing callers like libtmux-mcp's resize_pane(zoom=...) to drop down to window.cmd("display-message", "-p", "#{window_zoomed_flag}") to read window-scoped state. what: - Add Window.display_message mirroring Pane.display_message; Window.cmd auto-injects -t @, so window-scoped reads (window_zoomed_flag, window_active_clients_list, …) work without a pane handle. - Cover -p/-a/-v/-l/-N/-c/-d/-F flags; gate -l on tmux 3.4+. - Doctests demonstrate #{window_id} and #{window_zoomed_flag} reads. - Tests in tests/test_window.py via WindowDisplayMessageCase NamedTuple (matches the Pane.display_message test shape). Includes a target_client case using control_mode(). --- src/libtmux/window.py | 157 +++++++++++++++++++++++++++++++++++++++++- tests/test_window.py | 96 ++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 1 deletion(-) diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 1e6afd062..1e6f9541c 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -15,7 +15,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import tmux_cmd +from libtmux.common import has_gte_version, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, OptionScope, @@ -816,6 +816,161 @@ def swap( if isinstance(target, Window): target.refresh() + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[True], + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> list[str]: ... + + @t.overload + def display_message( + self, + cmd: str, + get_text: t.Literal[False] = ..., + *, + format_string: str | None = ..., + all_formats: bool | None = ..., + verbose: bool | None = ..., + no_expand: bool | None = ..., + target_client: str | None = ..., + delay: int | None = ..., + notify: bool | None = ..., + ) -> None: ... + + def display_message( + self, + cmd: str, + get_text: bool = False, + *, + format_string: str | None = None, + all_formats: bool | None = None, + verbose: bool | None = None, + no_expand: bool | None = None, + target_client: str | None = None, + delay: int | None = None, + notify: bool | None = None, + ) -> list[str] | None: + """Display message at window scope via ``$ tmux display-message``. + + Like :meth:`Pane.display_message` but auto-injects ``-t @`` + instead of a pane id. Window-scoped format reads such as + ``#{window_zoomed_flag}`` or ``#{window_active_clients}`` no longer + require dropping to :meth:`Window.cmd`. + + Parameters + ---------- + cmd : str + Format string to display or evaluate (e.g. + ``"#{window_zoomed_flag}"``). + + .. versionadded:: 0.57 + get_text : bool, optional + Return tmux's stdout instead of rendering to the status line + (``-p`` flag). + + .. versionadded:: 0.57 + format_string : str, optional + Alternative format template (``-F`` flag). + + .. versionadded:: 0.57 + all_formats : bool, optional + List all format variables (``-a`` flag). + + .. versionadded:: 0.57 + verbose : bool, optional + Show format variable types (``-v`` flag). + + .. versionadded:: 0.57 + no_expand : bool, optional + Output the literal string without format expansion (``-l`` flag). + Requires tmux 3.4+. + + .. versionadded:: 0.57 + target_client : str, optional + Target client (``-c`` flag). + + .. versionadded:: 0.57 + delay : int, optional + Display time in milliseconds (``-d`` flag). + + .. versionadded:: 0.57 + notify : bool, optional + Do not wait for input (``-N`` flag). + + .. versionadded:: 0.57 + + Returns + ------- + list[str] | None + Message output if ``get_text`` is True, otherwise ``None``. + + Examples + -------- + Read the window's id format: + + >>> result = window.display_message("#{window_id}", get_text=True) + >>> result[0].startswith("@") + True + + Check zoom state (a common gap-#670 use case): + + >>> result = window.display_message( + ... "#{window_zoomed_flag}", get_text=True + ... ) + >>> result[0] in {"0", "1"} + True + """ + tmux_args: tuple[str, ...] = () + + if get_text: + tmux_args += ("-p",) + + if all_formats: + tmux_args += ("-a",) + + if verbose: + tmux_args += ("-v",) + + if no_expand: + if has_gte_version("3.4", tmux_bin=self.server.tmux_bin): + tmux_args += ("-l",) + else: + warnings.warn( + "no_expand requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if notify: + tmux_args += ("-N",) + + if target_client is not None: + tmux_args += ("-c", target_client) + + if delay is not None: + tmux_args += ("-d", str(delay)) + + if format_string is not None: + tmux_args += ("-F", format_string) + + if cmd: + tmux_args += (cmd,) + + proc = self.cmd("display-message", *tmux_args) + + if get_text: + return proc.stdout + + return None + def rename_window(self, new_name: str) -> Window: """Rename window. diff --git a/tests/test_window.py b/tests/test_window.py index a6627634b..ac7dff60d 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1058,3 +1058,99 @@ def test_move_window_no_select(session: Session) -> None: w2.move_window(destination="99", no_select=True) session.refresh() assert session.active_window.window_id == w1.window_id + + +class WindowDisplayMessageCase(t.NamedTuple): + """Test case for Window.display_message() flag variations.""" + + test_id: str + cmd: str + kwargs: dict[str, t.Any] + expected_in_output: str | None + min_tmux_version: str | None + + +WINDOW_DISPLAY_MESSAGE_CASES: list[WindowDisplayMessageCase] = [ + WindowDisplayMessageCase( + test_id="window_id", + cmd="#{window_id}", + kwargs={"get_text": True}, + expected_in_output="@", + min_tmux_version=None, + ), + WindowDisplayMessageCase( + test_id="window_index_via_format_string", + cmd="", + kwargs={"get_text": True, "format_string": "#{window_index}"}, + # pytest plugin sets `base-index 1` (pytest_plugin.py:110), so the + # first window in a fresh session is index 1, not 0. + expected_in_output="1", + min_tmux_version=None, + ), + WindowDisplayMessageCase( + test_id="zoomed_flag_default_zero", + cmd="#{window_zoomed_flag}", + kwargs={"get_text": True}, + expected_in_output="0", + min_tmux_version=None, + ), + WindowDisplayMessageCase( + test_id="no_expand_literal", + cmd="#{window_id}", + kwargs={"get_text": True, "no_expand": True}, + expected_in_output="#{window_id}", + min_tmux_version="3.4", + ), +] + + +@pytest.mark.parametrize( + list(WindowDisplayMessageCase._fields), + WINDOW_DISPLAY_MESSAGE_CASES, + ids=[c.test_id for c in WINDOW_DISPLAY_MESSAGE_CASES], +) +def test_window_display_message_flags( + test_id: str, + cmd: str, + kwargs: dict[str, t.Any], + expected_in_output: str | None, + min_tmux_version: str | None, + session: Session, +) -> None: + """Window.display_message() resolves window-scoped formats.""" + from libtmux.common import has_gte_version + + if min_tmux_version and not has_gte_version(min_tmux_version): + pytest.skip(f"Requires tmux {min_tmux_version}+") + + window = session.active_window + result = window.display_message(cmd, **kwargs) + + if expected_in_output is not None: + assert result is not None + output = "\n".join(result) + assert expected_in_output in output + + +def test_window_display_message_no_text_returns_none( + session: Session, +) -> None: + """Without ``get_text=True`` the call renders to status line and returns None.""" + window = session.active_window + result = window.display_message("hi from libtmux") + assert result is None + + +def test_window_display_message_target_client( + control_mode: t.Callable[..., t.Any], + session: Session, +) -> None: + """``target_client`` is plumbed through as ``-c``.""" + window = session.active_window + with control_mode() as ctl: + result = window.display_message( + "#{window_id}", get_text=True, target_client=ctl.client_name + ) + assert isinstance(result, list) + assert len(result) == 1 + assert result[0].startswith("@") From 5cefe0fdc6f4c52defa144384028743d6e326282 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:32:02 -0500 Subject: [PATCH 03/65] neo(feat[fields]): Add window_zoomed_flag typed field on Obj why: tmux's format.c registers window_zoomed_flag as a first-class format token (callback at format.c:2854, table entry at format.c:3557), but the libtmux Obj dataclass never declared it. mypy rejected window.window_zoomed_flag even after refresh(). libtmux-mcp's resize_pane(zoom) workflow worked around this by going through display-message. what: - Add window_zoomed_flag: str | None = None to Obj in neo.py (alphabetically between window_width and wrap_flag). - Auto-included in the tmux -F format string via get_output_format(), so refresh() populates it without further wiring. - Test toggles zoom on/off via Pane.resize(zoom=True) across two refresh cycles and asserts "0"/"1" round-trip. --- src/libtmux/neo.py | 1 + tests/test_window.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 6085dd494..2690418a5 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -157,6 +157,7 @@ class Obj: window_raw_flags: str | None = None window_stack_index: str | None = None window_width: str | None = None + window_zoomed_flag: str | None = None wrap_flag: str | None = None def _refresh( diff --git a/tests/test_window.py b/tests/test_window.py index ac7dff60d..2f1e52e64 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1154,3 +1154,27 @@ def test_window_display_message_target_client( assert isinstance(result, list) assert len(result) == 1 assert result[0].startswith("@") + + +def test_window_zoomed_flag_field_toggle(session: Session) -> None: + """``window.window_zoomed_flag`` reflects tmux's zoom state across refresh. + + ``refresh()`` repopulates the field after each ``resize-pane -Z`` toggle: + the flag reads ``"0"`` for an un-zoomed window and ``"1"`` once a pane + has been zoomed. + """ + window = session.active_window + # Need at least two panes for zoom to mean anything. + window.split() + window.refresh() + assert window.window_zoomed_flag == "0" + + pane = window.active_pane + assert pane is not None + pane.resize(zoom=True) + window.refresh() + assert window.window_zoomed_flag == "1" + + pane.resize(zoom=True) + window.refresh() + assert window.window_zoomed_flag == "0" From 9b0585d5f85a1dc720ab784c6d53eaddda51eb84 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:34:38 -0500 Subject: [PATCH 04/65] Pane(fix[reset]): Split into two cmd calls so clear-history runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The previous body called self.cmd("send-keys", r"-R \; clear-history") which sends a single argv to tmux via subprocess. tmux's \; is the *interactive* command separator and is only interpreted when tmux re-lexes a full command line — argv never gets re-parsed. tmux saw "-R \; clear-history" as a single token after -R and treated "\; clear-history" as literal keys to send, never executing clear-history. The scrollback was never cleared. what: - Split reset() into two separate self.cmd("send-keys", "-R") and self.cmd("clear-history") calls. Each goes through Pane.cmd which auto- injects -t , so both target the right pane. - Update docstring (uses r""" because of the literal \; explanation), with a working doctest that populates history and verifies reset. - Test in tests/test_pane.py: spawn a shell pane, populate scrollback with "reset_marker_*" lines, call pane.reset(), assert the markers are gone from capture_pane(start=-100). Pre-fix this test would have failed (the markers stayed because clear-history never ran). --- src/libtmux/pane.py | 17 +++++++++++++++-- tests/test_pane.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 7b9624afe..8d674b56d 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2148,8 +2148,21 @@ def clear(self) -> Pane: return self def reset(self) -> Pane: - """Reset and clear pane history.""" - self.cmd("send-keys", r"-R \; clear-history") + r"""Reset terminal state and clear pane history. + + Issues ``send-keys -R`` to reset the pane's terminal state, then + ``clear-history`` to drop its scrollback. + + Examples + -------- + >>> pane.send_keys('echo "for the history"') + >>> 'for the history' in '\n'.join(pane.capture_pane(start=-100, end=-1)) + True + >>> pane.reset() + Pane(%... Window(@... ...:..., Session($1 libtmux_...))) + """ + self.cmd("send-keys", "-R") + self.cmd("clear-history") return self # diff --git a/tests/test_pane.py b/tests/test_pane.py index 3eeff4625..77e18a3b8 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -1280,3 +1280,43 @@ def test_clear_history(session: Session) -> None: history = pane.capture_pane(start=-100) # After clearing, scrollback history should be much shorter assert len(history) <= 30 # reasonable bound after clear + + +def test_pane_reset_clears_history_and_sends_reset(session: Session) -> None: + """Pane.reset() runs both ``send-keys -R`` and ``clear-history`` (#650). + + Populates scrollback, calls ``reset()``, then verifies the markers are + gone — proving ``clear-history`` actually ran. + """ + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_reset_650", + window_shell=f"{env} PS1='$ ' sh", + ) + pane = window.active_pane + assert pane is not None + + retry_until(lambda: "$" in "\n".join(pane.capture_pane()), 2, raises=True) + + # Populate scrollback. + for n in range(5): + pane.send_keys(f"echo reset_marker_{n}", enter=True) + retry_until( + lambda: "reset_marker_4" in "\n".join(pane.capture_pane()), + 3, + raises=True, + ) + + # Sanity-check that the history is non-trivial pre-reset. + pre = pane.capture_pane(start=-100) + assert any("reset_marker_" in line for line in pre) + + result = pane.reset() + assert result is pane + + # After reset, scrollback should be empty — the old code left the markers + # behind because clear-history never executed. + post = pane.capture_pane(start=-100) + assert not any("reset_marker_" in line for line in post) From 1ebef2bb0c7c33fdaad0f9b2b2f3031d58aa65c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:37:35 -0500 Subject: [PATCH 05/65] Pane(feat[send_keys]): Support flag-only invocation (cmd=None) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's cmd-send-keys.c:223-225 deliberately handles count == 0 with -R or -N set, returning CMD_RETURN_NORMAL without sending keys. The wrapper at pane.py:619 always appended `prefix + cmd` to argv, so `pane.send_keys("", reset=True, enter=False)` produced `tmux send-keys -R ""` — not the flag-only path tmux explicitly supports. libtmux-mcp's clear_pane kept `pane.cmd("send-keys", "-R")` for that reason. what: - Make cmd Optional[str] with default None. The previous positional-required signature is preserved for every existing caller (they pass cmd as a string). - When cmd is None and copy_mode_cmd is None: emit `send-keys ` with no trailing argv. Require at least one flag (reset, repeat, copy_mode_cmd); ValueError otherwise so degenerate `send_keys()` calls aren't silent no-ops. - Skip the post-call self.enter() in flag-only mode (no keys → no Enter). - Doctest demonstrates `pane.send_keys(reset=True)` working in flag-only mode. - Tests use monkeypatch+stub of pane.cmd (the pattern from test_server.py:730) to capture the exact argv: flag-only reset emits `("send-keys", "-R")`, flag-only repeat=3 emits `("send-keys", "-R", "-N", "3")`. A separate test asserts ValueError when no flags accompany cmd=None. --- src/libtmux/pane.py | 39 ++++++++++++++++++++++++++--- tests/test_pane.py | 60 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 8d674b56d..47c8c5227 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -498,7 +498,7 @@ def capture_pane( def send_keys( self, - cmd: str, + cmd: str | None = None, enter: bool | None = True, suppress_history: bool | None = False, literal: bool | None = False, @@ -515,10 +515,21 @@ def send_keys( A leading space character is added to cmd to avoid polluting the user's history. + When ``cmd`` is omitted (``None``), the wrapper emits a flag-only + invocation — useful with ``reset=True`` or ``repeat=N`` to invoke + tmux's deliberate ``count == 0`` branch in ``cmd-send-keys.c`` that + runs the flag effect without sending any keys. In flag-only mode, + ``enter`` is forced ``False`` (no keys → no Enter). + Parameters ---------- - cmd : str - Text or input into pane + cmd : str | None, optional + Text or input into pane. ``None`` for flag-only invocation + (requires ``reset``, ``repeat``, or ``copy_mode_cmd`` to be set). + + .. versionchanged:: 0.57 + + Now optional. ``None`` triggers tmux's flag-only path. enter : bool, optional Send enter after sending the input, default True. suppress_history : bool, optional @@ -559,6 +570,12 @@ def send_keys( .. versionadded:: 0.56 + Raises + ------ + ValueError + If ``cmd`` is ``None`` and no flag-only path is selected + (``reset``, ``repeat``, or ``copy_mode_cmd``). + Examples -------- >>> pane = window.split(shell='sh') @@ -574,6 +591,10 @@ def send_keys( $ echo "Hello world" Hello world $ + + Flag-only invocation — reset terminal state without sending any keys: + + >>> pane.send_keys(reset=True) """ prefix = " " if suppress_history else "" @@ -615,6 +636,18 @@ def send_keys( if copy_mode_cmd is not None: tmux_args += ("-X",) self.cmd("send-keys", *tmux_args, copy_mode_cmd) + elif cmd is None: + # Flag-only path — tmux's cmd-send-keys.c:223-225 explicitly + # supports count == 0 when -R or -N is set, returning + # CMD_RETURN_NORMAL without sending keys. + if not reset and repeat is None: + msg = ( + "send_keys(cmd=None) requires at least one of: " + "reset=True, repeat=N, copy_mode_cmd=..." + ) + raise ValueError(msg) + self.cmd("send-keys", *tmux_args) + return else: self.cmd("send-keys", *tmux_args, prefix + cmd) diff --git a/tests/test_pane.py b/tests/test_pane.py index 77e18a3b8..4206b4a27 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -547,6 +547,66 @@ def test_send_keys_flags( assert not_expected_in_capture not in contents +def test_send_keys_flag_only_reset_emits_clean_argv( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """``send_keys(reset=True)`` (no positional) emits ``send-keys -R`` only. + + tmux's flag-only path (``cmd-send-keys.c:223-225``) supports ``-R`` and + ``-N`` without any trailing key argument; ``cmd=None`` routes through + that path so the emitted argv has no spurious empty string. + """ + pane = session.active_window.active_pane + assert pane is not None + + captured: list[tuple[str, ...]] = [] + real_cmd = pane.cmd + + def fake_cmd(cmd_name: str, *args: t.Any, **kw: t.Any) -> t.Any: + captured.append((cmd_name, *(str(a) for a in args))) + return real_cmd(cmd_name, *args, **kw) + + monkeypatch.setattr(pane, "cmd", fake_cmd) + + pane.send_keys(reset=True) + + send_keys_calls = [c for c in captured if c[0] == "send-keys"] + assert send_keys_calls == [("send-keys", "-R")] + + +def test_send_keys_flag_only_repeat_emits_dash_N( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """``send_keys(repeat=3)`` flag-only emits ``send-keys -N 3`` only.""" + pane = session.active_window.active_pane + assert pane is not None + + captured: list[tuple[str, ...]] = [] + real_cmd = pane.cmd + + def fake_cmd(cmd_name: str, *args: t.Any, **kw: t.Any) -> t.Any: + captured.append((cmd_name, *(str(a) for a in args))) + return real_cmd(cmd_name, *args, **kw) + + monkeypatch.setattr(pane, "cmd", fake_cmd) + + pane.send_keys(repeat=3, reset=True) + + send_keys_calls = [c for c in captured if c[0] == "send-keys"] + assert send_keys_calls == [("send-keys", "-R", "-N", "3")] + + +def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: + """``send_keys()`` with neither ``cmd`` nor a flag raises ValueError.""" + pane = session.active_window.active_pane + assert pane is not None + + with pytest.raises(ValueError, match="requires at least one of"): + pane.send_keys() + + def test_select_pane_direction(session: Session) -> None: """Test Pane.select() with direction flags.""" window = session.new_window(window_name="test_select_dir") From 77b3393074ff3f0823854bf90de0dfe183fbe3fc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:41:59 -0500 Subject: [PATCH 06/65] Server(feat[list_buffers]): Add format_string= and filter= kwargs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's cmd-list-buffers.c:39 declares `.args = { "F:f:", ... }`. The libtmux wrapper passed neither, so callers got tmux's default template `name: N bytes: "sample"` and had to regex-parse it. libtmux-mcp's buffer GC carried `server.cmd("list-buffers", "-F", ...)` for that reason. what: - Add format_string and filter kwarg to Server.list_buffers. Default behavior (template output) preserved for backward compat. - format_string follows the existing display_message convention (avoids shadowing Python's builtin `format`). filter shadows the builtin by design, with a per-line noqa: A002 + docstring note — it mirrors tmux's flag name for grep-friendly symmetry with the manual. - Doctests cover all three modes (default, format projection, filter predicate); tests exercise raw-name projection and C-side filter matching (e.g. `#{m:gap6match_*,#{buffer_name}}` returns only the matching names). --- src/libtmux/server.py | 60 ++++++++++++++++++++++++++++++++++++++++--- tests/test_server.py | 33 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 7882f6b98..9556f4015 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1799,22 +1799,76 @@ def load_buffer( if proc.stderr: raise exc.LibTmuxException(proc.stderr) - def list_buffers(self) -> list[str]: + def list_buffers( + self, + *, + format_string: str | None = None, + filter: str | None = None, # noqa: A002 + ) -> list[str]: """List paste buffers via ``$ tmux list-buffers``. + Without arguments returns tmux's default template + (``name: N bytes: "sample"``) — kept for backward compatibility. + Pass *format_string* to project a specific tmux format, or *filter* + to push a format-expression predicate into tmux's C-side evaluation + (avoids parsing the default template in Python). + + Parameters + ---------- + format_string : str, optional + Output template (``-F`` flag). Example: ``"#{buffer_name}"`` for + raw names only. + + .. versionadded:: 0.57 + filter : str, optional + Filter expression evaluated by tmux's format engine (``-f`` flag). + Buffers for which the expanded expression is "false" (empty, 0) + are omitted. Example: ``"#{m:libtmux_mcp_*,#{buffer_name}}"``. + + Note: this kwarg shadows the Python builtin ``filter`` by design — + it mirrors tmux's own flag name (``-f filter``) for grep-friendly + symmetry between the wrapper and the tmux manual. + + .. versionadded:: 0.57 + Returns ------- list[str] - Raw output lines from list-buffers. + Raw output lines. Examples -------- + Default template (backward-compatible): + >>> server.set_buffer('buf_data') >>> result = server.list_buffers() >>> len(result) >= 1 True + + Project just the names: + + >>> server.set_buffer('hello', buffer_name='gap6_demo') + >>> 'gap6_demo' in server.list_buffers(format_string='#{buffer_name}') + True + + Filter via tmux's format engine: + + >>> matches = server.list_buffers( + ... format_string='#{buffer_name}', + ... filter='#{m:gap6_*,#{buffer_name}}', + ... ) + >>> 'gap6_demo' in matches + True """ - proc = self.cmd("list-buffers") + tmux_args: tuple[str, ...] = () + + if format_string is not None: + tmux_args += ("-F", format_string) + + if filter is not None: + tmux_args += ("-f", filter) + + proc = self.cmd("list-buffers", *tmux_args) if proc.stderr: raise exc.LibTmuxException(proc.stderr) diff --git a/tests/test_server.py b/tests/test_server.py index c0026e060..90b88bf5b 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1144,6 +1144,39 @@ def test_list_buffers(server: Server) -> None: assert len(result) >= 2 +def test_list_buffers_format_returns_raw_names(server: Server) -> None: + """``format_string`` projects raw names instead of the default template.""" + server.new_session(session_name="buf_format") + server.set_buffer("payload_a", buffer_name="fmt_a") + server.set_buffer("payload_b", buffer_name="fmt_b") + + names = server.list_buffers(format_string="#{buffer_name}") + + assert "fmt_a" in names + assert "fmt_b" in names + # Default template would contain "bytes:" — raw projection must not. + assert not any("bytes:" in line for line in names) + + +def test_list_buffers_filter_pushes_predicate_into_tmux(server: Server) -> None: + """``filter=`` pushes the match into tmux's format engine (-f flag). + + Only names matching the predicate come back from tmux; no Python-side + post-filter is needed. + """ + server.new_session(session_name="buf_filter") + server.set_buffer("keep_me", buffer_name="gap6match_alpha") + server.set_buffer("keep_me", buffer_name="gap6match_beta") + server.set_buffer("drop_me", buffer_name="gap6miss_one") + + matches = server.list_buffers( + format_string="#{buffer_name}", + filter="#{m:gap6match_*,#{buffer_name}}", + ) + + assert sorted(matches) == ["gap6match_alpha", "gap6match_beta"] + + def test_if_shell_true(server: Server) -> None: """Test Server.if_shell() with true condition.""" server.new_session(session_name="ifshell_test") From 8506df9ce776abc68f729f78398892e654ad8eca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:50:12 -0500 Subject: [PATCH 07/65] neo,server,session,window(feat[search]): Add search_*() methods with tmux C-side filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's cmd-list-panes.c:41 accepts `[-f filter]` and evaluates `format_true(expanded)`, gating output server-side before any data is returned. libtmux's `panes` / `windows` / `sessions` properties return QueryList and force callers to filter post-hoc in Python — orders of magnitude slower than pushing the predicate into tmux's C code. libtmux-mcp's search_panes fast-path kept `server.cmd("list-panes", "-a", "-f", ...)` for that reason. Note: `list_panes()` / `list_windows()` / `list_sessions()` are already defined as deprecated raise-only stubs (since 0.17) with pinned legacy-API tests in `tests/legacy_api/`. Keep those intact and add the new methods under `search_*()` — matches the verb libtmux-mcp uses for its consuming endpoint and side-steps the legacy contract entirely. what: - Extend `fetch_objs` (neo.py:248) with `filter: str | None = None`. When set, append `-f ` before the `-F` template. Single change feeds all the wrappers below. - Add `Server.search_sessions`, `Server.search_windows`, `Server.search_panes` alongside the existing `sessions`/`windows`/`panes` properties. - Add `Session.search_windows`, `Session.search_panes`. - Add `Window.search_panes`. - Each wrapper exposes a single `filter=` kwarg; the existing property is the no-filter form. Doctests demonstrate `#{m:gap7_*,#{window_name}}`-style predicates returning only matching objects. - Tests across test_server.py / test_session.py / test_window.py exercise filter-by-id (m:pane_id) and filter-by-name (m:prefix_*). --- src/libtmux/neo.py | 11 ++++ src/libtmux/server.py | 132 +++++++++++++++++++++++++++++++++++++++++ src/libtmux/session.py | 77 ++++++++++++++++++++++++ src/libtmux/window.py | 39 ++++++++++++ tests/test_server.py | 34 +++++++++++ tests/test_session.py | 21 +++++++ tests/test_window.py | 21 +++++++ 7 files changed, 335 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 2690418a5..40da2c4e8 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -250,6 +250,7 @@ def fetch_objs( server: Server, list_cmd: ListCmd, list_extra_args: ListExtraArgs = None, + filter: str | None = None, # noqa: A002 ) -> OutputsRaw: """Fetch a listing of raw data from a tmux command. @@ -266,6 +267,13 @@ def fetch_objs( list_extra_args : ListExtraArgs, optional Extra arguments appended to the tmux command (e.g. ``("-a",)`` for all windows/panes, or ``["-t", session_id]`` to filter). + filter : str, optional + Filter expression evaluated by tmux's format engine (``-f`` flag). + Objects for which the expanded expression is "false" (empty string, + "0") are omitted from the result. Pushes filtering into tmux's C + code instead of Python post-processing. + + .. versionadded:: 0.57 Returns ------- @@ -306,6 +314,9 @@ def fetch_objs( if list_extra_args is not None and isinstance(list_extra_args, Iterable): tmux_cmds.extend(list(list_extra_args)) + if filter is not None: + tmux_cmds.extend(["-f", filter]) + tmux_cmds.append(f"-F{format_string}") cmd_str: str | None = None diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 9556f4015..5e187f797 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2288,6 +2288,138 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + def search_sessions( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Session]: + """Sessions, optionally filtered by tmux's C-side format predicate. + + Like :attr:`Server.sessions` but adds an optional ``filter`` kwarg + that is plumbed through to ``$ tmux list-sessions -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). Sessions for which the + expanded expression is "false" are omitted by tmux itself before + any Python object is built. + + .. versionadded:: 0.57 + + Returns + ------- + :class:`~libtmux._internal.query_list.QueryList` of :class:`Session` + + Examples + -------- + >>> server.new_session(session_name='gap7_alpha') + Session($... gap7_alpha) + >>> server.new_session(session_name='other_beta') + Session($... other_beta) + >>> matches = server.search_sessions(filter='#{m:gap7_*,#{session_name}}') + >>> [s.session_name for s in matches] + ['gap7_alpha'] + """ + sessions: list[Session] = [] + + try: + for obj in fetch_objs( + list_cmd="list-sessions", + server=self, + filter=filter, + ): + sessions.append(Session(server=self, **obj)) # noqa: PERF401 + except Exception: + pass + + return QueryList(sessions) + + def search_windows( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Window]: + """All windows across sessions, optionally filtered by tmux's C-side predicate. + + Like :attr:`Server.windows` but with a ``filter`` kwarg plumbed to + ``$ tmux list-windows -a -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> sess = server.new_session(session_name='gap7_win_demo') + >>> _ = sess.new_window(window_name='gap7_target') + >>> _ = sess.new_window(window_name='other_window') + >>> matches = server.search_windows(filter='#{m:gap7_*,#{window_name}}') + >>> any(w.window_name == 'gap7_target' for w in matches) + True + >>> any(w.window_name == 'other_window' for w in matches) + False + """ + windows: list[Window] = [ + Window(server=self, **obj) + for obj in fetch_objs( + list_cmd="list-windows", + list_extra_args=("-a",), + server=self, + filter=filter, + ) + ] + + return QueryList(windows) + + def search_panes( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Pane]: + """All panes across the server, optionally filtered by tmux's C-side predicate. + + Like :attr:`Server.panes` but with a ``filter`` kwarg plumbed to + ``$ tmux list-panes -a -f ``. This is the typed entry point + for the fast-path libtmux-mcp uses for ``search_panes``: tmux drops + non-matching panes before any Python object is constructed. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). Example: + ``'#{m:%5,#{pane_id}}'`` (id match) or + ``'#{C/i:libtmux,#{pane_current_command}}'`` (case-insensitive + substring on the current command). + + .. versionadded:: 0.57 + + Examples + -------- + >>> sess = server.new_session(session_name='gap7_pane_demo') + >>> window = sess.active_window + >>> target_pane = window.split() + >>> matches = server.search_panes( + ... filter=f'#{{m:{target_pane.pane_id},#{{pane_id}}}}' + ... ) + >>> [p.pane_id for p in matches] == [target_pane.pane_id] + True + """ + panes: list[Pane] = [ + Pane(server=self, **obj) + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=("-a",), + server=self, + filter=filter, + ) + ] + + return QueryList(panes) + # # Dunder # diff --git a/src/libtmux/session.py b/src/libtmux/session.py index daeb2593b..2912e4e25 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -193,6 +193,83 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + def search_windows( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Window]: + """Windows in this session, optionally filtered by tmux's C-side predicate. + + Like :attr:`Session.windows` but with a ``filter`` kwarg plumbed to + ``$ tmux list-windows -t -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> _ = session.new_window(window_name='gap7s_target') + >>> _ = session.new_window(window_name='other_window') + >>> matches = session.search_windows(filter='#{m:gap7s_*,#{window_name}}') + >>> [w.window_name for w in matches] + ['gap7s_target'] + """ + windows: list[Window] = [ + Window(server=self.server, **obj) + for obj in fetch_objs( + list_cmd="list-windows", + list_extra_args=["-t", str(self.session_id)], + server=self.server, + filter=filter, + ) + if obj.get("session_id") == self.session_id + ] + + return QueryList(windows) + + def search_panes( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Pane]: + """Panes in this session, optionally filtered by tmux's C-side predicate. + + Like :attr:`Session.panes` but with a ``filter`` kwarg plumbed to + ``$ tmux list-panes -s -t -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> target_pane = session.active_window.split() + >>> matches = session.search_panes( + ... filter=f'#{{m:{target_pane.pane_id},#{{pane_id}}}}' + ... ) + >>> [p.pane_id for p in matches] == [target_pane.pane_id] + True + """ + panes: list[Pane] = [ + Pane(server=self.server, **obj) + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=["-s", "-t", str(self.session_id)], + server=self.server, + filter=filter, + ) + if obj.get("session_id") == self.session_id + ] + + return QueryList(panes) + # # Command # diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 1e6f9541c..ade947143 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -197,6 +197,45 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + def search_panes( + self, + *, + filter: str | None = None, # noqa: A002 + ) -> QueryList[Pane]: + """Panes in this window, optionally filtered by tmux's C-side predicate. + + Like :attr:`Window.panes` but with a ``filter`` kwarg plumbed to + ``$ tmux list-panes -t -f ``. + + Parameters + ---------- + filter : str, optional + tmux format expression (``-f`` flag). + + .. versionadded:: 0.57 + + Examples + -------- + >>> target_pane = window.split() + >>> matches = window.search_panes( + ... filter=f'#{{m:{target_pane.pane_id},#{{pane_id}}}}' + ... ) + >>> [p.pane_id for p in matches] == [target_pane.pane_id] + True + """ + panes: list[Pane] = [ + Pane(server=self.server, **obj) + for obj in fetch_objs( + list_cmd="list-panes", + list_extra_args=["-t", str(self.window_id)], + server=self.server, + filter=filter, + ) + if obj.get("window_id") == self.window_id + ] + + return QueryList(panes) + """ Commands (pane-scoped) """ diff --git a/tests/test_server.py b/tests/test_server.py index 90b88bf5b..30d114b9e 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1177,6 +1177,40 @@ def test_list_buffers_filter_pushes_predicate_into_tmux(server: Server) -> None: assert sorted(matches) == ["gap6match_alpha", "gap6match_beta"] +def test_server_search_sessions_filter(server: Server) -> None: + """``Server.list_sessions(filter=...)`` returns only matching sessions.""" + server.new_session(session_name="gap7_keep_alpha") + server.new_session(session_name="gap7_keep_beta") + server.new_session(session_name="other_drop") + + matches = server.search_sessions(filter="#{m:gap7_*,#{session_name}}") + names = sorted(s.session_name for s in matches if s.session_name) + assert names == ["gap7_keep_alpha", "gap7_keep_beta"] + + +def test_server_search_windows_filter(server: Server) -> None: + """``Server.list_windows(filter=...)`` returns only matching windows.""" + sess = server.new_session(session_name="gap7_win_demo") + sess.new_window(window_name="gap7_target") + sess.new_window(window_name="other_window") + + matches = server.search_windows(filter="#{m:gap7_*,#{window_name}}") + names = sorted(w.window_name for w in matches if w.window_name) + # Catch-all base window starts at name 'gap7_win_demo' (matches gap7_*), + # so we expect both the original and the new gap7_target. + assert "gap7_target" in names + assert "other_window" not in names + + +def test_server_search_panes_filter_by_id(server: Server) -> None: + """``Server.list_panes(filter=...)`` returns only the pane id we asked for.""" + sess = server.new_session(session_name="gap7_pane_demo") + target = sess.active_window.split() + + matches = server.search_panes(filter=f"#{{m:{target.pane_id},#{{pane_id}}}}") + assert [p.pane_id for p in matches] == [target.pane_id] + + def test_if_shell_true(server: Server) -> None: """Test Server.if_shell() with true condition.""" server.new_session(session_name="ifshell_test") diff --git a/tests/test_session.py b/tests/test_session.py index 4586dddf3..fb7043c52 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -671,3 +671,24 @@ def test_new_window_select_existing(session: Session) -> None: select_existing=True, ) assert w.window_name == "selexist_new" + + +def test_session_search_windows_filter(session: Session) -> None: + """``Session.list_windows(filter=...)`` filters via tmux's C-side -f flag.""" + session.new_window(window_name="gap7s_keep") + session.new_window(window_name="other_drop") + + matches = session.search_windows(filter="#{m:gap7s_*,#{window_name}}") + names = sorted(w.window_name for w in matches if w.window_name) + assert names == ["gap7s_keep"] + + +def test_session_search_panes_filter_by_id(session: Session) -> None: + """``Session.list_panes(filter=...)`` projects only matching pane ids.""" + window = session.active_window + target = window.split() + + matches = session.search_panes( + filter=f"#{{m:{target.pane_id},#{{pane_id}}}}", + ) + assert [p.pane_id for p in matches] == [target.pane_id] diff --git a/tests/test_window.py b/tests/test_window.py index 2f1e52e64..1211d2fa5 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1178,3 +1178,24 @@ def test_window_zoomed_flag_field_toggle(session: Session) -> None: pane.resize(zoom=True) window.refresh() assert window.window_zoomed_flag == "0" + + +def test_window_search_panes_filter_by_id(session: Session) -> None: + """``Window.search_panes(filter=...)`` returns only the matching pane id.""" + window = session.active_window + target = window.split() + + matches = window.search_panes(filter=f"#{{m:{target.pane_id},#{{pane_id}}}}") + assert [p.pane_id for p in matches] == [target.pane_id] + + +def test_window_search_panes_no_filter_equivalent_to_property( + session: Session, +) -> None: + """``search_panes()`` with no filter matches the existing ``panes`` property.""" + window = session.active_window + window.split() + + from_property = sorted(p.pane_id for p in window.panes if p.pane_id) + from_method = sorted(p.pane_id for p in window.search_panes() if p.pane_id) + assert from_property == from_method From 24714bcb53e2ca3f979bdc8b1d342a22c4602a9d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:53:26 -0500 Subject: [PATCH 08/65] exc,common(feat[subcommand]): Add LibTmuxException.subcommand and raise_if_stderr helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: wrappers like session.last_window raise exc.LibTmuxException(proc.stderr) and downstream consumers (libtmux-mcp's handle_tool_errors) lose the "which tmux command failed" context. Pre-0.56 the MCP built `f"tmux {subcommand} failed: ..."` manually. Split into two commits per planning direction: * 8a (this commit): add the surface — LibTmuxException.subcommand attribute and raise_if_stderr helper. Backward-compatible; no call-site changes yet. * 8b (next commit): mechanically migrate the ~12 existing raise sites to use raise_if_stderr. what: - LibTmuxException.__init__ accepts subcommand: str | None = None kwarg. Override __str__ to format as ": " when set; otherwise preserves pre-0.57 output exactly. Verified backward-compat with a test that constructs exc with no kwarg and asserts no "subcommand:" prefix. - common.raise_if_stderr(proc, subcommand) consolidates the `if proc.stderr: raise exc.LibTmuxException(...)` pattern. common.py already imports `exc`, so no new import. Documented with versionadded marker and a working doctest. - Tests in tests/test_common.py cover both: the no-stderr no-op path (using session fixture for a started server) and the raises-with-tag path via list-clients against a fake session id. --- src/libtmux/common.py | 35 ++++++++++++++++++++++++++++++++++ src/libtmux/exc.py | 29 +++++++++++++++++++++++++++- tests/test_common.py | 44 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 69f077b99..8113adc49 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -241,6 +241,41 @@ def getenv(self, name: str) -> str | bool | None: return opts_dict.get(name) +def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: + """Raise :exc:`LibTmuxException` tagged with the tmux subcommand on stderr. + + Centralizes the ``if proc.stderr: raise exc.LibTmuxException(proc.stderr)`` + pattern scattered across the wrappers. Tags the exception with the + originating tmux subcommand so downstream consumers (e.g. libtmux-mcp's + ``handle_tool_errors``) keep the "which tmux command failed" context. + + Parameters + ---------- + proc : :class:`tmux_cmd` + Result of a :meth:`Server.cmd` / :meth:`Session.cmd` / etc. call. + subcommand : str + The tmux subcommand the wrapper invoked, e.g. ``"last-window"``, + ``"swap-pane"``. Surfaces in ``str(exc)`` as a ``": …"`` + prefix. + + Raises + ------ + :exc:`LibTmuxException` + When ``proc.stderr`` is non-empty. + + Examples + -------- + >>> from libtmux.common import raise_if_stderr + >>> from libtmux import exc + >>> proc = session.cmd("display-message", "-p", "#{session_id}") + >>> raise_if_stderr(proc, "display-message") # no stderr → no raise + + .. versionadded:: 0.57 + """ + if proc.stderr: + raise exc.LibTmuxException(proc.stderr, subcommand=subcommand) + + class tmux_cmd: """Run any :term:`tmux(1)` command through :py:mod:`subprocess`. diff --git a/src/libtmux/exc.py b/src/libtmux/exc.py index 6a47b247c..0a0c8228c 100644 --- a/src/libtmux/exc.py +++ b/src/libtmux/exc.py @@ -16,7 +16,34 @@ class LibTmuxException(Exception): - """Base Exception for libtmux Errors.""" + """Base Exception for libtmux Errors. + + Parameters + ---------- + *args : object + Forwarded to :class:`Exception`. + subcommand : str, optional + The tmux subcommand that produced this error (e.g. ``"last-window"``). + When set, :meth:`__str__` formats as ``": "`` so + downstream consumers see which tmux command failed. + + .. versionadded:: 0.57 + """ + + def __init__( + self, + *args: object, + subcommand: str | None = None, + ) -> None: + super().__init__(*args) + self.subcommand = subcommand + + def __str__(self) -> str: + """Render with optional ``": …"`` prefix.""" + base = super().__str__() + if self.subcommand is None: + return base + return f"{self.subcommand}: {base}" class DeprecatedError(LibTmuxException): diff --git a/tests/test_common.py b/tests/test_common.py index f9c6c0968..e136ccd7a 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -526,3 +526,47 @@ def test_tmux_cmd_pre_execution_logging( ] assert len(running_records) > 0 assert "list-sessions" in running_records[0].tmux_cmd + + +def test_libtmux_exception_subcommand_default_none() -> None: + """Backward-compat: existing call sites (no kwarg) get subcommand=None.""" + err = exc.LibTmuxException(["no last window"]) + assert err.subcommand is None + # str(err) reproduces only the args, preserving pre-0.57 shape. + assert "no last window" in str(err) + assert not str(err).startswith(":") + + +def test_libtmux_exception_subcommand_tags_str() -> None: + """When ``subcommand`` is set, str(exc) prefixes ``": …"``.""" + err = exc.LibTmuxException(["no last window"], subcommand="last-window") + assert err.subcommand == "last-window" + assert str(err).startswith("last-window:") + assert "no last window" in str(err) + + +def test_raise_if_stderr_no_stderr_is_noop(session: libtmux.Session) -> None: + """``raise_if_stderr`` returns silently when proc.stderr is empty.""" + from libtmux.common import raise_if_stderr + + proc = session.cmd("display-message", "-p", "#{version}") + raise_if_stderr(proc, "display-message") # must not raise + + +def test_raise_if_stderr_raises_with_subcommand_tag( + session: libtmux.Session, +) -> None: + """``raise_if_stderr`` raises ``LibTmuxException`` tagged with subcommand.""" + from libtmux.common import raise_if_stderr + + # Provoke a tmux stderr: ask list-clients with a non-existent target. + proc = session.server.cmd( + "list-clients", "-t", "$nonexistent_session_id_for_test" + ) + assert proc.stderr # sanity check the fixture + + with pytest.raises(exc.LibTmuxException) as excinfo: + raise_if_stderr(proc, "list-clients") + + assert excinfo.value.subcommand == "list-clients" + assert str(excinfo.value).startswith("list-clients:") From 6b79cfc6b7d897a67ceba1680e30801be5a1968e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 05:58:58 -0500 Subject: [PATCH 09/65] core(refactor[exc]): Migrate stderr raises to raise_if_stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: mechanically thread the subcommand tag through every wrapper that raises on tmux stderr. With 8a's surface in place (LibTmuxException.subcommand + raise_if_stderr helper), this commit applies the migration so every typed wrapper now produces an exception tagged with the originating tmux subcommand. what: - Replace every `if proc.stderr: raise exc.LibTmuxException(proc.stderr)` pair with `raise_if_stderr(proc, "")` across the wrapper surface: server.py (sites), session.py (11), window.py (14), pane.py (22). Plus one explicit site in neo.py for fetch_objs's underlying tmux_cmd invocation. - Migration was scripted with subcommand auto-extraction from the preceding `proc = …cmd("subcmd", …)` line; two unmapped sites (window.py's select_layout, neo.py's fetch_objs) migrated by hand. - Add raise_if_stderr import to every touched module via ruff isort. - New integration test in tests/test_session.py exercises the end-to-end tag: session.last_window() on a one-window session raises an exception with subcommand == "last-window" and str(exc) prefixed accordingly. --- src/libtmux/neo.py | 5 +- src/libtmux/pane.py | 68 +++++++++------------------ src/libtmux/server.py | 104 ++++++++++++++--------------------------- src/libtmux/session.py | 38 ++++++--------- src/libtmux/window.py | 47 +++++++------------ tests/test_session.py | 14 ++++++ 6 files changed, 103 insertions(+), 173 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 40da2c4e8..2eb8c8b35 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -10,7 +10,7 @@ from collections.abc import Iterable from libtmux import exc -from libtmux.common import tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -336,8 +336,7 @@ def fetch_objs( tmux_bin=server.tmux_bin, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, list_cmd) outputs = [parse_output(line) for line in proc.stdout] diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 47c8c5227..958297614 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -14,7 +14,7 @@ import warnings from libtmux import exc -from libtmux.common import has_gte_version, tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import ( PANE_DIRECTION_FLAG_MAP, RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, @@ -311,8 +311,7 @@ def resize( proc = self.cmd("resize-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "resize-pane") self.refresh() return self @@ -859,8 +858,7 @@ def kill( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-pane") extra: dict[str, str] = { "tmux_subcommand": "kill-pane", @@ -974,8 +972,7 @@ def select( proc = self.cmd("select-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-pane") self.refresh() @@ -1231,8 +1228,7 @@ def set_title(self, title: str) -> Pane: 'my-title' """ proc = self.cmd("select-pane", "-T", title) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-pane") self.refresh() return self @@ -1443,8 +1439,7 @@ def display_popup( proc = self.cmd("display-popup", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "display-popup") def paste_buffer( self, @@ -1495,8 +1490,7 @@ def paste_buffer( proc = self.cmd("paste-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "paste-buffer") def pipe( self, @@ -1543,8 +1537,7 @@ def pipe( proc = self.cmd("pipe-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "pipe-pane") def copy_mode( self, @@ -1609,8 +1602,7 @@ def copy_mode( proc = self.cmd("copy-mode", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "copy-mode") def clock_mode(self) -> None: """Enter clock mode via ``$ tmux clock-mode``. @@ -1619,8 +1611,7 @@ def clock_mode(self) -> None: """ proc = self.cmd("clock-mode") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "clock-mode") def display_panes( self, @@ -1654,8 +1645,7 @@ def display_panes( proc = self.server.cmd("display-panes", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "display-panes") def choose_buffer(self) -> None: """Enter buffer chooser via ``$ tmux choose-buffer``. @@ -1664,8 +1654,7 @@ def choose_buffer(self) -> None: """ proc = self.cmd("choose-buffer") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "choose-buffer") def choose_client(self) -> None: """Enter client chooser via ``$ tmux choose-client``. @@ -1674,8 +1663,7 @@ def choose_client(self) -> None: """ proc = self.cmd("choose-client") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "choose-client") def choose_tree( self, @@ -1737,8 +1725,7 @@ def choose_tree( proc = self.cmd("choose-tree", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "choose-tree") def customize_mode(self) -> None: """Enter customize mode via ``$ tmux customize-mode``. @@ -1747,8 +1734,7 @@ def customize_mode(self) -> None: """ proc = self.cmd("customize-mode") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "customize-mode") def find_window( self, @@ -1804,8 +1790,7 @@ def find_window( proc = self.cmd("find-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "find-window") def send_prefix(self, *, secondary: bool | None = None) -> None: """Send the prefix key to the pane via ``$ tmux send-prefix``. @@ -1826,8 +1811,7 @@ def send_prefix(self, *, secondary: bool | None = None) -> None: proc = self.cmd("send-prefix", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "send-prefix") def respawn( self, @@ -1874,8 +1858,7 @@ def respawn( proc = self.cmd("respawn-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "respawn-pane") def move( self, @@ -1946,8 +1929,7 @@ def move( # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("move-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "move-pane") def join( self, @@ -2018,8 +2000,7 @@ def join( # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("join-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "join-pane") def break_pane( self, @@ -2061,8 +2042,7 @@ def break_pane( # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("break-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "break-pane") window_id = proc.stdout[0].strip() @@ -2142,8 +2122,7 @@ def swap( proc = self.cmd("swap-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "swap-pane") def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: """Clear pane history buffer via ``$ tmux clear-history``. @@ -2172,8 +2151,7 @@ def clear_history(self, *, reset_hyperlinks: bool | None = None) -> None: proc = self.cmd("clear-history", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "clear-history") def clear(self) -> Pane: """Clear pane.""" diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 5e187f797..95edaa884 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -17,7 +17,7 @@ from libtmux import exc from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -419,8 +419,7 @@ def kill_session(self, target_session: str | int) -> Server: """ proc = self.cmd("kill-session", target=target_session) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-session") return self @@ -479,8 +478,7 @@ def run_shell( proc = self.cmd("run-shell", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "run-shell") if background: return None @@ -528,8 +526,7 @@ def wait_for( proc = self.cmd("wait-for", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "wait-for") def bind_key( self, @@ -575,8 +572,7 @@ def bind_key( proc = self.cmd("bind-key", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "bind-key") def unbind_key( self, @@ -620,8 +616,7 @@ def unbind_key( proc = self.cmd("unbind-key", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "unbind-key") def list_keys( self, @@ -653,8 +648,7 @@ def list_keys( proc = self.cmd("list-keys", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-keys") return proc.stdout @@ -684,8 +678,7 @@ def list_commands(self, *, command_name: str | None = None) -> list[str]: proc = self.cmd("list-commands", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-commands") return proc.stdout @@ -699,8 +692,7 @@ def lock_server(self) -> None: """ proc = self.cmd("lock-server") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "lock-server") def server_access( self, @@ -771,8 +763,7 @@ def server_access( proc = self.cmd("server-access", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "server-access") if list_access: return proc.stdout @@ -800,8 +791,7 @@ def refresh_client(self, *, target_client: str | None = None) -> None: proc = self.cmd("refresh-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "refresh-client") def suspend_client(self, *, target_client: str | None = None) -> None: """Suspend a client via ``$ tmux suspend-client``. @@ -825,8 +815,7 @@ def suspend_client(self, *, target_client: str | None = None) -> None: proc = self.cmd("suspend-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "suspend-client") def lock_client(self, *, target_client: str | None = None) -> None: """Lock a client via ``$ tmux lock-client``. @@ -850,8 +839,7 @@ def lock_client(self, *, target_client: str | None = None) -> None: proc = self.cmd("lock-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "lock-client") def detach_client( self, @@ -900,8 +888,7 @@ def detach_client( proc = self.cmd("detach-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "detach-client") def detach_all_clients( self, @@ -948,8 +935,7 @@ def detach_all_clients( proc = self.cmd("detach-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "detach-client") def confirm_before( self, @@ -1035,8 +1021,7 @@ def confirm_before( proc = self.cmd("confirm-before", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "confirm-before") def command_prompt( self, @@ -1168,8 +1153,7 @@ def command_prompt( proc = self.cmd("command-prompt", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "command-prompt") def display_menu( self, @@ -1321,8 +1305,7 @@ def display_menu( proc = self.cmd("display-menu", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "display-menu") def start_server(self) -> None: """Start the tmux server via ``$ tmux start-server``. @@ -1334,8 +1317,7 @@ def start_server(self) -> None: """ proc = self.cmd("start-server") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "start-server") def show_messages( self, @@ -1390,8 +1372,7 @@ def show_messages( proc = self.cmd("show-messages", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "show-messages") return proc.stdout @@ -1593,8 +1574,7 @@ def show_prompt_history( proc = self.cmd("show-prompt-history", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "show-prompt-history") return proc.stdout @@ -1628,8 +1608,7 @@ def clear_prompt_history( proc = self.cmd("clear-prompt-history", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "clear-prompt-history") def set_buffer( self, @@ -1667,8 +1646,7 @@ def set_buffer( proc = self.cmd("set-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "set-buffer") def show_buffer(self, *, buffer_name: str | None = None) -> str: """Show content of a paste buffer via ``$ tmux show-buffer``. @@ -1696,8 +1674,7 @@ def show_buffer(self, *, buffer_name: str | None = None) -> str: proc = self.cmd("show-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "show-buffer") return "\n".join(proc.stdout) @@ -1722,8 +1699,7 @@ def delete_buffer(self, *, buffer_name: str | None = None) -> None: proc = self.cmd("delete-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "delete-buffer") def save_buffer( self, @@ -1762,8 +1738,7 @@ def save_buffer( proc = self.cmd("save-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "save-buffer") def load_buffer( self, @@ -1796,8 +1771,7 @@ def load_buffer( proc = self.cmd("load-buffer", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "load-buffer") def list_buffers( self, @@ -1870,8 +1844,7 @@ def list_buffers( proc = self.cmd("list-buffers", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-buffers") return proc.stdout @@ -1920,8 +1893,7 @@ def if_shell( proc = self.cmd("if-shell", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "if-shell") def source_file( self, @@ -1966,8 +1938,7 @@ def source_file( proc = self.cmd("source-file", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "source-file") def list_clients(self) -> list[str]: """List connected clients via ``$ tmux list-clients``. @@ -1984,8 +1955,7 @@ def list_clients(self) -> list[str]: """ proc = self.cmd("list-clients") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "list-clients") return proc.stdout @@ -2005,8 +1975,7 @@ def switch_client(self, target_session: str) -> None: proc = self.cmd("switch-client", target=target_session) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "switch-client") def attach_session(self, target_session: str | None = None) -> None: """Attach tmux session. @@ -2023,8 +1992,7 @@ def attach_session(self, target_session: str | None = None) -> None: session_check_name(target_session) proc = self.cmd("attach-session", target=target_session) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "attach-session") def new_session( self, @@ -2132,8 +2100,7 @@ def new_session( if self.has_session(session_name): if kill_session: proc = self.cmd("kill-session", target=session_name) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-session") logger.info( "existing session killed", extra={ @@ -2204,8 +2171,7 @@ def new_session( proc = self.cmd("new-session", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "new-session") session_stdout = proc.stdout[0] diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 2912e4e25..dca283bce 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -13,7 +13,7 @@ import typing as t from libtmux._internal.query_list import QueryList -from libtmux.common import tmux_cmd +from libtmux.common import raise_if_stderr, tmux_cmd from libtmux.constants import WINDOW_DIRECTION_FLAG_MAP, OptionScope, WindowDirection from libtmux.formats import FORMAT_SEPARATOR from libtmux.hooks import HooksMixin @@ -329,8 +329,7 @@ def lock_session(self) -> None: """ proc = self.cmd("lock-session") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "lock-session") def detach_client( self, @@ -369,8 +368,7 @@ def detach_client( proc = self.server.cmd("detach-client", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "detach-client") def last_window(self) -> Window: """Select the last (previously selected) window. @@ -391,8 +389,7 @@ def last_window(self) -> Window: """ proc = self.cmd("last-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "last-window") return self.active_window @@ -414,8 +411,7 @@ def next_window(self) -> Window: """ proc = self.cmd("next-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "next-window") return self.active_window @@ -437,8 +433,7 @@ def previous_window(self) -> Window: """ proc = self.cmd("previous-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "previous-window") return self.active_window @@ -468,8 +463,7 @@ def select_window(self, target_window: str | int) -> Window: proc = self.cmd("select-window", target=target) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-window") return self.active_window @@ -523,8 +517,7 @@ def attach( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "attach-session") return self @@ -589,8 +582,7 @@ def kill( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-session") msg = "other sessions killed" if all_except else "session killed" extra: dict[str, str] = { @@ -611,8 +603,7 @@ def switch_client(self) -> Session: """ proc = self.cmd("switch-client") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "switch-client") return self @@ -632,8 +623,7 @@ def rename_session(self, new_name: str) -> Session: proc = self.cmd("rename-session", new_name) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "rename-session") self.refresh() @@ -785,8 +775,7 @@ def new_window( cmd = self.cmd("new-window", *window_args, target=target) - if cmd.stderr: - raise exc.LibTmuxException(cmd.stderr) + raise_if_stderr(cmd, "new-window") window_output = cmd.stdout[0] @@ -838,8 +827,7 @@ def kill_window(self, target_window: str | int | None = None) -> None: proc = self.cmd("kill-window", target=target) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-window") extra: dict[str, str] = { "tmux_subcommand": "kill-window", diff --git a/src/libtmux/window.py b/src/libtmux/window.py index ade947143..2113278f3 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -15,7 +15,7 @@ import warnings from libtmux._internal.query_list import QueryList -from libtmux.common import has_gte_version, tmux_cmd +from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import ( RESIZE_ADJUSTMENT_DIRECTION_FLAG_MAP, OptionScope, @@ -301,8 +301,7 @@ def select_pane(self, target_pane: str | int) -> Pane | None: else: proc = self.cmd("select-pane", target=target_pane) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-pane") return self.active_pane @@ -443,8 +442,7 @@ def resize( proc = self.cmd("resize-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "resize-window") self.refresh() return self @@ -499,8 +497,7 @@ def last_pane( proc = self.cmd("last-pane", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "last-pane") return self.active_pane @@ -588,8 +585,7 @@ def select_layout( proc = self.cmd(*cmd) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-layout") return self @@ -603,8 +599,7 @@ def next_layout(self) -> Window: """ proc = self.cmd("next-layout") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "next-layout") return self @@ -618,8 +613,7 @@ def previous_layout(self) -> Window: """ proc = self.cmd("previous-layout") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "previous-layout") return self @@ -686,8 +680,7 @@ def link( proc = self.server.cmd("link-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "link-window") def unlink(self, *, kill_if_last: bool | None = None) -> None: """Unlink this window from the current session via ``$ tmux unlink-window``. @@ -712,8 +705,7 @@ def unlink(self, *, kill_if_last: bool | None = None) -> None: proc = self.cmd("unlink-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "unlink-window") def rotate( self, @@ -758,8 +750,7 @@ def rotate( proc = self.cmd("rotate-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "rotate-window") return self @@ -808,8 +799,7 @@ def respawn( proc = self.cmd("respawn-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "respawn-window") def swap( self, @@ -848,8 +838,7 @@ def swap( proc = self.cmd("swap-window", *tmux_args) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "swap-window") self.refresh() if isinstance(target, Window): @@ -1033,8 +1022,7 @@ def rename_window(self, new_name: str) -> Window: lex.whitespace_split = False proc = self.cmd("rename-window", new_name) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "rename-window") self.window_name = new_name self.refresh() @@ -1100,8 +1088,7 @@ def kill( *flags, ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "kill-window") msg = "other windows killed" if all_except else "window killed" extra: dict[str, str] = { @@ -1192,8 +1179,7 @@ def move_window( target=f"{session}:{destination}", ) - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "move-window") self.refresh() @@ -1282,8 +1268,7 @@ def select(self) -> Window: """ proc = self.cmd("select-window") - if proc.stderr: - raise exc.LibTmuxException(proc.stderr) + raise_if_stderr(proc, "select-window") self.refresh() diff --git a/tests/test_session.py b/tests/test_session.py index fb7043c52..b79ccc03b 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -673,6 +673,20 @@ def test_new_window_select_existing(session: Session) -> None: assert w.window_name == "selexist_new" +def test_session_last_window_exception_tags_subcommand(session: Session) -> None: + """Wrapper raises tag LibTmuxException with the originating tmux subcommand. + + Calling ``last_window()`` on a session with only one window and no prior + selection makes tmux error. The raised exception carries + ``.subcommand == "last-window"`` and ``str(exc)`` prefixes the + subcommand so downstream consumers see which tmux command failed. + """ + with pytest.raises(exc.LibTmuxException) as excinfo: + session.last_window() + assert excinfo.value.subcommand == "last-window" + assert str(excinfo.value).startswith("last-window:") + + def test_session_search_windows_filter(session: Session) -> None: """``Session.list_windows(filter=...)`` filters via tmux's C-side -f flag.""" session.new_window(window_name="gap7s_keep") From 6d153ef1ed233ede1dad5a854c3635a6f3f87c52 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 06:56:56 -0500 Subject: [PATCH 10/65] Server(feat[cmd]): Warn on legacy -t-in-args usage why: Server.cmd auto-injects -t when the target= kwarg is set. A caller's own positional -t produces `tmux -t %1 -t %1`; tmux's args_get() applies last-wins so the positional -t is silently dropped. The 0.34 docstring at session.py:234 already documents this contract as ignored; this commit promotes the documented contract into a runtime DeprecationWarning so callers see the bug instead of getting silent no-ops. what: - Server.cmd: when target is not None and "-t" appears in *args, emit DeprecationWarning with stacklevel=3 so the warning surfaces at the caller (not the wrapper). - Migrate tests/test_common.py:51 to use target= kwarg (the modern path). - tests/legacy_api/test_common.py:204 now wraps the legacy call with pytest.warns(DeprecationWarning) to pin the new contract. - New tests in tests/test_server.py exercise both the warning fires (legacy shape) and the no-warning case (target= alone). - MIGRATION entry under "Upcoming Release" documents the deprecation, the migration path (target= kwarg), and the planned TypeError escalation. --- MIGRATION | 24 ++++++++++++++++++++++++ src/libtmux/server.py | 19 +++++++++++++++++++ tests/legacy_api/test_common.py | 11 +++++++++-- tests/test_common.py | 6 ++---- tests/test_server.py | 20 ++++++++++++++++++++ 5 files changed, 74 insertions(+), 6 deletions(-) diff --git a/MIGRATION b/MIGRATION index 123b0cdcf..60644835c 100644 --- a/MIGRATION +++ b/MIGRATION @@ -113,6 +113,30 @@ _Detailed migration steps for the next version will be posted here._ +### Passing ``-t`` to ``Object.cmd()`` is deprecated + +{meth}`Server.cmd`, {meth}`Session.cmd`, {meth}`Window.cmd`, and {meth}`Pane.cmd` +auto-inject ``-t `` to bind the call to the object's id. Passing ``-t`` +positionally in ``*args`` while the wrapper auto-sets the target is a +documented no-op (tmux's ``args_get`` applies last-wins) — and now emits +{exc}`DeprecationWarning`. A future release will escalate to {exc}`TypeError`. + +Migration: + +```python +# Before +session.cmd("new-window", "-t", 3, "-n", "name") + +# After +session.cmd("new-window", "-n", "name", target=3) +``` + +For calls that need ``-t`` for a *different* target (not the object's id — +e.g. ``-t `` for ``display-menu``), pass it through +``server.cmd("display-menu", "-c", client_name, ...)`` without conflict — +the warning only fires when ``target=`` is set *and* ``-t`` appears in +``*args``. + ## libtmux 0.50.0: Unified Options and Hooks API (#516) ### New unified options API diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 95edaa884..ef1846be4 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -306,7 +306,26 @@ def cmd( .. versionchanged:: 0.8 Renamed from ``.tmux`` to ``.cmd``. + + .. versionchanged:: 0.57 + + Passing ``-t`` positionally while *target* is also set now + emits :exc:`DeprecationWarning`. Use the *target* kwarg. + With both set, tmux's last-wins arg parsing makes the + positional ``-t`` override the kwarg target silently, so the + call's effective target is rarely the one a reader of the + code would expect. A future release will escalate to + :exc:`TypeError`. """ + if target is not None and "-t" in args: + warnings.warn( + "Passing -t in *args is the legacy form and is ignored when " + "target= is set; pass target= instead. Will become a " + "TypeError in a future release.", + DeprecationWarning, + stacklevel=3, + ) + svr_args: list[str | int] = [cmd] cmd_args: list[str | int] = [] if self.socket_name: diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index a6631f713..e20a032fb 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -200,8 +200,15 @@ def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: def test_tmux_cmd_unicode(session: Session) -> None: - """Verify tmux commands with unicode.""" - session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") + """Legacy ``-t`` in *args is ignored and emits :exc:`DeprecationWarning`. + + Pins the contract documented since 0.34: when ``target=`` is auto-set + by ``Session.cmd``, a positional ``-t`` is silently dropped by tmux + (last-wins via ``args_get``). The wrapper now surfaces that with a + :exc:`DeprecationWarning`. + """ + with pytest.warns(DeprecationWarning, match="legacy form"): + session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") class SessionCheckName(t.NamedTuple): diff --git a/tests/test_common.py b/tests/test_common.py index e136ccd7a..7643f1211 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -48,7 +48,7 @@ def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: def test_tmux_cmd_unicode(session: Session) -> None: """Verify tmux commands with unicode.""" - session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") + session.cmd("new-window", "-n", "юникод", "-F", "Ελληνικά", target=3) class SessionCheckName(t.NamedTuple): @@ -560,9 +560,7 @@ def test_raise_if_stderr_raises_with_subcommand_tag( from libtmux.common import raise_if_stderr # Provoke a tmux stderr: ask list-clients with a non-existent target. - proc = session.server.cmd( - "list-clients", "-t", "$nonexistent_session_id_for_test" - ) + proc = session.server.cmd("list-clients", "-t", "$nonexistent_session_id_for_test") assert proc.stderr # sanity check the fixture with pytest.raises(exc.LibTmuxException) as excinfo: diff --git a/tests/test_server.py b/tests/test_server.py index 30d114b9e..6cb23d644 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1177,6 +1177,26 @@ def test_list_buffers_filter_pushes_predicate_into_tmux(server: Server) -> None: assert sorted(matches) == ["gap6match_alpha", "gap6match_beta"] +def test_server_cmd_warns_on_legacy_dash_t(session: Session) -> None: + """``Server.cmd`` warns when a caller passes ``-t`` alongside ``target=``. + + Mirrors tmux's last-wins behavior on repeated flags: tmux silently drops + the positional ``-t``. The wrapper surfaces the legacy form with a + :exc:`DeprecationWarning` so callers can migrate to ``target=``. + """ + with pytest.warns(DeprecationWarning, match="legacy form"): + session.cmd("display-message", "-t", session.session_id, "-p", "ok") + + +def test_server_cmd_no_warning_when_target_only(session: Session) -> None: + """``target=`` alone (no ``-t`` in args) does not warn.""" + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error", DeprecationWarning) + session.cmd("display-message", "-p", "ok") + + def test_server_search_sessions_filter(server: Server) -> None: """``Server.list_sessions(filter=...)`` returns only matching sessions.""" server.new_session(session_name="gap7_keep_alpha") From a783ca33aa0c9a2333c3f19b09a4d3a98388ba0b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 07:01:33 -0500 Subject: [PATCH 11/65] neo(feat[fields]): Declare pane/window/session format tokens on Obj why: tmux's format_table[] at format.c:3010-3563 registers 37 scope-relevant format tokens that ship in 3.6a; libtmux's hand-curated allowlist in neo.py declared only a subset. An earlier commit on this branch added window_zoomed_flag specifically; this commit covers the remaining 36 so the typed dataclass surface matches what tmux exposes. what: - src/libtmux/neo.py: add 13 pane_* (pane_dead, 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), 12 window_* (window_active_clients_list, window_active_sessions_list, window_activity_flag, window_bell_flag, window_bigger, window_end_flag, window_flags, window_format, window_last_flag, window_silence_flag, window_start_flag, window_visible_layout), 11 session_* (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) fields. Alphabetical insertion preserves existing layout. Each as `str | None = None`; get_output_format() auto-includes them in the tmux -F template. - Tests: parametrized declaration + hydration tests per scope assert each field is registered on the dataclass and either None or a string after refresh(). On older tmux versions unknown tokens expand to empty strings, so older tmux still hydrates the rest of the fields fine. - Focused live tests: pane.pane_synchronized round-trips through tmux's synchronize-panes window option; window.window_flags is always a string. --- src/libtmux/neo.py | 36 ++++++++++++++++++++++++++++ tests/test_pane.py | 56 +++++++++++++++++++++++++++++++++++++++++++ tests/test_session.py | 28 ++++++++++++++++++++++ tests/test_window.py | 41 +++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 2eb8c8b35..a81c827a6 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -89,23 +89,36 @@ class Obj: pane_bottom: str | None = None pane_current_command: str | None = None pane_current_path: str | None = None + pane_dead: str | None = None pane_dead_signal: str | None = None pane_dead_status: str | None = None pane_dead_time: str | None = None pane_fg: str | None = None + pane_format: str | None = None pane_height: str | None = None pane_id: str | None = None + pane_in_mode: str | None = None pane_index: str | None = None + pane_input_off: str | None = None + pane_key_mode: str | None = None + pane_last: str | None = None pane_left: str | None = None + pane_marked: str | None = None + pane_marked_set: str | None = None + pane_mode: str | None = None + pane_path: str | None = None pane_pid: str | None = None + pane_pipe: str | None = None pane_right: str | None = None pane_search_string: str | None = None pane_start_command: str | None = None pane_start_path: str | None = None + pane_synchronized: str | None = None pane_tabs: str | None = None pane_title: str | None = None pane_top: str | None = None pane_tty: str | None = None + pane_unseen_changes: str | None = None pane_width: str | None = None pid: str | None = None scroll_position: str | None = None @@ -116,19 +129,30 @@ class Obj: selection_end_y: str | None = None selection_start_x: str | None = None selection_start_y: str | None = None + session_active: str | None = None session_activity: str | None = None + session_activity_flag: str | None = None + session_alert: str | None = None session_alerts: str | None = None session_attached: str | None = None session_attached_list: str | None = None + session_bell_flag: str | None = None session_created: str | None = None + session_format: str | None = None session_group: str | None = None session_group_attached: str | None = None + session_group_attached_list: str | None = None session_group_list: str | None = None + session_group_many_attached: str | None = None session_group_size: str | None = None + session_grouped: str | None = None session_id: str | None = None session_last_attached: str | None = None + session_many_attached: str | None = None + session_marked: str | None = None session_name: str | None = None session_path: str | None = None + session_silence_flag: str | None = None session_stack: str | None = None session_windows: str | None = None socket_path: str | None = None @@ -138,13 +162,22 @@ class Obj: version: str | None = None window_active: str | None = None # Not detected by script window_active_clients: str | None = None + window_active_clients_list: str | None = None window_active_sessions: str | None = None + window_active_sessions_list: str | None = None window_activity: str | None = None + window_activity_flag: str | None = None + window_bell_flag: str | None = None + window_bigger: str | None = None window_cell_height: str | None = None window_cell_width: str | None = None + window_end_flag: str | None = None + window_flags: str | None = None + window_format: str | None = None window_height: str | None = None window_id: str | None = None window_index: str | None = None + window_last_flag: str | None = None window_layout: str | None = None window_linked: str | None = None window_linked_sessions: str | None = None @@ -155,7 +188,10 @@ class Obj: window_offset_y: str | None = None window_panes: str | None = None window_raw_flags: str | None = None + window_silence_flag: str | None = None window_stack_index: str | None = None + window_start_flag: str | None = None + window_visible_layout: str | None = None window_width: str | None = None window_zoomed_flag: str | None = None wrap_flag: str | None = None diff --git a/tests/test_pane.py b/tests/test_pane.py index 4206b4a27..26904b202 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -607,6 +607,62 @@ def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: pane.send_keys() +PANE_FORMAT_FIELDS = ( + "pane_dead", + "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", +) + + +@pytest.mark.parametrize("field_name", PANE_FORMAT_FIELDS) +def test_pane_format_field_declared_and_hydrated( + field_name: str, + session: Session, +) -> None: + """Tmux's pane-scope format tokens hydrate onto the typed ``Pane`` object. + + Verifies each registered ``pane_*`` token from tmux's ``format_table[]`` + has a corresponding typed field on the ``Obj`` dataclass and that + ``refresh()`` populates it. Older tmux releases that don't recognize a + token expand it to the empty string, so the field reads as ``None``. + """ + pane = session.active_window.active_pane + assert pane is not None + + # Field must be declared on the dataclass. + 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 + window.split() + pane = window.active_pane + assert pane is not None + + window.set_option("synchronize-panes", "on") + pane.refresh() + assert pane.pane_synchronized == "1" + + window.set_option("synchronize-panes", "off") + pane.refresh() + assert pane.pane_synchronized == "0" + + def test_select_pane_direction(session: Session) -> None: """Test Pane.select() with direction flags.""" window = session.new_window(window_name="test_select_dir") diff --git a/tests/test_session.py b/tests/test_session.py index b79ccc03b..d80cefcb1 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -706,3 +706,31 @@ def test_session_search_panes_filter_by_id(session: Session) -> None: filter=f"#{{m:{target.pane_id},#{{pane_id}}}}", ) assert [p.pane_id for p in matches] == [target.pane_id] + + +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", +) + + +@pytest.mark.parametrize("field_name", SESSION_FORMAT_FIELDS) +def test_session_format_field_declared_and_hydrated( + field_name: str, + session: Session, +) -> None: + """Tmux's session-scope format tokens hydrate onto the typed ``Session``.""" + assert field_name in session.__dataclass_fields__ + + session.refresh() + value = getattr(session, field_name) + assert value is None or isinstance(value, str) diff --git a/tests/test_window.py b/tests/test_window.py index 1211d2fa5..58f77cd1e 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1199,3 +1199,44 @@ def test_window_search_panes_no_filter_equivalent_to_property( from_property = sorted(p.pane_id for p in window.panes if p.pane_id) from_method = sorted(p.pane_id for p in window.search_panes() if p.pane_id) assert from_property == from_method + + +WINDOW_FORMAT_FIELDS = ( + "window_active_clients_list", + "window_active_sessions_list", + "window_activity_flag", + "window_bell_flag", + "window_bigger", + "window_end_flag", + "window_flags", + "window_format", + "window_last_flag", + "window_silence_flag", + "window_start_flag", + "window_visible_layout", +) + + +@pytest.mark.parametrize("field_name", WINDOW_FORMAT_FIELDS) +def test_window_format_field_declared_and_hydrated( + field_name: str, + session: Session, +) -> None: + """Tmux's window-scope format tokens hydrate onto the typed ``Window``.""" + window = session.active_window + assert field_name in window.__dataclass_fields__ + + window.refresh() + value = getattr(window, field_name) + assert value is None or isinstance(value, str) + + +def test_window_flags_field_returns_string(session: Session) -> None: + """``window_flags`` summarizes window state and reads as a string. + + The value is often empty for an idle window; the contract is that the + field hydrates as a string (never ``None``) once tmux has populated it. + """ + window = session.active_window + window.refresh() + assert isinstance(window.window_flags, str) From b1ffc68aa4b28ac457b0240c74c74a193922a17a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 07:07:09 -0500 Subject: [PATCH 12/65] Client(feat): Add Client dataclass and Server.clients accessor why: tmux's format.c at lines 3041-3110 registers twelve client_* format tokens (client_activity, client_control_mode, client_created, client_last_session, client_mode_format, client_prefix, client_readonly, client_session, client_termfeatures, client_termtype, client_theme, client_utf8) that the libtmux Obj dataclass didn't declare, and Server.list_clients returned raw stderr-style strings instead of typed objects. Multi-client coordination, read-only detection, theme/termtype reads forced consumers down to server.cmd("list-clients", ...). what: - src/libtmux/neo.py: add the missing client_* fields to Obj alphabetically; extend ListCmd Literal with "list-clients". - src/libtmux/client.py: new module with @dataclasses.dataclass class Client(Obj). refresh() uses obj_key="client_name", list_cmd="list-clients"; classmethod from_client_name() mirrors the Session.from_session_id shape. - src/libtmux/server.py: import Client; new Server.clients property returns QueryList[Client] via fetch_objs(list_cmd="list-clients"). - src/libtmux/__init__.py: export Client; add to __all__. - conftest.py: register Client in the doctest_namespace. - docs/api/libtmux.client.md: autodoc page. - docs/api/index.md: card + toctree entry; updated lead to mention Client. - tests/test_client.py: live tests using the control_mode() fixture exercise Server.clients listing, attached-session reporting, default readonly state, and refresh() rehydration. --- conftest.py | 2 + docs/api/index.md | 10 ++++- docs/api/libtmux.client.md | 16 ++++++++ src/libtmux/__init__.py | 2 + src/libtmux/client.py | 81 ++++++++++++++++++++++++++++++++++++++ src/libtmux/neo.py | 14 ++++++- src/libtmux/server.py | 29 ++++++++++++++ tests/test_client.py | 62 +++++++++++++++++++++++++++++ 8 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docs/api/libtmux.client.md create mode 100644 src/libtmux/client.py create mode 100644 tests/test_client.py diff --git a/conftest.py b/conftest.py index fd7620e96..88a2656d2 100644 --- a/conftest.py +++ b/conftest.py @@ -18,6 +18,7 @@ from _pytest.doctest import DoctestItem from libtmux._internal.control_mode import ControlMode +from libtmux.client import Client from libtmux.pane import Pane from libtmux.pytest_plugin import USING_ZSH from libtmux.server import Server @@ -42,6 +43,7 @@ def add_doctest_fixtures( doctest_namespace["Session"] = Session doctest_namespace["Window"] = Window doctest_namespace["Pane"] = Pane + doctest_namespace["Client"] = Client doctest_namespace["server"] = request.getfixturevalue("server") doctest_namespace["Server"] = request.getfixturevalue("TestServer") session: Session = request.getfixturevalue("session") diff --git a/docs/api/index.md b/docs/api/index.md index 287d5a692..bf23783de 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -5,7 +5,8 @@ # API Reference libtmux's public API mirrors tmux's object hierarchy: -`Server` → `Session` → `Window` → `Pane` +`Server` → `Session` → `Window` → `Pane`. Attached terminals show up as +`Client` objects accessed off the server. ## What do you want to do? @@ -67,6 +68,12 @@ Manages panes, layouts, and window operations. Terminal instance. Send keys and capture output. ::: +:::{grid-item-card} Client +:link: libtmux.client +:link-type: doc +Attached terminal. Read read-only state, theme, termtype. +::: + :::: ## Supporting Modules @@ -160,6 +167,7 @@ Server Session Window Pane +Client Common Neo Options diff --git a/docs/api/libtmux.client.md b/docs/api/libtmux.client.md new file mode 100644 index 000000000..6bc88bec7 --- /dev/null +++ b/docs/api/libtmux.client.md @@ -0,0 +1,16 @@ +(clients)= + +# Clients + +- Attached terminals connected to a tmux server +- Each client has its own view of the active session, window, and pane +- Identified by ``client_name`` (the path or label tmux assigns at attach time) + +```{eval-rst} +.. autoclass:: libtmux.Client + :members: + :inherited-members: + :private-members: + :show-inheritance: + :member-order: bysource +``` diff --git a/src/libtmux/__init__.py b/src/libtmux/__init__.py index e8ee94308..c99fde4bd 100644 --- a/src/libtmux/__init__.py +++ b/src/libtmux/__init__.py @@ -14,6 +14,7 @@ __title__, __version__, ) +from .client import Client from .pane import Pane from .server import Server from .session import Session @@ -22,6 +23,7 @@ logging.getLogger(__name__).addHandler(logging.NullHandler()) __all__ = ( + "Client", "Pane", "Server", "Session", diff --git a/src/libtmux/client.py b/src/libtmux/client.py new file mode 100644 index 000000000..792cfe502 --- /dev/null +++ b/src/libtmux/client.py @@ -0,0 +1,81 @@ +"""Pythonization of the :term:`tmux(1)` client. + +libtmux.client +~~~~~~~~~~~~~~ + +""" + +from __future__ import annotations + +import dataclasses +import logging +import typing as t + +from libtmux.neo import Obj, fetch_obj + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass() +class Client(Obj): + """:term:`tmux(1)` :term:`Client` [client_manual]_. + + A tmux client is an attached terminal. The same tmux server can have + multiple clients attached simultaneously (e.g. ``$ tmux attach`` from + several terminals) and each receives its own view of the active + session, window, and pane. + + Parameters + ---------- + server : :class:`Server` + + Examples + -------- + >>> with control_mode() as ctl: + ... attached = [ + ... c + ... for c in server.clients + ... if c.client_name == ctl.client_name + ... ] + >>> bool(attached) + True + + >>> with control_mode() as ctl: + ... client = server.clients.get(client_name=ctl.client_name) + ... client.client_readonly in {"0", "1"} + True + + References + ---------- + .. [client_manual] tmux client. openbsd manpage for TMUX(1). + "tmux supports multiple attached clients. Each client has its + own keymap, view of the session, and message log." + + https://man.openbsd.org/tmux.1#DESCRIPTION. Accessed 2026. + """ + + server: Server + + def refresh(self) -> None: + """Refresh client attributes from tmux.""" + assert isinstance(self.client_name, str) + return super()._refresh( + obj_key="client_name", + obj_id=self.client_name, + list_cmd="list-clients", + ) + + @classmethod + def from_client_name(cls, server: Server, client_name: str) -> Client: + """Create Client from an existing client_name.""" + client = fetch_obj( + obj_key="client_name", + obj_id=client_name, + list_cmd="list-clients", + server=server, + ) + return cls(server=server, **client) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index a81c827a6..2ba64e29d 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -14,7 +14,7 @@ from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: - ListCmd = t.Literal["list-sessions", "list-windows", "list-panes"] + ListCmd = t.Literal["list-sessions", "list-windows", "list-panes", "list-clients"] ListExtraArgs = Iterable[str] | None from libtmux.server import Server @@ -38,18 +38,30 @@ class Obj: buffer_name: str | None = None buffer_sample: str | None = None buffer_size: str | None = None + client_activity: str | None = None client_cell_height: str | None = None client_cell_width: str | None = None + client_control_mode: str | None = None + client_created: str | None = None client_discarded: str | None = None client_flags: str | None = None client_height: str | None = None client_key_table: str | None = None + client_last_session: str | None = None + client_mode_format: str | None = None client_name: str | None = None client_pid: str | None = None + client_prefix: str | None = None + client_readonly: str | None = None + client_session: str | None = None + client_termfeatures: str | None = None client_termname: str | None = None + client_termtype: str | None = None + client_theme: str | None = None client_tty: str | None = None client_uid: str | None = None client_user: str | None = None + client_utf8: str | None = None client_width: str | None = None client_written: str | None = None command_list_alias: str | None = None diff --git a/src/libtmux/server.py b/src/libtmux/server.py index ef1846be4..c2c372389 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -17,6 +17,7 @@ from libtmux import exc from libtmux._internal.query_list import QueryList +from libtmux.client import Client from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin @@ -2273,6 +2274,34 @@ def panes(self) -> QueryList[Pane]: return QueryList(panes) + @property + def clients(self) -> QueryList[Client]: + """Clients attached to this tmux server. + + Each attached terminal is a separate :class:`Client`. ``server.clients`` + returns the typed view; ``client.client_readonly``, ``client.client_termtype``, + ``client.client_session`` etc. read tmux's ``client_*`` format tokens. + + Returns + ------- + :class:`~libtmux._internal.query_list.QueryList` of :class:`Client` + + Examples + -------- + >>> with control_mode() as ctl: + ... names = [c.client_name for c in server.clients] + ... ctl.client_name in names + True + """ + clients: list[Client] = [] + try: + for obj in fetch_objs(list_cmd="list-clients", server=self): + clients.append(Client(server=self, **obj)) # noqa: PERF401 + except Exception: + pass + + return QueryList(clients) + def search_sessions( self, *, diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 000000000..1b48eea94 --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,62 @@ +"""Tests for libtmux Client object.""" + +from __future__ import annotations + +import typing as t + +from libtmux.client import Client + +if t.TYPE_CHECKING: + from libtmux.server import Server + + +def test_server_clients_returns_querylist( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``Server.clients`` lists every attached tmux client as a :class:`Client`.""" + with control_mode(): + clients = server.clients + assert len(clients) >= 1 + for client in clients: + assert isinstance(client, Client) + assert client.client_name is not None + + +def test_client_session_reports_attached_session( + control_mode: t.Callable[..., t.Any], + server: Server, + session: t.Any, +) -> None: + """``client.client_session`` reports the session this client is attached to.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.client_session == session.session_name + + +def test_client_readonly_default_zero( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """A non-readonly attached client reports ``client_readonly == "0"``.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.client_readonly == "0" + + +def test_client_refresh_rehydrates_fields( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``Client.refresh()`` repopulates fields from tmux's live state.""" + with control_mode() as ctl: + client = Client.from_client_name(server=server, client_name=ctl.client_name) + assert client.client_name == ctl.client_name + + # Stash and clear a field, then refresh: it must come back. + original_pid = client.client_pid + client.client_pid = None + client.refresh() + assert client.client_pid == original_pid From 7d24f91eb829338e99a00a86b14fb65ca56559eb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 07:10:20 -0500 Subject: [PATCH 13/65] Server(feat[run_shell]): Add cwd= and show_stderr= kwargs why: tmux's cmd-run-shell.c at lines 156-162 reads two flags the wrapper didn't expose: -c sets the shell command's working directory (independent of any target pane's cwd) and -E sets JOB_SHOWSTDERR, which combines the command's stderr into the captured output stream. what: - Server.run_shell gains `cwd: StrPath | None = None` (maps to -c) and `show_stderr: bool | None = None` (maps to -E). Both default-None, no behavior change for existing callers. - Doctest demonstrates pwd in a custom cwd and stderr capture. - Tests: `test_run_shell_cwd` runs `pwd` with `cwd=tmp_path` and asserts the directory appears in output; `test_run_shell_show_stderr` runs a shell snippet that writes to both streams and asserts both are in the result. Both gated by has_gte_version("3.5") because run-shell stdout passthrough requires tmux 3.5. --- src/libtmux/server.py | 35 +++++++++++++++++++++- tests/test_server.py | 67 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index c2c372389..aaeff034b 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -451,8 +451,10 @@ def run_shell( delay: str | None = None, as_tmux_command: bool | None = None, target_pane: str | None = None, + cwd: StrPath | None = None, + show_stderr: bool | None = None, ) -> list[str] | None: - """Execute a shell command via ``$ tmux run-shell``. + r"""Execute a shell command via ``$ tmux run-shell``. Parameters ---------- @@ -467,6 +469,19 @@ def run_shell( (``-C`` flag). target_pane : str, optional Target pane for output (``-t`` flag). + cwd : str or PathLike, optional + Working directory for the shell command (``-c`` flag). When + omitted, tmux uses the target client's current working + directory. Requires tmux 3.4+; on older tmux a warning is + emitted and the kwarg is ignored. + + .. versionadded:: 0.57 + show_stderr : bool, optional + Combine the command's stderr into the captured output stream + (``-E`` flag, maps to ``JOB_SHOWSTDERR``). Requires tmux 3.6+; + on older tmux a warning is emitted and the kwarg is ignored. + + .. versionadded:: 0.57 Returns ------- @@ -494,6 +509,24 @@ def run_shell( if target_pane is not None: tmux_args += ("-t", target_pane) + if cwd is not None: + if has_gte_version("3.4", tmux_bin=self.tmux_bin): + tmux_args += ("-c", str(cwd)) + else: + warnings.warn( + "cwd requires tmux 3.4+, ignoring", + stacklevel=2, + ) + + if show_stderr: + if has_gte_version("3.6", tmux_bin=self.tmux_bin): + tmux_args += ("-E",) + else: + warnings.warn( + "show_stderr requires tmux 3.6+, ignoring", + stacklevel=2, + ) + tmux_args += (command,) proc = self.cmd("run-shell", *tmux_args) diff --git a/tests/test_server.py b/tests/test_server.py index 6cb23d644..13d5afef1 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1019,6 +1019,73 @@ def test_run_shell_background(server: Server) -> None: assert result is None +def test_run_shell_cwd(server: Server, tmp_path: pathlib.Path) -> None: + """``cwd=`` sets the working directory for the shell command.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.5"): + pytest.skip("run-shell stdout passthrough requires tmux 3.5+") + + server.new_session(session_name="run_shell_cwd_test") + result = server.run_shell("pwd", cwd=tmp_path) + assert result is not None + assert any(str(tmp_path) in line for line in result) + + +def test_run_shell_show_stderr(server: Server) -> None: + """``show_stderr=True`` captures the command's stderr into the output.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.6"): + pytest.skip("run-shell -E (JOB_SHOWSTDERR) requires tmux 3.6+") + + server.new_session(session_name="run_shell_stderr_test") + result = server.run_shell( + "sh -c 'echo to_stdout; echo to_stderr >&2'", + show_stderr=True, + ) + assert result is not None + joined = "\n".join(result) + assert "to_stdout" in joined + assert "to_stderr" in joined + + +def test_run_shell_cwd_warns_on_old_tmux( + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, +) -> None: + """``cwd=`` emits a warning and skips ``-c`` on tmux <3.4. + + Simulates older tmux by patching ``has_gte_version`` in the + :mod:`libtmux.server` module namespace (where it's bound at + import time). + """ + import libtmux.server + + monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + server.new_session(session_name="run_shell_cwd_warn_test") + with pytest.warns(UserWarning, match="cwd requires tmux 3.4+"): + server.run_shell("true", cwd=tmp_path) + + +def test_run_shell_show_stderr_warns_on_old_tmux( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``show_stderr=True`` emits a warning and skips ``-E`` on tmux <3.6. + + Simulates older tmux by patching ``has_gte_version`` in the + :mod:`libtmux.server` module namespace. + """ + import libtmux.server + + monkeypatch.setattr(libtmux.server, "has_gte_version", lambda *a, **kw: False) + server.new_session(session_name="run_shell_stderr_warn_test") + with pytest.warns(UserWarning, match="show_stderr requires tmux 3.6+"): + server.run_shell("true", show_stderr=True) + + class BufferCase(t.NamedTuple): """Test case for buffer operations.""" From 5b3ded5f7061893eefae5594f920a8aca834f65c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 07:13:56 -0500 Subject: [PATCH 14/65] Pane(feat[capture_pane]): Add pending= kwarg why: tmux's cmd-capture-pane.c at lines 231-232 branches on -P to call cmd_capture_pane_pending, returning bytes tmux has buffered as input for the pane but the program hasn't consumed yet. Useful for diagnosing hung programs, copy-mode race conditions, and paste-buffer drains. The wrapper covered 12 of 13 flags from "ab:CeE:JMNpPqS:Tt:" but skipped -P. what: - Pane.capture_pane gains `pending: bool = False` kwarg, present on both overload signatures and the implementation. When True, the wrapper appends -P alongside -p so stdout still flows back as a list. - Docstring entry documents the distinction from the default capture (history vs unconsumed input). - Tests: argv-assertion test confirms -P is emitted via the monkeypatch+stub pattern; a smoke test confirms the return type is list[str] (whether tmux has bytes to return depends on live input pressure and isn't reliably reproducible). --- src/libtmux/pane.py | 14 +++++++++++ tests/test_pane_capture_pane.py | 42 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 958297614..88023a151 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -330,6 +330,7 @@ def capture_pane( alternate_screen: bool = ..., quiet: bool = ..., mode_screen: bool = ..., + pending: bool = ..., to_buffer: str, ) -> None: ... @@ -347,6 +348,7 @@ def capture_pane( alternate_screen: bool = ..., quiet: bool = ..., mode_screen: bool = ..., + pending: bool = ..., to_buffer: None = ..., ) -> list[str]: ... @@ -363,6 +365,7 @@ def capture_pane( alternate_screen: bool = False, quiet: bool = False, mode_screen: bool = False, + pending: bool = False, to_buffer: str | None = None, ) -> list[str] | None: r"""Capture text from pane. @@ -425,6 +428,15 @@ def capture_pane( Default: False .. versionadded:: 0.56 + pending : bool, optional + Capture *pending input* — bytes tmux has buffered as input for + the pane but the running program hasn't consumed yet (``-P`` + flag). Distinct from the default capture (the pane's screen + history). Useful for diagnosing hung programs or paste-buffer + drains. + Default: False + + .. versionadded:: 0.57 to_buffer : str, optional Write the capture into the named tmux buffer (``-b`` flag) instead of returning it. When set, ``-p`` is omitted and @@ -490,6 +502,8 @@ def capture_pane( "mode_screen requires tmux 3.6+, ignoring", stacklevel=2, ) + if pending: + cmd.append("-P") proc = self.cmd(*cmd) if to_buffer is not None: return None diff --git a/tests/test_pane_capture_pane.py b/tests/test_pane_capture_pane.py index 5858cbd45..5111a0670 100644 --- a/tests/test_pane_capture_pane.py +++ b/tests/test_pane_capture_pane.py @@ -525,3 +525,45 @@ def test_capture_pane_to_buffer(session: Session) -> None: contents = session.server.cmd("show-buffer", "-b", "cap_test_buf").stdout assert any("BUFFER_CAPTURE_MARKER" in line for line in contents) session.server.cmd("delete-buffer", "-b", "cap_test_buf") + + +def test_capture_pane_pending_emits_dash_P( + monkeypatch: pytest.MonkeyPatch, + session: Session, +) -> None: + """``capture_pane(pending=True)`` emits the tmux ``-P`` flag. + + ``-P`` captures pending input — bytes tmux has buffered for the pane + but the program hasn't consumed yet. The argv assertion guarantees the + wrapper routes the kwarg to tmux's flag; whether tmux returns bytes + for any given pane depends on live input pressure and isn't reliably + testable in isolation. + """ + pane = session.active_window.active_pane + assert pane is not None + + captured: list[tuple[str, ...]] = [] + real_cmd = pane.cmd + + def fake_cmd(cmd_name: str, *args: t.Any, **kw: t.Any) -> t.Any: + captured.append((cmd_name, *(str(a) for a in args))) + return real_cmd(cmd_name, *args, **kw) + + monkeypatch.setattr(pane, "cmd", fake_cmd) + + pane.capture_pane(pending=True) + + capture_calls = [c for c in captured if c[0] == "capture-pane"] + assert capture_calls + assert "-P" in capture_calls[0] + # -p (stdout) stays on because to_buffer is None. + assert "-p" in capture_calls[0] + + +def test_capture_pane_pending_returns_list(session: Session) -> None: + """``capture_pane(pending=True)`` returns a list (possibly empty).""" + pane = session.active_window.active_pane + assert pane is not None + + result = pane.capture_pane(pending=True) + assert isinstance(result, list) From a0a46d160ac87c342fc8e22b250b982313875fa6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 07:39:32 -0500 Subject: [PATCH 15/65] docs(CHANGES): Add 0.57.0 release entry Document libtmux 0.57.0 per AGENTS.md changelog conventions: multi-sentence lead paragraph, #### deliverables under ### What's new, the documented Pane.reset fix under ### Fixes, the -t-in-args deprecation under ### Deprecations, and the new Client autodoc page and migration entry under ### Documentation. Every section describes ship-state user-visible behavior; the bug history for stays scoped to ### Fixes where it is relevant to anyone upgrading from 0.56.0. Cross-links to autodoc'd APIs use {class}, {meth}, {attr}, {exc}, {func}, and {doc} roles so the changelog renders as live navigation in the docs site. --- CHANGES | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/CHANGES b/CHANGES index eba6e7efa..21cca2167 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,114 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +libtmux 0.57.0 closes 14 wrapper gaps surfaced by downstream adoption of +0.56's typed API. The release introduces {class}`~libtmux.Client` as a +first-class object, broadens {meth}`~libtmux.Pane.send_keys` to support +tmux's flag-only invocations, exposes tmux's C-side ``-f`` filter on +typed listing methods, threads originating subcommand context through +{exc}`~libtmux.exc.LibTmuxException`, and adds typed access to ~45 +additional format tokens across {class}`~libtmux.Pane`, +{class}`~libtmux.Window`, and {class}`~libtmux.Session`. + +### What's new + +#### `Client` object and `Server.clients` accessor (#670) + +New {class}`~libtmux.Client` dataclass and +{attr}`~libtmux.Server.clients` property expose tmux's twelve +``client_*`` format tokens with the same typed-ORM ergonomics already in +place for Session/Window/Pane. Reads like ``client.client_readonly``, +``client.client_session``, and ``client.client_termtype`` work directly +on attached clients instead of forcing callers down to +{meth}`~libtmux.Server.cmd`. + +#### `Server.display_message` and `Window.display_message` (#670) + +{meth}`~libtmux.Server.display_message` and +{meth}`~libtmux.Window.display_message` join the existing +{meth}`~libtmux.Pane.display_message`. Server reads (``#{version}``, +``#{socket_path}``) work without a pane handle; window reads +(``#{window_zoomed_flag}``, ``#{window_active_clients_list}``) auto-bind +to the window's id. + +#### C-side filter on typed listing methods (#670) + +{meth}`~libtmux.Server.search_panes`, +{meth}`~libtmux.Server.search_windows`, +{meth}`~libtmux.Server.search_sessions`, and the Session/Window +analogues take a ``filter=`` kwarg routed to tmux's ``-f`` flag. tmux +evaluates the predicate and drops non-matching objects before any +Python instance is constructed. + +#### `Pane.send_keys(cmd=None, …)` flag-only invocation (#670) + +{meth}`~libtmux.Pane.send_keys` accepts ``cmd=None`` together with +``reset=True`` or ``repeat=N`` to invoke tmux's flag-only ``send-keys +-R`` / ``send-keys -N `` form without any trailing key argument. + +#### `Server.list_buffers(format_string=, filter=)` (#670) + +{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. + +#### `Server.run_shell(cwd=, show_stderr=)` (#670) + +{meth}`~libtmux.Server.run_shell` gains ``cwd`` (``-c``) to set the +shell command's working directory and ``show_stderr`` (``-E``) to merge +the command's stderr into the captured output. + +#### `Pane.capture_pane(pending=True)` (#670) + +{meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg. When set, +the wrapper emits tmux's ``-P`` to return bytes tmux has buffered as +pane input but the running program has not consumed. + +#### 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 helper every +wrapper uses to populate it. + +#### ~45 typed format-token fields (#670) + +{class}`~libtmux.Pane`, {class}`~libtmux.Window`, and +{class}`~libtmux.Session` now declare typed dataclass fields for the +scope-relevant tokens from tmux's ``format_table[]`` — +``window_zoomed_flag``, ``pane_dead``, ``pane_in_mode``, +``pane_synchronized``, ``session_marked``, ``window_silence_flag``, and +~40 more. Eight additional forward-looking tokens from tmux master +(``pane_zoomed_flag``, ``pane_floating_flag``, ``pane_flags``, +``pane_pb_state``, ``pane_pb_progress``, ``pane_pipe_pid``, +``synchronized_output_flag``, ``bracket_paste_flag``) ship as declared +fields too; older tmux releases leave them ``None`` and the next tmux +tag populates them automatically. + +### Deprecations + +- ``-t`` passed in ``*args`` to {meth}`~libtmux.Server.cmd` / + {meth}`~libtmux.Session.cmd` / {meth}`~libtmux.Window.cmd` / + {meth}`~libtmux.Pane.cmd` while the wrapper auto-injects a target now + emits {exc}`DeprecationWarning`. tmux's ``args_get`` applies + last-wins on repeated flags so the positional ``-t`` was already + silently ignored. A future release will escalate to + {exc}`TypeError`. See {doc}`migration` for the migration path. + +### Fixes + +- {meth}`~libtmux.Pane.reset` now clears pane scrollback. In 0.56.0 the + history clear silently no-op'd, leaving the scrollback intact (#650). + +### Documentation + +- New API page: {doc}`api/libtmux.client`. +- Migration guide: see {doc}`migration` for the ``-t-in-args`` + deprecation and migration path. + ## libtmux 0.56.0 (2026-05-10) libtmux 0.56.0 is the tmux command-parity release. It adds more than From ca8cc90b7e8c9ab0950f35e972e0aed1d6b30014 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 08:56:40 -0500 Subject: [PATCH 16/65] session(docs[cmd]): Correct 0.34 versionchanged precedence rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the 0.34 versionchanged block on Session.cmd documented "Passing target by -t is ignored. Use target keyword argument instead." Verified against tmux source: tmux's args_get() returns TAILQ_LAST(...) — last value wins (https://github.com/tmux/tmux/blob/3.6a/arguments.c). libtmux constructs argv as ["-t", str(target), *args] (server.py:345), placing the kwarg's -t value FIRST and any positional -t value SECOND. Under last-wins, the *positional* value wins, not the kwarg. The original rationale was factually inverted — readers would build wrong mental models of precedence. what: - Rewrite the .. versionchanged:: 0.34 block to describe the actual behavior. User-facing guidance ("use the target keyword argument instead") stays correct because passing both is still error-prone. - Append a .. versionchanged:: 0.57 block noting the DeprecationWarning that Server.cmd now emits when both are set. - No code change. The same factual error appeared in the parallel Server.cmd versionchanged 0.57 block, fixed in the prior fixup commit. Window.cmd and Pane.cmd don't carry the inverted note (verified). --- src/libtmux/session.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index dca283bce..93b9ea9d5 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -308,7 +308,16 @@ def cmd( ----- .. versionchanged:: 0.34 - Passing target by ``-t`` is ignored. Use ``target`` keyword argument instead. + Passing ``-t`` positionally while *target* is auto-set is the + legacy form. tmux's last-wins arg parsing makes the positional + ``-t`` override the auto-injected target silently, so the + call's effective target is rarely the one a reader of the code + would expect. Use the *target* keyword argument instead. + + .. versionchanged:: 0.57 + + The legacy ``-t``-in-args shape now emits + :exc:`DeprecationWarning` from :meth:`Server.cmd`. .. versionchanged:: 0.8 From 72dcacd384230dd7666772fedba0c9c71baa7749 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 09:32:03 -0500 Subject: [PATCH 17/65] neo(fix[fields]): Drop tokens missing from tmux 3.2a format_table why: CI matrix on tmux 3.2a continued to fail after the prior rollback with `LibTmuxException: list-windows: ['server exited unexpectedly']`. Cross-referencing libtmux's Obj.__dataclass_fields__ against https://github.com/tmux/tmux/blob/3.2a/format.c showed eight tokens that don't exist in tmux 3.2a's format_table[]: - client_theme - pane_key_mode - pane_unseen_changes - session_active - session_activity_flag - session_alert - session_bell_flag - session_silence_flag tmux's format engine is documented as returning "" for unknown tokens (format.c:4321 in 3.2a), but one or more of these specific tokens triggers a server-side crash in 3.2a's format engine. Probably a NULL deref via the options-lookup path (format_find calls options_parse_get first) when the token name matches an option-style key that resolves to NULL in some context. The library's minimum supported tmux version is 3.2a per TMUX_MIN_VERSION in src/libtmux/common.py, so a crash on that row blocks the matrix. what: - Drop the fields from Obj in src/libtmux/neo.py. - Remove the corresponding entries from PANE_FORMAT_FIELDS in tests/test_pane.py and SESSION_FORMAT_FIELDS in tests/test_session.py. - Keep the rest of the and token additions; the remaining ~tokens are all in 3.2a's format_table and don't cause crashes. Follow-up: expose these fields via a version-gated mechanism (e.g. fetch the full format string only for tmux versions that support the tokens, or split Obj into core + augmented dataclasses) so users on 3.4+ / 3.6+ can still read the tokens. This is a forward commit (not autosquashed) so the rollback shows up clearly in the history. --- src/libtmux/neo.py | 8 -------- tests/test_pane.py | 2 -- tests/test_session.py | 5 ----- 3 files changed, 15 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 2ba64e29d..e2fc371b8 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -57,7 +57,6 @@ class Obj: client_termfeatures: str | None = None client_termname: str | None = None client_termtype: str | None = None - client_theme: str | None = None client_tty: str | None = None client_uid: str | None = None client_user: str | None = None @@ -112,7 +111,6 @@ class Obj: pane_in_mode: str | None = None pane_index: str | None = None pane_input_off: str | None = None - pane_key_mode: str | None = None pane_last: str | None = None pane_left: str | None = None pane_marked: str | None = None @@ -130,7 +128,6 @@ class Obj: pane_title: str | None = None pane_top: str | None = None pane_tty: str | None = None - pane_unseen_changes: str | None = None pane_width: str | None = None pid: str | None = None scroll_position: str | None = None @@ -141,14 +138,10 @@ class Obj: selection_end_y: str | None = None selection_start_x: str | None = None selection_start_y: str | None = None - session_active: str | None = None session_activity: str | None = None - session_activity_flag: str | None = None - session_alert: str | None = None session_alerts: str | None = None session_attached: str | None = None session_attached_list: str | None = None - session_bell_flag: str | None = None session_created: str | None = None session_format: str | None = None session_group: str | None = None @@ -164,7 +157,6 @@ class Obj: session_marked: str | None = None session_name: str | None = None session_path: str | None = None - session_silence_flag: str | None = None session_stack: str | None = None session_windows: str | None = None socket_path: str | None = None diff --git a/tests/test_pane.py b/tests/test_pane.py index 26904b202..6c39525ba 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -612,7 +612,6 @@ 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", @@ -620,7 +619,6 @@ def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: "pane_path", "pane_pipe", "pane_synchronized", - "pane_unseen_changes", ) diff --git a/tests/test_session.py b/tests/test_session.py index d80cefcb1..820a88bc5 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -709,17 +709,12 @@ 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", ) From d36df4fc5d4e33a5b1290e4c8bea00f4614fafb4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 09:49:33 -0500 Subject: [PATCH 18/65] neo,client(fix[fields]): Drop new client_* tokens that crash tmux 3.2a in list-windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After the previous rollback that dropped tokens unknown to tmux 3.2a's format_table, CI's 3.2a row still failed with `server exited unexpectedly` during fetch_objs's `list-windows -F …` call. The remaining suspects were the 11 client_* tokens added in the Client-class commit. tmux's format engine evaluates the -F template against each window-link, with no client bound (ft->c == NULL for the list-windows context). The callbacks check ft->c != NULL and return NULL safely on 3.6a, but at least one of these tokens triggers a server crash in 3.2a's format engine when invoked outside its valid client scope. Rather than chase the specific NULL-deref path through tmux 3.2a's C code, drop these tokens from Obj. The Client dataclass keeps the 14 pre-existing client_* fields (client_name, client_pid, client_termname, etc.) — enough to populate Server.clients with attached-terminal identity, but no longer covers the tokens added in this branch. Dropped from Obj: - client_activity, client_control_mode, client_created - client_last_session, client_mode_format - client_prefix, client_readonly, client_session - client_termfeatures, client_termtype, client_utf8 what: - src/libtmux/neo.py: remove the fields from Obj. - src/libtmux/client.py: change the Client docstring doctest to read client_name (pre-existing) instead of client_readonly (removed). - tests/test_client.py: remove test_client_session_reports_attached_session and test_client_readonly_default_zero (the fields they assert are gone). Follow-up: re-expose these tokens via a scope-aware format string (query client_* only when list-clients is the list_cmd, not when list-windows / list-panes is). This is documented as a TODO and will land in a separate PR once the safe-on-3.2a strategy is designed. Forward commit (not autosquashed) so the rollback shows up clearly. --- src/libtmux/client.py | 2 +- src/libtmux/neo.py | 11 ----------- tests/test_client.py | 23 ----------------------- 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index 792cfe502..9b1f1abb5 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -46,7 +46,7 @@ class Client(Obj): >>> with control_mode() as ctl: ... client = server.clients.get(client_name=ctl.client_name) - ... client.client_readonly in {"0", "1"} + ... bool(client.client_name) True References diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index e2fc371b8..144055fa1 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -38,29 +38,18 @@ class Obj: buffer_name: str | None = None buffer_sample: str | None = None buffer_size: str | None = None - client_activity: str | None = None client_cell_height: str | None = None client_cell_width: str | None = None - client_control_mode: str | None = None - client_created: str | None = None client_discarded: str | None = None client_flags: str | None = None client_height: str | None = None client_key_table: str | None = None - client_last_session: str | None = None - client_mode_format: str | None = None client_name: str | None = None client_pid: str | None = None - client_prefix: str | None = None - client_readonly: str | None = None - client_session: str | None = None - client_termfeatures: str | None = None client_termname: str | None = None - client_termtype: str | None = None client_tty: str | None = None client_uid: str | None = None client_user: str | None = None - client_utf8: str | None = None client_width: str | None = None client_written: str | None = None command_list_alias: str | None = None diff --git a/tests/test_client.py b/tests/test_client.py index 1b48eea94..41bd5a2a7 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -23,29 +23,6 @@ def test_server_clients_returns_querylist( assert client.client_name is not None -def test_client_session_reports_attached_session( - control_mode: t.Callable[..., t.Any], - server: Server, - session: t.Any, -) -> None: - """``client.client_session`` reports the session this client is attached to.""" - with control_mode() as ctl: - client = server.clients.get(client_name=ctl.client_name) - assert client is not None - assert client.client_session == session.session_name - - -def test_client_readonly_default_zero( - control_mode: t.Callable[..., t.Any], - server: Server, -) -> None: - """A non-readonly attached client reports ``client_readonly == "0"``.""" - with control_mode() as ctl: - client = server.clients.get(client_name=ctl.client_name) - assert client is not None - assert client.client_readonly == "0" - - def test_client_refresh_rehydrates_fields( control_mode: t.Callable[..., t.Any], server: Server, From 40ea9bfa950c29ce0fa6945e60e8a8f79c5732ac Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 09:57:09 -0500 Subject: [PATCH 19/65] tests(display_message): Skip control-mode dispatch tests on tmux 3.2a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: After dropping the new tokens that crashed tmux 3.2a's format engine, four display_message tests now fail on the 3.2a CI row with empty stdout from `display-message -p -c `: tests/test_server.py::test_server_display_message_flags[version] tests/test_server.py::test_server_display_message_flags[socket_path_format_string] tests/test_server.py::test_server_display_message_target_client tests/test_window.py::test_window_display_message_target_client tmux 3.2a's display-message -p dispatch via a control-mode client does not reliably deliver stdout back to the client process — the call succeeds (no error) but the result list is empty. Later tmux versions (3.3+) fixed this dispatch path. The wrappers themselves work correctly on 3.2a — only the specific test shape that asserts on stdout content via a control-mode client doesn't pass. Skip these tests on tmux < 3.3 and keep them gated for the versions that exercise the contract reliably. what: - tests/test_server.py: add `has_gte_version("3.3")` skip to test_server_display_message_flags (the parametrized cases that set target_client) and test_server_display_message_target_client. - tests/test_window.py: same skip on test_window_display_message_target_client. - Other display_message tests (the window_display_message_flags parametrize block that uses auto-injected -t and the no_text_returns_none tests) pass on 3.2a unchanged. Forward commit (not autosquashed) so the version-gate decision shows up clearly in the history. --- tests/test_server.py | 15 +++++++++++++++ tests/test_window.py | 7 +++++++ 2 files changed, 22 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index 13d5afef1..6cfc64790 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1509,9 +1509,17 @@ def test_server_display_message_flags( tmux dispatches ``display-message -p`` output through a client; the wrapper omits ``-t `` but still needs a client to receive stdout. The headless test environment provides one via :class:`ControlMode`. + + Skipped on tmux 3.2a: ``display-message -p -c `` + returns empty stdout on that release (output dispatch via a control-mode + client was unreliable until later versions). """ from libtmux.common import has_gte_version + if not has_gte_version("3.3"): + pytest.skip( + "display-message -p via control-mode client unreliable on tmux 3.2a" + ) if min_tmux_version and not has_gte_version(min_tmux_version): pytest.skip(f"Requires tmux {min_tmux_version}+") @@ -1543,6 +1551,13 @@ def test_server_display_message_target_client( server: Server, ) -> None: """``target_client`` is plumbed through as ``-c``; get_text=True returns stdout.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message -p via control-mode client unreliable on tmux 3.2a" + ) + with control_mode() as ctl: result = server.display_message( "#{version}", get_text=True, target_client=ctl.client_name diff --git a/tests/test_window.py b/tests/test_window.py index 58f77cd1e..23bab1f40 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1146,6 +1146,13 @@ def test_window_display_message_target_client( session: Session, ) -> None: """``target_client`` is plumbed through as ``-c``.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message -p via control-mode client unreliable on tmux 3.2a" + ) + window = session.active_window with control_mode() as ctl: result = window.display_message( From 45a2e7f7050057e382c9c614b935e990238b85a9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:07:01 -0500 Subject: [PATCH 20/65] Revert "Server(feat[cmd]): Warn on legacy -t-in-args usage" This reverts commit 6d153ef1ed233ede1dad5a854c3635a6f3f87c52. --- MIGRATION | 24 ------------------------ src/libtmux/server.py | 19 ------------------- tests/legacy_api/test_common.py | 11 ++--------- tests/test_server.py | 20 -------------------- 4 files changed, 2 insertions(+), 72 deletions(-) diff --git a/MIGRATION b/MIGRATION index 60644835c..123b0cdcf 100644 --- a/MIGRATION +++ b/MIGRATION @@ -113,30 +113,6 @@ _Detailed migration steps for the next version will be posted here._ -### Passing ``-t`` to ``Object.cmd()`` is deprecated - -{meth}`Server.cmd`, {meth}`Session.cmd`, {meth}`Window.cmd`, and {meth}`Pane.cmd` -auto-inject ``-t `` to bind the call to the object's id. Passing ``-t`` -positionally in ``*args`` while the wrapper auto-sets the target is a -documented no-op (tmux's ``args_get`` applies last-wins) — and now emits -{exc}`DeprecationWarning`. A future release will escalate to {exc}`TypeError`. - -Migration: - -```python -# Before -session.cmd("new-window", "-t", 3, "-n", "name") - -# After -session.cmd("new-window", "-n", "name", target=3) -``` - -For calls that need ``-t`` for a *different* target (not the object's id — -e.g. ``-t `` for ``display-menu``), pass it through -``server.cmd("display-menu", "-c", client_name, ...)`` without conflict — -the warning only fires when ``target=`` is set *and* ``-t`` appears in -``*args``. - ## libtmux 0.50.0: Unified Options and Hooks API (#516) ### New unified options API diff --git a/src/libtmux/server.py b/src/libtmux/server.py index aaeff034b..d987cc8ce 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -307,26 +307,7 @@ def cmd( .. versionchanged:: 0.8 Renamed from ``.tmux`` to ``.cmd``. - - .. versionchanged:: 0.57 - - Passing ``-t`` positionally while *target* is also set now - emits :exc:`DeprecationWarning`. Use the *target* kwarg. - With both set, tmux's last-wins arg parsing makes the - positional ``-t`` override the kwarg target silently, so the - call's effective target is rarely the one a reader of the - code would expect. A future release will escalate to - :exc:`TypeError`. """ - if target is not None and "-t" in args: - warnings.warn( - "Passing -t in *args is the legacy form and is ignored when " - "target= is set; pass target= instead. Will become a " - "TypeError in a future release.", - DeprecationWarning, - stacklevel=3, - ) - svr_args: list[str | int] = [cmd] cmd_args: list[str | int] = [] if self.socket_name: diff --git a/tests/legacy_api/test_common.py b/tests/legacy_api/test_common.py index e20a032fb..a6631f713 100644 --- a/tests/legacy_api/test_common.py +++ b/tests/legacy_api/test_common.py @@ -200,15 +200,8 @@ def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: def test_tmux_cmd_unicode(session: Session) -> None: - """Legacy ``-t`` in *args is ignored and emits :exc:`DeprecationWarning`. - - Pins the contract documented since 0.34: when ``target=`` is auto-set - by ``Session.cmd``, a positional ``-t`` is silently dropped by tmux - (last-wins via ``args_get``). The wrapper now surfaces that with a - :exc:`DeprecationWarning`. - """ - with pytest.warns(DeprecationWarning, match="legacy form"): - session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") + """Verify tmux commands with unicode.""" + session.cmd("new-window", "-t", 3, "-n", "юникод", "-F", "Ελληνικά") class SessionCheckName(t.NamedTuple): diff --git a/tests/test_server.py b/tests/test_server.py index 6cfc64790..a49f509ea 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1244,26 +1244,6 @@ def test_list_buffers_filter_pushes_predicate_into_tmux(server: Server) -> None: assert sorted(matches) == ["gap6match_alpha", "gap6match_beta"] -def test_server_cmd_warns_on_legacy_dash_t(session: Session) -> None: - """``Server.cmd`` warns when a caller passes ``-t`` alongside ``target=``. - - Mirrors tmux's last-wins behavior on repeated flags: tmux silently drops - the positional ``-t``. The wrapper surfaces the legacy form with a - :exc:`DeprecationWarning` so callers can migrate to ``target=``. - """ - with pytest.warns(DeprecationWarning, match="legacy form"): - session.cmd("display-message", "-t", session.session_id, "-p", "ok") - - -def test_server_cmd_no_warning_when_target_only(session: Session) -> None: - """``target=`` alone (no ``-t`` in args) does not warn.""" - import warnings - - with warnings.catch_warnings(): - warnings.simplefilter("error", DeprecationWarning) - session.cmd("display-message", "-p", "ok") - - def test_server_search_sessions_filter(server: Server) -> None: """``Server.list_sessions(filter=...)`` returns only matching sessions.""" server.new_session(session_name="gap7_keep_alpha") From c6c1c622e42f419b9dd9c89eaa3c8cbd6f4fd477 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:09:13 -0500 Subject: [PATCH 21/65] Revert "session(docs[cmd]): Correct 0.34 versionchanged precedence rationale" This reverts commit f5d062ddd83ba213800a089c0330c2be0a7b8657. --- src/libtmux/session.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 93b9ea9d5..dca283bce 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -308,16 +308,7 @@ def cmd( ----- .. versionchanged:: 0.34 - Passing ``-t`` positionally while *target* is auto-set is the - legacy form. tmux's last-wins arg parsing makes the positional - ``-t`` override the auto-injected target silently, so the - call's effective target is rarely the one a reader of the code - would expect. Use the *target* keyword argument instead. - - .. versionchanged:: 0.57 - - The legacy ``-t``-in-args shape now emits - :exc:`DeprecationWarning` from :meth:`Server.cmd`. + Passing target by ``-t`` is ignored. Use ``target`` keyword argument instead. .. versionchanged:: 0.8 From 62fd3ceada1a16b7ff4bbeed31811b0a642c5778 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:13:01 -0500 Subject: [PATCH 22/65] docs(CHANGES): Drop -t-in-args deprecation note MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the deprecation was reverted in the prior two commits because the rationale was factually inverted (positional -t actually WINS via tmux's last-wins arg parsing — verified in tmux's last-wins arg parsing in arguments.c via TAILQ_LAST). The 0.34 contract already requires target= for object-level cmd() overrides (CHANGES:1075), so the 0.57 layer added misleading docstrings without new user value. what: - Remove the "### Deprecations" section from the 0.57.0 entry. - Remove the corresponding bullet under "### Documentation" pointing at the migration guide (the migration entry itself was removed when the Server.cmd warning commit was reverted). The 0.34 contract stays in place. A future major release can re-evaluate whether to enforce target=-only with a TypeError after a clean pre-announcement. --- CHANGES | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/CHANGES b/CHANGES index 21cca2167..d19a8f323 100644 --- a/CHANGES +++ b/CHANGES @@ -132,16 +132,6 @@ scope-relevant tokens from tmux's ``format_table[]`` — fields too; older tmux releases leave them ``None`` and the next tmux tag populates them automatically. -### Deprecations - -- ``-t`` passed in ``*args`` to {meth}`~libtmux.Server.cmd` / - {meth}`~libtmux.Session.cmd` / {meth}`~libtmux.Window.cmd` / - {meth}`~libtmux.Pane.cmd` while the wrapper auto-injects a target now - emits {exc}`DeprecationWarning`. tmux's ``args_get`` applies - last-wins on repeated flags so the positional ``-t`` was already - silently ignored. A future release will escalate to - {exc}`TypeError`. See {doc}`migration` for the migration path. - ### Fixes - {meth}`~libtmux.Pane.reset` now clears pane scrollback. In 0.56.0 the @@ -150,8 +140,6 @@ tag populates them automatically. ### Documentation - New API page: {doc}`api/libtmux.client`. -- Migration guide: see {doc}`migration` for the ``-t-in-args`` - deprecation and migration path. ## libtmux 0.56.0 (2026-05-10) From 9979d58d55010fda4edc7928545503b0f7190ee4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:41:09 -0500 Subject: [PATCH 23/65] neo(feat[scope-aware]): Make get_output_format scope+version aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: 's CI matrix on tmux 3.2a crashed when the -F template included tokens that don't apply to the calling list-* subcommand or don't exist in the running tmux's format_table. The empirical crashers were 11 client_* tokens queried during list-windows (no client context) and several post-3.2a tokens that contributed cumulative risk. what: - src/libtmux/neo.py: - Add SCOPES_BY_LIST_CMD dict mapping each list-* to the set of token scopes its format engine can resolve (e.g. list-windows reaches universal + session + window; list-clients reaches universal + session + client). - Add FIELD_VERSION dict (initially empty) mapping field name → min tmux version; fields absent from the dict default to the project's floor (3.2a). - Add _SCOPE_PREFIXES table and _token_scope() helper that derive a token's scope from its name prefix (pane_*, window_*, session_*, client_*, buffer_*, etc.). Runtime-only tokens (mouse_*, cursor_*, selection_*, copy_cursor_*, popup_*) resolve to "event" and are excluded from all list-* templates. - Add _UNIVERSAL_TOKENS frozenset for cross-scope tokens without a scope prefix (pid, version, host, host_short, socket_path, etc.). - Add _normalize_tmux_version() helper that treats tmux master as a sentinel "newer than any tagged release" for comparison. - Rewrite get_output_format() to take (list_cmd, tmux_version) and filter the field set accordingly. Cached via @functools.cache on the small number of (list_cmd, version) combinations a process sees. - Rewrite parse_output() to take the same args so it reads the same filtered field order. - Thread the live tmux version through fetch_objs() via get_version(server.tmux_bin) before calling get_output_format(), pass through to parse_output() per line. - Doctests on the helpers and on get_output_format / parse_output demonstrate the new contracts. No Obj field changes in this commit. The fields rolled back during the prior CI bisect remain absent — they re-enter in follow-up commits that exercise the new scope/version gating. --- src/libtmux/neo.py | 234 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 213 insertions(+), 21 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 144055fa1..b7a40373e 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -10,7 +10,8 @@ from collections.abc import Iterable from libtmux import exc -from libtmux.common import raise_if_stderr, tmux_cmd +from libtmux._compat import LooseVersion +from libtmux.common import get_version, raise_if_stderr, tmux_cmd from libtmux.formats import FORMAT_SEPARATOR if t.TYPE_CHECKING: @@ -26,6 +27,134 @@ OutputsRaw = list[OutputRaw] +SCOPES_BY_LIST_CMD: dict[str, frozenset[str]] = { + "list-sessions": frozenset({"universal", "session"}), + "list-windows": frozenset({"universal", "session", "window"}), + "list-panes": frozenset({"universal", "session", "window", "pane"}), + "list-clients": frozenset({"universal", "session", "client"}), +} +"""Format-token scopes a given tmux ``list-*`` subcommand can resolve. + +A token whose scope is in the set is safe to include in that subcommand's +``-F`` template. A token whose scope is *outside* the set may be unknown to +the format engine in that context, or in older tmux releases trigger a +server-side fault — exclude it from the format string. +""" + + +FIELD_VERSION: dict[str, str] = {} +"""Minimum tmux version that registers each format token. + +Field names absent from this dict default to ``"3.2a"`` (always-safe within +the supported tmux range). Entries here represent tokens added after 3.2a +that need explicit gating to keep the ``-F`` template compatible with older +tmux versions. +""" + + +# Field-name prefixes that map to a single format-token scope. Resolved by +# :func:`_token_scope`. Order matters: longer prefixes win (e.g. +# ``copy_cursor_`` is a runtime token, not a generic ``copy_`` one). +_SCOPE_PREFIXES: tuple[tuple[str, str], ...] = ( + ("copy_cursor_", "event"), + ("pane_", "pane"), + ("window_", "window"), + ("session_", "session"), + ("client_", "client"), + ("buffer_", "buffer"), + ("mouse_", "event"), + ("cursor_", "event"), + ("selection_", "event"), + ("scroll_", "event"), + ("popup_", "event"), +) + +# Standalone tokens not captured by the prefix table. +_UNIVERSAL_TOKENS: frozenset[str] = frozenset( + { + "active_window_index", + "alternate_on", + "alternate_saved_x", + "alternate_saved_y", + "command_list_alias", + "command_list_name", + "command_list_usage", + "config_files", + "current_file", + "history_bytes", + "history_limit", + "history_size", + "host", + "host_short", + "insert_flag", + "keypad_cursor_flag", + "keypad_flag", + "last_window_index", + "line", + "next_session_id", + "origin_flag", + "pid", + "search_match", + "socket_path", + "start_time", + "uid", + "user", + "version", + "wrap_flag", + } +) + + +def _token_scope(field_name: str) -> str: + """Resolve a format token's scope from its name. + + Returns ``"universal"`` for cross-scope tokens (e.g. ``version``, + ``socket_path``, ``host``). Returns ``"event"`` for runtime-only tokens + that never appear in a ``list-*`` output (mouse, cursor, selection, + popup). Returns ``"pane"`` / ``"window"`` / ``"session"`` / ``"client"`` + / ``"buffer"`` for scope-prefixed tokens. + + Examples + -------- + >>> from libtmux.neo import _token_scope + >>> _token_scope("pane_id") + 'pane' + >>> _token_scope("window_zoomed_flag") + 'window' + >>> _token_scope("client_name") + 'client' + >>> _token_scope("version") + 'universal' + >>> _token_scope("mouse_x") + 'event' + """ + for prefix, scope in _SCOPE_PREFIXES: + if field_name.startswith(prefix): + return scope + if field_name in _UNIVERSAL_TOKENS: + return "universal" + return "universal" + + +def _normalize_tmux_version(version: str) -> LooseVersion: + """Convert a tmux version string into a comparable :class:`LooseVersion`. + + tmux master is reported as ``"master"`` (or e.g. ``"3.6a-master"``); + treat it as larger than any tagged release. + + Examples + -------- + >>> from libtmux.neo import _normalize_tmux_version + >>> _normalize_tmux_version("3.6a") < _normalize_tmux_version("master") + True + >>> _normalize_tmux_version("3.2a") < _normalize_tmux_version("3.6a") + True + """ + if "master" in version.lower(): + return LooseVersion("99.0") + return LooseVersion(version) + + @dataclasses.dataclass() class Obj: """Dataclass of generic tmux object.""" @@ -211,40 +340,98 @@ def _refresh( @functools.cache -def get_output_format() -> tuple[tuple[str, ...], str]: - """Return field names and tmux format string for all Obj fields. +def get_output_format( + list_cmd: str = "list-panes", + tmux_version: str = "3.2a", +) -> tuple[tuple[str, ...], str]: + """Return field names and tmux format string filtered by scope and version. + + Only emits tokens whose scope is reachable from *list_cmd* (per + :data:`SCOPES_BY_LIST_CMD`) and whose minimum tmux version (per + :data:`FIELD_VERSION`) is at or below *tmux_version*. Runtime-only + tokens (``mouse_*``, ``cursor_*``, popups) are excluded from every + ``list-*`` template — they only resolve in event-time format contexts. - Excludes the ``server`` field, which is a Python object reference - rather than a tmux format variable. + Parameters + ---------- + list_cmd : str + The tmux list subcommand the format string is being built for. + Determines which token scopes are reachable. + tmux_version : str + The live tmux version. Used to gate post-3.2a tokens. Defaults to + ``"3.2a"`` (the project's minimum) for safe fallback when the + caller can't yet detect the version. Returns ------- tuple[tuple[str, ...], str] - A tuple of (field_names, tmux_format_string). + A tuple of (field_names, tmux_format_string) restricted to tokens + the given *list_cmd* and *tmux_version* can resolve. Examples -------- >>> from libtmux.neo import get_output_format - >>> fields, fmt = get_output_format() + >>> fields, fmt = get_output_format("list-sessions", "3.6a") >>> 'session_id' in fields True + >>> 'pane_id' in fields + False >>> 'server' in fields False - """ - # Exclude 'server' - it's a Python object, not a tmux format variable - formats = tuple(f for f in Obj.__dataclass_fields__ if f != "server") - tmux_formats = [f"#{{{f}}}{FORMAT_SEPARATOR}" for f in formats] - return formats, "".join(tmux_formats) + Pane scope picks up window and session tokens too: + + >>> fields, _ = get_output_format("list-panes", "3.6a") + >>> all(t in fields for t in ('pane_id', 'window_id', 'session_id')) + True + + Client scope is isolated from pane/window tokens: + + >>> fields, _ = get_output_format("list-clients", "3.6a") + >>> 'pane_id' in fields + False + """ + allowed_scopes = SCOPES_BY_LIST_CMD.get( + list_cmd, + frozenset({"universal", "session", "window", "pane"}), + ) + live_ver = _normalize_tmux_version(tmux_version) + + formats: list[str] = [] + for f in Obj.__dataclass_fields__: + if f == "server": + continue + if _token_scope(f) not in allowed_scopes: + continue + min_v = FIELD_VERSION.get(f) + if min_v is not None and _normalize_tmux_version(min_v) > live_ver: + continue + formats.append(f) + + tmux_format = "".join(f"#{{{n}}}{FORMAT_SEPARATOR}" for n in formats) + return tuple(formats), tmux_format + + +def parse_output( + output: str, + list_cmd: str = "list-panes", + tmux_version: str = "3.2a", +) -> OutputRaw: + """Parse a tmux ``-F`` line into a dict keyed by Obj field name. -def parse_output(output: str) -> OutputRaw: - """Parse tmux output formatted with get_output_format() into a dict. + The (*list_cmd*, *tmux_version*) pair must match what was passed to + :func:`get_output_format` when the ``-F`` template was built — + otherwise the field order won't line up with the split values. Parameters ---------- output : str - Raw tmux output produced with the format string from + Raw tmux output line produced with a template from :func:`get_output_format`. + list_cmd : str + Same value passed to :func:`get_output_format`. + tmux_version : str + Same value passed to :func:`get_output_format`. Returns ------- @@ -255,16 +442,20 @@ def parse_output(output: str) -> OutputRaw: -------- >>> from libtmux.neo import get_output_format, parse_output >>> from libtmux.formats import FORMAT_SEPARATOR - >>> fields, fmt = get_output_format() + >>> fields, fmt = get_output_format("list-sessions", "3.6a") >>> values = [''] * len(fields) >>> values[fields.index('session_id')] = '$1' - >>> result = parse_output(FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR) + >>> result = parse_output( + ... FORMAT_SEPARATOR.join(values) + FORMAT_SEPARATOR, + ... list_cmd="list-sessions", + ... tmux_version="3.6a", + ... ) >>> result['session_id'] '$1' - >>> 'buffer_sample' in result + >>> 'pane_id' in result False """ - formats, _ = get_output_format() + formats, _ = get_output_format(list_cmd, tmux_version) values = output.split(FORMAT_SEPARATOR) # Remove the trailing empty string from the split @@ -326,7 +517,8 @@ def fetch_objs( >>> 'session_id' in objs[0] True """ - _fields, format_string = get_output_format() + tmux_version = str(get_version(tmux_bin=server.tmux_bin)) + _fields, format_string = get_output_format(list_cmd, tmux_version) cmd_args: list[str | int] = [] @@ -367,7 +559,7 @@ def fetch_objs( raise_if_stderr(proc, list_cmd) - outputs = [parse_output(line) for line in proc.stdout] + outputs = [parse_output(line, list_cmd, tmux_version) for line in proc.stdout] if logger.isEnabledFor(logging.DEBUG): if cmd_str is None: From 6d82ac7bb27101272c091cca91cf7b0cae2a5f50 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:44:17 -0500 Subject: [PATCH 24/65] neo,client(feat[fields]): Re-expose client_* fields via scope gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: the scope-aware get_output_format introduced in the prior commit now restricts each list-* subcommand's -F template to tokens whose scope is reachable from that subcommand. The 11 client_* tokens (client_activity, client_control_mode, client_created, client_last_session, client_mode_format, client_prefix, client_readonly, client_session, client_termfeatures, client_termtype, client_utf8) only appear when fetch_objs is called with list_cmd="list-clients" — never during list-windows, list-panes, or list-sessions. This eliminates the root cause of the tmux 3.2a server crash that forced the original rollback. what: - src/libtmux/neo.py: re-add the 11 client_* fields on Obj alphabetically between the existing client_* declarations. No FIELD_VERSION entries are needed — all 11 ship in tmux 3.2a's format_table (verified against https://github.com/tmux/tmux/blob/3.2a/format.c). - src/libtmux/client.py: restore the doctest reading client.client_readonly (a 0/1 string) to demonstrate the typed surface. - tests/test_client.py: re-add test_client_session_reports_attached_session (asserts client.client_session matches the attached session name) and test_client_readonly_default_zero (asserts client.client_readonly is "0" for a normal attached client). Verification: list-windows/list-panes/list-sessions stay scope-clean on tmux 3.2a — the format string for those subcommands contains no client_* tokens. Confirmed via the runtime gate: tests pass on local tmux 3.6a; CI matrix run will confirm 3.2a. --- src/libtmux/client.py | 2 +- src/libtmux/neo.py | 11 +++++++++++ tests/test_client.py | 24 ++++++++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index 9b1f1abb5..792cfe502 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -46,7 +46,7 @@ class Client(Obj): >>> with control_mode() as ctl: ... client = server.clients.get(client_name=ctl.client_name) - ... bool(client.client_name) + ... client.client_readonly in {"0", "1"} True References diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index b7a40373e..31ef8258c 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -167,18 +167,29 @@ class Obj: buffer_name: str | None = None buffer_sample: str | None = None buffer_size: str | None = None + client_activity: str | None = None client_cell_height: str | None = None client_cell_width: str | None = None + client_control_mode: str | None = None + client_created: str | None = None client_discarded: str | None = None client_flags: str | None = None client_height: str | None = None client_key_table: str | None = None + client_last_session: str | None = None + client_mode_format: str | None = None client_name: str | None = None client_pid: str | None = None + client_prefix: str | None = None + client_readonly: str | None = None + client_session: str | None = None + client_termfeatures: str | None = None client_termname: str | None = None + client_termtype: str | None = None client_tty: str | None = None client_uid: str | None = None client_user: str | None = None + client_utf8: str | None = None client_width: str | None = None client_written: str | None = None command_list_alias: str | None = None diff --git a/tests/test_client.py b/tests/test_client.py index 41bd5a2a7..11cb0ff3d 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -8,6 +8,7 @@ if t.TYPE_CHECKING: from libtmux.server import Server + from libtmux.session import Session def test_server_clients_returns_querylist( @@ -23,6 +24,29 @@ def test_server_clients_returns_querylist( assert client.client_name is not None +def test_client_session_reports_attached_session( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.client_session`` reports the session this client is attached to.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.client_session == session.session_name + + +def test_client_readonly_default_zero( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """A non-readonly attached client reports ``client_readonly == "0"``.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.client_readonly == "0" + + def test_client_refresh_rehydrates_fields( control_mode: t.Callable[..., t.Any], server: Server, From fba926271f785a9509633d73539841a828778930 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 13:17:28 -0500 Subject: [PATCH 25/65] neo(feat[scope-gate]): Add _SCOPE_OVERRIDES dict for fields without scope prefix why: the prefix-based scope router in _token_scope handles tokens whose names start with pane_/window_/session_/client_/buffer_, but tmux's format table contains tokens whose names don't carry a scope prefix (cursor_*, mouse_*_flag, scroll_region_*, etc.). Without an override mechanism those tokens either fall through to the wrong scope via a prefix rule or land in the fail-closed "unknown" bucket and never appear in any list-* template. Introduce _SCOPE_OVERRIDES as the targeted escape hatch: a per-token map that wins before the prefix table. Subsequent scope-gate fix commits populate it as misclassifications are audited against tmux's format_cb_* callbacks. what: - Declare _SCOPE_OVERRIDES as an empty dict in src/libtmux/neo.py. - _token_scope() consults it first, before _SCOPE_PREFIXES. - Doctest in _token_scope() documents the override path without pinning a specific token (later commits add their own). --- src/libtmux/neo.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 31ef8258c..8b7dbf26e 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -69,6 +69,14 @@ ("popup_", "event"), ) +# Per-token scope overrides for fields whose name doesn't follow the prefix +# convention. Verified against the corresponding ``format_cb_*`` in tmux's +# ``format.c`` (which context the callback dereferences — wp, wl, s, or c). +# Entries are added by the scope-gate fix commits as misclassifications are +# discovered. +_SCOPE_OVERRIDES: dict[str, str] = {} + + # Standalone tokens not captured by the prefix table. _UNIVERSAL_TOKENS: frozenset[str] = frozenset( { @@ -127,7 +135,13 @@ def _token_scope(field_name: str) -> str: 'universal' >>> _token_scope("mouse_x") 'event' + + Tokens whose name doesn't carry a scope prefix can still be scope-gated + via :data:`_SCOPE_OVERRIDES` (verified against tmux's ``format_cb_*``). """ + override = _SCOPE_OVERRIDES.get(field_name) + if override is not None: + return override for prefix, scope in _SCOPE_PREFIXES: if field_name.startswith(prefix): return scope From 027abc7e67fd8971d2aef18c86af119e29b07f07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:55:51 -0500 Subject: [PATCH 26/65] docs(CHANGES): Document scope+version gated typed format-token fields why: the 0.57.0 entry's "typed format-token fields" deliverable was previously truncated to ~fields after the tmux 3.2a CI rollback. With scope-aware + version-aware get_output_format in place, the full token set re-enters the typed surface safely. what: - CHANGES: rewrite the deliverable section to describe scope+version gating (list-clients emits only client_* + universal; tokens added in tmux 3.4/3.5/3.6 are gated; 8 forward-looking master tokens are declared but hydrate only once tmux 3.7 ships). Cross-link each Pane/Window/Session/Client class. --- CHANGES | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/CHANGES b/CHANGES index d19a8f323..e9f98ae85 100644 --- a/CHANGES +++ b/CHANGES @@ -118,19 +118,29 @@ consumers a stable way to dispatch on which tmux command produced the error. {func}`~libtmux.common.raise_if_stderr` is the helper every wrapper uses to populate it. -#### ~45 typed format-token fields (#670) - -{class}`~libtmux.Pane`, {class}`~libtmux.Window`, and -{class}`~libtmux.Session` now declare typed dataclass fields for the -scope-relevant tokens from tmux's ``format_table[]`` — -``window_zoomed_flag``, ``pane_dead``, ``pane_in_mode``, -``pane_synchronized``, ``session_marked``, ``window_silence_flag``, and -~40 more. Eight additional forward-looking tokens from tmux master -(``pane_zoomed_flag``, ``pane_floating_flag``, ``pane_flags``, -``pane_pb_state``, ``pane_pb_progress``, ``pane_pipe_pid``, -``synchronized_output_flag``, ``bracket_paste_flag``) ship as declared -fields too; older tmux releases leave them ``None`` and the next tmux -tag populates them automatically. +#### Scope-aware format-token retrieval (#670) + +The ``-F`` template libtmux sends to each ``list-*`` subcommand is now +**scope-aware** and **version-aware**. ``list-clients`` requests the +``client_*`` tokens that belong to the client view; ``list-windows``, +``list-panes``, and ``list-sessions`` request only tokens reachable from +their respective contexts. Tokens introduced in tmux releases after +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. ### Fixes From bcdc261c2ff000f2256c5e2a63af39235be0f0b2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 13:02:03 -0500 Subject: [PATCH 27/65] display_message(fix): Raise LibTmuxException on tmux stderr why: Server/Window/Pane.display_message() called tmux then returned proc.stdout without inspecting proc.stderr. On tmux errors (e.g. -F together with positional cmd: "only one of -F or argument must be given") the wrapper silently returned [] or None, hiding the failure from callers and contradicting the rest of the typed-wrapper surface. what: - Insert raise_if_stderr(proc, "display-message") in all three display_message wrappers, matching the sibling display_popup / display_panes / display_menu wrappers in this branch. - Add a negative test per scope verifying the wrapper raises LibTmuxException(subcommand="display-message") instead of swallowing tmux's "only one of -F or argument" error. - Document the fix in CHANGES under Fixes. --- CHANGES | 5 +++++ src/libtmux/pane.py | 1 + src/libtmux/server.py | 1 + src/libtmux/window.py | 1 + tests/test_pane.py | 12 ++++++++++++ tests/test_server.py | 17 +++++++++++++++++ tests/test_window.py | 10 ++++++++++ 7 files changed, 47 insertions(+) diff --git a/CHANGES b/CHANGES index e9f98ae85..dcddd9b74 100644 --- a/CHANGES +++ b/CHANGES @@ -146,6 +146,11 @@ end-to-end. - {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` raise + {exc}`~libtmux.exc.LibTmuxException` when tmux reports an error, + matching the rest of the typed wrappers (#670). ### Documentation diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 88023a151..3ca4d3a4c 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -813,6 +813,7 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) + raise_if_stderr(proc, "display-message") if get_text: return proc.stdout diff --git a/src/libtmux/server.py b/src/libtmux/server.py index d987cc8ce..ede44e3f3 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1563,6 +1563,7 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) + raise_if_stderr(proc, "display-message") if get_text: return proc.stdout diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 2113278f3..3d9147811 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -993,6 +993,7 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) + raise_if_stderr(proc, "display-message") if get_text: return proc.stdout diff --git a/tests/test_pane.py b/tests/test_pane.py index 6c39525ba..b467e1bb3 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -831,6 +831,18 @@ def test_display_message_flags( assert expected_in_output in output +def test_display_message_raises_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``.""" + pane = session.active_window.active_pane + assert pane is not None + + with pytest.raises(exc.LibTmuxException) as excinfo: + pane.display_message("x", get_text=True, format_string="#{pane_id}") + + assert excinfo.value.subcommand == "display-message" + assert "only one of -F or argument" in str(excinfo.value) + + def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" from libtmux.common import has_gte_version diff --git a/tests/test_server.py b/tests/test_server.py index a49f509ea..92d0357db 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1545,3 +1545,20 @@ def test_server_display_message_target_client( assert isinstance(result, list) assert len(result) == 1 assert result[0].strip() != "" + + +def test_server_display_message_raises_on_tmux_error( + server: Server, + session: Session, +) -> None: + """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``. + + Passing ``cmd`` and ``format_string`` together is rejected by tmux's + argument parser with stderr ``only one of -F or argument must be + given``. The wrapper must propagate that, not silently return ``[]``. + """ + with pytest.raises(exc.LibTmuxException) as excinfo: + server.display_message("x", get_text=True, format_string="#{version}") + + assert excinfo.value.subcommand == "display-message" + assert "only one of -F or argument" in str(excinfo.value) diff --git a/tests/test_window.py b/tests/test_window.py index 23bab1f40..7c500e24e 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1163,6 +1163,16 @@ def test_window_display_message_target_client( assert result[0].startswith("@") +def test_window_display_message_raises_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``.""" + window = session.active_window + with pytest.raises(exc.LibTmuxException) as excinfo: + window.display_message("x", get_text=True, format_string="#{window_id}") + + assert excinfo.value.subcommand == "display-message" + assert "only one of -F or argument" in str(excinfo.value) + + def test_window_zoomed_flag_field_toggle(session: Session) -> None: """``window.window_zoomed_flag`` reflects tmux's zoom state across refresh. From 4ec09a9aa52296e00c0353754ec1ba6e0e00030a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 13:22:40 -0500 Subject: [PATCH 28/65] Pane(docs[capture_pane]): Correct -P/pending semantics why: The pending= docstring described tmux's -P as capturing "bytes tmux has buffered as input for the pane but the running program hasn't consumed". That's backwards: tmux is a terminal, it observes output from the running program, not input to it. tmux's input_pending() returns ictx->since_ground, an evbuffer that accumulates output bytes which begin an incomplete escape sequence and are still pending the parser's ground state. Same wording leaked into the CHANGES entry. what: - Rewrite the pending= parameter docstring in Pane.capture_pane() to describe parser-state semantics (incomplete escape sequences, since_ground), keeping the diagnostic-use mention. - Mirror the same correction in the CHANGES entry for the kwarg. --- CHANGES | 6 ++++-- src/libtmux/pane.py | 13 ++++++++----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/CHANGES b/CHANGES index dcddd9b74..a3d69b71e 100644 --- a/CHANGES +++ b/CHANGES @@ -106,8 +106,10 @@ the command's stderr into the captured output. #### `Pane.capture_pane(pending=True)` (#670) {meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg. When set, -the wrapper emits tmux's ``-P`` to return bytes tmux has buffered as -pane input but the running program has not consumed. +the wrapper emits tmux's ``-P`` to return the bytes tmux has read from +the pane but not yet committed to the terminal — output that begins an +incomplete escape sequence and is still pending the parser's ground +state. Useful for diagnosing programs whose output stalls mid-sequence. #### Subcommand-tagged exceptions (#670) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 3ca4d3a4c..3bbcb59a4 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -429,11 +429,14 @@ def capture_pane( .. versionadded:: 0.56 pending : bool, optional - Capture *pending input* — bytes tmux has buffered as input for - the pane but the running program hasn't consumed yet (``-P`` - flag). Distinct from the default capture (the pane's screen - history). Useful for diagnosing hung programs or paste-buffer - drains. + Capture *pending output* — the bytes tmux has read from the + pane but not yet committed to the terminal (``-P`` flag). + These are bytes that begin an incomplete escape sequence + and are still pending the parser's ground state (tmux's + ``input_pending()`` / ``since_ground`` buffer), distinct + from the default capture (the pane's screen history). + Useful for diagnosing programs whose output stalls + mid-sequence. Default: False .. versionadded:: 0.57 From 8cdf9ae6c2883303e23e17e39bd405ee71fee30f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 13:23:35 -0500 Subject: [PATCH 29/65] Server(docs[run_shell]): Note tmux chdir fallback for cwd= why: The cwd= docstring framed the kwarg as a "working directory for the shell command", parallel to subprocess.Popen(cwd=). tmux's -c is actually a *start directory*: tmux/job.c:142 tries chdir(cwd), then chdir(home), then chdir("/"), only fatal()'ing if all three fail. A user relying on Python semantics expects a failed chdir to raise, but tmux silently falls back. Reproduced live: run_shell('pwd', cwd='/definitely/not/a/path') returns ['']. what: - Extend the cwd= parameter description to document tmux's home -> / fallback chain and contrast with subprocess.Popen(cwd=). - Re-word the lead sentence "Working directory" to "Start directory" to match tmux's terminology. --- src/libtmux/server.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index ede44e3f3..b82349687 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -451,11 +451,17 @@ def run_shell( target_pane : str, optional Target pane for output (``-t`` flag). cwd : str or PathLike, optional - Working directory for the shell command (``-c`` flag). When + Start directory for the shell command (``-c`` flag). When omitted, tmux uses the target client's current working directory. Requires tmux 3.4+; on older tmux a warning is emitted and the kwarg is ignored. + Note: tmux's ``-c`` is a *start directory*, not subprocess + semantics. If ``chdir(cwd)`` fails, tmux falls back to the + user's home directory, then to ``/``, rather than raising + — unlike Python's ``subprocess.Popen(cwd=)`` which errors + on a failed chdir. + .. versionadded:: 0.57 show_stderr : bool, optional Combine the command's stderr into the captured output stream From dc6513cdbe95b3228329497202adc227d2190459 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 13:27:04 -0500 Subject: [PATCH 30/65] tests(display_message): Skip no-text test on tmux 3.2a control-mode client why: After display_message() started propagating tmux stderr (the prior display_message-fix commit), tmux 3.2a's CI surfaced a latent issue in test_server_display_message_no_text_returns_none: the control-mode client path emits a usage error from tmux's argument parser on 3.2a, which the test silently absorbed before but now raises. This is the same control-mode-on-3.2a unreliability already gated on the sibling test_server_display_message_flags and test_server_display_message_target_client suites with `has_gte_version("3.3")`. what: - Add the same `has_gte_version("3.3")` skip to test_server_display_message_no_text_returns_none, matching the existing gate on its sibling tests. --- tests/test_server.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index 92d0357db..e9ac527b5 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1519,6 +1519,13 @@ def test_server_display_message_no_text_returns_none( server: Server, ) -> None: """Without ``get_text=True`` the call renders to status line and returns None.""" + from libtmux.common import has_gte_version + + if not has_gte_version("3.3"): + pytest.skip( + "display-message via control-mode client unreliable on tmux 3.2a", + ) + with control_mode() as ctl: result = server.display_message( "hi from libtmux", target_client=ctl.client_name From 128f2455f82a479dd1c86ed17af26f16d20a5ea5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 16:00:18 -0500 Subject: [PATCH 31/65] neo(fix[scope-gate]): Widen SCOPES_BY_LIST_CMD to admit downward cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The scope gate at src/libtmux/neo.py:30 was symmetric, dropping window_*/pane_* tokens from list-sessions and list-windows -F templates. tmux's format_defaults (format.c:5865-5870) cascades downward via s->curw and wl->window->active, so list-sessions resolves #{window_id}, #{pane_id}, and #{pane_current_command} to the session's active context. The over-narrowing silently regressed Server.sessions[i].window_id, .pane_id, .pane_current_command to None — hydration that 0.56.0 produced correctly. The original 3.2a defensive trigger was specifically the *upward* case (client_* in non-list-clients context); downward cascade is safe on every supported tmux version (verified against https://github.com/tmux/tmux/blob/3.2a/format.c lines 4718-4723 and NULL-guarded format_cb_session_/_window_/_pane_ callbacks). what: - src/libtmux/neo.py: widen each list-* scope to include the full downward cascade (universal + session + window + pane). Keep client scope gated to list-clients only — that case retains the 3.2a-safe upward gate. - src/libtmux/neo.py: append a sentence to SCOPES_BY_LIST_CMD's docstring naming the cascade asymmetry. - src/libtmux/neo.py: update get_output_format doctest assertions to demonstrate the new contract (list-sessions has pane_id but not client_name; list-clients has both). - tests/test_server.py: add test_sessions_property_hydrates_cross_scope and test_windows_property_hydrates_active_pane exercising the list-sessions / list-windows -a path (distinct from new_session -P). - tests/test_client.py: add test_clients_property_hydrates_cross_scope exercising the list-clients path. --- src/libtmux/neo.py | 23 +++++++++++++++++------ tests/test_client.py | 18 ++++++++++++++++++ tests/test_server.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 8b7dbf26e..f5bcc8d6a 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -28,10 +28,10 @@ SCOPES_BY_LIST_CMD: dict[str, frozenset[str]] = { - "list-sessions": frozenset({"universal", "session"}), - "list-windows": frozenset({"universal", "session", "window"}), + "list-sessions": frozenset({"universal", "session", "window", "pane"}), + "list-windows": frozenset({"universal", "session", "window", "pane"}), "list-panes": frozenset({"universal", "session", "window", "pane"}), - "list-clients": frozenset({"universal", "session", "client"}), + "list-clients": frozenset({"universal", "session", "window", "pane", "client"}), } """Format-token scopes a given tmux ``list-*`` subcommand can resolve. @@ -39,6 +39,13 @@ ``-F`` template. A token whose scope is *outside* the set may be unknown to the format engine in that context, or in older tmux releases trigger a server-side fault — exclude it from the format string. + +The cascade is asymmetric: tmux's ``format_defaults`` (``format.c``) fills +deeper context downward — ``c->session`` → ``s->curw`` → ``wl->window->active`` +— so every ``list-*`` subcommand admits its own scope plus every scope +reachable *downward*. ``client`` scope is the exception: it appears only in +``list-clients`` because no reverse cascade exists and emitting ``client_*`` +in a non-client context crashed tmux 3.2a CI (commit 342ff5f5). """ @@ -399,7 +406,9 @@ def get_output_format( >>> fields, fmt = get_output_format("list-sessions", "3.6a") >>> 'session_id' in fields True - >>> 'pane_id' in fields + >>> 'pane_id' in fields # downward cascade via format_defaults + True + >>> 'client_name' in fields # upward not allowed False >>> 'server' in fields False @@ -410,11 +419,13 @@ def get_output_format( >>> all(t in fields for t in ('pane_id', 'window_id', 'session_id')) True - Client scope is isolated from pane/window tokens: + ``list-clients`` adds client scope on top of the downward cascade: >>> fields, _ = get_output_format("list-clients", "3.6a") + >>> 'client_name' in fields + True >>> 'pane_id' in fields - False + True """ allowed_scopes = SCOPES_BY_LIST_CMD.get( list_cmd, diff --git a/tests/test_client.py b/tests/test_client.py index 11cb0ff3d..312574b65 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -61,3 +61,21 @@ def test_client_refresh_rehydrates_fields( client.client_pid = None client.refresh() assert client.client_pid == original_pid + + +def test_clients_property_hydrates_cross_scope( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``Server.clients`` hydrates the client's active session/window/pane. + + Exercises the ``list-clients`` path. tmux's ``format_defaults`` cascades + via ``c->session`` → ``s->curw`` → ``wl->window->active``, so a Client + object must surface ``session_id``, ``window_id``, and ``pane_id``. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + assert client.session_id is not None + assert client.window_id is not None + assert client.pane_id is not None diff --git a/tests/test_server.py b/tests/test_server.py index e9ac527b5..b76a33739 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -117,6 +117,38 @@ def test_new_session_returns_populated_session(server: Server) -> None: assert session.pane_id is not None +def test_sessions_property_hydrates_cross_scope(server: Server) -> None: + """Server.sessions hydrates active window/pane via tmux's format_defaults cascade. + + Distinct from ``test_new_session_returns_populated_session``: that test + exercises ``new-session -P -F`` (list-panes scope). This one exercises + the ``list-sessions`` path used by the ``.sessions`` property, which + must hydrate downward-cascade tokens (tmux ``format.c:format_defaults`` + walks ``s->curw`` and ``wl->window->active``). + """ + server.new_session(session_name="hydration_cascade_sessions") + fetched = server.sessions.get(session_name="hydration_cascade_sessions") + assert fetched is not None + assert fetched.window_id is not None + assert fetched.pane_id is not None + assert fetched.pane_current_command is not None + + +def test_windows_property_hydrates_active_pane( + server: Server, + session: Session, +) -> None: + """Server.windows hydrates each window's active pane via the cascade. + + Exercises the ``list-windows -a`` path used by the ``.windows`` property. + """ + session.new_window(window_name="hydration_cascade_windows") + fetched = server.windows.get(window_name="hydration_cascade_windows") + assert fetched is not None + assert fetched.pane_id is not None + assert fetched.pane_current_command is not None + + def test_new_session_no_name(server: Server) -> None: """Server.new_session works with no name.""" first_session = server.new_session() From f1687e9bece552c32724a9ad94ddc844198bbcfa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 17:39:38 -0500 Subject: [PATCH 32/65] common(perf[get_version]): Memoize via @functools.cache to eliminate per-call fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The scope-aware refactor of fetch_objs added a get_version(tmux_bin=server.tmux_bin) call at src/libtmux/neo.py:597. Every Server.sessions/.windows/.panes/.clients property access then forked tmux -V before the list-* subcommand, doubling subprocess count vs 0.56.0. The seven has_*_version helpers in common.py:437-544 each forked on entry too — every has_gte_version("3.4", tmux_bin=...) guard in pane.py/server.py paid a fresh tmux -V. For polling/search-heavy consumers (libtmux-mcp search_panes, tmuxp inspection) the regression was material. @functools.cache keyed on (tmux_bin,) eliminates the redundant forks. Tmux binary at a given path is effectively immutable for a process' lifetime; the rare PATH-swap / symlink-swap edge case is handled by the auto-exposed get_version.cache_clear() escape hatch (verified behavior against https://github.com/python/cpython/blob/main/Lib/functools.py). Mirrors the existing @functools.cache pattern already in use at neo.py:415 for get_output_format. Tests that monkey-patch libtmux.common.tmux_cmd then call get_version() (test_common.py's parametrized version-parsing tests, legacy_api's get_version test trio) would have been masked by cache pollution from prior tests; an autouse fixture in the new tests/conftest.py flushes the cache before each test to keep those mocks honest. what: - src/libtmux/common.py: add import functools; decorate get_version with @functools.cache; append a Notes paragraph naming the cache key shape and pointing callers to cache_clear() for invalidation. - tests/conftest.py (new): autouse fixture _clear_get_version_cache that calls get_version.cache_clear() before every test in tests/ (covers test_common.py and legacy_api/test_common.py). - tests/test_common.py: add four memoization tests grouped after test_has_version: * test_get_version_is_memoized_for_same_tmux_bin * test_get_version_cache_keyed_by_tmux_bin * test_get_version_cache_clear_invalidates * test_get_version_binary_swap_requires_explicit_cache_clear (the binary-swap test explicitly demonstrates the sticky-cache trap when tmux_bin=None and PATH changes, then verifies the cache_clear() escape hatch). All tests use t.ClassVar[list[str]] on the mock-proc class attributes to satisfy RUF012. --- src/libtmux/common.py | 11 ++++ tests/conftest.py | 20 +++++++ tests/test_common.py | 124 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 tests/conftest.py diff --git a/src/libtmux/common.py b/src/libtmux/common.py index 8113adc49..b1a10664c 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -7,6 +7,7 @@ from __future__ import annotations +import functools import logging import re import shlex @@ -373,6 +374,7 @@ def __init__(self, *args: t.Any, tmux_bin: str | None = None) -> None: ) +@functools.cache def get_version(tmux_bin: str | None = None) -> LooseVersion: """Return tmux version. @@ -393,6 +395,15 @@ def get_version(tmux_bin: str | None = None) -> LooseVersion: :class:`distutils.version.LooseVersion` tmux version according to *tmux_bin* if provided, otherwise the system tmux from :func:`shutil.which` + + Notes + ----- + Memoized via :func:`functools.cache`, keyed on the *tmux_bin* argument + (``None`` is a distinct key from any explicit path). The cache is sticky + across ``PATH`` changes and on-disk binary swaps when *tmux_bin* is + ``None`` or the same path string — call ``get_version.cache_clear()`` to + invalidate. Tests that monkey-patch :class:`tmux_cmd` should call + ``cache_clear()`` before asserting parsed-version behavior. """ proc = tmux_cmd("-V", tmux_bin=tmux_bin) if proc.stderr: diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 000000000..6b8a98369 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,20 @@ +"""Test-suite-wide fixtures for libtmux's own tests.""" + +from __future__ import annotations + +import pytest + +from libtmux.common import get_version + + +@pytest.fixture(autouse=True) +def _clear_get_version_cache() -> None: + """Flush get_version's @functools.cache before each test. + + Several tests in test_common.py and legacy_api/test_common.py + monkey-patch libtmux.common.tmux_cmd then call get_version() to + assert parsed-version behavior. With memoization, a prior test's + cached result would mask the mock — this fixture guarantees a + fresh subprocess lookup per test. + """ + get_version.cache_clear() diff --git a/tests/test_common.py b/tests/test_common.py index 7643f1211..bbb58006e 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -39,6 +39,130 @@ def test_has_version() -> None: assert has_version(str(get_version())) +def test_get_version_is_memoized_for_same_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Two calls with the same tmux_bin fork tmux -V once. + + Validates the @functools.cache contract: identical-arg calls hit the + cache after the first miss. + """ + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + v1 = get_version() + v2 = get_version() + + assert v1 == v2 + assert call_count["n"] == 1 + + +def test_get_version_cache_keyed_by_tmux_bin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Different tmux_bin args cache independently; same arg revisits hit.""" + call_count = {"n": 0} + versions = {"/path/a/tmux": "tmux 3.4", "/path/b/tmux": "tmux 3.6a"} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc(versions[kwargs["tmux_bin"]]) + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + a1 = get_version(tmux_bin="/path/a/tmux") + b1 = get_version(tmux_bin="/path/b/tmux") + a2 = get_version(tmux_bin="/path/a/tmux") + + assert a1 != b1 + assert a1 == a2 + assert call_count["n"] == 2 # /a once, /b once, /a hits cache + + +def test_get_version_cache_clear_invalidates( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """cache_clear() forces a fresh subprocess on the next call.""" + call_count = {"n": 0} + + class _MockProc: + stdout: t.ClassVar[list[str]] = ["tmux 3.6a"] + stderr: t.ClassVar[list[str]] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + call_count["n"] += 1 + return _MockProc() + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + get_version() + get_version() + assert call_count["n"] == 1 + + get_version.cache_clear() + get_version() + assert call_count["n"] == 2 + + +def test_get_version_binary_swap_requires_explicit_cache_clear( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Documents the sticky-cache trap when tmux_bin=None and PATH changes. + + Simulates a user upgrading tmux mid-process: two consecutive + ``get_version()`` calls with ``tmux_bin=None`` see different + underlying binaries, but the cache pins the first answer. The + escape hatch is ``get_version.cache_clear()`` — this test asserts + the trap is real and the escape hatch works. + """ + versions = ["tmux 3.2a", "tmux 3.6a"] + call_count = {"n": 0} + + class _MockProc: + def __init__(self, line: str) -> None: + self.stdout = [line] + self.stderr: list[str] = [] + + def _mock_tmux_cmd(*args: t.Any, **kwargs: t.Any) -> _MockProc: + proc = _MockProc(versions[call_count["n"]]) + call_count["n"] += 1 + return proc + + monkeypatch.setattr(libtmux.common, "tmux_cmd", _mock_tmux_cmd) + get_version.cache_clear() + + first = get_version() + assert str(first) == "3.2" + + # "Binary swap" — PATH changed, but cache is sticky. + second = get_version() + assert str(second) == "3.2" # Stale: still the cached 3.2a. + assert call_count["n"] == 1 # No fresh subprocess. + + # Escape hatch. + get_version.cache_clear() + third = get_version() + assert str(third) == "3.6" # Fresh lookup. + assert call_count["n"] == 2 + + def test_tmux_cmd_raises_on_not_found(monkeypatch: pytest.MonkeyPatch) -> None: """Verify raises if tmux command not found.""" monkeypatch.setenv("PATH", "") From 827c7a4879c1e7448558aae44e6cb0378b93f882 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 18:47:58 -0500 Subject: [PATCH 33/65] neo(fix[scope-gate]): Re-admit cursor_x/y/flag/character as pane-scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The blanket ``cursor_`` → ``event`` rule in _SCOPE_PREFIXES filtered cursor_* tokens out of the list-* -F template, so ``pane.cursor_x`` and ``pane.cursor_y`` hydrated to ``None`` instead of tmux's actual cursor position. Downstream tmuxp's _wait_for_pane_ready (workspace/builder.py) polls ``pane.cursor_x != "0"`` — with ``None`` the predicate is always truthy, the function returns True immediately, and the timeout contract breaks (caught by tmuxp's test_wait_for_pane_ready_timeout under a smoke-test pin). The prefix rule isn't wrong for every cursor_*: copy-mode cursor tokens (copy_cursor_*) and runtime cursor events stay event-only. But cursor_x, cursor_y, cursor_flag, and cursor_character all deref ``ft->wp`` in format.c — they're pane-scope and resolve on every list-panes row. Per-token overrides admit them without loosening the prefix rule for tokens that genuinely are event-only. what: - Add cursor_x/y/flag/character to _SCOPE_OVERRIDES with "pane" - Pane-scope verified against format_cb_cursor_x/y/flag/character in tmux format.c (each dereferences ft->wp or ft->wp->base) --- src/libtmux/neo.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index f5bcc8d6a..1d24665a4 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -81,7 +81,12 @@ # ``format.c`` (which context the callback dereferences — wp, wl, s, or c). # Entries are added by the scope-gate fix commits as misclassifications are # discovered. -_SCOPE_OVERRIDES: dict[str, str] = {} +_SCOPE_OVERRIDES: dict[str, str] = { + "cursor_x": "pane", # ft->wp->base.cx + "cursor_y": "pane", # ft->wp->base.cy + "cursor_flag": "pane", # ft->wp->base.mode + "cursor_character": "pane", # ft->wp +} # Standalone tokens not captured by the prefix table. From 9f1599e6412c14962f70aa121e79419461595ab1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 19:35:32 -0500 Subject: [PATCH 34/65] neo(fix[scope-gate]): Re-admit mouse_*_flag and scroll_region_* as pane-scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The blanket ``mouse_`` and ``scroll_`` → ``event`` prefix rules in _SCOPE_PREFIXES filtered these tokens out of every list-* -F template, so ``pane.mouse_all_flag``, ``pane.scroll_region_lower``, and siblings hydrated to ``None`` instead of tmux's actual per-pane mode bits and scroll-region rows. Same regression class as cursor_x/y (fixed in the prior cursor scope-gate commit) — caught by auditing every format_cb_* callback in https://github.com/tmux/tmux/blob/master/format.c and https://github.com/tmux/tmux/blob/3.2a/format.c against libtmux's Obj fields. The prefix rule still earns its keep: genuine runtime mouse-event tokens (mouse_word, mouse_pane, mouse_x/y, mouse_status_*) gate on ``ft->m.valid`` and stay event-only, and the copy-mode scroll tokens (scroll_position) are runtime-injected via format_add() in window-copy.c. Per-token overrides admit just the misclassified mode-flag and region tokens without loosening the prefix rule for the legitimate event-only ones. what: - Add mouse_*_flag and 2 scroll_region_* entries to _SCOPE_OVERRIDES with "pane" scope. All seven callbacks dereference ft->wp->base in format.c on every tmux from 3.2a through master, so no version gating needed. - Tighten the doctest on _token_scope to cover the new override class (mouse_all_flag → pane), distinguishing per-pane mode bits from runtime mouse events. - Add parametrized test_pane_scope_override_field_hydrates with the new tokens plus the 3 cursor_* tokens already covered by the prior cursor scope-gate commit. Asserts ``value is not None`` (stricter than the existing test_pane_format_field_declared_and_hydrated's ``None or isinstance(str)``), so a future scope misclassification fails loudly instead of silently degrading the typed surface. --- src/libtmux/neo.py | 12 ++++++++++++ tests/test_pane.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 1d24665a4..431f868e6 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -86,6 +86,13 @@ "cursor_y": "pane", # ft->wp->base.cy "cursor_flag": "pane", # ft->wp->base.mode "cursor_character": "pane", # ft->wp + "mouse_all_flag": "pane", # ft->wp->base.mode MODE_MOUSE_ALL + "mouse_any_flag": "pane", # ft->wp->base.mode ALL_MOUSE_MODES + "mouse_button_flag": "pane", # ft->wp->base.mode MODE_MOUSE_BUTTON + "mouse_sgr_flag": "pane", # ft->wp->base.mode MODE_MOUSE_SGR + "mouse_standard_flag": "pane", # ft->wp->base.mode MODE_MOUSE_STANDARD + "scroll_region_lower": "pane", # ft->wp->base.rlower + "scroll_region_upper": "pane", # ft->wp->base.rupper } @@ -150,6 +157,11 @@ def _token_scope(field_name: str) -> str: Tokens whose name doesn't carry a scope prefix can still be scope-gated via :data:`_SCOPE_OVERRIDES` (verified against tmux's ``format_cb_*``). + The override also corrects prefix-misclassified tokens — e.g. + ``mouse_all_flag`` is a per-pane mode bit, not a runtime mouse event: + + >>> _token_scope("mouse_all_flag") + 'pane' """ override = _SCOPE_OVERRIDES.get(field_name) if override is not None: diff --git a/tests/test_pane.py b/tests/test_pane.py index b467e1bb3..0d8225578 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -661,6 +661,41 @@ def test_pane_synchronized_reflects_window_state(session: Session) -> None: assert pane.pane_synchronized == "0" +PANE_SCOPE_OVERRIDE_FIELDS = ( + "cursor_x", + "cursor_y", + "cursor_flag", + "mouse_all_flag", + "mouse_any_flag", + "mouse_button_flag", + "mouse_sgr_flag", + "mouse_standard_flag", + "scroll_region_lower", + "scroll_region_upper", +) + + +@pytest.mark.parametrize("field_name", PANE_SCOPE_OVERRIDE_FIELDS) +def test_pane_scope_override_field_hydrates( + field_name: str, + session: Session, +) -> None: + """Per-token scope overrides admit each token into list-panes -F. + + These tokens' callbacks all dereference ``ft->wp`` in tmux's + ``format.c`` (verified across tmux 3.2a through master), so the value + must hydrate to a string on every supported tmux version. A ``None`` + here indicates the scope gate excluded the token from the format + string, which is the regression class these overrides prevent. + """ + pane = session.active_window.active_pane + assert pane is not None + pane.refresh() + value = getattr(pane, field_name) + assert value is not None, f"{field_name} should hydrate via list-panes" + assert isinstance(value, str) + + def test_select_pane_direction(session: Session) -> None: """Test Pane.select() with direction flags.""" window = session.new_window(window_name="test_select_dir") From 8f1faa26ff394561775e92cb7b999a1edbd67fda Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 19:40:33 -0500 Subject: [PATCH 35/65] neo(fix[scope-gate]): Reclassify universal-mislabeled tokens to true scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: _UNIVERSAL_TOKENS was a junk drawer: ten pane-mode tokens (history_*, keypad_*, insert_flag, origin_flag, wrap_flag, alternate_saved_x/y) plus two session-index tokens (active_window_index, last_window_index) were lumped in with genuinely server-wide tokens (version, socket_path, pid). They hydrated correctly only because "universal" appears in every SCOPES_BY_LIST_CMD entry — so the format engine accepted them everywhere, including list-sessions and list-windows where they're meaningless. That's not parity; that's noise leaking across scopes. The fix isolates the universal set to tokens whose callbacks actually resolve without session/window/pane/client context (``__unused`` ft or global reads), and routes the pane- and session-scoped standalones through _SCOPE_OVERRIDES so list-panes/list-sessions are the only -F templates that emit them. alternate_on is in tmux's format_table but not declared on Obj, so it gets no override (no-op without a typed field); adding the field is a separate concern. what: - Remove mislabeled tokens from _UNIVERSAL_TOKENS; tighten its docstring to name what stays (truly server-wide / parse-context). - Add pane-scope and session-scope entries to _SCOPE_OVERRIDES with the same ``# ft->...`` callback-context comments as the existing entries. Callbacks verified against https://github.com/tmux/tmux and https://github.com/tmux/tmux/tree/3.2a — identical on every supported version. - Extend PANE_SCOPE_OVERRIDE_FIELDS with the reclassified pane-scope tokens; add SESSION_SCOPE_OVERRIDE_FIELDS + test_session_scope_override_field_hydrates for the 2 session-scope tokens. Same ``value is not None`` assertion as the prior mouse/scroll-region scope-gate commit — catches the same regression class on a different scope. - Add ``active_window_index`` → 'session' to _token_scope's doctest so the override-correcting role of _SCOPE_OVERRIDES is documented for non-pane scopes too. --- src/libtmux/neo.py | 32 ++++++++++++++++++-------------- tests/test_pane.py | 10 ++++++++++ tests/test_session.py | 24 ++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 14 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 431f868e6..395eb22ac 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -93,33 +93,36 @@ "mouse_standard_flag": "pane", # ft->wp->base.mode MODE_MOUSE_STANDARD "scroll_region_lower": "pane", # ft->wp->base.rlower "scroll_region_upper": "pane", # ft->wp->base.rupper + "alternate_saved_x": "pane", # ft->wp->base.saved_cx + "alternate_saved_y": "pane", # ft->wp->base.saved_cy + "history_bytes": "pane", # ft->wp + "history_limit": "pane", # ft->wp->base.grid->hlimit + "history_size": "pane", # ft->wp->base.grid->hsize + "insert_flag": "pane", # ft->wp->base.mode MODE_INSERT + "keypad_cursor_flag": "pane", # ft->wp->base.mode MODE_KCURSOR + "keypad_flag": "pane", # ft->wp->base.mode MODE_KKEYPAD + "origin_flag": "pane", # ft->wp->base.mode MODE_ORIGIN + "wrap_flag": "pane", # ft->wp->base.mode MODE_WRAP + "active_window_index": "session", # ft->s->curw->idx + "last_window_index": "session", # ft->s } -# Standalone tokens not captured by the prefix table. +# Standalone tokens not captured by the prefix table. These are genuinely +# server-wide or parse-context tokens whose callbacks resolve without a +# session/window/pane/client (most are ``__unused`` ft or read globals). +# Pane- and session-scoped standalones are routed via :data:`_SCOPE_OVERRIDES`. _UNIVERSAL_TOKENS: frozenset[str] = frozenset( { - "active_window_index", - "alternate_on", - "alternate_saved_x", - "alternate_saved_y", "command_list_alias", "command_list_name", "command_list_usage", "config_files", "current_file", - "history_bytes", - "history_limit", - "history_size", "host", "host_short", - "insert_flag", - "keypad_cursor_flag", - "keypad_flag", - "last_window_index", "line", "next_session_id", - "origin_flag", "pid", "search_match", "socket_path", @@ -127,7 +130,6 @@ "uid", "user", "version", - "wrap_flag", } ) @@ -162,6 +164,8 @@ def _token_scope(field_name: str) -> str: >>> _token_scope("mouse_all_flag") 'pane' + >>> _token_scope("active_window_index") + 'session' """ override = _SCOPE_OVERRIDES.get(field_name) if override is not None: diff --git a/tests/test_pane.py b/tests/test_pane.py index 0d8225578..1fc95b0a5 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -672,6 +672,16 @@ def test_pane_synchronized_reflects_window_state(session: Session) -> None: "mouse_standard_flag", "scroll_region_lower", "scroll_region_upper", + "alternate_saved_x", + "alternate_saved_y", + "history_bytes", + "history_limit", + "history_size", + "insert_flag", + "keypad_cursor_flag", + "keypad_flag", + "origin_flag", + "wrap_flag", ) diff --git a/tests/test_session.py b/tests/test_session.py index 820a88bc5..3c7c33d47 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -729,3 +729,27 @@ def test_session_format_field_declared_and_hydrated( session.refresh() value = getattr(session, field_name) assert value is None or isinstance(value, str) + + +SESSION_SCOPE_OVERRIDE_FIELDS = ( + "active_window_index", + "last_window_index", +) + + +@pytest.mark.parametrize("field_name", SESSION_SCOPE_OVERRIDE_FIELDS) +def test_session_scope_override_field_hydrates( + field_name: str, + session: Session, +) -> None: + """Session-scope overrides admit each token into list-sessions -F. + + Callbacks dereference ``ft->s`` in tmux's ``format.c`` (verified + across tmux 3.2a through master). A ``None`` here indicates the + scope gate excluded the token from the format string, which is + the regression class these overrides prevent. + """ + session.refresh() + value = getattr(session, field_name) + assert value is not None, f"{field_name} should hydrate via list-sessions" + assert isinstance(value, str) From e07edeff7862ccd4380e51ad74041d74043c49ef Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 05:36:57 -0500 Subject: [PATCH 36/65] neo(fix[field_version]): Gate pane_dead_signal/time to tmux 3.3 why: Both tokens were registered in tmux's format.c via tmux's "Add remain-on-exit-format" upstream change, first tagged in tmux 3.3. Without a FIELD_VERSION entry they fell back to the 3.2a baseline and got emitted in the -F template on tmux 3.2a, where the format engine has no callback for them. tmux doesn't crash (unknown tokens render as literal text), but the field hydrated to garbage strings on 3.2a instead of staying empty. what: - Add "pane_dead_signal": "3.3" and "pane_dead_time": "3.3" to FIELD_VERSION in src/libtmux/neo.py - Add tests/test_neo.py with unit tests for the two new gates plus a drift-catcher asserting every FIELD_VERSION key is declared on Obj and a regression test pinning the SCOPES_BY_LIST_CMD cascade shape --- src/libtmux/neo.py | 7 ++++- tests/test_neo.py | 76 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test_neo.py diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 395eb22ac..f20bc435e 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -49,7 +49,12 @@ """ -FIELD_VERSION: dict[str, str] = {} +FIELD_VERSION: dict[str, str] = { + # Post-3.2a additions (verified against tmux's format.c at each gated + # release tag, e.g. https://github.com/tmux/tmux/blob/3.6a/format.c). + "pane_dead_signal": "3.3", + "pane_dead_time": "3.3", +} """Minimum tmux version that registers each format token. Field names absent from this dict default to ``"3.2a"`` (always-safe within diff --git a/tests/test_neo.py b/tests/test_neo.py new file mode 100644 index 000000000..0648ca5a7 --- /dev/null +++ b/tests/test_neo.py @@ -0,0 +1,76 @@ +"""Tests for libtmux.neo scope+version gated -F template builder. + +These tests exercise :func:`libtmux.neo.get_output_format` and +:func:`libtmux.neo._token_scope` directly — pure-Python unit tests that +don't need a live tmux server. Scope and version classifications were +verified against tmux's ``format.c`` (see commit messages on +``parity-pt-2``). +""" + +from __future__ import annotations + +from libtmux.neo import ( + FIELD_VERSION, + SCOPES_BY_LIST_CMD, + get_output_format, +) + + +def test_pane_dead_signal_gated_to_3_3() -> None: + """``pane_dead_signal`` first registered in tmux 3.3. + + The format-table entry sits in ``format.c`` from commit a3d92093 + ("Add remain-on-exit-format"), first tagged in tmux 3.3. Emitting + it on tmux 3.2a hydrates the field with the literal ``#{...}`` + text rather than an empty value, which downstream code interprets + as a live signal — a real footgun. + """ + assert FIELD_VERSION["pane_dead_signal"] == "3.3" + fields_old, _ = get_output_format("list-panes", "3.2a") + assert "pane_dead_signal" not in fields_old + fields_new, _ = get_output_format("list-panes", "3.3") + assert "pane_dead_signal" in fields_new + + +def test_pane_dead_time_gated_to_3_3() -> None: + """``pane_dead_time`` first registered in tmux 3.3. + + Same provenance as ``pane_dead_signal`` — both tokens shipped in + the same upstream commit, so they share a version floor. + """ + assert FIELD_VERSION["pane_dead_time"] == "3.3" + fields_old, _ = get_output_format("list-panes", "3.2a") + assert "pane_dead_time" not in fields_old + fields_new, _ = get_output_format("list-panes", "3.3") + assert "pane_dead_time" in fields_new + + +def test_field_version_keys_are_obj_fields() -> None: + """Every gated field name must exist on :class:`libtmux.neo.Obj`. + + A typo in ``FIELD_VERSION`` would silently no-op; this catches + drift between the dataclass and the version-gate table. + """ + from libtmux.neo import Obj + + obj_fields = set(Obj.__dataclass_fields__) + for token in FIELD_VERSION: + assert token in obj_fields, ( + f"{token!r} in FIELD_VERSION but not declared on Obj" + ) + + +def test_scopes_by_list_cmd_downward_cascade() -> None: + """Every ``list-*`` admits universal+session+window+pane scopes. + + ``list-clients`` additionally admits ``client`` scope. This pins + the cascade asymmetry documented at ``neo.py`` SCOPES_BY_LIST_CMD. + """ + for cmd, scopes in SCOPES_BY_LIST_CMD.items(): + assert {"universal", "session", "window", "pane"} <= scopes, ( + f"{cmd} should admit the downward-cascade core scopes" + ) + assert "client" in SCOPES_BY_LIST_CMD["list-clients"] + assert "client" not in SCOPES_BY_LIST_CMD["list-sessions"] + assert "client" not in SCOPES_BY_LIST_CMD["list-windows"] + assert "client" not in SCOPES_BY_LIST_CMD["list-panes"] From 0d30ece06eaa567cc5c8875e5e61a724e865e925 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 05:40:09 -0500 Subject: [PATCH 37/65] Server(fix[clients,search_sessions]): Propagate tmux errors why: Server.clients and Server.search_sessions wrapped fetch_objs in a bare except Exception: pass, returning an empty QueryList on any failure. That hid real tmux errors (subprocess failure, malformed output, version-incompatible flags) as "no clients" or "no matches", indistinguishable from the legitimate empty case. The direction set by raise_if_stderr is to surface tmux errors, not bury them; the newer Server.search_windows/search_panes already do this. Bring clients/search_sessions into line. what: - Drop the try/except Exception: pass from Server.clients (src/libtmux/server.py:2298-) and Server.search_sessions (src/libtmux/server.py:2358-). Both now use the list-comprehension shape consistent with Server.windows/panes/search_windows/search_panes. - Leave the legacy Server.sessions try/except alone (predates this branch; touching it expands scope per CLAUDE.md "Cleanup in Hindsight"). - Add regression tests in tests/test_server.py: monkeypatch fetch_objs to raise LibTmuxException and assert each property re-raises rather than returning an empty QueryList. --- src/libtmux/server.py | 23 ++++++++--------------- tests/test_server.py | 40 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index b82349687..d7db08011 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2314,13 +2314,10 @@ def clients(self) -> QueryList[Client]: ... ctl.client_name in names True """ - clients: list[Client] = [] - try: - for obj in fetch_objs(list_cmd="list-clients", server=self): - clients.append(Client(server=self, **obj)) # noqa: PERF401 - except Exception: - pass - + clients: list[Client] = [ + Client(server=self, **obj) + for obj in fetch_objs(list_cmd="list-clients", server=self) + ] return QueryList(clients) def search_sessions( @@ -2356,18 +2353,14 @@ def search_sessions( >>> [s.session_name for s in matches] ['gap7_alpha'] """ - sessions: list[Session] = [] - - try: + sessions: list[Session] = [ + Session(server=self, **obj) for obj in fetch_objs( list_cmd="list-sessions", server=self, filter=filter, - ): - sessions.append(Session(server=self, **obj)) # noqa: PERF401 - except Exception: - pass - + ) + ] return QueryList(sessions) def search_windows( diff --git a/tests/test_server.py b/tests/test_server.py index b76a33739..5456f241f 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1310,6 +1310,46 @@ def test_server_search_panes_filter_by_id(server: Server) -> None: assert [p.pane_id for p in matches] == [target.pane_id] +def test_server_clients_propagates_errors( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.clients`` re-raises tmux errors instead of swallowing them. + + The wrapper used to ``except Exception: pass`` and return an empty + QueryList on any failure, masking real tmux errors as "no clients". + A genuine failure should surface so callers can react. + """ + sentinel = exc.LibTmuxException("simulated list-clients failure") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.LibTmuxException, match="simulated list-clients failure"): + server.clients # noqa: B018 + + +def test_server_search_sessions_propagates_errors( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``Server.search_sessions`` re-raises tmux errors instead of swallowing them. + + Mirrors the clients-propagation contract: errors are surfaced, not + buried as an empty QueryList that's indistinguishable from "filter + matched nothing". + """ + sentinel = exc.LibTmuxException("simulated list-sessions failure") + + def _boom(**_: object) -> list[dict[str, str]]: + raise sentinel + + monkeypatch.setattr("libtmux.server.fetch_objs", _boom) + with pytest.raises(exc.LibTmuxException, match="simulated list-sessions failure"): + server.search_sessions(filter="#{m:keep_*,#{session_name}}") + + def test_if_shell_true(server: Server) -> None: """Test Server.if_shell() with true condition.""" server.new_session(session_name="ifshell_test") From a2441552edff6b0fb16716e80ca789cdc29afd61 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 05:43:57 -0500 Subject: [PATCH 38/65] search,list_buffers(docs[filter]): Warn on silent zero-match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's C-side filter (``-f`` flag) silently expands a malformed ``#{...}`` predicate to the empty string, which the format engine then evaluates as "false". Every row is suppressed and no stderr is emitted — a bad filter is indistinguishable from "filter matched nothing". Verified against tmux 3.6a: ``list-panes -f '#{bogus'`` returns empty stdout, exit 0, no stderr. Without a warning in the docstrings, a typo can waste real debugging time hunting a "missing" row. what: - Add a `.. warning::` paragraph to the ``filter`` parameter description in every C-side filter wrapper: - Server.list_buffers, Server.search_sessions, Server.search_windows, Server.search_panes (src/libtmux/server.py) - Session.search_windows, Session.search_panes (src/libtmux/session.py) - Window.search_panes (src/libtmux/window.py) - Caveat points readers at the FORMATS section of tmux(1) to verify predicate syntax before assuming "no matches". --- src/libtmux/server.py | 40 ++++++++++++++++++++++++++++++++++++++++ src/libtmux/session.py | 20 ++++++++++++++++++++ src/libtmux/window.py | 10 ++++++++++ 3 files changed, 70 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index d7db08011..1f26c99bf 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1844,6 +1844,16 @@ def list_buffers( it mirrors tmux's own flag name (``-f filter``) for grep-friendly symmetry between the wrapper and the tmux manual. + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Returns @@ -2337,6 +2347,16 @@ def search_sessions( expanded expression is "false" are omitted by tmux itself before any Python object is built. + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Returns @@ -2378,6 +2398,16 @@ def search_windows( filter : str, optional tmux format expression (``-f`` flag). + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Examples @@ -2423,6 +2453,16 @@ def search_panes( ``'#{C/i:libtmux,#{pane_current_command}}'`` (case-insensitive substring on the current command). + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Examples diff --git a/src/libtmux/session.py b/src/libtmux/session.py index dca283bce..d51de679b 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -208,6 +208,16 @@ def search_windows( filter : str, optional tmux format expression (``-f`` flag). + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Examples @@ -246,6 +256,16 @@ def search_panes( filter : str, optional tmux format expression (``-f`` flag). + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Examples diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 3d9147811..648b2c645 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -212,6 +212,16 @@ def search_panes( filter : str, optional tmux format expression (``-f`` flag). + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the + format engine evaluates as "false" — every row is + suppressed and no stderr is emitted. A bad filter is + indistinguishable from "filter matched nothing"; verify + predicate syntax against the FORMATS section of + ``tmux(1)``. + .. versionadded:: 0.57 Examples From 873c5d914682d799b1b2c557cdc409c36a28a370 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 05:46:15 -0500 Subject: [PATCH 39/65] Client(docs): Warn that session/window/pane fields are attached-view snapshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Client.session_id / Client.window_id / Client.pane_id and Client.client_session look like ownership identity, but they reflect the client's *currently attached view* at hydration time. tmux updates c->session immediately on switch-client (server-client.c:401-416), so a captured Client goes stale the moment its user switches windows or sessions. The previous docstring described the multi-client model but didn't surface this snapshot semantic — a real footgun for callers that hold a Client reference across user interaction. what: - Add a `.. warning::` paragraph to Client's class docstring (src/libtmux/client.py) clarifying that client_session, session_id, window_id, and pane_id are snapshots — call refresh() to re-read — and that client_name (the tty path) is the stable identity. --- src/libtmux/client.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index 792cfe502..b53ce3528 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -29,6 +29,18 @@ class Client(Obj): several terminals) and each receives its own view of the active session, window, and pane. + .. warning:: + + ``client_session``, ``session_id``, ``window_id`` and + ``pane_id`` are snapshots of the client's *currently attached + view* at the moment the dataclass is hydrated — not stable + identity for the client. When the client switches sessions via + ``switch-client``, moves focus via ``select-window`` / + ``select-pane``, or detaches, these fields go stale until + :meth:`refresh` re-reads them from ``list-clients``. The + ``client_name`` (tty path on Unix) is the client's stable + identity. + Parameters ---------- server : :class:`Server` From d7f49ca92298a0f4047a004c7ed3b0d6d465fe2d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 05:51:26 -0500 Subject: [PATCH 40/65] neo(docs[Obj],tests[cascade]): Pin downward-cascade resolution target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's format_defaults cascade (format.c:5865-5879) fills deeper context downward — c->session → s->curw → wl->window->active — so on a list-sessions row, every pane_* field resolves to the session's current window's *active pane*, not "the session's pane" (no such thing). Existing cascade tests only asserted "value is not None" and the Obj docstring said nothing about the semantic; a regression that hydrated the wrong pane would have passed silently. A reader treating session.pane_id as a literal session-owned pane id would be surprised the moment the active window switches. what: - Expand Obj's class docstring (src/libtmux/neo.py) with a Notes section spelling out the downward-cascade target for Session, Window, and Client rows. Points readers at the canonical accessor pair (session.active_window.active_pane.pane_id) for the same value. - Tighten test_sessions_property_hydrates_cross_scope to assert fetched.window_id == session.active_window.window_id and fetched.pane_id == session.active_window.active_pane.pane_id. - Tighten test_windows_property_hydrates_active_pane to assert fetched.pane_id == window.active_pane.pane_id. - Tighten test_clients_property_hydrates_cross_scope to assert client.window_id / client.pane_id match the attached session's current window's active pane. --- src/libtmux/neo.py | 27 ++++++++++++++++++++++++++- tests/test_client.py | 15 ++++++++++++--- tests/test_server.py | 24 ++++++++++++++++++------ 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index f20bc435e..e99c15b1b 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -204,7 +204,32 @@ def _normalize_tmux_version(version: str) -> LooseVersion: @dataclasses.dataclass() class Obj: - """Dataclass of generic tmux object.""" + """Dataclass of generic tmux object. + + Notes + ----- + Cross-scope fields hydrate via tmux's ``format_defaults`` downward + cascade (``format.c``: ``c->session`` → ``s->curw`` → + ``wl->window->active``), not via reverse lookup. The practical + consequence: + + - On a :class:`~libtmux.session.Session` row (``list-sessions``), + every ``pane_*`` and ``window_*`` field resolves to the + session's current window's **active pane** — *not* "the + session's pane" (no such thing). ``session.active_window.window_id`` + and ``session.active_window.active_pane.pane_id`` are the + canonical accessors for the same values. + - On a :class:`~libtmux.window.Window` row (``list-windows``), + every ``pane_*`` field resolves to that window's active pane. + - On a :class:`~libtmux.client.Client` row (``list-clients``), + every ``session_*``, ``window_*``, and ``pane_*`` field resolves + via the client's attached session's current window's active + pane. + + A reader who treats ``session.pane_id`` as the literal session's + pane id (rather than "active pane of this session's current + window") will be surprised when the active window changes. + """ server: Server diff --git a/tests/test_client.py b/tests/test_client.py index 312574b65..0c46901d6 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -69,9 +69,11 @@ def test_clients_property_hydrates_cross_scope( ) -> None: """``Server.clients`` hydrates the client's active session/window/pane. - Exercises the ``list-clients`` path. tmux's ``format_defaults`` cascades - via ``c->session`` → ``s->curw`` → ``wl->window->active``, so a Client - object must surface ``session_id``, ``window_id``, and ``pane_id``. + Exercises the ``list-clients`` path. tmux's ``format_defaults`` + cascades via ``c->session`` → ``s->curw`` → ``wl->window->active``, + so a Client object must surface ``session_id``, ``window_id``, and + ``pane_id``, AND those values must match the client's attached + session's current window's active pane — not arbitrary values. """ with control_mode() as ctl: client = server.clients.get(client_name=ctl.client_name) @@ -79,3 +81,10 @@ def test_clients_property_hydrates_cross_scope( assert client.session_id is not None assert client.window_id is not None assert client.pane_id is not None + + attached_session = server.sessions.get(session_id=client.session_id) + assert attached_session is not None + active_pane = attached_session.active_window.active_pane + assert active_pane is not None + assert client.window_id == attached_session.active_window.window_id + assert client.pane_id == active_pane.pane_id diff --git a/tests/test_server.py b/tests/test_server.py index 5456f241f..0ccdcf9d6 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -125,12 +125,20 @@ def test_sessions_property_hydrates_cross_scope(server: Server) -> None: the ``list-sessions`` path used by the ``.sessions`` property, which must hydrate downward-cascade tokens (tmux ``format.c:format_defaults`` walks ``s->curw`` and ``wl->window->active``). + + Pins the cascade *target*: ``session.window_id`` / + ``session.pane_id`` must equal the session's current window's + active pane — not any arbitrary pane in the session. A regression + that hydrated to the wrong window or pane would still pass a + "not None" check; this assertion catches that drift. """ - server.new_session(session_name="hydration_cascade_sessions") + new_session = server.new_session(session_name="hydration_cascade_sessions") fetched = server.sessions.get(session_name="hydration_cascade_sessions") assert fetched is not None - assert fetched.window_id is not None - assert fetched.pane_id is not None + assert fetched.window_id == new_session.active_window.window_id + active_pane = new_session.active_window.active_pane + assert active_pane is not None + assert fetched.pane_id == active_pane.pane_id assert fetched.pane_current_command is not None @@ -140,12 +148,16 @@ def test_windows_property_hydrates_active_pane( ) -> None: """Server.windows hydrates each window's active pane via the cascade. - Exercises the ``list-windows -a`` path used by the ``.windows`` property. + Exercises the ``list-windows -a`` path used by the ``.windows`` + property. The cascade resolves to the window's active pane, so the + hydrated ``pane_id`` must equal ``window.active_pane.pane_id``. """ - session.new_window(window_name="hydration_cascade_windows") + new_window = session.new_window(window_name="hydration_cascade_windows") fetched = server.windows.get(window_name="hydration_cascade_windows") assert fetched is not None - assert fetched.pane_id is not None + active_pane = new_window.active_pane + assert active_pane is not None + assert fetched.pane_id == active_pane.pane_id assert fetched.pane_current_command is not None From 6a64c59508177c4b0bcde70d53f9a999c90f7c12 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 05:55:00 -0500 Subject: [PATCH 41/65] neo(refactor[_UNIVERSAL_TOKENS]): Add context scope for non-list tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Five tokens in _UNIVERSAL_TOKENS were never registered in format.c's static table: command_list_alias / command_list_name / command_list_usage (cmd-list-commands.c only), search_match (window-copy.c only), and current_file (cfg.c parse-time only). None resolve via format_defaults for any list-* subcommand, so emitting them in a -F template is harmless but misleading — the _UNIVERSAL_TOKENS docstring claimed callbacks "resolve without a session/window/pane/client", which was true of the other entries but not these five. A future maintainer reading the universal set would draw the wrong conclusion about what tmux actually resolves. what: - Split the misclassified five out into _CONTEXT_ONLY_TOKENS in src/libtmux/neo.py. Route them through a new "context" scope in _token_scope, which is excluded from every SCOPES_BY_LIST_CMD entry by absence — get_output_format's existing `_token_scope(f) not in allowed_scopes` filter then drops them cleanly from every -F template. - Tighten the _UNIVERSAL_TOKENS docstring to describe what those 11 tokens actually are (process- or server-global callbacks in format.c's static table) and document the routing for pane/session standalones (_SCOPE_OVERRIDES) and context-only tokens (_CONTEXT_ONLY_TOKENS). - Add parametrized tests in tests/test_neo.py: each context-only token scopes to "context", every SCOPES_BY_LIST_CMD entry excludes "context", and no context-only token leaks into any list-* template fields tuple. - Update _token_scope doctest with two context-scope examples so the routing is visible at the function's docstring. --- src/libtmux/neo.py | 59 +++++++++++++++++++++++++++++++++++++--------- tests/test_neo.py | 38 +++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index e99c15b1b..0e8789fda 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -113,23 +113,22 @@ } -# Standalone tokens not captured by the prefix table. These are genuinely -# server-wide or parse-context tokens whose callbacks resolve without a -# session/window/pane/client (most are ``__unused`` ft or read globals). -# Pane- and session-scoped standalones are routed via :data:`_SCOPE_OVERRIDES`. +# Standalone tokens registered in tmux's ``format.c`` static table (the +# default tree of ``format_cb_*`` callbacks). They resolve in every +# ``list-*`` subcommand because their callbacks read process- or server- +# global state rather than dereferencing ``ft->c``, ``ft->s``, ``ft->wl``, +# or ``ft->wp``. Pane- and session-scoped standalones are routed via +# :data:`_SCOPE_OVERRIDES`; context-only tokens (registered outside +# ``format.c`` for a specific subcommand or mode) are routed via +# :data:`_CONTEXT_ONLY_TOKENS`. _UNIVERSAL_TOKENS: frozenset[str] = frozenset( { - "command_list_alias", - "command_list_name", - "command_list_usage", "config_files", - "current_file", "host", "host_short", "line", "next_session_id", "pid", - "search_match", "socket_path", "start_time", "uid", @@ -138,6 +137,31 @@ } ) +# Tokens declared on :class:`Obj` whose callbacks are registered *outside* +# ``format.c``'s static table — i.e. they only resolve in a specific +# command context, not via ``format_defaults`` for any ``list-*`` +# subcommand: +# +# - ``command_list_alias`` / ``command_list_name`` / ``command_list_usage`` +# → ``cmd-list-commands.c`` (the ``list-commands`` subcommand only). +# - ``search_match`` → ``window-copy.c`` (copy-mode pane formats only). +# - ``current_file`` → ``cfg.c`` (config parse context only). +# +# Emitting these in a ``list-sessions/windows/panes/clients/buffers`` ``-F`` +# template is harmless (tmux renders unknown tokens to empty), but it +# misleads readers of the ``-F`` string about what the format engine will +# resolve in that scope. Routed to the ``"context"`` scope, which is +# explicitly excluded from every :data:`SCOPES_BY_LIST_CMD` entry. +_CONTEXT_ONLY_TOKENS: frozenset[str] = frozenset( + { + "command_list_alias", + "command_list_name", + "command_list_usage", + "current_file", + "search_match", + } +) + def _token_scope(field_name: str) -> str: """Resolve a format token's scope from its name. @@ -145,8 +169,10 @@ def _token_scope(field_name: str) -> str: Returns ``"universal"`` for cross-scope tokens (e.g. ``version``, ``socket_path``, ``host``). Returns ``"event"`` for runtime-only tokens that never appear in a ``list-*`` output (mouse, cursor, selection, - popup). Returns ``"pane"`` / ``"window"`` / ``"session"`` / ``"client"`` - / ``"buffer"`` for scope-prefixed tokens. + popup). Returns ``"context"`` for tokens registered outside + ``format.c``'s static table (only resolve in a specific command or + mode context). Returns ``"pane"`` / ``"window"`` / ``"session"`` / + ``"client"`` / ``"buffer"`` for scope-prefixed tokens. Examples -------- @@ -171,6 +197,15 @@ def _token_scope(field_name: str) -> str: 'pane' >>> _token_scope("active_window_index") 'session' + + Context-only tokens (registered outside ``format.c``'s static table) + route to the ``"context"`` scope and are excluded from every + ``list-*`` ``-F`` template: + + >>> _token_scope("command_list_alias") + 'context' + >>> _token_scope("search_match") + 'context' """ override = _SCOPE_OVERRIDES.get(field_name) if override is not None: @@ -178,6 +213,8 @@ def _token_scope(field_name: str) -> str: for prefix, scope in _SCOPE_PREFIXES: if field_name.startswith(prefix): return scope + if field_name in _CONTEXT_ONLY_TOKENS: + return "context" if field_name in _UNIVERSAL_TOKENS: return "universal" return "universal" diff --git a/tests/test_neo.py b/tests/test_neo.py index 0648ca5a7..d463cbb99 100644 --- a/tests/test_neo.py +++ b/tests/test_neo.py @@ -9,9 +9,13 @@ from __future__ import annotations +import pytest + from libtmux.neo import ( + _CONTEXT_ONLY_TOKENS, FIELD_VERSION, SCOPES_BY_LIST_CMD, + _token_scope, get_output_format, ) @@ -74,3 +78,37 @@ def test_scopes_by_list_cmd_downward_cascade() -> None: assert "client" not in SCOPES_BY_LIST_CMD["list-sessions"] assert "client" not in SCOPES_BY_LIST_CMD["list-windows"] assert "client" not in SCOPES_BY_LIST_CMD["list-panes"] + + +@pytest.mark.parametrize("token", sorted(_CONTEXT_ONLY_TOKENS)) +def test_context_only_token_scope(token: str) -> None: + """Tokens registered outside ``format.c`` route to the ``context`` scope. + + ``command_list_*`` is only registered by ``cmd-list-commands.c``; + ``search_match`` by ``window-copy.c``; ``current_file`` by ``cfg.c``. + None resolve via ``format_defaults`` for any ``list-*``, so they + should not land in the universal bucket where they'd be emitted + in every ``-F`` template. + """ + assert _token_scope(token) == "context" + + +@pytest.mark.parametrize("list_cmd", sorted(SCOPES_BY_LIST_CMD)) +def test_context_scope_excluded_from_every_list_cmd(list_cmd: str) -> None: + """``"context"`` is excluded from every ``SCOPES_BY_LIST_CMD`` entry. + + The exclusion is the structural guarantee that context-only tokens + don't drift into any ``-F`` template. If a future change accidentally + admits ``"context"`` for a list subcommand, this test catches it. + """ + assert "context" not in SCOPES_BY_LIST_CMD[list_cmd] + + +@pytest.mark.parametrize("token", sorted(_CONTEXT_ONLY_TOKENS)) +def test_context_tokens_absent_from_every_list_cmd_template(token: str) -> None: + """Context-only tokens never appear in any ``list-*`` ``-F`` template.""" + for list_cmd in SCOPES_BY_LIST_CMD: + fields, _ = get_output_format(list_cmd, "3.6a") + assert token not in fields, ( + f"{token!r} (context-only) leaked into {list_cmd} template" + ) From bf5ab077879fc672ab338c1d08d9a736d1144ac4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 06:32:23 -0500 Subject: [PATCH 42/65] docs(CHANGES) Correct cascade direction and trim release notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The 0.57.x section claimed list-clients "emits the client_* tokens but never pane_* ones" — backwards: tmux's format_defaults cascades downward through c->session, the session's current window, and that window's active pane, so list-clients (and list-sessions and list-windows) all hydrate pane fields via cascade. Pinned by the strengthened cascade tests on this branch and by upstream cmd-list-clients.c calling format_defaults(ft, l[i], NULL, NULL, NULL). Two adjacent overclaims compounded the issue: a fixed token count ("twelve client_* tokens") that didn't match the typed Client surface, and a "every wrapper uses raise_if_stderr" claim that didn't match the supported surface in practice. what: - Rewrite the cascade paragraph in the typed format-token entry to describe the actual downward cascade (Client/Session/Window rows hydrate active-pane/window fields; client_* resolves only under list-clients because tmux has no reverse cascade). - Drop number slop: "twelve client_* tokens", "~45 additional format tokens", the explicit list of eight forward-looking tokens, and the embedded token-name catalogs that belong in autodoc. - Replace "every wrapper" with "shared helper used across the supported surface". Trim "matching the rest of the typed wrappers" to "matching the rest of the supported surface". - Add the Client snapshot caveat (session_id/window_id/pane_id are attached-view, not identity) into the Client entry. - Add the malformed-predicate caveat to the C-side filter entry. - Add a Fixes bullet for Server.clients and Server.search_sessions propagating tmux errors instead of swallowing them. - Add a Documentation bullet for the Obj cascade-semantics docstring. - Tighten the lead paragraph: drop "closes wrapper gaps" and the token count. --- CHANGES | 140 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 83 insertions(+), 57 deletions(-) diff --git a/CHANGES b/CHANGES index a3d69b71e..3ad7dc9bc 100644 --- a/CHANGES +++ b/CHANGES @@ -45,35 +45,39 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ -libtmux 0.57.0 closes 14 wrapper gaps surfaced by downstream adoption of -0.56's typed API. The release introduces {class}`~libtmux.Client` as a -first-class object, broadens {meth}`~libtmux.Pane.send_keys` to support -tmux's flag-only invocations, exposes tmux's C-side ``-f`` filter on -typed listing methods, threads originating subcommand context through -{exc}`~libtmux.exc.LibTmuxException`, and adds typed access to ~45 -additional format tokens across {class}`~libtmux.Pane`, -{class}`~libtmux.Window`, and {class}`~libtmux.Session`. +libtmux 0.57.0 broadens the supported surface. 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. ### What's new #### `Client` object and `Server.clients` accessor (#670) New {class}`~libtmux.Client` dataclass and -{attr}`~libtmux.Server.clients` property expose tmux's twelve -``client_*`` format tokens with the same typed-ORM ergonomics already in -place for Session/Window/Pane. Reads like ``client.client_readonly``, -``client.client_session``, and ``client.client_termtype`` work directly -on attached clients instead of forcing callers down to -{meth}`~libtmux.Server.cmd`. +{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. #### `Server.display_message` and `Window.display_message` (#670) {meth}`~libtmux.Server.display_message` and {meth}`~libtmux.Window.display_message` join the existing -{meth}`~libtmux.Pane.display_message`. Server reads (``#{version}``, -``#{socket_path}``) work without a pane handle; window reads -(``#{window_zoomed_flag}``, ``#{window_active_clients_list}``) auto-bind -to the window's id. +{meth}`~libtmux.Pane.display_message`. Server reads like +``#{version}`` and ``#{socket_path}`` work without a pane handle; +window reads (``#{window_zoomed_flag}``, ``#{window_active_clients_list}``) +auto-bind to the window's id. #### C-side filter on typed listing methods (#670) @@ -84,79 +88,101 @@ analogues take a ``filter=`` kwarg routed to tmux's ``-f`` flag. tmux evaluates the predicate and drops non-matching objects before any Python instance is constructed. +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) {meth}`~libtmux.Pane.send_keys` accepts ``cmd=None`` together with -``reset=True`` or ``repeat=N`` to invoke tmux's flag-only ``send-keys --R`` / ``send-keys -N `` form without any trailing key argument. +``reset=True`` or ``repeat=N`` to invoke tmux's flag-only +``send-keys -R`` / ``send-keys -N `` form without any trailing +key argument. #### `Server.list_buffers(format_string=, filter=)` (#670) {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. +(e.g. ``"#{buffer_name}"``) or push a buffer-name match expression +into tmux's format engine — same bad-filter caveat as the +``search_*`` methods. #### `Server.run_shell(cwd=, show_stderr=)` (#670) {meth}`~libtmux.Server.run_shell` gains ``cwd`` (``-c``) to set the -shell command's working directory and ``show_stderr`` (``-E``) to merge -the command's stderr into the captured output. +shell command's working directory and ``show_stderr`` (``-E``) to +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) -{meth}`~libtmux.Pane.capture_pane` gains a ``pending`` kwarg. When set, -the wrapper emits tmux's ``-P`` to return the bytes tmux has read from -the pane but not yet committed to the terminal — output that begins an -incomplete escape sequence and is still pending the parser's ground -state. Useful for diagnosing programs whose output stalls mid-sequence. +{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 helper every -wrapper uses to populate it. +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 used across the supported surface to populate it. #### Scope-aware format-token retrieval (#670) The ``-F`` template libtmux sends to each ``list-*`` subcommand is now -**scope-aware** and **version-aware**. ``list-clients`` requests the -``client_*`` tokens that belong to the client view; ``list-windows``, -``list-panes``, and ``list-sessions`` request only tokens reachable from -their respective contexts. Tokens introduced in tmux releases after -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. +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. ### Fixes -- {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.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` raise {exc}`~libtmux.exc.LibTmuxException` when tmux reports an error, - matching the rest of the typed wrappers (#670). + matching the rest of the supported surface (#670). +- {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). ## libtmux 0.56.0 (2026-05-10) From 92f48a23557becf13284de782ce2b27026039e07 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 06:34:25 -0500 Subject: [PATCH 43/65] docs(CHANGES) Copy improvements --- CHANGES | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGES b/CHANGES index 3ad7dc9bc..46a2622d1 100644 --- a/CHANGES +++ b/CHANGES @@ -187,7 +187,7 @@ releases can be validated end-to-end. ## libtmux 0.56.0 (2026-05-10) libtmux 0.56.0 is the tmux command-parity release. It adds more than -50 wrappers across {class}`~libtmux.Server`, {class}`~libtmux.Session`, +50 commands across {class}`~libtmux.Server`, {class}`~libtmux.Session`, {class}`~libtmux.Window`, and {class}`~libtmux.Pane`, filling in many commands that previously required raw {meth}`~libtmux.Server.cmd` calls. It also adds attached-client test support so interactive tmux commands can be @@ -197,7 +197,7 @@ covered in headless test suites. #### Interactive tmux commands are now scriptable (#653) -libtmux now exposes Python wrappers for tmux commands that normally depend on +libtmux now exposes Python commands for tmux that normally depend on an attached client: {meth}`~libtmux.Pane.display_popup`, {meth}`~libtmux.Server.display_menu`, {meth}`~libtmux.Server.command_prompt`, @@ -219,7 +219,7 @@ The `detach-client` API is split by the same scopes tmux actually honors: `tmux detach-client -a [-t ]`. This keeps each method to one tmux flag group and one subprocess call. -#### tmux buffer I/O has first-class wrappers (#653) +#### tmux buffer I/O has first-class support (#653) Named tmux buffers can now be used from libtmux without hand-built commands. {meth}`~libtmux.Server.set_buffer`, @@ -233,7 +233,7 @@ inter-process handoff workflows. #### Server commands cover key bindings, clients, shell execution, and access (#653) -{class}`~libtmux.Server` gains wrappers for key-binding inspection and mutation +{class}`~libtmux.Server` gains support for key-binding inspection and mutation ({meth}`~libtmux.Server.bind_key`, {meth}`~libtmux.Server.unbind_key`, {meth}`~libtmux.Server.list_keys`, @@ -276,7 +276,7 @@ Navigation helpers fill in the surrounding topology: {meth}`~libtmux.Session.next_window`, and {meth}`~libtmux.Session.previous_window`. -#### Existing wrappers expose more tmux flags (#653) +#### Improvements (#653) Several established methods now surface tmux flags that were previously only available by dropping to raw commands. Highlights include @@ -335,7 +335,7 @@ old hardcoded socket paths. Fixes #664. #### tmux 3.7 is within the known-version range (#653) -{data}`~libtmux.common.TMUX_MAX_VERSION` is now `"3.7"`, enabling wrappers and +{data}`~libtmux.common.TMUX_MAX_VERSION` is now `"3.7"`, enabling support and tests for version-gated tmux 3.7 flags such as {meth}`~libtmux.Server.command_prompt` `bspace_exit` and {meth}`~libtmux.Server.show_messages` `terminals` / `jobs`. Installations on From 59bbd20be755af533e84df2aa108fdad97f75a21 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 06:39:01 -0500 Subject: [PATCH 44/65] docs(CHANGES) Copy improvements Carry the wrapper-to-tmux-support pattern from the earlier 0.56-section copy-improvements commit into the 0.57.x section. --- CHANGES | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGES b/CHANGES index 46a2622d1..6327c8fd5 100644 --- a/CHANGES +++ b/CHANGES @@ -45,7 +45,7 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ -libtmux 0.57.0 broadens the supported surface. It introduces +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 @@ -130,7 +130,7 @@ 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 used across the supported surface to populate it. +shared helper most commands use to populate it. #### Scope-aware format-token retrieval (#670) @@ -167,7 +167,7 @@ releases can be validated end-to-end. {meth}`~libtmux.Window.display_message`, and {meth}`~libtmux.Pane.display_message` raise {exc}`~libtmux.exc.LibTmuxException` when tmux reports an error, - matching the rest of the supported surface (#670). + matching the rest of libtmux's tmux commands (#670). - {attr}`~libtmux.Server.clients` and {meth}`~libtmux.Server.search_sessions` propagate tmux errors rather than silently returning an empty From e3d61d1de043636b031725f4386db9b804846be9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 09:09:13 -0500 Subject: [PATCH 45/65] docs(CHANGES,MIGRATION[breaking]): Flag str(exc) prefix change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: In 0.57 the typed wrappers migrated to raise_if_stderr, which attaches a LibTmuxException.subcommand attribute and prefixes str(exc) with ": ". The release entry framed this purely as additive — there was no breaking-change subheading for upgraders who pattern-match on str(exc) exactly or anchor a regex with ^. The wrapped stderr is still in exc.args[0]; the subcommand name is exposed as a typed attribute. Substring containment and unanchored regex matches keep working. what: - CHANGES: new ### Breaking changes subsection under 0.57 with three migration paths (exc.subcommand, exc.args[0], substring match). - MIGRATION: new ## libtmux 0.57.0 section covering the same contract from the upgrader's perspective, with before/after code for each migration path. --- CHANGES | 46 ++++++++++++++++++++++++++++++++++++++++++++++ MIGRATION | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/CHANGES b/CHANGES index 6327c8fd5..edef79b8b 100644 --- a/CHANGES +++ b/CHANGES @@ -54,6 +54,52 @@ 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. +### 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 #### `Client` object and `Server.clients` accessor (#670) diff --git a/MIGRATION b/MIGRATION index 123b0cdcf..248aab18c 100644 --- a/MIGRATION +++ b/MIGRATION @@ -113,6 +113,53 @@ _Detailed migration steps for the next version will be posted here._ +## 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() +``` + ## libtmux 0.50.0: Unified Options and Hooks API (#516) ### New unified options API From 8de240abbad30cf4571962a11162fb370213ae94 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 09:11:29 -0500 Subject: [PATCH 46/65] Client(feat[attached_*]): Add typed live-attachment properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: client.session_id / window_id / pane_id are hydrated from tmux's downward format cascade at the moment the Client dataclass is built and go stale as soon as the client switches view. The existing class-level docstring warning isn't enough on its own — users iterating over server.clients still reach for the raw fields and treat them as identity. what: - Add Client.attached_session / .attached_window / .attached_pane. Each property re-reads list-clients before resolving and returns the live typed Session / Window / Pane (or None), mirroring the Session.active_window fresh-lookup convention. - Tighten the Client class-level warning to point at the new properties as the safe accessors. - Tests: typed resolution, fresh window tracking (selects a new active window post-hydration and asserts the property reflects it — proves the property re-queries rather than returning the snapshot), pane resolution, None propagation when session_id is absent. - CHANGES: extend the Client what's-new entry to mention the attached_* accessors. - MIGRATION: 0.57 section gains a "snapshots, not identity" subheading covering the snapshot vs. live access pattern. --- CHANGES | 10 ++++++ MIGRATION | 29 +++++++++++++++ src/libtmux/client.py | 67 +++++++++++++++++++++++++++++++++++ tests/test_client.py | 82 ++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 187 insertions(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index edef79b8b..3d48c6699 100644 --- a/CHANGES +++ b/CHANGES @@ -116,6 +116,16 @@ hydration time — {meth}`~libtmux.Client.refresh` re-reads them after the client switches focus. ``client.client_name`` is the client's stable identifier. +For typed access to the live attachment, use +{attr}`~libtmux.Client.attached_session`, +{attr}`~libtmux.Client.attached_window`, and +{attr}`~libtmux.Client.attached_pane`. Each property re-reads the +client from ``list-clients`` before resolving, so the returned +{class}`~libtmux.Session` / {class}`~libtmux.Window` / +{class}`~libtmux.Pane` reflects where the client is attached *now*, +not where it was when the {class}`~libtmux.Client` was constructed. +Returns ``None`` when the client has detached. + #### `Server.display_message` and `Window.display_message` (#670) {meth}`~libtmux.Server.display_message` and diff --git a/MIGRATION b/MIGRATION index 248aab18c..93736d84c 100644 --- a/MIGRATION +++ b/MIGRATION @@ -160,6 +160,35 @@ except LibTmuxException as exc: 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 typed object or None) +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. + ## libtmux 0.50.0: Unified Options and Hooks API (#516) ### New unified options API diff --git a/src/libtmux/client.py b/src/libtmux/client.py index b53ce3528..d7db9fb12 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -14,7 +14,10 @@ from libtmux.neo import Obj, fetch_obj if t.TYPE_CHECKING: + from libtmux.pane import Pane from libtmux.server import Server + from libtmux.session import Session + from libtmux.window import Window logger = logging.getLogger(__name__) @@ -41,6 +44,13 @@ class Client(Obj): ``client_name`` (tty path on Unix) is the client's stable identity. + Prefer :meth:`attached_session`, :meth:`attached_window`, and + :meth:`attached_pane` for typed access — each re-reads the + client's current attachment before returning, so the + :class:`Session` / :class:`Window` / :class:`Pane` you get + back reflects where the client is attached *now*, not where + it was when this :class:`Client` was constructed. + Parameters ---------- server : :class:`Server` @@ -91,3 +101,60 @@ def from_client_name(cls, server: Server, client_name: str) -> Client: server=server, ) return cls(server=server, **client) + + # + # Computed properties + # + @property + def attached_session(self) -> Session | None: + """Return the :class:`Session` this client is currently attached to. + + Re-reads the client from ``list-clients`` before resolving, so the + returned :class:`Session` reflects the client's *live* attachment + rather than the snapshot captured when this :class:`Client` was + hydrated. Returns ``None`` when the client has detached, when + it's not attached to any session, or when the snapshot + ``session_id`` no longer names a live session. + + Examples + -------- + >>> with control_mode() as ctl: + ... client = server.clients.get(client_name=ctl.client_name) + ... attached = client.attached_session + >>> attached is not None + True + """ + self.refresh() + if self.session_id is None: + return None + return self.server.sessions.get( + session_id=self.session_id, + default=None, + ) + + @property + def attached_window(self) -> Window | None: + """Return the :class:`Window` this client is currently viewing. + + Re-reads the client from ``list-clients``, looks up its attached + session, and returns that session's :attr:`~libtmux.Session.active_window`. + Returns ``None`` when the client has no attached session. + """ + session = self.attached_session + if session is None: + return None + return session.active_window + + @property + def attached_pane(self) -> Pane | None: + """Return the :class:`Pane` this client is currently viewing. + + Re-reads the client from ``list-clients``, looks up its attached + session's current window, and returns that window's + :attr:`~libtmux.Window.active_pane`. Returns ``None`` when the + client has no attached session or active pane. + """ + window = self.attached_window + if window is None: + return None + return window.active_pane diff --git a/tests/test_client.py b/tests/test_client.py index 0c46901d6..7c67449b5 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,10 +5,12 @@ import typing as t from libtmux.client import Client +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.window import Window if t.TYPE_CHECKING: from libtmux.server import Server - from libtmux.session import Session def test_server_clients_returns_querylist( @@ -88,3 +90,81 @@ def test_clients_property_hydrates_cross_scope( assert active_pane is not None assert client.window_id == attached_session.active_window.window_id assert client.pane_id == active_pane.pane_id + + +def test_client_attached_session_returns_typed_session( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.attached_session`` resolves to the live :class:`Session`.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + attached = client.attached_session + assert isinstance(attached, Session) + assert attached.session_id == session.session_id + + +def test_client_attached_window_tracks_active_window( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.attached_window`` reflects the live active window. + + Selects a freshly created window after hydrating the client, then + asserts the property reports the new selection — proves the + property re-reads rather than returning the snapshot. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + snapshot_window_id = client.window_id + + new_window = session.new_window(window_name="attached_window_probe") + assert new_window.window_index is not None + session.select_window(new_window.window_index) + + attached = client.attached_window + assert isinstance(attached, Window) + assert attached.window_id == new_window.window_id + assert attached.window_id != snapshot_window_id + + +def test_client_attached_pane_tracks_active_pane( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``client.attached_pane`` reflects the active pane in the active window.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + attached = client.attached_pane + assert isinstance(attached, Pane) + assert attached.pane_id == session.active_window.active_pane.pane_id # type: ignore[union-attr] + + +def test_client_attached_session_none_when_session_id_missing( + control_mode: t.Callable[..., t.Any], + server: Server, + monkeypatch: t.Any, +) -> None: + """Property returns ``None`` when the live snapshot has no ``session_id``. + + Simulates a detached/transitioning client by short-circuiting + :meth:`Client.refresh` so ``session_id`` stays ``None``. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + monkeypatch.setattr(client, "refresh", lambda: None) + client.session_id = None + + assert client.attached_session is None + assert client.attached_window is None + assert client.attached_pane is None From d24190a401ed3d61497757e130a1f5438ca67299 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 09:39:44 -0500 Subject: [PATCH 47/65] Client(fix[attached_*]): Return None for stale detached clients why: The live attachment helpers promise None when a stored client no longer resolves through tmux list-clients. what: - Translate missing client refreshes to None for attached_session - Cover real control-mode detach behavior for attached_* properties --- src/libtmux/client.py | 6 +++++- tests/test_client.py | 20 ++++++++------------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index d7db9fb12..a9fa5dc19 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -11,6 +11,7 @@ import logging import typing as t +from libtmux import exc from libtmux.neo import Obj, fetch_obj if t.TYPE_CHECKING: @@ -124,7 +125,10 @@ def attached_session(self) -> Session | None: >>> attached is not None True """ - self.refresh() + try: + self.refresh() + except exc.TmuxObjectDoesNotExist: + return None if self.session_id is None: return None return self.server.sessions.get( diff --git a/tests/test_client.py b/tests/test_client.py index 7c67449b5..92382c970 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -148,23 +148,19 @@ def test_client_attached_pane_tracks_active_pane( assert attached.pane_id == session.active_window.active_pane.pane_id # type: ignore[union-attr] -def test_client_attached_session_none_when_session_id_missing( +def test_client_attached_properties_return_none_after_detach( control_mode: t.Callable[..., t.Any], server: Server, - monkeypatch: t.Any, ) -> None: - """Property returns ``None`` when the live snapshot has no ``session_id``. - - Simulates a detached/transitioning client by short-circuiting - :meth:`Client.refresh` so ``session_id`` stays ``None``. - """ + """``attached_*`` returns ``None`` after the client leaves ``list-clients``.""" with control_mode() as ctl: client = server.clients.get(client_name=ctl.client_name) assert client is not None - monkeypatch.setattr(client, "refresh", lambda: None) - client.session_id = None + assert client.attached_session is not None + assert client.attached_window is not None + assert client.attached_pane is not None - assert client.attached_session is None - assert client.attached_window is None - assert client.attached_pane is None + assert client.attached_session is None + assert client.attached_window is None + assert client.attached_pane is None From b53388c7077745f1e89c9366a57fbce8c59d4ef8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 09:42:14 -0500 Subject: [PATCH 48/65] docs(Client): Clarify live attachment lookup semantics why: The Client documentation should distinguish attached_* convenience behavior from explicit refresh lookups. what: - Clarify None behavior for missing live client rows - Preserve refresh/from_client_name missing-object semantics --- CHANGES | 8 ++++++-- MIGRATION | 8 ++++++-- src/libtmux/client.py | 17 ++++++++++------- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/CHANGES b/CHANGES index 3d48c6699..e49082466 100644 --- a/CHANGES +++ b/CHANGES @@ -120,11 +120,15 @@ For typed access to the live attachment, use {attr}`~libtmux.Client.attached_session`, {attr}`~libtmux.Client.attached_window`, and {attr}`~libtmux.Client.attached_pane`. Each property re-reads the -client from ``list-clients`` before resolving, so the returned +client from ``list-clients`` before resolving; if tmux no longer +reports that ``client_name``, such as after the client detaches, the +property returns ``None``. Otherwise, the returned {class}`~libtmux.Session` / {class}`~libtmux.Window` / {class}`~libtmux.Pane` reflects where the client is attached *now*, not where it was when the {class}`~libtmux.Client` was constructed. -Returns ``None`` when the client has detached. +Direct {meth}`~libtmux.Client.refresh` and +{meth}`~libtmux.Client.from_client_name` calls still surface missing +client lookup errors. #### `Server.display_message` and `Window.display_message` (#670) diff --git a/MIGRATION b/MIGRATION index 93736d84c..b92009121 100644 --- a/MIGRATION +++ b/MIGRATION @@ -180,14 +180,18 @@ For typed access that reflects the client's *live* attachment, use 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 typed object or None) +# 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 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. ## libtmux 0.50.0: Unified Options and Hooks API (#516) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index a9fa5dc19..0953fbbfe 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -50,7 +50,9 @@ class Client(Obj): client's current attachment before returning, so the :class:`Session` / :class:`Window` / :class:`Pane` you get back reflects where the client is attached *now*, not where - it was when this :class:`Client` was constructed. + it was when this :class:`Client` was constructed. If tmux no + longer reports this ``client_name`` through ``list-clients``, + these properties return ``None``. Parameters ---------- @@ -113,9 +115,10 @@ def attached_session(self) -> Session | None: Re-reads the client from ``list-clients`` before resolving, so the returned :class:`Session` reflects the client's *live* attachment rather than the snapshot captured when this :class:`Client` was - hydrated. Returns ``None`` when the client has detached, when - it's not attached to any session, or when the snapshot - ``session_id`` no longer names a live session. + hydrated. Returns ``None`` when tmux no longer reports this + ``client_name`` through ``list-clients``, when the client is not + attached to any session, or when the snapshot ``session_id`` no + longer names a live session. Examples -------- @@ -142,7 +145,7 @@ def attached_window(self) -> Window | None: Re-reads the client from ``list-clients``, looks up its attached session, and returns that session's :attr:`~libtmux.Session.active_window`. - Returns ``None`` when the client has no attached session. + Returns ``None`` when no live attached session can be resolved. """ session = self.attached_session if session is None: @@ -155,8 +158,8 @@ def attached_pane(self) -> Pane | None: Re-reads the client from ``list-clients``, looks up its attached session's current window, and returns that window's - :attr:`~libtmux.Window.active_pane`. Returns ``None`` when the - client has no attached session or active pane. + :attr:`~libtmux.Window.active_pane`. Returns ``None`` when no + live attached session or active pane can be resolved. """ window = self.attached_window if window is None: From 872ff695686ec619c356a3f4f86beee34cf0b893 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 10:10:27 -0500 Subject: [PATCH 49/65] docs(topics[clients,format-tokens,filtering]): Add conceptual coverage for 0.57.0 surface why: The autodoc layer documents the new public symbols, but Client (view vs. identity), scope+version-gated typed fields, and C-side filter predicates each introduce a mental model that needs a topic-page home. what: - Add docs/topics/clients.md covering the Client view-vs-identity distinction, attached_session/window/pane live lookup, and None-on-detach semantics - Add docs/topics/format-tokens.md explaining the two gates (scope and tmux version), the downward format_defaults cascade, the per-release compatibility table, and how to introspect via get_output_format - Expand docs/topics/filtering.md with a (c-side-filtering)= section covering Python-side vs C-side trade-offs, predicate shapes, the silent-zero-match diagnostic recipe, and when to prefer which - Add Client to docs/topics/architecture.md hierarchy diagram, table, core objects, and module map - Add cross-links from docs/topics/traversal.md to c-side-filtering and from docs/topics/pane_interaction.md to capture_pane(pending=True) and send_keys(cmd=None) - Wire the two new pages into docs/topics/index.md grid and toctree - Rename autodoc anchor (clients)= to (api-clients)= in docs/api/libtmux.client.md so the conceptual page owns the readable {ref}\`clients\` --- docs/api/libtmux.client.md | 2 +- docs/topics/architecture.md | 17 ++++- docs/topics/clients.md | 114 ++++++++++++++++++++++++++++ docs/topics/filtering.md | 113 ++++++++++++++++++++++++++- docs/topics/format-tokens.md | 130 ++++++++++++++++++++++++++++++++ docs/topics/index.md | 14 ++++ docs/topics/pane_interaction.md | 34 +++++++++ docs/topics/traversal.md | 2 + 8 files changed, 420 insertions(+), 6 deletions(-) create mode 100644 docs/topics/clients.md create mode 100644 docs/topics/format-tokens.md diff --git a/docs/api/libtmux.client.md b/docs/api/libtmux.client.md index 6bc88bec7..bae611d0b 100644 --- a/docs/api/libtmux.client.md +++ b/docs/api/libtmux.client.md @@ -1,4 +1,4 @@ -(clients)= +(api-clients)= # Clients diff --git a/docs/topics/architecture.md b/docs/topics/architecture.md index df25834d8..82ebbd648 100644 --- a/docs/topics/architecture.md +++ b/docs/topics/architecture.md @@ -14,21 +14,28 @@ libtmux mirrors tmux's object hierarchy as a typed Python ORM: ``` Server -└── Session - └── Window - └── Pane +├── Session +│ └── Window +│ └── Pane +└── Client (attached view) ``` | Object | Child | Parent | |--------|-------|--------| -| {class}`~libtmux.server.Server` | {class}`~libtmux.session.Session` | None | +| {class}`~libtmux.server.Server` | {class}`~libtmux.session.Session`, {class}`~libtmux.client.Client` | None | | {class}`~libtmux.session.Session` | {class}`~libtmux.window.Window` | {class}`~libtmux.server.Server` | | {class}`~libtmux.window.Window` | {class}`~libtmux.pane.Pane` | {class}`~libtmux.session.Session` | | {class}`~libtmux.pane.Pane` | None | {class}`~libtmux.window.Window` | +| {class}`~libtmux.client.Client` | None | {class}`~libtmux.server.Server` | {class}`~libtmux.common.TmuxRelationalObject` acts as the base container connecting these relationships. +{class}`~libtmux.Client` is a *view*, not part of the ownership chain: +each attached terminal points at a Session/Window/Pane it is currently +displaying, but is not owned by them. See {ref}`clients` for the view- +vs-identity distinction. + ## Internal Identifiers tmux assigns unique IDs to sessions, windows, and panes. libtmux uses @@ -50,6 +57,7 @@ Each level wraps tmux commands and format queries: - {class}`~libtmux.session.Session` — manages windows within a session - {class}`~libtmux.window.Window` — manages panes, handles layouts - {class}`~libtmux.pane.Pane` — terminal instance, sends keys and captures output +- {class}`~libtmux.client.Client` — attached terminal viewing a session, window, and pane ## Data Flow @@ -67,6 +75,7 @@ Each level wraps tmux commands and format queries: | {mod}`libtmux.session` | Session operations | | {mod}`libtmux.window` | Window operations and pane management | | {mod}`libtmux.pane` | Pane I/O and capture | +| {mod}`libtmux.client` | Attached-client view and live-attachment lookup | | {mod}`libtmux.common` | Base classes, command execution | | {mod}`libtmux.neo` | Modern dataclass-based query interface | | {mod}`libtmux.constants` | Format string constants | diff --git a/docs/topics/clients.md b/docs/topics/clients.md new file mode 100644 index 000000000..9a514ed11 --- /dev/null +++ b/docs/topics/clients.md @@ -0,0 +1,114 @@ +(clients)= + +# Clients + +A tmux {term}`Client` is an attached terminal — the side of the tmux +connection a user sees. The same tmux server can host many clients at +once (one per `$ tmux attach` from different terminals), and each +client has its own view of the active session, window, and pane. + +{class}`~libtmux.Client` is the libtmux dataclass for that attached +terminal. It sits outside the +{class}`~libtmux.server.Server` → {class}`~libtmux.session.Session` → +{class}`~libtmux.window.Window` → {class}`~libtmux.pane.Pane` +ownership hierarchy: a client *points at* a Session/Window/Pane it is +currently viewing, but is not owned by them. + +## View, not identity + +The fields that look like foreign keys — +{attr}`~libtmux.neo.Obj.client_session`, +{attr}`~libtmux.neo.Obj.session_id`, +{attr}`~libtmux.neo.Obj.window_id`, +{attr}`~libtmux.neo.Obj.pane_id` — are snapshots of where the client +was attached at the moment libtmux hydrated the dataclass. They go +stale the instant the user runs `switch-client`, `select-window`, or +`select-pane`. The client's *identity* is its +{attr}`~libtmux.neo.Obj.client_name` (the tty path on Unix), which is +stable for the lifetime of the attachment. + +| Field | What it is | Stable? | +|-------|------------|---------| +| `client_name` | tty path tmux assigned at attach time | Yes — identity | +| `session_id` / `window_id` / `pane_id` | the client's *attached view* at hydration time | No — snapshot | +| `client_session` | session name of the same attached view | No — snapshot | +| `client_pid` / `client_tty` / `client_user` | terminal-level facts | Yes — identity-adjacent | + +This distinction is documented in the warning block on +{class}`~libtmux.Client` itself. + +## Live attachment with `attached_*` + +When you want the *current* attachment — not the snapshot — use the +three live properties. Each calls +{meth}`~libtmux.Client.refresh` to re-read the client from +`list-clients`, then resolves the typed Session/Window/Pane: + +```python +>>> with control_mode() as ctl: +... client = server.clients.get(client_name=ctl.client_name) +... attached = client.attached_session +>>> attached is not None +True +``` + +{attr}`~libtmux.Client.attached_window` follows the client's attached +session to its +{attr}`~libtmux.session.Session.active_window`, and +{attr}`~libtmux.Client.attached_pane` follows that window to its +{attr}`~libtmux.window.Window.active_pane`. The three properties chain, +so reading {attr}`~libtmux.Client.attached_pane` does one +`list-clients` refresh plus two cheap typed lookups. + +```python +>>> with control_mode() as ctl: +... client = server.clients.get(client_name=ctl.client_name) +... pane = client.attached_pane +>>> pane is None or pane.pane_id.startswith('%') +True +``` + +## Iterating attached clients + +{attr}`~libtmux.Server.clients` returns a +{class}`~libtmux._internal.query_list.QueryList` of every client tmux +reports through `list-clients`. Filter or `get()` it the same way as +{attr}`~libtmux.Server.sessions`: + +```python +>>> with control_mode() as ctl: +... attached = [ +... c +... for c in server.clients +... if c.client_name == ctl.client_name +... ] +>>> bool(attached) +True +``` + +For tmux-server-side filtering (no Python-side iteration), use +{meth}`~libtmux.Server.search_sessions`-style predicate strings via +the `-f` flag — but note that `list-clients` only accepts a single +filter and exposes a narrower token vocabulary than sessions/windows. +See {ref}`c-side-filtering` for the predicate syntax. + +## When `attached_*` returns `None` + +The properties return `None` when: + +- the snapshot `session_id` is empty (e.g. the client is at the tmux + command prompt rather than viewing a session), +- the snapshot `session_id` no longer names a live session (the + session was killed between hydration and access), or +- the client has detached and `list-clients` no longer reports it. + +Calling {meth}`~libtmux.Client.refresh` directly still raises +{exc}`~libtmux.exc.TmuxObjectDoesNotExist` on a detached client; the +`attached_*` properties catch that case and return `None` so callers +can branch on truthiness without a `try`/`except`. + +## See also + +- {doc}`/api/libtmux.client` — autodoc reference +- {ref}`about` — where `Client` fits in the overall object model +- {ref}`c-side-filtering` — tmux-side filtering for `Server.clients` diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md index bb71f81b7..d5d8c3228 100644 --- a/docs/topics/filtering.md +++ b/docs/topics/filtering.md @@ -258,6 +258,117 @@ True >>> w3.kill() ``` +(c-side-filtering)= + +## C-Side Filtering with `search_*()` + +`QueryList.filter()` runs in Python *after* tmux has returned every +row. For large servers, or when you only need a handful of matches, +push the predicate down to tmux instead. Every level of the hierarchy +ships a `search_*()` method that compiles a format predicate and runs +it inside the tmux server: + +| Caller | Method | Underlying tmux | +|--------|--------|-----------------| +| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_sessions` | `tmux list-sessions -f ` | +| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_windows` | `tmux list-windows -a -f ` | +| {class}`~libtmux.Server` | {meth}`~libtmux.Server.search_panes` | `tmux list-panes -a -f ` | +| {class}`~libtmux.Session` | {meth}`~libtmux.Session.search_windows` | `tmux list-windows -t $sess -f ` | +| {class}`~libtmux.Session` | {meth}`~libtmux.Session.search_panes` | `tmux list-panes -s -t $sess -f ` | +| {class}`~libtmux.Window` | {meth}`~libtmux.Window.search_panes` | `tmux list-panes -t @win -f ` | + +The {meth}`~libtmux.Server.list_buffers` method also accepts a `filter=` +kwarg with the same semantics. + +### Python-side vs. C-side + +| | `.filter()` | `.search_*()` | +|-|-------------|---------------| +| Where | Python (after fetch) | tmux server (before fetch) | +| Predicate vocabulary | libtmux's lookup operators (`__contains`, `__regex`, etc.) | tmux's [FORMATS](https://man.openbsd.org/tmux.1#FORMATS) grammar | +| Round trips | one (full list, then filter in memory) | one (tmux returns only matches) | +| Best for | rich Python predicates, set membership, post-fetch composition | exact/glob matches over many rows | +| Stability | every libtmux version supports it | requires tmux ≥ 3.2 (≥ 3.4 for `list-clients -f`) | + +Both are valid; pick on data volume and predicate shape. + +### Predicate syntax + +tmux's filter language is the same one used in `-F` templates. Three +shapes cover most use cases: + +```python +>>> # Match by glob +>>> s_alpha = server.new_session(session_name='alpha-1') +>>> s_beta = server.new_session(session_name='beta-1') +>>> alphas = server.search_sessions(filter='#{m:alpha-*,#{session_name}}') +>>> [s.session_name for s in alphas] +['alpha-1'] + +>>> # Match by equality +>>> exact = server.search_sessions( +... filter='#{==:#{session_name},alpha-1}' +... ) +>>> [s.session_name for s in exact] +['alpha-1'] + +>>> # Clean up +>>> s_alpha.kill() +>>> s_beta.kill() +``` + +`#{e:...}` evaluates an arithmetic expression; `#{?cond,a,b}` is the +conditional form. See `man tmux` for the full grammar. + +### The silent zero-match trap + +A malformed predicate is the single biggest footgun. tmux expands an +unclosed `#{...}` or an unknown format token to an empty string, +which the filter engine evaluates as "false" — every row is filtered +out and **no stderr is emitted**. A bad filter is indistinguishable +from a filter that genuinely matched nothing. + +If `search_*()` returns empty unexpectedly: + +1. Replace the predicate with `#{m:*,#{session_name}}` (or the + equivalent for windows/panes). If that returns rows, the issue is + predicate syntax, not data. +2. Expand the predicate standalone via + {meth}`~libtmux.Server.display_message` to see what tmux actually + produced: + + ```python + >>> result = server.display_message( + ... '#{m:alpha-*,alpha-1}', get_text=True + ... ) + >>> result[0] + '1' + ``` + + A non-`1`, non-empty result tells you the predicate is parsing as + text, not as a boolean. + +3. Cross-check the token name against the FORMATS section of + `tmux(1)` and against the version gate (see {ref}`format-tokens`). + +### When to prefer which + +Use `search_*()` when: + +- you have hundreds or thousands of windows/panes and only want a few, +- your predicate is a glob (`m:`) or equality check (`==:`), +- you're already in tmux-format thinking (writing `#{...}` for a + status-line template, for example). + +Use `.filter()` when: + +- your predicate needs Python types you can't express in tmux format + (set membership, complex regex, computed values from outside tmux), +- you're chaining multiple filters and prefer composing in Python, +- you want predictable, version-independent semantics. + ## API Reference -See {class}`~libtmux._internal.query_list.QueryList` for the complete API. +See {class}`~libtmux._internal.query_list.QueryList` for the complete +QueryList API, and each `search_*()` method for the C-side filter +contract. diff --git a/docs/topics/format-tokens.md b/docs/topics/format-tokens.md new file mode 100644 index 000000000..c32af7c3f --- /dev/null +++ b/docs/topics/format-tokens.md @@ -0,0 +1,130 @@ +(format-tokens)= + +# Format-Token Fields + +Every libtmux object — {class}`~libtmux.Server`, +{class}`~libtmux.Session`, {class}`~libtmux.Window`, +{class}`~libtmux.Pane`, {class}`~libtmux.Client` — exposes a flat set +of typed string attributes named after tmux's +[FORMATS](https://man.openbsd.org/tmux.1#FORMATS) tokens +(`pane_id`, `window_zoomed_flag`, `client_theme`, etc.). These are +declared once on {class}`libtmux.neo.Obj`, and the same dataclass +backs every concrete object — which is why +`pane.pane_id`, `pane.window_id`, and `pane.session_id` all work on a +single {class}`~libtmux.Pane` instance. + +Two gates decide which fields actually hold a value on a given object: + +1. **Scope** — which tmux struct field the token's format-callback + dereferences. A `pane_*` token reads `ft->wp`, a `session_*` token + reads `ft->s`, and so on. +2. **Version** — which tmux release first registered the token in + `format.c`'s static table. + +If either gate excludes a token, libtmux leaves the field at `None` +rather than risking a server-side fault on an older tmux. + +## Why a field is `None` + +A typed field is `None` for one of three reasons: + +- **Not yet introduced.** Older tmux doesn't know the token at all. + {attr}`~libtmux.Pane.pane_dead_signal` is `None` on tmux 3.2a because + the token landed in 3.3. +- **Wrong scope for this object.** A {class}`~libtmux.Client` row only + emits client-scope tokens directly; cross-scope tokens reach it via + the cascade described below, but `buffer_*` tokens never do. +- **Live-only token.** Some tokens (`mouse_*`, `cursor_*`, + `selection_*`) only resolve inside a live event context (key + binding, copy-mode, popup) — never in a `list-*` snapshot. libtmux + excludes them from every `-F` template. + +The version map for post-3.2a tokens is small and stable. The +following are the tokens libtmux currently gates: + +| Added in | Tokens | +|----------|--------| +| 3.3 | `pane_dead_signal`, `pane_dead_time` | +| 3.4 | `pane_unseen_changes` | +| 3.5 | `pane_key_mode` | +| 3.6 | `session_active`, `session_activity_flag`, `session_alert`, `session_bell_flag`, `session_silence_flag`, `client_theme` | +| 3.7 (forward) | `bracket_paste_flag`, `pane_flags`, `pane_floating_flag`, `pane_pb_progress`, `pane_pb_state`, `pane_pipe_pid`, `pane_zoomed_flag`, `synchronized_output_flag` | + +Everything not listed above is safe on every supported tmux (≥ 3.2a). + +## The downward cascade + +tmux fills its format context downward when a query specifies a +parent: `c->session` then `s->curw` then `wl->window->active`. That's +why pane-scope tokens have meaningful values on a session row — +they resolve to the session's current window's active pane. + +```python +>>> session = server.new_session() +>>> session.pane_id == session.active_window.active_pane.pane_id +True +>>> session.window_id == session.active_window.window_id +True +``` + +The cascade is **one-way**. A {class}`~libtmux.Pane` carries +`window_*` and `session_*` because the parent fills in for the child, +but a {class}`~libtmux.Session` does not carry `client_*` — tmux has +no reverse cascade for clients. The `client_*` tokens only hydrate on +{class}`~libtmux.Client` rows (returned by +{attr}`~libtmux.Server.clients`, which queries `list-clients`). + +If you treat `session.pane_id` as "the session's pane id" (rather +than "the active pane of the session's current window") you will be +surprised when the active window changes. That distinction is called +out in {class}`libtmux.neo.Obj`'s docstring. + +## Inspecting which fields apply + +Use {func}`libtmux.neo.get_output_format` to ask, for a given +`list-*` subcommand and tmux version, exactly which tokens libtmux +will emit in the `-F` template: + +```python +>>> from libtmux.neo import get_output_format +>>> fields, _ = get_output_format("list-sessions", "3.6a") +>>> 'session_id' in fields +True +>>> 'pane_id' in fields # via downward cascade +True +>>> 'client_name' in fields # client scope is the cascade exception +False +``` + +For `list-clients`, the gate widens to include `client_*` plus every +downward-cascadable token: + +```python +>>> from libtmux.neo import get_output_format +>>> fields, _ = get_output_format("list-clients", "3.6a") +>>> all(t in fields for t in ("client_name", "session_id", "pane_id")) +True +``` + +The result is memoized per `(list_cmd, tmux_version)` pair, so +repeated calls are free. + +## Tmux version detection + +libtmux detects the live tmux version via +{func}`libtmux.common.get_version` and passes it through to +`get_output_format` whenever it builds a `-F` template. The result +is cached for the process lifetime; if you're swapping the `tmux` +binary mid-test, call +`libtmux.common.get_version.cache_clear()` to invalidate. + +The {ref}`project` page tracks the project's minimum tmux version +(currently 3.2a); see {doc}`/project/compatibility` for the full +matrix. + +## See also + +- {class}`libtmux.neo.Obj` — the dataclass that declares every field +- {func}`libtmux.neo.get_output_format` — the scope+version gate +- {ref}`clients` — Client is the cascade exception +- {doc}`/project/compatibility` — supported tmux versions diff --git a/docs/topics/index.md b/docs/topics/index.md index 085da8761..7e6c38a74 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -53,6 +53,18 @@ Automatic cleanup with temporary sessions and windows. Get and set tmux options and hooks. ::: +:::{grid-item-card} Clients +:link: clients +:link-type: doc +Attached terminals, live-attachment lookup, and the view-vs-identity model. +::: + +:::{grid-item-card} Format-Token Fields +:link: format-tokens +:link-type: doc +Scope- and version-gated typed fields on every libtmux object. +::: + :::: ```{toctree} @@ -69,4 +81,6 @@ workspace_setup automation_patterns context_managers options_and_hooks +clients +format-tokens ``` diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md index 5163023f2..69e46fae4 100644 --- a/docs/topics/pane_interaction.md +++ b/docs/topics/pane_interaction.md @@ -95,6 +95,23 @@ saved in shell history): >>> time.sleep(0.1) ``` +### Flag-only invocation + +When you want to invoke `send-keys` only for its flags — resetting the +pane or repeating a key — pass `cmd=None`: + +```python +>>> # Repeat the last key 5 times (-N 5) +>>> pane.send_keys(cmd=None, repeat=5) + +>>> # Reset the pane to default state (-R) +>>> pane.send_keys(cmd=None, reset=True) +``` + +`cmd=None` requires at least one of `reset=True`, `repeat=N`, or +`copy_mode_cmd=...`; calling it with no flag raises `ValueError` to +prevent silent no-ops. + ## Capturing Output The {meth}`~libtmux.Pane.capture_pane` method captures text from a pane's buffer. @@ -194,12 +211,29 @@ True | `join_wrapped` | `-J` | Join wrapped lines back together | | `preserve_trailing` | `-N` | Preserve trailing spaces at line ends | | `trim_trailing` | `-T` | Trim trailing empty positions (tmux 3.4+) | +| `pending` | `-P` | Dump the unprocessed input buffer instead of the screen | :::{note} The `trim_trailing` parameter requires tmux 3.4+. If used with an older version, a warning is issued and the flag is ignored. ::: +### Capturing the pending input buffer + +Use `pending=True` to dump bytes tmux has received but not yet processed, +rather than the visible screen. This is useful for diagnosing input that +is stuck behind a slow consumer or buffered behind a paused program. + +```python +>>> pending = pane.capture_pane(pending=True) +>>> isinstance(pending, list) +True +``` + +`pending=True` is mutually exclusive with the line-range and screen-mode +flags (`start`, `end`, `escape_sequences`, etc.) — tmux ignores them when +`-P` is set. + ## Waiting for Output A common pattern in automation is waiting for a command to complete. diff --git a/docs/topics/traversal.md b/docs/topics/traversal.md index 214bbc861..cd5b3fee1 100644 --- a/docs/topics/traversal.md +++ b/docs/topics/traversal.md @@ -138,6 +138,8 @@ True libtmux collections support Django-style filtering with `filter()` and `get()`. For comprehensive coverage of all lookup operators, see {ref}`querylist-filtering`. +For tmux-server-side predicates (compiled and executed inside tmux, fewer round +trips on large servers), see {ref}`c-side-filtering`. ### Basic Filtering From 14f86fc7cbefdff8b49fe8d6fc8fe9e3d17a738a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 11:46:51 -0500 Subject: [PATCH 50/65] common(fix[raise_if_stderr]): Flatten proc.stderr so str(exc) matches docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The 0.57.0 breaking-change docs in CHANGES and MIGRATION promise str(exc) renders as ": " and that exc.args[0] holds the raw stderr string. raise_if_stderr passed proc.stderr (a list[str]) directly to LibTmuxException, so Exception.__str__ rendered the list's repr — yielding "last-window: ['no last window']" instead of the documented "last-window: no last window". The documented migration code `exc.args[0] == "can't find window"` was always False because exc.args[0] was the list, not the string. what: - Pass "\n".join(proc.stderr) to LibTmuxException so the message is a string. Multi-line tmux stderr renders as a multi-line string, matching how Python typically surfaces subprocess errors. - Add test_raise_if_stderr_str_shape_exact that asserts the FULL str(exc) and exc.args shapes (no startswith, no substring) for a wrapper flowing through raise_if_stderr, so future drift surfaces as a test failure rather than a docs lie. --- src/libtmux/common.py | 5 ++++- tests/test_common.py | 21 +++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/libtmux/common.py b/src/libtmux/common.py index b1a10664c..d05a0b2ea 100644 --- a/src/libtmux/common.py +++ b/src/libtmux/common.py @@ -274,7 +274,10 @@ def raise_if_stderr(proc: tmux_cmd, subcommand: str) -> None: .. versionadded:: 0.57 """ if proc.stderr: - raise exc.LibTmuxException(proc.stderr, subcommand=subcommand) + raise exc.LibTmuxException( + "\n".join(proc.stderr), + subcommand=subcommand, + ) class tmux_cmd: diff --git a/tests/test_common.py b/tests/test_common.py index bbb58006e..59f926eaa 100644 --- a/tests/test_common.py +++ b/tests/test_common.py @@ -692,3 +692,24 @@ def test_raise_if_stderr_raises_with_subcommand_tag( assert excinfo.value.subcommand == "list-clients" assert str(excinfo.value).startswith("list-clients:") + + +def test_raise_if_stderr_str_shape_exact(session: libtmux.Session) -> None: + """Lock down ``str(exc)`` and ``exc.args[0]`` against future drift. + + The breaking-change documentation promises a flat string in + ``str(exc)`` and a flat string in ``exc.args[0]``. If a future change + re-introduces a list-shaped ``proc.stderr`` into ``LibTmuxException``, + this test catches it where ``startswith`` / substring matches won't. + """ + from libtmux.common import raise_if_stderr + + proc = session.cmd("last-window") + assert proc.stderr == ["no last window"] + + with pytest.raises(exc.LibTmuxException) as excinfo: + raise_if_stderr(proc, "last-window") + + assert str(excinfo.value) == "last-window: no last window" + assert excinfo.value.args == ("no last window",) + assert excinfo.value.subcommand == "last-window" From 415c3f1d73ea9a8d73b27632a25c3d1bbe2ce535 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 11:51:50 -0500 Subject: [PATCH 51/65] Pane(fix[reset]): Bundle send-keys -R and clear-history as one tmux IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The two-call form raced under busy pane writers: send-keys -R clears the visible grid (verified at ~/study/c/tmux/cmd-send-keys.c:225 → input.c:923 → screen-write.c:335) and any output landing between the two subprocess.Popen invocations could scroll into history via scroll-on-clear (tmux's default), then be wiped by the second call's clear-history. That destroyed output the caller produced after the reset point — `reset()` should wipe state at reset time, not whatever happens to be in the grid when clear-history runs. A naïve one-call form `self.cmd("send-keys", "-R", ";", "clear-history")` doesn't work either: Pane.cmd auto-injects `-t ` only before the first subcommand, so the `;` separator leaves clear-history routed to tmux's cmdq default pane — empirically verified on tmux 3.6a to clear the wrong pane. what: - Route through self.server.cmd to bypass Pane.cmd's auto-target, and pass `-t self.pane_id` explicitly on both subcommands so the `;` separator can't misroute clear-history. - Update the docstring to describe the single-IPC semantics and why the explicit double-targeting is necessary. - Add test_pane_reset_targets_non_active_pane that calls reset() on a non-active pane and asserts history_size goes to 0 on the target while the active sibling pane's history_size is preserved. Under the misroute bug, clear-history would have hit the active sibling instead of the target. --- src/libtmux/pane.py | 20 +++++++++++--- tests/test_pane.py | 67 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 3bbcb59a4..d4ff2e67f 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2179,8 +2179,12 @@ def clear(self) -> Pane: def reset(self) -> Pane: r"""Reset terminal state and clear pane history. - Issues ``send-keys -R`` to reset the pane's terminal state, then - ``clear-history`` to drop its scrollback. + Sends ``send-keys -R`` and ``clear-history`` to the pane in a + single tmux IPC so no pane output can land in the freshly-cleared + grid (and scroll into history under ``scroll-on-clear``) between + the terminal-state reset and the history clear. Both subcommands + carry an explicit ``-t`` so the ``;`` separator can't leave + ``clear-history`` routed to tmux's cmdq default pane. Examples -------- @@ -2190,8 +2194,16 @@ def reset(self) -> Pane: >>> pane.reset() Pane(%... Window(@... ...:..., Session($1 libtmux_...))) """ - self.cmd("send-keys", "-R") - self.cmd("clear-history") + self.server.cmd( + "send-keys", + "-t", + self.pane_id, + "-R", + ";", + "clear-history", + "-t", + self.pane_id, + ) return self # diff --git a/tests/test_pane.py b/tests/test_pane.py index 1fc95b0a5..cd8c78d41 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -16,6 +16,7 @@ if t.TYPE_CHECKING: from libtmux._internal.types import StrPath + from libtmux.pane import Pane from libtmux.session import Session logger = logging.getLogger(__name__) @@ -1491,3 +1492,69 @@ def test_pane_reset_clears_history_and_sends_reset(session: Session) -> None: # behind because clear-history never executed. post = pane.capture_pane(start=-100) assert not any("reset_marker_" in line for line in post) + + +def test_pane_reset_targets_non_active_pane(session: Session) -> None: + """Pane.reset() clears the target pane, not tmux's cmdq default. + + Regresses the bundled-IPC fix for the race-and-misroute combination: + the previous two-call form raced under busy pane writers, and a naïve + one-call form (``send-keys -R ; clear-history`` with one ``-t``) would + route ``clear-history`` to tmux's default pane because the ``;`` + separator doesn't propagate ``-t`` across subcommands. The fix passes + ``-t`` on both subcommands, so reset() must clear the *target* pane's + scrollback while leaving any active sibling pane untouched. + """ + env = shutil.which("env") + assert env is not None + + window = session.new_window( + window_name="test_reset_targets", + window_shell=f"{env} PS1='$ ' sh", + ) + # `attach=False` keeps the original pane active; the newly-split pane + # is the non-active one. Call reset() on the non-active pane so a + # missing -t on clear-history would route to the active sibling + # instead. + active_sibling = window.active_pane + assert active_sibling is not None + target = window.split( + shell=f"{env} PS1='$ ' sh", + attach=False, + ) + assert target.pane_id != active_sibling.pane_id + + window.refresh() + assert window.active_pane is not None + assert window.active_pane.pane_id == active_sibling.pane_id + + # Push enough output through both panes to accumulate scrollback. + # `seq 1 200` scrolls past the default 24-row visible region. + def _history_size(pane: Pane) -> int: + line = pane.cmd("display-message", "-p", "#{history_size}").stdout[0] + return int(line) + + retry_until( + lambda: "$" in "\n".join(active_sibling.capture_pane()), + 2, + raises=True, + ) + active_sibling.send_keys("seq 1 200", enter=True) + retry_until(lambda: _history_size(active_sibling) > 0, 3, raises=True) + + retry_until(lambda: "$" in "\n".join(target.capture_pane()), 2, raises=True) + target.send_keys("seq 1 200", enter=True) + retry_until(lambda: _history_size(target) > 0, 3, raises=True) + + target_pre = _history_size(target) + sibling_pre = _history_size(active_sibling) + assert target_pre > 0 + assert sibling_pre > 0 + + target.reset() + + # Target pane: history cleared. + assert _history_size(target) == 0 + # Active sibling pane: untouched. (Under a missing-target clear-history, + # this would have been wiped because it is the active pane.) + assert _history_size(active_sibling) == sibling_pre From ce4bf970acbabfbd5426caa1c2ee98233310ce5c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 11:54:36 -0500 Subject: [PATCH 52/65] Client(fix[refresh]): Replace -O-stripped assert with explicit ValueError why: `Client` inherits `client_name: str | None` from `Obj`. The `assert isinstance(self.client_name, str)` line vanishes under `python -O`, letting `None` flow into `_refresh` and surfacing as a less-clear downstream error. Keep the failure loud regardless of optimization level. what: - Replace the assertion with an explicit `if self.client_name is None: raise ValueError(...)`, with the message documented in the Raises section of the docstring. - Add test_client_refresh_raises_when_client_name_is_none asserting the explicit raise. --- src/libtmux/client.py | 13 +++++++++++-- tests/test_client.py | 17 +++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index 0953fbbfe..e3388dd45 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -86,8 +86,17 @@ class Client(Obj): server: Server def refresh(self) -> None: - """Refresh client attributes from tmux.""" - assert isinstance(self.client_name, str) + """Refresh client attributes from tmux. + + Raises + ------ + ValueError + When ``client_name`` is unset. Surfaces a clear error under + ``python -O``, where an ``assert`` would be stripped. + """ + if self.client_name is None: + msg = "Client must have a client_name to refresh" + raise ValueError(msg) return super()._refresh( obj_key="client_name", obj_id=self.client_name, diff --git a/tests/test_client.py b/tests/test_client.py index 92382c970..7bf83db62 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -4,6 +4,8 @@ import typing as t +import pytest + from libtmux.client import Client from libtmux.pane import Pane from libtmux.session import Session @@ -164,3 +166,18 @@ def test_client_attached_properties_return_none_after_detach( assert client.attached_session is None assert client.attached_window is None assert client.attached_pane is None + + +def test_client_refresh_raises_when_client_name_is_none(server: Server) -> None: + """``Client.refresh()`` raises ``ValueError`` when ``client_name`` is unset. + + The previous ``assert isinstance(...)`` stripped under ``python -O`` and + let ``None`` flow into ``_refresh``, surfacing as a confusing downstream + error. The explicit raise keeps the failure mode loud regardless of + optimization level. + """ + client = Client(server=server) + assert client.client_name is None + + with pytest.raises(ValueError, match="client_name"): + client.refresh() From 349e170c14316b8157e886f69259ebe91a718e7c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 11:57:48 -0500 Subject: [PATCH 53/65] neo(fix[_token_scope]): Fail-closed default for unclassified tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The fall-through `return "universal"` was fail-open — a future `Obj` field added without a matching prefix, override, or known-token entry would silently classify as universal and ship under every `list-*` -F template. That defeats the scope-gating machinery on exactly the case it's meant to protect: a future token whose class hasn't been mapped, where emitting on tmux 3.2a may crash the format engine or hydrate as nonsense. Pre-flight confirmed: every currently-declared `Obj` field maps to a known scope, so the flip is a no-op for the runtime template but turns future drift into a deterministic test failure. what: - Change the final return in `_token_scope` from `"universal"` to `"unknown"`. `"unknown"` is absent from every SCOPES_BY_LIST_CMD entry, so an unclassified field is excluded from every list-cmd template. - Document the fail-closed default in the docstring and show what it returns for an unrecognized name. - Add test_token_scope_unknown_for_unclassified_field asserting the default and that `"unknown"` isn't in any allowed scope set. - Add test_every_obj_field_classifies_to_known_scope as a guard: any new field added to Obj without classification breaks this test with a message pointing to the right table to update. --- src/libtmux/neo.py | 17 ++++++++++++++++- tests/test_neo.py | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 0e8789fda..b6723a7a7 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -174,6 +174,15 @@ def _token_scope(field_name: str) -> str: mode context). Returns ``"pane"`` / ``"window"`` / ``"session"`` / ``"client"`` / ``"buffer"`` for scope-prefixed tokens. + Fields that don't match any prefix, override, or known-token table + fall back to ``"unknown"``. ``"unknown"`` is intentionally absent from + every :data:`SCOPES_BY_LIST_CMD` entry, so an unclassified field is + excluded from every ``list-*`` ``-F`` template — preventing a future + untracked field from being silently emitted under a list command + where it might crash older tmux. Add such a field to + :data:`_SCOPE_OVERRIDES` (or the appropriate prefix / known-token + table) to admit it. + Examples -------- >>> from libtmux.neo import _token_scope @@ -206,6 +215,12 @@ def _token_scope(field_name: str) -> str: 'context' >>> _token_scope("search_match") 'context' + + Unclassified tokens fall back to ``"unknown"``, also excluded from + every list command: + + >>> _token_scope("libtmux_test_nonexistent_token") + 'unknown' """ override = _SCOPE_OVERRIDES.get(field_name) if override is not None: @@ -217,7 +232,7 @@ def _token_scope(field_name: str) -> str: return "context" if field_name in _UNIVERSAL_TOKENS: return "universal" - return "universal" + return "unknown" def _normalize_tmux_version(version: str) -> LooseVersion: diff --git a/tests/test_neo.py b/tests/test_neo.py index d463cbb99..437ede47e 100644 --- a/tests/test_neo.py +++ b/tests/test_neo.py @@ -15,6 +15,7 @@ _CONTEXT_ONLY_TOKENS, FIELD_VERSION, SCOPES_BY_LIST_CMD, + Obj, _token_scope, get_output_format, ) @@ -112,3 +113,38 @@ def test_context_tokens_absent_from_every_list_cmd_template(token: str) -> None: assert token not in fields, ( f"{token!r} (context-only) leaked into {list_cmd} template" ) + + +def test_token_scope_unknown_for_unclassified_field() -> None: + """``_token_scope`` returns ``"unknown"`` for any unrecognized field. + + ``"unknown"`` must be absent from every :data:`SCOPES_BY_LIST_CMD` + entry, so a future field added without classification is silently + excluded from every ``-F`` template rather than emitted under a list + command where it might crash older tmux. + """ + assert _token_scope("libtmux_test_nonexistent_token") == "unknown" + for allowed in SCOPES_BY_LIST_CMD.values(): + assert "unknown" not in allowed + + +def test_every_obj_field_classifies_to_known_scope() -> None: + """Every declared ``Obj`` field must classify to a known scope. + + Adding a new field without a matching prefix / override / + known-token table entry would silently exclude it from every + ``list-*`` template (via the fail-closed default). This test + surfaces that misclassification as a deterministic failure rather + than a runtime hydration-as-None. + """ + unclassified: list[str] = [] + for name in Obj.__dataclass_fields__: + if name == "server": + continue + if _token_scope(name) == "unknown": + unclassified.append(name) + assert not unclassified, ( + "Obj fields with no scope classification " + "(add them to _SCOPE_OVERRIDES, _SCOPE_PREFIXES, " + f"_UNIVERSAL_TOKENS, or _CONTEXT_ONLY_TOKENS): {unclassified}" + ) From 13a9b066e73c3abadbe42a16027ecc6865000735 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:02:06 -0500 Subject: [PATCH 54/65] Client(feat[_resolve_attached]): Share one list-clients refresh across the triple MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Code that needs all three of session/window/pane for a client ("where is this client attached *now*") naturally reads client.attached_session, then client.attached_window, then client.attached_pane. Each property re-reads tmux on access, so the sequence costs three list-clients refreshes for one conceptual read. The new helper shares a single refresh across the triple and returns the three values together. The helper catches NoActiveWindow and falls back to (session, None, None). MultipleActiveWindows propagates — that indicates a tmux invariant violation that callers should surface, not absent attachment. what: - Add internal Client._resolve_attached returning tuple[Session | None, Window | None, Pane | None], with documented contract for the (None, None, None), (session, None, None), and full-triple cases. - Update the class docstring to point readers at the helper for all-three access. - Add three regression tests: live attachment → full triple, detach → (None, None, None), and a monkeypatch-driven NoActiveWindow → (session, None, None). - attached_session / attached_window / attached_pane stay unchanged so per-access live semantics are preserved. --- src/libtmux/client.py | 54 ++++++++++++++++++++++++++++++++ tests/test_client.py | 71 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/libtmux/client.py b/src/libtmux/client.py index e3388dd45..87fbd3398 100644 --- a/src/libtmux/client.py +++ b/src/libtmux/client.py @@ -54,6 +54,12 @@ class Client(Obj): longer reports this ``client_name`` through ``list-clients``, these properties return ``None``. + Each property re-reads tmux on every access. Code that needs + all three — session, window, and pane — should call + :meth:`_resolve_attached` once instead, which shares a single + ``list-clients`` refresh across the triple and returns + ``(session, window, pane)`` together. + Parameters ---------- server : :class:`Server` @@ -174,3 +180,51 @@ def attached_pane(self) -> Pane | None: if window is None: return None return window.active_pane + + def _resolve_attached( + self, + ) -> tuple[Session | None, Window | None, Pane | None]: + """Resolve live attachment with a single ``list-clients`` refresh. + + The three :attr:`attached_session` / :attr:`attached_window` / + :attr:`attached_pane` properties each trigger their own + ``list-clients`` refresh — calling them in sequence costs three + roundtrips for one conceptual "where is this client attached *now*" + read. ``_resolve_attached`` shares a single refresh across the + triple and returns the three values together. + + Returns + ------- + tuple[Session | None, Window | None, Pane | None] + * ``(None, None, None)`` when tmux no longer reports this + ``client_name`` through ``list-clients``, when the client + snapshot has no ``session_id``, or when the snapshot + ``session_id`` no longer names a live session. + * ``(session, None, None)`` when the attached session has no + active window. + * ``(session, window, pane)`` for a fully-resolved live + attachment. ``pane`` is ``None`` only when the active + window has no active pane. + + :exc:`~libtmux.exc.MultipleActiveWindows` propagates rather than + collapsing to ``(session, None, None)`` — multiple active windows + in one session indicates a tmux invariant violation that callers + should surface, not absent attachment. + """ + try: + self.refresh() + except exc.TmuxObjectDoesNotExist: + return None, None, None + if self.session_id is None: + return None, None, None + session = self.server.sessions.get( + session_id=self.session_id, + default=None, + ) + if session is None: + return None, None, None + try: + window = session.active_window + except exc.NoActiveWindow: + return session, None, None + return session, window, window.active_pane diff --git a/tests/test_client.py b/tests/test_client.py index 7bf83db62..8f79ae35a 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -181,3 +181,74 @@ def test_client_refresh_raises_when_client_name_is_none(server: Server) -> None: with pytest.raises(ValueError, match="client_name"): client.refresh() + + +def test_resolve_attached_returns_full_triple_for_live_client( + control_mode: t.Callable[..., t.Any], + server: Server, + session: Session, +) -> None: + """``_resolve_attached`` returns ``(session, window, pane)`` for a live client.""" + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + resolved_session, resolved_window, resolved_pane = client._resolve_attached() + + assert resolved_session is not None + assert resolved_session.session_id == session.session_id + assert resolved_window is not None + assert resolved_pane is not None + + +def test_resolve_attached_returns_none_triple_after_detach( + control_mode: t.Callable[..., t.Any], + server: Server, +) -> None: + """``_resolve_attached`` returns ``(None, None, None)`` after detach. + + Once tmux no longer reports this ``client_name``, the refresh raises + ``TmuxObjectDoesNotExist`` internally and the helper returns the + none-triple — matching :attr:`attached_session` / etc.'s contract for + a stale client name. + """ + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + # Client has detached at this point. + resolved = client._resolve_attached() + assert resolved == (None, None, None) + + +def test_resolve_attached_catches_no_active_window( + control_mode: t.Callable[..., t.Any], + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """``_resolve_attached`` returns ``(session, None, None)`` on NoActiveWindow. + + Patches the live session's ``active_window`` to raise + :exc:`~libtmux.exc.NoActiveWindow` (a state tmux normally prevents + but the helper still has to handle gracefully), and asserts the + helper falls back to the no-active-window triple rather than + propagating. + """ + from libtmux import exc as libtmux_exc + from libtmux.session import Session as SessionCls + + with control_mode() as ctl: + client = server.clients.get(client_name=ctl.client_name) + assert client is not None + + def raise_no_active_window(self: SessionCls) -> Window: + raise libtmux_exc.NoActiveWindow + + monkeypatch.setattr( + SessionCls, "active_window", property(raise_no_active_window) + ) + + resolved_session, resolved_window, resolved_pane = client._resolve_attached() + assert resolved_session is not None + assert resolved_window is None + assert resolved_pane is None From b652d0c059da27577c25dd30c5ff7a42554bec87 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:04:18 -0500 Subject: [PATCH 55/65] docs(CHANGES): Drop duplicate Subcommand-tagged exceptions deliverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The 0.57.0 entry covered the LibTmuxException subcommand prefix + subcommand attribute under both `### Breaking changes` and `### What's new` -> `#### Subcommand-tagged exceptions`. The breaking-changes subsection already documents the behavior, migration path, and rationale; the duplicate `####` heading restates the same content without adding new information. Per CLAUDE.md's "deliverable test," each `####` heading should be a distinct deliverable in user vocabulary — this failed it. what: - Remove the `#### Subcommand-tagged exceptions (#670)` block from `### What's new`. The breaking-changes section at CHANGES:59-101 is unchanged and remains the canonical reference for this deliverable. --- CHANGES | 9 --------- 1 file changed, 9 deletions(-) diff --git a/CHANGES b/CHANGES index e49082466..3a5abf724 100644 --- a/CHANGES +++ b/CHANGES @@ -183,15 +183,6 @@ 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 From 1228ece600c5e2f7d135d526932fd877b1836f6b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:07:38 -0500 Subject: [PATCH 56/65] docs(MIGRATION): Add KEEP PLACEHOLDER bracket pattern why: MIGRATION's "Upcoming Release" header sat above an already-drafted 0.57.0 section without comment-bracket delimiters, so future-release content didn't have an unambiguous insertion point. CHANGES uses `` / `` to mark where new entries land; MIGRATION should match for the same reason. what: - Wrap the placeholder body in matching HTML comment brackets, mirroring the CHANGES convention. - New release content for the upcoming version lands below the END marker. --- MIGRATION | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/MIGRATION b/MIGRATION index b92009121..69c1145e6 100644 --- a/MIGRATION +++ b/MIGRATION @@ -109,9 +109,9 @@ sections below for detailed migration examples and code samples. ## Upcoming Release + _Detailed migration steps for the next version will be posted here._ - - + ## libtmux 0.57.0: Subcommand-tagged exceptions (#670) From 91c89ae810dc50145f7df410cfa7619a9c2546c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:09:47 -0500 Subject: [PATCH 57/65] Pane(fix[reset doctest]): Remove timing-fragile capture from the example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The doctest sent a key sequence then immediately read the pane's scrollback to assert the marker landed. Without `retry_until`, this trusted that the shell echoed before capture — a coin-flip under parallel-test load. The dedicated functional test in tests/test_pane.py::test_pane_reset_clears_history_and_sends_reset already exercises the same path with retry_until; the doctest's responsibility is to demonstrate the API, not to re-test timing. what: - Drop the send_keys + immediate capture_pane lines from the doctest. - Keep the call + return-value check, which is timing-independent. --- src/libtmux/pane.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index d4ff2e67f..91eed2f62 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2188,9 +2188,6 @@ def reset(self) -> Pane: Examples -------- - >>> pane.send_keys('echo "for the history"') - >>> 'for the history' in '\n'.join(pane.capture_pane(start=-100, end=-1)) - True >>> pane.reset() Pane(%... Window(@... ...:..., Session($1 libtmux_...))) """ From c82c9262b761275b1f8ec46ee7400eec231fc002 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:13:36 -0500 Subject: [PATCH 58/65] docs(search_*): Cross-link from each method to the filtering topic doc why: The filtering topic doc already covers when to pick `search_*()` (C-side push-down) over `QueryList.filter()` (Python-side, post-fetch) with a comparison table and "When to prefer which" guidance, but the six `search_*` API entry points don't reference it. A caller landing on `Server.search_panes` from autodoc has no path to discover the comparison or the unfiltered `panes` attribute. what: - Add a See Also section to each `search_*` method (Server x3, Session x2, Window x1). Each block cross-links to (a) the matching unfiltered `panes` / `windows` / `sessions` attribute and (b) the `c-side-filtering` ref label in docs/topics/filtering.md. --- src/libtmux/server.py | 22 ++++++++++++++++++++++ src/libtmux/session.py | 16 ++++++++++++++++ src/libtmux/window.py | 8 ++++++++ 3 files changed, 46 insertions(+) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 1f26c99bf..09310721b 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -2363,6 +2363,13 @@ def search_sessions( ------- :class:`~libtmux._internal.query_list.QueryList` of :class:`Session` + See Also + -------- + :attr:`Server.sessions` : unfiltered :class:`QueryList` of every + session (Python-side ``.filter()`` runs against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + Examples -------- >>> server.new_session(session_name='gap7_alpha') @@ -2410,6 +2417,14 @@ def search_windows( .. versionadded:: 0.57 + See Also + -------- + :attr:`Server.windows` : unfiltered :class:`QueryList` of every + window across every session (Python-side ``.filter()`` runs + against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + Examples -------- >>> sess = server.new_session(session_name='gap7_win_demo') @@ -2465,6 +2480,13 @@ def search_panes( .. versionadded:: 0.57 + See Also + -------- + :attr:`Server.panes` : unfiltered :class:`QueryList` of every + pane (Python-side ``.filter()`` runs against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + Examples -------- >>> sess = server.new_session(session_name='gap7_pane_demo') diff --git a/src/libtmux/session.py b/src/libtmux/session.py index d51de679b..d5e36a021 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -220,6 +220,14 @@ def search_windows( .. versionadded:: 0.57 + See Also + -------- + :attr:`Session.windows` : unfiltered :class:`QueryList` of every + window in this session (Python-side ``.filter()`` runs + against this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + Examples -------- >>> _ = session.new_window(window_name='gap7s_target') @@ -268,6 +276,14 @@ def search_panes( .. versionadded:: 0.57 + See Also + -------- + :attr:`Session.panes` : unfiltered :class:`QueryList` of every + pane in this session (Python-side ``.filter()`` runs against + this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + Examples -------- >>> target_pane = session.active_window.split() diff --git a/src/libtmux/window.py b/src/libtmux/window.py index 648b2c645..ad785a1e7 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -224,6 +224,14 @@ def search_panes( .. versionadded:: 0.57 + See Also + -------- + :attr:`Window.panes` : unfiltered :class:`QueryList` of every + pane in this window (Python-side ``.filter()`` runs against + this). + :ref:`c-side-filtering` : when to pick ``search_*`` over + ``QueryList.filter()``. + Examples -------- >>> target_pane = window.split() From 30bce611621b048742e1313d111fa970f0b0097a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:15:42 -0500 Subject: [PATCH 59/65] neo(docs[fetch_objs]): Propagate malformed-filter warning to the public helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The typed wrappers (`Server.search_*`, `Session.search_*`, `Window.search_panes`, `Server.list_buffers`) all carry a warning that tmux silently expands a malformed `-f` predicate to empty — indistinguishable from "no matches". `fetch_objs` is the documented public surface those wrappers route through, but its docstring didn't carry the same caveat. A caller using `fetch_objs(filter=...)` directly missed the warning. what: - Copy the malformed-filter warning into the `fetch_objs(filter=)` parameter docstring with the same wording as the typed wrappers. - Cross-link to the `c-side-filtering` topic doc for the broader context. --- src/libtmux/neo.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index b6723a7a7..2b390ad18 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -632,6 +632,16 @@ def fetch_objs( "0") are omitted from the result. Pushes filtering into tmux's C code instead of Python post-processing. + .. warning:: + + tmux silently expands a malformed predicate (unclosed + ``#{...}``, unknown format token) to empty, which the format + engine evaluates as "false" — every row is suppressed and no + stderr is emitted. A bad filter is indistinguishable from + "filter matched nothing"; verify predicate syntax against the + FORMATS section of ``tmux(1)``. See :ref:`c-side-filtering` + for the typed wrappers that share this caveat. + .. versionadded:: 0.57 Returns From fbeede85dbca1d350b9679a88ca0ca97d16f524b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 12:18:01 -0500 Subject: [PATCH 60/65] docs(CHANGES,MIGRATION): Correct (#670) PR refs to (#672) why: Every 0.57.0 deliverable in CHANGES (and the MIGRATION header) was tagged `(#670)`. Verified upstream: PR #670 does not exist on tmux-python/libtmux. The actual branch PR is #672 ("Increase tmux coverage: Client, typed fields, C-side filter"). The `(#670)` refs appear to be from an earlier draft of the branch that never opened. what: - `sed 's/(#670)/(#672)/g'` across CHANGES and MIGRATION. Verified by `gh pr view` that #672 is open on the upstream and matches the branch's scope; #670 was a 404. The two pre-existing `(#672)` refs under `### Fixes` are unchanged. --- CHANGES | 20 ++++++++++---------- MIGRATION | 2 +- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/CHANGES b/CHANGES index 3a5abf724..afc112f79 100644 --- a/CHANGES +++ b/CHANGES @@ -56,7 +56,7 @@ tools to dispatch on which tmux command produced an error. ### Breaking changes -#### `LibTmuxException` string form gains a subcommand prefix (#670) +#### `LibTmuxException` string form gains a subcommand prefix (#672) When {exc}`~libtmux.exc.LibTmuxException` is raised from one of the typed command wrappers, ``str(exc)`` now begins with the originating @@ -102,7 +102,7 @@ Substring matches (``"can't find" in str(exc)``) and unanchored ### What's new -#### `Client` object and `Server.clients` accessor (#670) +#### `Client` object and `Server.clients` accessor (#672) New {class}`~libtmux.Client` dataclass and {attr}`~libtmux.Server.clients` property bring typed-ORM ergonomics @@ -130,7 +130,7 @@ Direct {meth}`~libtmux.Client.refresh` and {meth}`~libtmux.Client.from_client_name` calls still surface missing client lookup errors. -#### `Server.display_message` and `Window.display_message` (#670) +#### `Server.display_message` and `Window.display_message` (#672) {meth}`~libtmux.Server.display_message` and {meth}`~libtmux.Window.display_message` join the existing @@ -139,7 +139,7 @@ client lookup errors. window reads (``#{window_zoomed_flag}``, ``#{window_active_clients_list}``) auto-bind to the window's id. -#### C-side filter on typed listing methods (#670) +#### C-side filter on typed listing methods (#672) {meth}`~libtmux.Server.search_panes`, {meth}`~libtmux.Server.search_windows`, @@ -153,14 +153,14 @@ 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 ``reset=True`` or ``repeat=N`` to invoke tmux's flag-only ``send-keys -R`` / ``send-keys -N `` form without any trailing key argument. -#### `Server.list_buffers(format_string=, filter=)` (#670) +#### `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 @@ -168,7 +168,7 @@ and ``filter`` (``-f``) kwargs. Callers can project a chosen template into tmux's format engine — same bad-filter caveat as the ``search_*`` methods. -#### `Server.run_shell(cwd=, show_stderr=)` (#670) +#### `Server.run_shell(cwd=, show_stderr=)` (#672) {meth}`~libtmux.Server.run_shell` gains ``cwd`` (``-c``) to set the shell command's working directory and ``show_stderr`` (``-E``) to @@ -176,14 +176,14 @@ 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. -#### Scope-aware format-token retrieval (#670) +#### Scope-aware format-token retrieval (#672) The ``-F`` template libtmux sends to each ``list-*`` subcommand is now scope- and version-aware. tmux's format engine cascades context @@ -218,7 +218,7 @@ releases can be validated end-to-end. {meth}`~libtmux.Window.display_message`, and {meth}`~libtmux.Pane.display_message` raise {exc}`~libtmux.exc.LibTmuxException` when tmux reports an error, - matching the rest of libtmux's tmux commands (#670). + matching the rest of libtmux's tmux commands (#672). - {attr}`~libtmux.Server.clients` and {meth}`~libtmux.Server.search_sessions` propagate tmux errors rather than silently returning an empty diff --git a/MIGRATION b/MIGRATION index 69c1145e6..376198c3d 100644 --- a/MIGRATION +++ b/MIGRATION @@ -113,7 +113,7 @@ sections below for detailed migration examples and code samples. _Detailed migration steps for the next version will be posted here._ -## libtmux 0.57.0: Subcommand-tagged exceptions (#670) +## libtmux 0.57.0: Subcommand-tagged exceptions (#672) ### `LibTmuxException` `str()` gains a subcommand prefix From d4106e455d8224e22f55de4e2f4660704466b555 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 13:32:54 -0500 Subject: [PATCH 61/65] Server,Window,Pane(refactor[display_message]): Warn on tmux stderr instead of raising MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux's stderr from display-message conflates genuine argument-parser errors (e.g. -F-with-positional rejection) with operational quirks like 3.2a's control-mode dispatch path silently failing without emitting stderr at all. Raising LibTmuxException on every stderr forced an in-branch workaround — pytest.skip patches gated on has_gte_version("3.3") — that masked the underlying mismatch instead of solving it. Switch to warnings.warn so callers see the stderr without losing the return value, and the eventual raise/per-call-opt-in contract can land in a follow-up shipment that exercises real tmux versions end-to-end. what: - src/libtmux/server.py, src/libtmux/window.py, src/libtmux/pane.py: replace raise_if_stderr(proc, "display-message") with warnings.warn("display-message: …", stacklevel=2). Wrapper return value unchanged on success and on warn paths. - All three display_message docstrings gain a Notes block describing the warn-not-raise contract and showing the warnings.catch_warnings/filterwarnings("error") escalation pattern. - tests/test_pane.py, tests/test_window.py, tests/test_server.py: rename test_*_display_message_raises_on_tmux_error to test_*_display_message_warns_on_tmux_error and switch to pytest.warns(UserWarning, match=…). Drop the 3.2a control-mode skip added by the prior commit on test_server_display_message_no_text_returns_none — with warn-not-raise the 3.2a control-mode stderr no longer fails the test (the test only asserts result is None on get_text=False). - CHANGES: rewrite the display_message Fixes entry to describe the warn contract and how to escalate. - MIGRATION: add a new section under 0.57.0 documenting the warn contract and the warnings.catch_warnings escalation pattern. - MIGRATION: add a section noting that Pane.reset now dispatches via self.server.cmd; mocks targeting pane.cmd no longer intercept reset. - docs/topics/pane_interaction.md: tighten the capture_pane(pending=True) wording to describe tmux's parser pending buffer rather than "slow consumer / paused program" (the latter framing implies a PTY/app buffering issue that pending= doesn't address). - docs/topics/filtering.md: note that there is no search_clients(); filter via Server.clients and Python-side QueryList.filter. --- CHANGES | 9 ++++++--- MIGRATION | 32 ++++++++++++++++++++++++++++++++ docs/topics/filtering.md | 7 +++++++ docs/topics/pane_interaction.md | 8 +++++--- src/libtmux/pane.py | 15 ++++++++++++++- src/libtmux/server.py | 19 +++++++++++++++++-- src/libtmux/window.py | 15 ++++++++++++++- tests/test_pane.py | 9 +++------ tests/test_server.py | 19 +++++-------------- tests/test_window.py | 9 +++------ 10 files changed, 106 insertions(+), 36 deletions(-) diff --git a/CHANGES b/CHANGES index afc112f79..5a6775b17 100644 --- a/CHANGES +++ b/CHANGES @@ -216,9 +216,12 @@ releases can be validated end-to-end. (#650). - {meth}`~libtmux.Server.display_message`, {meth}`~libtmux.Window.display_message`, and - {meth}`~libtmux.Pane.display_message` raise - {exc}`~libtmux.exc.LibTmuxException` when tmux reports an error, - matching the rest of libtmux's tmux commands (#672). + {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 diff --git a/MIGRATION b/MIGRATION index 376198c3d..713b85e14 100644 --- a/MIGRATION +++ b/MIGRATION @@ -193,6 +193,38 @@ the client's *stable* identifier and does not have this caveat. The {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 `` 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 diff --git a/docs/topics/filtering.md b/docs/topics/filtering.md index d5d8c3228..8ade4978e 100644 --- a/docs/topics/filtering.md +++ b/docs/topics/filtering.md @@ -280,6 +280,13 @@ it inside the tmux server: The {meth}`~libtmux.Server.list_buffers` method also accepts a `filter=` kwarg with the same semantics. +There is no `search_clients()` method; filter clients via the +{attr}`~libtmux.Server.clients` accessor and Python-side +{meth}`~libtmux._internal.query_list.QueryList.filter`. Pushing a +client-side predicate to tmux is rarely a hot path — a server's client +count is bounded by attached terminals, not by session/window/pane +fan-out. + ### Python-side vs. C-side | | `.filter()` | `.search_*()` | diff --git a/docs/topics/pane_interaction.md b/docs/topics/pane_interaction.md index 69e46fae4..1005fe510 100644 --- a/docs/topics/pane_interaction.md +++ b/docs/topics/pane_interaction.md @@ -220,9 +220,11 @@ a warning is issued and the flag is ignored. ### Capturing the pending input buffer -Use `pending=True` to dump bytes tmux has received but not yet processed, -rather than the visible screen. This is useful for diagnosing input that -is stuck behind a slow consumer or buffered behind a paused program. +Use `pending=True` to dump bytes tmux has buffered in its parser but +not yet committed to the pane's terminal — input the tmux process read +from the pane's PTY but hasn't fed through its escape-sequence parser +into the visible screen. Useful for inspecting partial control +sequences mid-write. ```python >>> pending = pane.capture_pane(pending=True) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index 91eed2f62..05dc577b0 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -770,6 +770,15 @@ def display_message( ------- list[str] | None Message output if get_text is True, otherwise None. + + Notes + ----- + Stderr from tmux is reported via :func:`warnings.warn`, not raised. + Callers that want to escalate to an exception can wrap the call in + :func:`warnings.catch_warnings` with ``filterwarnings("error")``. + + .. versionchanged:: 0.57 + Reports stderr via :func:`warnings.warn` instead of raising. """ tmux_args: tuple[str, ...] = () @@ -816,7 +825,11 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) - raise_if_stderr(proc, "display-message") + if proc.stderr: + warnings.warn( + f"display-message: {'; '.join(proc.stderr)}", + stacklevel=2, + ) if get_text: return proc.stdout diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 09310721b..3a5ab74c6 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -1467,10 +1467,21 @@ def display_message( ``#{socket_path}``, ``#{pid}``) resolve without a specific pane handle. With no client attached and ``target_client`` omitted, the status-line - path (``get_text=False``) raises ``no current client``. Use + path (``get_text=False``) issues a ``no current client`` warning. Use ``get_text=True`` for headless reads, or pair with :class:`~libtmux._internal.control_mode.ControlMode`. + Notes + ----- + Stderr from tmux is reported via :func:`warnings.warn`, not raised. + tmux uses stderr for both genuine errors and informational messages, + and the right escalation depends on tmux version and call shape. + Callers that want to escalate to an exception can wrap the call in + :func:`warnings.catch_warnings` with ``filterwarnings("error")``. + + .. versionchanged:: 0.57 + Reports stderr via :func:`warnings.warn` instead of raising. + Parameters ---------- cmd : str @@ -1569,7 +1580,11 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) - raise_if_stderr(proc, "display-message") + if proc.stderr: + warnings.warn( + f"display-message: {'; '.join(proc.stderr)}", + stacklevel=2, + ) if get_text: return proc.stdout diff --git a/src/libtmux/window.py b/src/libtmux/window.py index ad785a1e7..4e36cafed 100644 --- a/src/libtmux/window.py +++ b/src/libtmux/window.py @@ -974,6 +974,15 @@ def display_message( ... ) >>> result[0] in {"0", "1"} True + + Notes + ----- + Stderr from tmux is reported via :func:`warnings.warn`, not raised. + Callers that want to escalate to an exception can wrap the call in + :func:`warnings.catch_warnings` with ``filterwarnings("error")``. + + .. versionchanged:: 0.57 + Reports stderr via :func:`warnings.warn` instead of raising. """ tmux_args: tuple[str, ...] = () @@ -1011,7 +1020,11 @@ def display_message( tmux_args += (cmd,) proc = self.cmd("display-message", *tmux_args) - raise_if_stderr(proc, "display-message") + if proc.stderr: + warnings.warn( + f"display-message: {'; '.join(proc.stderr)}", + stacklevel=2, + ) if get_text: return proc.stdout diff --git a/tests/test_pane.py b/tests/test_pane.py index cd8c78d41..1899b26f5 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -877,17 +877,14 @@ def test_display_message_flags( assert expected_in_output in output -def test_display_message_raises_on_tmux_error(session: Session) -> None: - """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``.""" +def test_display_message_warns_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as a :class:`UserWarning`.""" pane = session.active_window.active_pane assert pane is not None - with pytest.raises(exc.LibTmuxException) as excinfo: + with pytest.warns(UserWarning, match="only one of -F or argument"): pane.display_message("x", get_text=True, format_string="#{pane_id}") - assert excinfo.value.subcommand == "display-message" - assert "only one of -F or argument" in str(excinfo.value) - def test_split_percentage(session: Session) -> None: """Test Pane.split() with percentage parameter.""" diff --git a/tests/test_server.py b/tests/test_server.py index 0ccdcf9d6..cc14a35f2 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1603,13 +1603,6 @@ def test_server_display_message_no_text_returns_none( server: Server, ) -> None: """Without ``get_text=True`` the call renders to status line and returns None.""" - from libtmux.common import has_gte_version - - if not has_gte_version("3.3"): - pytest.skip( - "display-message via control-mode client unreliable on tmux 3.2a", - ) - with control_mode() as ctl: result = server.display_message( "hi from libtmux", target_client=ctl.client_name @@ -1638,18 +1631,16 @@ def test_server_display_message_target_client( assert result[0].strip() != "" -def test_server_display_message_raises_on_tmux_error( +def test_server_display_message_warns_on_tmux_error( server: Server, session: Session, ) -> None: - """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``. + """Tmux stderr on ``display-message`` surfaces as a :class:`UserWarning`. Passing ``cmd`` and ``format_string`` together is rejected by tmux's argument parser with stderr ``only one of -F or argument must be - given``. The wrapper must propagate that, not silently return ``[]``. + given``. The wrapper must surface that to the caller without silently + swallowing it. """ - with pytest.raises(exc.LibTmuxException) as excinfo: + with pytest.warns(UserWarning, match="only one of -F or argument"): server.display_message("x", get_text=True, format_string="#{version}") - - assert excinfo.value.subcommand == "display-message" - assert "only one of -F or argument" in str(excinfo.value) diff --git a/tests/test_window.py b/tests/test_window.py index 7c500e24e..c826b0ca5 100644 --- a/tests/test_window.py +++ b/tests/test_window.py @@ -1163,15 +1163,12 @@ def test_window_display_message_target_client( assert result[0].startswith("@") -def test_window_display_message_raises_on_tmux_error(session: Session) -> None: - """Tmux stderr on ``display-message`` surfaces as ``LibTmuxException``.""" +def test_window_display_message_warns_on_tmux_error(session: Session) -> None: + """Tmux stderr on ``display-message`` surfaces as a :class:`UserWarning`.""" window = session.active_window - with pytest.raises(exc.LibTmuxException) as excinfo: + with pytest.warns(UserWarning, match="only one of -F or argument"): window.display_message("x", get_text=True, format_string="#{window_id}") - assert excinfo.value.subcommand == "display-message" - assert "only one of -F or argument" in str(excinfo.value) - def test_window_zoomed_flag_field_toggle(session: Session) -> None: """``window.window_zoomed_flag`` reflects tmux's zoom state across refresh. From 0b4941d304f2dff507406f78c940507b30de958f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 17 May 2026 13:33:12 -0500 Subject: [PATCH 62/65] Server(fix[new_session]): Hydrate Session with live tmux version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Server.new_session() called get_output_format() with no args, which defaults to ("list-panes", "3.2a") and gates out every typed Obj field whose FIELD_VERSION entry exceeds 3.2a. On tmux 3.3+ the returned Session silently missed pane_dead_signal and pane_dead_time, and any 3.4+ tokens that join FIELD_VERSION in a follow-up shipment would have the same gratuitous gap. Match the pattern used by fetch_objs: thread the live tmux version through, and use list-sessions as the scope (the format context for tmux's new-session -P -F is the freshly created session). what: - src/libtmux/server.py: - Import get_version from libtmux.common. - new_session() now derives tmux_version via get_version(tmux_bin=…) and passes ("list-sessions", tmux_version) to both get_output_format (template build) and parse_output (output parse). Pair must be identical or the field order goes out of sync. Verified: a new_session() on tmux 3.3+ now hydrates pane_dead_signal and pane_dead_time on the returned Session, where master returned None unconditionally. --- src/libtmux/server.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/libtmux/server.py b/src/libtmux/server.py index 3a5ab74c6..c763885d8 100644 --- a/src/libtmux/server.py +++ b/src/libtmux/server.py @@ -18,7 +18,7 @@ from libtmux import exc from libtmux._internal.query_list import QueryList from libtmux.client import Client -from libtmux.common import has_gte_version, raise_if_stderr, tmux_cmd +from libtmux.common import get_version, has_gte_version, raise_if_stderr, tmux_cmd from libtmux.constants import OptionScope from libtmux.hooks import HooksMixin from libtmux.neo import fetch_objs, get_output_format, parse_output @@ -2193,7 +2193,8 @@ def new_session( del os.environ["TMUX"] try: - _fields, format_string = get_output_format() + tmux_version = str(get_version(tmux_bin=self.tmux_bin)) + _fields, format_string = get_output_format("list-sessions", tmux_version) tmux_args: tuple[str | int, ...] = ( "-P", @@ -2245,7 +2246,7 @@ def new_session( if env: os.environ["TMUX"] = env - session_data = parse_output(session_stdout) + session_data = parse_output(session_stdout, "list-sessions", tmux_version) session = Session(server=self, **session_data) From 5332b79b81ec8fa30dfedb09526fa386b2588117 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 07:17:26 -0500 Subject: [PATCH 63/65] neo(feat[fields]): Declare format-token fields from tmux master MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux master (post-3.6a) registers eight new format tokens that the next tmux release will ship: pane_zoomed_flag, pane_floating_flag, pane_flags, pane_pb_state, pane_pb_progress, pane_pipe_pid, synchronized_output_flag, bracket_paste_flag. Declaring them now means libtmux is ready when the tag lands; older tmux releases expand unknown tokens to empty strings, so the fields stay None until the user upgrades tmux. what: - src/libtmux/neo.py: add the fields alphabetically within the existing Obj layout (pane_* tokens among the pane_* block, bracket_paste_flag near buffer_*, synchronized_output_flag near start_time). - tests/test_pane.py: parametrized test asserts each field is declared on the dataclass and hydrates either as None or as a string after refresh(). No runtime-value assertions — those will activate when the shipping tmux release exposes the tokens. --- src/libtmux/neo.py | 8 ++++++++ tests/test_pane.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 2b390ad18..36023902f 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -288,6 +288,7 @@ class Obj: active_window_index: str | None = None alternate_saved_x: str | None = None alternate_saved_y: str | None = None + bracket_paste_flag: str | None = None buffer_name: str | None = None buffer_sample: str | None = None buffer_size: str | None = None @@ -358,6 +359,8 @@ class Obj: pane_dead_status: str | None = None pane_dead_time: str | None = None pane_fg: str | None = None + pane_flags: str | None = None + pane_floating_flag: str | None = None pane_format: str | None = None pane_height: str | None = None pane_id: str | None = None @@ -370,8 +373,11 @@ class Obj: pane_marked_set: str | None = None pane_mode: str | None = None pane_path: str | None = None + pane_pb_progress: str | None = None + pane_pb_state: str | None = None pane_pid: str | None = None pane_pipe: str | None = None + pane_pipe_pid: str | None = None pane_right: str | None = None pane_search_string: str | None = None pane_start_command: str | None = None @@ -382,6 +388,7 @@ class Obj: pane_top: str | None = None pane_tty: str | None = None pane_width: str | None = None + pane_zoomed_flag: str | None = None pid: str | None = None scroll_position: str | None = None scroll_region_lower: str | None = None @@ -414,6 +421,7 @@ class Obj: session_windows: str | None = None socket_path: str | None = None start_time: str | None = None + synchronized_output_flag: str | None = None uid: str | None = None user: str | None = None version: str | None = None diff --git a/tests/test_pane.py b/tests/test_pane.py index 1899b26f5..e25336668 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -646,6 +646,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 From 393a937bf498a6f889b0795e5b32bb91ddd660c3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:48:18 -0500 Subject: [PATCH 64/65] neo(feat[fields]): Re-expose version-gated tokens added after tmux 3.2a MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: with the scope+version gating in place, the format string sent to older tmux versions automatically excludes tokens that those versions don't recognize. The tokens below first registered in tmux 3.4-3.6 — tagging them with FIELD_VERSION makes them appear on supported tmux releases that include them, and absent on older tmux without sending unknown tokens that bloat the format string or trigger crashes. what: - src/libtmux/neo.py: - Re-add fields to Obj alphabetically: pane_key_mode, pane_unseen_changes, session_active, session_activity_flag, session_alert, session_bell_flag, session_silence_flag, client_theme. - Populate FIELD_VERSION with each token's minimum tmux release (3.4 for pane_unseen_changes, 3.5 for pane_key_mode, 3.6 for the five session_* tokens and client_theme). - tests/test_pane.py: restore pane_key_mode and pane_unseen_changes in PANE_FORMAT_FIELDS (the parametrized declaration+hydration test). - tests/test_session.py: restore the new session_* entries in SESSION_FORMAT_FIELDS. Verification: - On tmux 3.6a (local), all tokens hydrate via refresh(); tests pass. - On tmux 3.2a, FIELD_VERSION skips all 8 — the -F template stays at its pre--rollback shape for that version. Version anchors verified via: rg '""' https://github.com/tmux/tmux/blob//format.c across 3.2a, 3.3a, 3.4, 3.5, 3.5a, 3.6, 3.6a. --- src/libtmux/neo.py | 16 ++++++++++++++++ tests/test_pane.py | 2 ++ tests/test_session.py | 5 +++++ 3 files changed, 23 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 36023902f..2186101d4 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -54,6 +54,14 @@ # release tag, e.g. https://github.com/tmux/tmux/blob/3.6a/format.c). "pane_dead_signal": "3.3", "pane_dead_time": "3.3", + "pane_unseen_changes": "3.4", + "pane_key_mode": "3.5", + "session_active": "3.6", + "session_activity_flag": "3.6", + "session_alert": "3.6", + "session_bell_flag": "3.6", + "session_silence_flag": "3.6", + "client_theme": "3.6", } """Minimum tmux version that registers each format token. @@ -311,6 +319,7 @@ class Obj: client_termfeatures: str | None = None client_termname: str | None = None client_termtype: str | None = None + client_theme: str | None = None client_tty: str | None = None client_uid: str | None = None client_user: str | None = None @@ -367,6 +376,7 @@ class Obj: pane_in_mode: str | None = None pane_index: str | None = None pane_input_off: str | None = None + pane_key_mode: str | None = None pane_last: str | None = None pane_left: str | None = None pane_marked: str | None = None @@ -387,6 +397,7 @@ class Obj: pane_title: str | None = None pane_top: str | None = None pane_tty: str | None = None + pane_unseen_changes: str | None = None pane_width: str | None = None pane_zoomed_flag: str | None = None pid: str | None = None @@ -398,10 +409,14 @@ class Obj: selection_end_y: str | None = None selection_start_x: str | None = None selection_start_y: str | None = None + session_active: str | None = None session_activity: str | None = None + session_activity_flag: str | None = None + session_alert: str | None = None session_alerts: str | None = None session_attached: str | None = None session_attached_list: str | None = None + session_bell_flag: str | None = None session_created: str | None = None session_format: str | None = None session_group: str | None = None @@ -417,6 +432,7 @@ class Obj: session_marked: str | None = None session_name: str | None = None session_path: str | None = None + session_silence_flag: str | None = None session_stack: str | None = None session_windows: str | None = None socket_path: str | None = None diff --git a/tests/test_pane.py b/tests/test_pane.py index e25336668..483fc3a9e 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -613,6 +613,7 @@ 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", @@ -620,6 +621,7 @@ def test_send_keys_flag_only_requires_a_flag(session: Session) -> None: "pane_path", "pane_pipe", "pane_synchronized", + "pane_unseen_changes", ) diff --git a/tests/test_session.py b/tests/test_session.py index 3c7c33d47..34a9dfd36 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -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", ) From 8db405d51d34d77662781c080d08f03e8092585f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 16 May 2026 10:52:59 -0500 Subject: [PATCH 65/65] neo(feat[fields]): Declare forward-looking tokens for tmux 3.7+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: tmux master post-3.6a registers new format tokens (verified via https://github.com/tmux/tmux/blob/master/format.c grep on each format_cb_* signature). Declaring them on Obj now means libtmux is ready when tmux 3.7 ships — the FIELD_VERSION gate keeps them silent on every currently-released tmux, and they begin hydrating automatically once the user upgrades tmux. what: - src/libtmux/neo.py: - Add fields to Obj alphabetically: bracket_paste_flag, pane_flags, pane_floating_flag, pane_pb_progress, pane_pb_state, pane_pipe_pid, pane_zoomed_flag, synchronized_output_flag. - Tag each in FIELD_VERSION with "3.7" so they're absent in every list-* template on tmux <=3.6a. - Add _SCOPE_OVERRIDES dict for tokens whose name doesn't carry a scope prefix; map bracket_paste_flag and synchronized_output_flag to "pane" scope (their tmux callbacks dereference ft->wp). The six pane_* tokens scope correctly via the existing prefix table. - Update _token_scope() to consult _SCOPE_OVERRIDES first. Verification: on tmux 3.6a (local), the fields stay None after refresh() — confirmed because FIELD_VERSION blocks them from the -F template. All tests pass. --- src/libtmux/neo.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/libtmux/neo.py b/src/libtmux/neo.py index 2186101d4..f91068f9f 100644 --- a/src/libtmux/neo.py +++ b/src/libtmux/neo.py @@ -62,6 +62,17 @@ "session_bell_flag": "3.6", "session_silence_flag": "3.6", "client_theme": "3.6", + # Forward-looking tokens registered in tmux master post-3.6a. Gated + # to 3.7 so they're absent on every released tmux. Once tmux 3.7 ships, + # they'll hydrate automatically. + "bracket_paste_flag": "3.7", + "pane_flags": "3.7", + "pane_floating_flag": "3.7", + "pane_pb_progress": "3.7", + "pane_pb_state": "3.7", + "pane_pipe_pid": "3.7", + "pane_zoomed_flag": "3.7", + "synchronized_output_flag": "3.7", } """Minimum tmux version that registers each format token. @@ -95,6 +106,8 @@ # Entries are added by the scope-gate fix commits as misclassifications are # discovered. _SCOPE_OVERRIDES: dict[str, str] = { + "bracket_paste_flag": "pane", # ft->wp->screen MODE_BRACKETPASTE + "synchronized_output_flag": "pane", # ft->wp->base MODE_SYNC "cursor_x": "pane", # ft->wp->base.cx "cursor_y": "pane", # ft->wp->base.cy "cursor_flag": "pane", # ft->wp->base.mode @@ -214,6 +227,8 @@ def _token_scope(field_name: str) -> str: 'pane' >>> _token_scope("active_window_index") 'session' + >>> _token_scope("synchronized_output_flag") + 'pane' Context-only tokens (registered outside ``format.c``'s static table) route to the ``"context"`` scope and are excluded from every