From 79bc31cdf47e41ef2ab75426e1d91049e17b1c2c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 11:56:24 -0500 Subject: [PATCH 01/42] Experimental(feat[chainable-commands]): add command-chain IR why: Promote the converged chainable-commands design from the PR #684 research into a documented, typed experimental API, beginning with the argv IR substrate. Establishes the _experimental package as a home for in-progress designs (mirroring _internal/docs/internals), explicitly outside the versioning policy. what: - Add src/libtmux/_experimental/ + chainable_commands subpackage - Add ir.py: CommandCall, CommandChain (argv/argvs/then/>>/run), CommandSpec, CommandRunner/CommandResultLike protocols, and ;-escaping, all with doctests - Add tests/_experimental/chainable_commands/test_ir.py (pure argv + live tmux one-dispatch and stop-on-error) - Add docs/experiment/ landing + IR autodoc page; wire into docs/index.md toctree; mark _experimental not-public in public-api.md --- .../api/libtmux._experimental.chain.ir.md | 13 + docs/experiment/index.md | 62 ++++ docs/index.md | 1 + docs/project/public-api.md | 8 +- src/libtmux/_experimental/__init__.py | 13 + src/libtmux/_experimental/chain/__init__.py | 39 +++ src/libtmux/_experimental/chain/ir.py | 322 ++++++++++++++++++ tests/_experimental/__init__.py | 1 + tests/_experimental/chain/__init__.py | 1 + tests/_experimental/chain/test_ir.py | 167 +++++++++ 10 files changed, 625 insertions(+), 2 deletions(-) create mode 100644 docs/experiment/api/libtmux._experimental.chain.ir.md create mode 100644 docs/experiment/index.md create mode 100644 src/libtmux/_experimental/__init__.py create mode 100644 src/libtmux/_experimental/chain/__init__.py create mode 100644 src/libtmux/_experimental/chain/ir.py create mode 100644 tests/_experimental/__init__.py create mode 100644 tests/_experimental/chain/__init__.py create mode 100644 tests/_experimental/chain/test_ir.py diff --git a/docs/experiment/api/libtmux._experimental.chain.ir.md b/docs/experiment/api/libtmux._experimental.chain.ir.md new file mode 100644 index 000000000..4aedf07b4 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.ir.md @@ -0,0 +1,13 @@ +# Command IR - `libtmux._experimental.chain.ir` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.ir + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md new file mode 100644 index 000000000..3f7cf99aa --- /dev/null +++ b/docs/experiment/index.md @@ -0,0 +1,62 @@ +(experimental)= + +# Experimental + +:::{danger} +**No stability guarantee.** Everything under `libtmux._experimental` is **not** +covered by the project's versioning policy. It can change or be removed between +any releases without notice. + +These APIs are published so the design can be exercised and reviewed before any +stability commitment. If you depend on something here and want it stabilized, +please [file an issue](https://github.com/tmux-python/libtmux/issues). +::: + +## Chainable commands + +The `libtmux._experimental.chain` package lets you author an ordered +sequence of typed tmux commands that compiles to **one** native +`tmux ... \; ...` invocation and dispatches once -- instead of issuing one +subprocess per command. It grows in layers: + +- **IR** -- the immutable argv intermediate representation: a + {class}`~libtmux._experimental.chain.ir.CommandCall` is one typed + command; a {class}`~libtmux._experimental.chain.ir.CommandChain` + is an ordered group that renders to a single argv with standalone `;` + separators and dispatches once. + +::::{grid} 1 2 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Command IR +:link: api/libtmux._experimental.chain.ir +:link-type: doc +Immutable argv primitives: `CommandCall`, `CommandChain`, `CommandSpec`. +::: + +:::: + +## At a glance + +Compose typed calls and dispatch them as one tmux invocation: + +```python +>>> from libtmux._experimental.chain.ir import CommandCall +>>> sequence = ( +... CommandCall("set-option", ("-g", "@cc_docs_a", "1")) +... >> CommandCall("set-option", ("-g", "@cc_docs_b", "2")) +... ) +>>> sequence.argv() +('set-option', '-g', '@cc_docs_a', '1', ';', 'set-option', '-g', '@cc_docs_b', '2') +>>> sequence.run(session.server).returncode +0 +>>> session.server.cmd("show-option", "-gv", "@cc_docs_b").stdout +['2'] +``` + +```{toctree} +:hidden: +:maxdepth: 1 + +api/libtmux._experimental.chain.ir +``` diff --git a/docs/index.md b/docs/index.md index f6e763e0c..d9457d8d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -101,6 +101,7 @@ topics/index api/index api/testing/index internals/index +experiment/index project/index history migration diff --git a/docs/project/public-api.md b/docs/project/public-api.md index 7fe1367db..805ab7d70 100644 --- a/docs/project/public-api.md +++ b/docs/project/public-api.md @@ -29,12 +29,16 @@ This includes: ## What Is Internal -Modules under `libtmux._internal` and `libtmux._vendor` are **not public**. -They may change or be removed without notice between any release. +Modules under `libtmux._internal`, `libtmux._vendor`, and +`libtmux._experimental` are **not public**. They may change or be removed +without notice between any release. `libtmux._experimental` additionally hosts +in-progress designs that are published for feedback before any stability +commitment (see {ref}`the experimental docs `). Do not import from: - `libtmux._internal.*` - `libtmux._vendor.*` +- `libtmux._experimental.*` ## Pre-1.0 Stability Policy diff --git a/src/libtmux/_experimental/__init__.py b/src/libtmux/_experimental/__init__.py new file mode 100644 index 000000000..93fa87f98 --- /dev/null +++ b/src/libtmux/_experimental/__init__.py @@ -0,0 +1,13 @@ +"""Experimental libtmux APIs. + +Note +---- +This is an **experimental** namespace. Everything under +:mod:`libtmux._experimental` is **not** covered by the project's versioning +policy and may change or be removed between any releases without notice. + +If you depend on something here and want it stabilized, please +`file an issue `_. +""" + +from __future__ import annotations diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py new file mode 100644 index 000000000..ac822780b --- /dev/null +++ b/src/libtmux/_experimental/chain/__init__.py @@ -0,0 +1,39 @@ +r"""Typed, chainable tmux command sequences (experimental). + +This package promotes the converged design from the ``chainable-commands`` +research into a typed, documented API. It lets callers author an ordered +sequence of tmux commands that compiles to **one** native ``tmux ... \\; ...`` +invocation and dispatches once, instead of issuing one subprocess per command. + +The layers build on each other: + +- :mod:`~libtmux._experimental.chain.ir` -- the immutable argv + intermediate representation (``CommandCall``, ``CommandChain``). + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +from libtmux._experimental.chain.ir import ( + Arg, + CommandCall, + CommandChain, + CommandResultLike, + CommandRunner, + CommandScope, + CommandSpec, +) + +__all__ = [ + "Arg", + "CommandCall", + "CommandChain", + "CommandResultLike", + "CommandRunner", + "CommandScope", + "CommandSpec", +] diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py new file mode 100644 index 000000000..143880a05 --- /dev/null +++ b/src/libtmux/_experimental/chain/ir.py @@ -0,0 +1,322 @@ +r"""Immutable argv intermediate representation for tmux command sequences. + +This module is the substrate for the chainable-commands API. A +:class:`CommandCall` is one typed tmux command before dispatch; a +:class:`CommandChain` is an ordered group of calls that renders to a single +native ``tmux ... \\; ...`` invocation and dispatches once through a +:class:`CommandRunner` (which a live :class:`libtmux.Server` already satisfies). + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +Arg: t.TypeAlias = "str | int" +"""A single tmux argument token. Integers are rendered with :func:`str`.""" + +CommandScope: t.TypeAlias = t.Literal["server", "session", "window", "pane"] +"""The tmux object scope a command targets.""" + + +class CommandResultLike(t.Protocol): + """Result protocol matching the libtmux command-result surface. + + A live :class:`libtmux.common.tmux_cmd` satisfies this protocol, as does + any object exposing ``stdout``/``stderr`` line lists and a ``returncode``. + """ + + stdout: list[str] + stderr: list[str] + returncode: int + + +class CommandRunner(t.Protocol): + """Object capable of dispatching one tmux command argv. + + A live :class:`libtmux.Server` (and :class:`libtmux.Session`, + :class:`libtmux.Window`, :class:`libtmux.Pane`) already matches this + protocol via its ``cmd()`` method, so sequences can be dispatched without + any adapter for the common case. + """ + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch a single tmux command and return its result.""" + ... + + +@dataclass(frozen=True, slots=True) +class CommandSpec: + """Static metadata describing a tmux command. + + Parameters + ---------- + name : str + The tmux command name, e.g. ``"rename-window"``. + scope : CommandScope + The tmux object scope the command targets. + chainable : bool + Whether the command may be folded into a one-dispatch sequence. A + command is chainable only when its output is not consumed mid-chain; + commands that must return output immediately (e.g. ``show-option``) set + this to ``False``. + + Examples + -------- + >>> CommandSpec(name="rename-window", scope="window") + CommandSpec(name='rename-window', scope='window', chainable=True) + >>> CommandSpec(name="show-option", scope="server", chainable=False).chainable + False + """ + + name: str + scope: CommandScope + chainable: bool = True + + +@dataclass(frozen=True, slots=True) +class CommandCall: + """One typed tmux command call before subprocess dispatch. + + Parameters + ---------- + name : str + The tmux command name. + args : tuple[Arg, ...] + Positional argument tokens, rendered in order after the target. + target : str | int | None + Optional ``-t`` target inserted immediately after the command name. + + Examples + -------- + >>> CommandCall("new-window", ("-d", "-n", "work")).argv() + ('new-window', '-d', '-n', 'work') + + A target is rendered as a ``-t`` flag right after the command name: + + >>> CommandCall("split-window", ("-h",), target="%1").argv() + ('split-window', '-t', '%1', '-h') + """ + + name: str + args: tuple[Arg, ...] = () + target: str | int | None = None + + def argv(self) -> tuple[str, ...]: + """Render this call as tmux argv tokens. + + Returns + ------- + tuple[str, ...] + The command name, optional ``-t ``, then each argument. + + Examples + -------- + >>> CommandCall("kill-window", target="@1").argv() + ('kill-window', '-t', '@1') + """ + rendered: list[str] = [self.name] + if self.target is not None: + rendered.extend(("-t", str(self.target))) + rendered.extend(_render_arg(arg) for arg in self.args) + return tuple(rendered) + + def then(self, other: CommandCall | CommandChain) -> CommandChain: + """Return a sequence with ``other`` appended after this call. + + Parameters + ---------- + other : CommandCall | CommandChain + The call or sequence to append. + + Returns + ------- + CommandChain + + Examples + -------- + >>> seq = CommandCall("new-window").then(CommandCall("split-window")) + >>> seq.argvs() + (('new-window',), ('split-window',)) + """ + if isinstance(other, CommandCall): + return CommandChain((self, other)) + return CommandChain((self, *other.calls)) + + def __rshift__(self, other: CommandCall | CommandChain) -> CommandChain: + """Compose command calls with ``>>``. + + Examples + -------- + >>> (CommandCall("new-window") >> CommandCall("split-window")).argv() + ('new-window', ';', 'split-window') + """ + return self.then(other) + + +@dataclass(frozen=True, slots=True) +class CommandChain: + r"""An ordered tmux command sequence dispatched as one invocation. + + A sequence renders to a single argv list using standalone ``;`` separator + tokens, mirroring tmux's native command-sequence syntax + (``tmux cmd-a \\; cmd-b``). Later commands do not run if an earlier command + in the sequence errors -- the same semantics tmux applies to ``;`` chains. + + Parameters + ---------- + calls : tuple[CommandCall, ...] + The ordered, non-empty calls in the sequence. + + Examples + -------- + >>> seq = CommandCall("new-window", ("-d",)) >> CommandCall("split-window", ("-h",)) + >>> seq.argv() + ('new-window', '-d', ';', 'split-window', '-h') + """ + + calls: tuple[CommandCall, ...] + + def __post_init__(self) -> None: + """Reject empty sequences. + + Examples + -------- + >>> CommandChain(()) + Traceback (most recent call last): + ... + ValueError: CommandChain requires at least one call + """ + if not self.calls: + msg = "CommandChain requires at least one call" + raise ValueError(msg) + + def argv(self) -> tuple[str, ...]: + """Render the full sequence with tmux ``;`` separators. + + Returns + ------- + tuple[str, ...] + One flat argv list, with a standalone ``";"`` token between calls. + + Examples + -------- + >>> seq = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + >>> seq.argv() + ('rename-window', 'work', ';', 'split-window') + """ + rendered: list[str] = [] + for index, call in enumerate(self.calls): + if index: + rendered.append(";") + rendered.extend(call.argv()) + return tuple(rendered) + + def argvs(self) -> tuple[tuple[str, ...], ...]: + """Render each call independently, without separators. + + Returns + ------- + tuple[tuple[str, ...], ...] + One argv tuple per call. Useful for asserting the compiled commands + in tests without reasoning about ``;`` placement. + + Examples + -------- + >>> seq = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + >>> seq.argvs() + (('rename-window', 'work'), ('split-window',)) + """ + return tuple(call.argv() for call in self.calls) + + def then(self, other: CommandCall | CommandChain) -> CommandChain: + """Return a sequence with ``other`` appended. + + Parameters + ---------- + other : CommandCall | CommandChain + + Returns + ------- + CommandChain + + Examples + -------- + >>> base = CommandChain((CommandCall("new-window"),)) + >>> base.then(CommandCall("split-window")).argvs() + (('new-window',), ('split-window',)) + """ + if isinstance(other, CommandCall): + return CommandChain((*self.calls, other)) + return CommandChain((*self.calls, *other.calls)) + + def __rshift__(self, other: CommandCall | CommandChain) -> CommandChain: + """Compose sequences with ``>>``. + + Examples + -------- + >>> seq = CommandChain((CommandCall("new-window"),)) + >>> (seq >> CommandCall("kill-window")).argvs() + (('new-window',), ('kill-window',)) + """ + return self.then(other) + + def run(self, runner: CommandRunner) -> CommandResultLike: + """Dispatch the whole sequence through one runner call. + + Parameters + ---------- + runner : CommandRunner + Any object with a ``Server.cmd``-shaped ``cmd()`` method. A live + :class:`libtmux.Server` works directly. + + Returns + ------- + CommandResultLike + The single result of the one-shot dispatch. + + Examples + -------- + Two server options set in a single tmux invocation: + + >>> seq = ( + ... CommandCall("set-option", ("-g", "@cc_demo_a", "1")) + ... >> CommandCall("set-option", ("-g", "@cc_demo_b", "2")) + ... ) + >>> result = seq.run(session.server) + >>> result.returncode + 0 + >>> session.server.cmd("show-option", "-gv", "@cc_demo_b").stdout + ['2'] + """ + argv = self.argv() + return runner.cmd(argv[0], *argv[1:]) + + +def _render_arg(arg: Arg) -> str: + r"""Render one argument token, escaping a trailing tmux separator. + + A literal argument that ends in ``;`` is escaped to ``\;`` so tmux does not + mistake it for a command separator inside a sequence. + + Examples + -------- + >>> _render_arg(50) + '50' + >>> _render_arg("echo hi;") + 'echo hi\\;' + """ + text = str(arg) + if text.endswith(";"): + return f"{text[:-1]}\\;" + return text diff --git a/tests/_experimental/__init__.py b/tests/_experimental/__init__.py new file mode 100644 index 000000000..3b1f242bf --- /dev/null +++ b/tests/_experimental/__init__.py @@ -0,0 +1 @@ +"""Tests for experimental libtmux APIs.""" diff --git a/tests/_experimental/chain/__init__.py b/tests/_experimental/chain/__init__.py new file mode 100644 index 000000000..0569ad661 --- /dev/null +++ b/tests/_experimental/chain/__init__.py @@ -0,0 +1 @@ +"""Tests for the experimental chainable-commands API.""" diff --git a/tests/_experimental/chain/test_ir.py b/tests/_experimental/chain/test_ir.py new file mode 100644 index 000000000..f1aa20b24 --- /dev/null +++ b/tests/_experimental/chain/test_ir.py @@ -0,0 +1,167 @@ +"""Tests for the chainable-commands argv intermediate representation.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +import pytest + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandChain, + CommandSpec, +) + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + from libtmux.session import Session + + +@dataclass +class _FakeResult: + """Minimal command result for runner tests.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _FakeRunner: + """Runner that records dispatches instead of touching tmux.""" + + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one command dispatch.""" + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["ok"]) + + +def _read_global_option(session: Session, name: str) -> list[str]: + """Read a global tmux option through the experimental IR. + + Dogfoods :class:`CommandChain` for the read-back so the assertion goes + through the typed ``CommandRunner`` protocol rather than a raw + ``server.cmd`` call. + """ + readback = CommandChain((CommandCall("show-option", ("-gv", name)),)) + return readback.run(session.server).stdout + + +def test_command_call_renders_argv() -> None: + """A call renders its name and positional arguments in order.""" + call = CommandCall("new-window", ("-d", "-n", "work")) + + assert call.argv() == ("new-window", "-d", "-n", "work") + + +def test_command_call_injects_target_after_name() -> None: + """A target renders as a ``-t`` flag immediately after the command name.""" + call = CommandCall("split-window", ("-h",), target="%1") + + assert call.argv() == ("split-window", "-t", "%1", "-h") + + +def test_command_call_renders_integer_arguments() -> None: + """Integer argument tokens render via :func:`str`.""" + call = CommandCall("resize-pane", ("-y", 20), target="%1") + + assert call.argv() == ("resize-pane", "-t", "%1", "-y", "20") + + +def test_command_sequence_renders_tmux_semicolon_sequence() -> None: + """Composed calls render with standalone ``;`` separator tokens.""" + sequence = CommandCall("new-window", ("-d",)) >> CommandCall( + "split-window", + ("-h",), + ) + + assert sequence.argv() == ( + "new-window", + "-d", + ";", + "split-window", + "-h", + ) + + +def test_command_sequence_argvs_renders_each_call_independently() -> None: + """``argvs`` keeps per-call argv tuples for easy assertions.""" + sequence = CommandCall("rename-window", ("work",)) >> CommandCall("split-window") + + assert sequence.argvs() == ( + ("rename-window", "work"), + ("split-window",), + ) + + +def test_command_sequence_escapes_literal_semicolon_arguments() -> None: + """A literal trailing ``;`` is escaped so tmux does not split on it.""" + sequence = CommandChain( + (CommandCall("send-keys", ("echo hi;",), target="%1"),), + ) + + assert sequence.argv() == ("send-keys", "-t", "%1", "echo hi\\;") + + +def test_command_sequence_rejects_empty() -> None: + """An empty sequence is a programming error.""" + with pytest.raises(ValueError, match="at least one call"): + CommandChain(()) + + +def test_command_sequence_runs_as_single_runner_call() -> None: + """``run`` dispatches the whole sequence through one runner call.""" + runner = _FakeRunner() + sequence = CommandCall("new-window", ("-d",)) >> CommandCall("split-window") + + sequence.run(runner) + + assert runner.calls == [ + ("new-window", ("-d", ";", "split-window"), None), + ] + + +def test_command_spec_defaults_to_chainable() -> None: + """Specs are chainable unless a command must return output immediately.""" + assert CommandSpec(name="rename-window", scope="window").chainable is True + assert ( + CommandSpec(name="show-option", scope="server", chainable=False).chainable + is False + ) + + +def test_tmux_executes_native_command_sequence(session: Session) -> None: + """A sequence dispatches as one native tmux invocation against a server.""" + sequence = CommandCall( + "set-option", + ("-g", "@cc_ir_a", "1"), + ) >> CommandCall("set-option", ("-g", "@cc_ir_b", "2")) + + result = sequence.run(session.server) + + assert result.returncode == 0 + assert _read_global_option(session, "@cc_ir_b") == ["2"] + + +def test_tmux_stops_native_sequence_after_error(session: Session) -> None: + """Tmux skips later commands in a sequence once one errors.""" + sequence = CommandCall( + "set-option", + ("-g", "@cc_ir_marker", "set"), + ) >> CommandCall("has-session", ("-t", "cc_definitely_missing_session")) + + result = sequence.run(session.server) + + assert result.returncode != 0 + # The first command ran before the erroring one stopped the rest. + assert _read_global_option(session, "@cc_ir_marker") == ["set"] From 2c1ec626ada5def663d1c1d644b62e537af6f799 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 12:06:52 -0500 Subject: [PATCH 02/42] Experimental(feat[chainable-commands]): add deferred query plan and live adapters why: Add the headline layer of the chainable-commands design -- a typed, target-safe deferred query that compiles to one native tmux command sequence -- plus the live-tmux bridge so plans resolve and dispatch against a real server in a single invocation. what: - Add plan.py: typed PaneTarget/WindowTarget/SessionTarget, command values (SendKeys/ResizePane/SelectLayout), PaneRef rows with bound .cmd/.window namespaces, lazy PaneQuery (filter/order_by/limit/all/first/map/commands), CommandPlan with pure to_chain(snapshot) and one-dispatch run() - Reuse ir.CommandChain (resolve the lab's duplicate-CommandChain collision) - Add adapters.py: snapshot_from_session() and SessionPlanRunner (PlanRunner over a live Session); cast Server to CommandRunner for clean mypy + ty - Add tests/_experimental tests: pure plan semantics + live snapshot/dispatch - Add plan + adapters autodoc pages; grow docs/experiment/index with deferred-plan examples and toctree entries --- ...libtmux._experimental.chain._connection.md | 13 + .../api/libtmux._experimental.chain.plan.md | 13 + docs/experiment/index.md | 65 ++ src/libtmux/_experimental/chain/__init__.py | 34 + .../_experimental/chain/_connection.py | 117 ++++ src/libtmux/_experimental/chain/plan.py | 596 ++++++++++++++++++ tests/_experimental/chain/test_connection.py | 73 +++ tests/_experimental/chain/test_plan.py | 249 ++++++++ 8 files changed, 1160 insertions(+) create mode 100644 docs/experiment/api/libtmux._experimental.chain._connection.md create mode 100644 docs/experiment/api/libtmux._experimental.chain.plan.md create mode 100644 src/libtmux/_experimental/chain/_connection.py create mode 100644 src/libtmux/_experimental/chain/plan.py create mode 100644 tests/_experimental/chain/test_connection.py create mode 100644 tests/_experimental/chain/test_plan.py diff --git a/docs/experiment/api/libtmux._experimental.chain._connection.md b/docs/experiment/api/libtmux._experimental.chain._connection.md new file mode 100644 index 000000000..6f291f668 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain._connection.md @@ -0,0 +1,13 @@ +# Live-tmux adapters - `libtmux._experimental.chain._connection` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain._connection + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/api/libtmux._experimental.chain.plan.md b/docs/experiment/api/libtmux._experimental.chain.plan.md new file mode 100644 index 000000000..b518fb157 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.plan.md @@ -0,0 +1,13 @@ +# Deferred plan - `libtmux._experimental.chain.plan` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.plan + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md index 3f7cf99aa..aafb60129 100644 --- a/docs/experiment/index.md +++ b/docs/experiment/index.md @@ -24,6 +24,15 @@ subprocess per command. It grows in layers: command; a {class}`~libtmux._experimental.chain.ir.CommandChain` is an ordered group that renders to a single argv with standalone `;` separators and dispatches once. +- **Plan** -- a typed, target-safe deferred query: a lazy + {class}`~libtmux._experimental.chain.plan.PaneQuery` resolves + against a pure {class}`~libtmux._experimental.chain.plan.TmuxSnapshot`, + maps each typed row to commands, and compiles to one sequence. +- **Adapters** -- the live-tmux bridge: + {func}`~libtmux._experimental.chain._connection.snapshot_from_session` + reads real panes, and + {class}`~libtmux._experimental.chain._connection.SessionPlanExecutor` + resolves and dispatches a plan against a real server in one invocation. ::::{grid} 1 2 2 2 :gutter: 2 2 3 3 @@ -34,6 +43,18 @@ subprocess per command. It grows in layers: Immutable argv primitives: `CommandCall`, `CommandChain`, `CommandSpec`. ::: +:::{grid-item-card} Deferred plan +:link: api/libtmux._experimental.chain.plan +:link-type: doc +Typed target-safe queries that compile to one command sequence. +::: + +:::{grid-item-card} Live-tmux adapters +:link: api/libtmux._experimental.chain._connection +:link-type: doc +`snapshot_from_session` and `SessionPlanExecutor` for real dispatch. +::: + :::: ## At a glance @@ -54,9 +75,53 @@ Compose typed calls and dispatch them as one tmux invocation: ['2'] ``` +Resolve a typed query against a snapshot and compile it to one sequence -- pure, +no tmux required: + +```python +>>> from libtmux._experimental.chain.plan import ( +... PaneRef, +... PaneTarget, +... SessionTarget, +... TmuxSnapshot, +... WindowTarget, +... panes, +... ) +>>> snapshot = TmuxSnapshot( +... panes=( +... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), +... pane_index=0, active=True, title="editor"), +... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), +... pane_index=1, active=True, title="logs"), +... ), +... ) +>>> plan = ( +... panes() +... .filter(active=True) +... .order_by("pane_index") +... .commands(lambda pane: pane.cmd.resize_pane(height=20)) +... ) +>>> plan.to_chain(snapshot).argvs() +(('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) +``` + +Against a live server, dispatch the same plan in one invocation with +{class}`~libtmux._experimental.chain._connection.SessionPlanExecutor`: + +```python +>>> from libtmux._experimental.chain import SessionPlanExecutor, panes +>>> runner = SessionPlanExecutor(session) +>>> live_plan = panes().filter(active=True).commands( +... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), +... ) +>>> live_plan.run(runner) +``` + ```{toctree} :hidden: :maxdepth: 1 api/libtmux._experimental.chain.ir +api/libtmux._experimental.chain.plan +api/libtmux._experimental.chain._connection ``` diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index ac822780b..4cd6eae86 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -9,6 +9,10 @@ - :mod:`~libtmux._experimental.chain.ir` -- the immutable argv intermediate representation (``CommandCall``, ``CommandChain``). +- :mod:`~libtmux._experimental.chain.plan` -- typed, target-safe + deferred query-command plans (``panes()``, ``CommandPlan``). +- :mod:`~libtmux._experimental.chain._connection` -- live-tmux + connection helpers (``snapshot_from_session``, ``SessionPlanExecutor``). Note ---- @@ -18,6 +22,10 @@ from __future__ import annotations +from libtmux._experimental.chain._connection import ( + SessionPlanExecutor, + snapshot_from_session, +) from libtmux._experimental.chain.ir import ( Arg, CommandCall, @@ -27,13 +35,39 @@ CommandScope, CommandSpec, ) +from libtmux._experimental.chain.plan import ( + CommandPlan, + CommandValue, + NoCommandsResolved, + PaneQuery, + PaneRef, + PaneTarget, + PlanRunner, + SessionTarget, + TmuxSnapshot, + WindowTarget, + panes, +) __all__ = [ "Arg", "CommandCall", "CommandChain", + "CommandPlan", "CommandResultLike", "CommandRunner", "CommandScope", "CommandSpec", + "CommandValue", + "NoCommandsResolved", + "PaneQuery", + "PaneRef", + "PaneTarget", + "PlanRunner", + "SessionPlanExecutor", + "SessionTarget", + "TmuxSnapshot", + "WindowTarget", + "panes", + "snapshot_from_session", ] diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py new file mode 100644 index 000000000..0d7beddfc --- /dev/null +++ b/src/libtmux/_experimental/chain/_connection.py @@ -0,0 +1,117 @@ +"""Live-tmux connection helpers for chainable-commands plans. + +These bridge the typed plan layer to a real :class:`libtmux.Session`: +:func:`snapshot_from_session` reads live panes into a pure +:class:`~libtmux._experimental.chain.plan.TmuxSnapshot`, and +:class:`SessionPlanExecutor` satisfies +:class:`~libtmux._experimental.chain.plan.PlanRunner` so a +:class:`~libtmux._experimental.chain.plan.CommandPlan` resolves and +dispatches against an actual tmux server in one invocation. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import typing as t + +from libtmux._experimental.chain.ir import ( + Arg, + CommandResultLike, + CommandRunner, +) +from libtmux._experimental.chain.plan import ( + PaneRef, + PaneTarget, + SessionTarget, + TmuxSnapshot, + WindowTarget, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def snapshot_from_session(session: Session) -> TmuxSnapshot: + """Read a live session's panes into a pure snapshot. + + Parameters + ---------- + session : libtmux.Session + A live session to read panes from. + + Returns + ------- + TmuxSnapshot + Typed pane rows with their pane, window, and session targets. + + Examples + -------- + >>> snapshot = snapshot_from_session(session) + >>> len(snapshot.panes) >= 1 + True + >>> snapshot.panes[0].pane_id.value.startswith("%") + True + """ + rows: list[PaneRef] = [] + for pane in session.panes: + pane_id = pane.pane_id + if pane_id is None: + continue + rows.append( + PaneRef( + pane_id=PaneTarget(pane_id), + window_id=WindowTarget(pane.window_id or ""), + session_id=SessionTarget(pane.session_id or ""), + pane_index=int(pane.pane_index) if pane.pane_index is not None else 0, + active=pane.pane_active == "1", + title=pane.pane_title or "", + ), + ) + return TmuxSnapshot(panes=tuple(rows)) + + +class SessionPlanExecutor: + r"""A :class:`PlanRunner` backed by a live :class:`libtmux.Session`. + + Dispatches commands through ``session.server.cmd`` and resolves snapshots + via :func:`snapshot_from_session`, so a plan executes against real tmux in a + single native ``\\;`` invocation. + + Examples + -------- + >>> runner = SessionPlanExecutor(session) + >>> runner.snapshot().panes[0].pane_id.value.startswith("%") + True + + A plan resolves and dispatches once through the runner: + + >>> from libtmux._experimental.chain.plan import panes + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> plan.run(runner) + """ + + def __init__(self, session: Session) -> None: + self.session = session + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command through the live server.""" + # A live Server already satisfies the CommandRunner protocol; the cast + # keeps the variadic dispatch cleanly typed (mypy and ty both resolve + # ``Server.cmd`` to a union otherwise). + runner = t.cast("CommandRunner", self.session.server) + return runner.cmd(cmd, *args, target=target) + + def snapshot(self) -> TmuxSnapshot: + """Return a fresh snapshot of the session's panes.""" + return snapshot_from_session(self.session) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py new file mode 100644 index 000000000..354206159 --- /dev/null +++ b/src/libtmux/_experimental/chain/plan.py @@ -0,0 +1,596 @@ +"""Typed, target-safe deferred query-command plans. + +A plan starts from a lazy :class:`PaneQuery`, resolves it against a pure +:class:`TmuxSnapshot`, maps each typed :class:`PaneRef` row to one or more +commands, and compiles the result into a single +:class:`~libtmux._experimental.chain.ir.CommandChain` -- which +dispatches once. Targets are typed (:class:`PaneTarget`, :class:`WindowTarget`, +:class:`SessionTarget`), so a row-bound command namespace cannot mis-target a +command. + +Compilation (:meth:`CommandPlan.to_chain`) is a pure function of the +snapshot, so a plan can be inspected in memory -- no tmux required -- and only +:meth:`CommandPlan.run` touches a live server. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import collections.abc as cabc +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux._experimental.chain.ir import ( + Arg, + CommandCall, + CommandChain, + CommandRunner, +) + +OrderField: t.TypeAlias = t.Literal["pane_id", "pane_index", "title"] +"""A :class:`PaneRef` field a query may order by.""" + +ResultT = t.TypeVar("ResultT") +MappedT = t.TypeVar("MappedT") + + +class NoCommandsResolved(RuntimeError): + """Raised when a deferred plan resolves to no concrete commands.""" + + +@dataclass(frozen=True, slots=True) +class PaneTarget: + """A typed tmux pane target (e.g. ``%1``). + + Examples + -------- + >>> PaneTarget("%1") + PaneTarget(value='%1') + >>> str(PaneTarget("%1")) + '%1' + """ + + value: str + + @classmethod + def coerce(cls, target: str | PaneTarget) -> PaneTarget: + """Normalize raw pane-target text into a typed target. + + Examples + -------- + >>> PaneTarget.coerce("%2") + PaneTarget(value='%2') + >>> PaneTarget.coerce(PaneTarget("%2")) + PaneTarget(value='%2') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class WindowTarget: + """A typed tmux window target (e.g. ``@1``). + + Examples + -------- + >>> str(WindowTarget("@1")) + '@1' + """ + + value: str + + @classmethod + def coerce(cls, target: str | WindowTarget) -> WindowTarget: + """Normalize raw window-target text into a typed target. + + Examples + -------- + >>> WindowTarget.coerce("@1") + WindowTarget(value='@1') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +@dataclass(frozen=True, slots=True) +class SessionTarget: + """A typed tmux session target (e.g. ``$0``). + + Examples + -------- + >>> str(SessionTarget("$0")) + '$0' + """ + + value: str + + @classmethod + def coerce(cls, target: str | SessionTarget) -> SessionTarget: + """Normalize raw session-target text into a typed target. + + Examples + -------- + >>> SessionTarget.coerce("$0") + SessionTarget(value='$0') + """ + if isinstance(target, str): + return cls(target) + return target + + def __str__(self) -> str: + """Render as the tmux target value.""" + return self.value + + +class CommandValue: + """Base for typed command values produced by a deferred plan. + + Subclasses carry their own typed target and compile to an + :class:`~libtmux._experimental.chain.ir.CommandCall`. + """ + + def to_call(self) -> CommandCall: + """Compile this command value into a shared command call.""" + raise NotImplementedError + + def argv(self) -> tuple[str, ...]: + """Render this command value as tmux argv tokens. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + return self.to_call().argv() + + +CommandLike: t.TypeAlias = "CommandValue | CommandCall" +IntoCommands: t.TypeAlias = "CommandLike | cabc.Iterable[CommandLike]" + + +@dataclass(frozen=True, slots=True) +class SendKeys(CommandValue): + """A typed ``send-keys`` command bound to a pane. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + + target: PaneTarget + command: str + enter: bool = False + + def to_call(self) -> CommandCall: + """Compile to a shared command call.""" + args: list[Arg] = [self.command] + if self.enter: + args.append("Enter") + return CommandCall("send-keys", tuple(args), target=self.target.value) + + +@dataclass(frozen=True, slots=True) +class ResizePane(CommandValue): + """A typed ``resize-pane`` command bound to a pane. + + Examples + -------- + >>> ResizePane(PaneTarget("%1"), height=20).argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ + + target: PaneTarget + height: int + + def to_call(self) -> CommandCall: + """Compile to a shared command call.""" + return CommandCall( + "resize-pane", + ("-y", self.height), + target=self.target.value, + ) + + +@dataclass(frozen=True, slots=True) +class SelectLayout(CommandValue): + """A typed ``select-layout`` command bound to a window. + + Examples + -------- + >>> SelectLayout(WindowTarget("@1"), "even-horizontal").argv() + ('select-layout', '-t', '@1', 'even-horizontal') + """ + + target: WindowTarget + layout: str + + def to_call(self) -> CommandCall: + """Compile to a shared command call.""" + return CommandCall("select-layout", (self.layout,), target=self.target.value) + + +class BoundPaneCommands: + """Pane command namespace bound to one typed pane target. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).send_keys("clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ + + def __init__(self, target: PaneTarget) -> None: + self.target = target + + def send_keys(self, command: str, *, enter: bool = False) -> SendKeys: + """Build a target-bound ``send-keys`` command.""" + return SendKeys(target=self.target, command=command, enter=enter) + + def resize_pane(self, *, height: int) -> ResizePane: + """Build a target-bound ``resize-pane`` command.""" + return ResizePane(target=self.target, height=height) + + +class BoundWindowCommands: + """Window command namespace bound to one typed window target. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ + + def __init__(self, target: WindowTarget) -> None: + self.target = target + + def select_layout(self, layout: str) -> SelectLayout: + """Build a target-bound ``select-layout`` command.""" + return SelectLayout(target=self.target, layout=layout) + + +@dataclass(frozen=True, slots=True) +class PaneRef: + """A typed pane row returned by a pane query. + + The ``cmd`` and ``window`` namespaces are pre-bound to this row's typed + targets, so commands built from a row cannot mis-target. + + Examples + -------- + >>> pane = PaneRef( + ... pane_id=PaneTarget("%1"), + ... window_id=WindowTarget("@1"), + ... session_id=SessionTarget("$0"), + ... pane_index=0, + ... active=True, + ... title="editor", + ... ) + >>> pane.cmd.send_keys("clear", enter=True).argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + >>> pane.window.select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ + + pane_id: PaneTarget + window_id: WindowTarget + session_id: SessionTarget + pane_index: int + active: bool + title: str + + @property + def cmd(self) -> BoundPaneCommands: + """Pane-scoped commands bound to this pane.""" + return BoundPaneCommands(self.pane_id) + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands bound to this pane's window.""" + return BoundWindowCommands(self.window_id) + + +CommandMapper: t.TypeAlias = cabc.Callable[[PaneRef], IntoCommands] + + +@dataclass(frozen=True, slots=True) +class TmuxSnapshot: + """A pure snapshot of tmux pane state used to resolve plans. + + Examples + -------- + >>> snapshot = TmuxSnapshot(panes=()) + >>> snapshot.panes + () + """ + + panes: tuple[PaneRef, ...] + + +class SnapshotProvider(t.Protocol): + """Object that can provide a pure tmux snapshot.""" + + def snapshot(self) -> TmuxSnapshot: + """Return a tmux snapshot.""" + ... + + +class PlanRunner(CommandRunner, SnapshotProvider, t.Protocol): + """A runner that can both resolve snapshots and dispatch commands.""" + + +SnapshotSource: t.TypeAlias = "TmuxSnapshot | SnapshotProvider" + + +@dataclass(frozen=True, slots=True) +class PaneQuery: + """A lazy pane query that can become a deferred command plan. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=1, active=False, title="logs"), + ... ), + ... ) + >>> [p.pane_id.value for p in panes().filter(active=True).all(snapshot)] + ['%1'] + """ + + active_filter: bool | None = None + ordering: OrderField | None = None + limit_count: int | None = None + + def filter(self, *, active: bool) -> PaneQuery: + """Return a query filtered by active state.""" + return dataclasses.replace(self, active_filter=active) + + def order_by(self, field: OrderField) -> PaneQuery: + """Return a query ordered by a known pane field.""" + return dataclasses.replace(self, ordering=field) + + def limit(self, count: int) -> PaneQuery: + """Return a query capped to ``count`` rows.""" + return dataclasses.replace(self, limit_count=count) + + def all(self, source: SnapshotSource) -> list[PaneRef]: + """Evaluate the query against a snapshot source. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=1, active=True, title="logs"), + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> [p.pane_id.value for p in panes().order_by("pane_index").all(snapshot)] + ['%1', '%2'] + """ + rows = list(_resolve_snapshot(source).panes) + if self.active_filter is not None: + rows = [row for row in rows if row.active == self.active_filter] + if self.ordering is not None: + ordering = self.ordering + rows.sort(key=lambda row: _order_value(row, ordering)) + if self.limit_count is not None: + rows = rows[: self.limit_count] + return rows + + def first(self, source: SnapshotSource) -> PaneRef | None: + """Evaluate the query and return its first row, or ``None``.""" + rows = self.limit(1).all(source) + if not rows: + return None + return rows[0] + + def map( + self, + mapper: cabc.Callable[[PaneRef], MappedT], + ) -> MappedPaneQuery[MappedT]: + """Return a data-only transformation query (no commands).""" + return MappedPaneQuery(query=self, mapper=mapper) + + def commands(self, mapper: CommandMapper) -> CommandPlan[None]: + """Return a deferred plan where each row maps to one or more commands. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> plan = panes().commands( + ... lambda pane: ( + ... pane.cmd.resize_pane(height=10), + ... pane.window.select_layout("tiled"), + ... ), + ... ) + >>> compiled = plan.to_chain(snapshot).argvs() + >>> compiled[0] + ('resize-pane', '-t', '%1', '-y', '10') + >>> compiled[1] + ('select-layout', '-t', '@1', 'tiled') + """ + return CommandPlan(_CommandPlanNode(query=self, mapper=mapper)) + + +@dataclass(frozen=True, slots=True) +class MappedPaneQuery(t.Generic[MappedT]): + """A data-only query transformation over pane rows. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).all(snapshot) + ['editor'] + """ + + query: PaneQuery + mapper: cabc.Callable[[PaneRef], MappedT] + + def all(self, source: SnapshotSource) -> list[MappedT]: + """Evaluate the query and transform every row.""" + return [self.mapper(row) for row in self.query.all(source)] + + def first(self, source: SnapshotSource) -> MappedT | None: + """Evaluate the query and transform the first row, or ``None``.""" + row = self.query.first(source) + if row is None: + return None + return self.mapper(row) + + +@dataclass(frozen=True, slots=True) +class _CommandPlanNode: + """A deferred query plus a command mapper (an unresolved plan node).""" + + query: PaneQuery + mapper: CommandMapper + + +@dataclass(frozen=True, slots=True) +class CommandPlan(t.Generic[ResultT]): + """A lazy command plan that resolves a query into a command sequence. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=1, active=True, title="logs"), + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> plan = ( + ... panes() + ... .filter(active=True) + ... .order_by("pane_index") + ... .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ... ) + >>> plan.to_chain(snapshot).argvs() + (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) + """ + + node: _CommandPlanNode + + def to_chain(self, source: SnapshotSource) -> CommandChain: + """Resolve the query and compile mapped commands (pure). + + Parameters + ---------- + source : SnapshotSource + A :class:`TmuxSnapshot` or a :class:`SnapshotProvider`. + + Returns + ------- + CommandChain + + Raises + ------ + NoCommandsResolved + If the resolved query produced no commands. + """ + calls: list[CommandCall] = [] + for row in self.node.query.all(source): + calls.extend(_to_calls(self.node.mapper(row))) + if not calls: + msg = "command plan resolved to no commands" + raise NoCommandsResolved(msg) + return CommandChain(tuple(calls)) + + def run(self: CommandPlan[None], runner: PlanRunner) -> None: + """Resolve, compile, and dispatch the plan in one tmux invocation. + + An empty plan is a no-op (it does not raise), mirroring libtmux's + lenient list-accessor contract. + + Examples + -------- + Dispatch ``send-keys`` to every active pane in one invocation, against + a live tmux server: + + >>> from libtmux._experimental.chain import SessionPlanExecutor + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> plan.run(SessionPlanExecutor(session)) + """ + try: + sequence = self.to_chain(runner) + except NoCommandsResolved: + return None + sequence.run(runner) + return None + + +def panes() -> PaneQuery: + """Start a lazy pane query. + + Examples + -------- + >>> panes() + PaneQuery(active_filter=None, ordering=None, limit_count=None) + """ + return PaneQuery() + + +def _resolve_snapshot(source: SnapshotSource) -> TmuxSnapshot: + if isinstance(source, TmuxSnapshot): + return source + return source.snapshot() + + +def _order_value(row: PaneRef, field: OrderField) -> str | int: + if field == "pane_id": + return row.pane_id.value + if field == "pane_index": + return row.pane_index + return row.title + + +def _to_calls(value: IntoCommands) -> tuple[CommandCall, ...]: + if isinstance(value, CommandCall): + return (value,) + if isinstance(value, CommandValue): + return (value.to_call(),) + if isinstance(value, str | bytes): + msg = "command mapper must return a command or iterable of commands" + raise TypeError(msg) + + calls: list[CommandCall] = [] + try: + iterator = iter(value) + except TypeError as exc: + msg = "command mapper must return a command or iterable of commands" + raise TypeError(msg) from exc + for item in iterator: + calls.extend(_to_calls(item)) + return tuple(calls) diff --git a/tests/_experimental/chain/test_connection.py b/tests/_experimental/chain/test_connection.py new file mode 100644 index 000000000..d0b6a014b --- /dev/null +++ b/tests/_experimental/chain/test_connection.py @@ -0,0 +1,73 @@ +"""Live-tmux integration tests for the chain connection layer.""" + +from __future__ import annotations + +import typing as t + +from libtmux._experimental.chain._connection import ( + SessionPlanExecutor, + snapshot_from_session, +) +from libtmux._experimental.chain.plan import PaneTarget, panes + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def test_snapshot_from_session_reads_live_panes(session: Session) -> None: + """A snapshot reflects the session's real panes with typed targets.""" + session.active_window.split() + + snapshot = snapshot_from_session(session) + + assert len(snapshot.panes) >= 2 + for pane in snapshot.panes: + assert isinstance(pane.pane_id, PaneTarget) + assert pane.pane_id.value.startswith("%") + assert pane.window_id.value.startswith("@") + assert pane.session_id.value.startswith("$") + + +def test_session_plan_runner_compiles_real_targets(session: Session) -> None: + """A plan resolved through the runner targets the session's real panes.""" + session.active_window.split() + runner = SessionPlanExecutor(session) + snapshot = runner.snapshot() + + plan = panes().commands(lambda pane: pane.cmd.send_keys("echo cc", enter=True)) + sequence = plan.to_chain(runner) + + # Every compiled command targets a real pane id from the live snapshot. + targeted = {argv[2] for argv in sequence.argvs()} + assert targeted == {pane.pane_id.value for pane in snapshot.panes} + + +def test_session_plan_runner_dispatches_plan_once(session: Session) -> None: + """Running a plan dispatches against the live server without error.""" + session.active_window.split() + runner = SessionPlanExecutor(session) + + plan = ( + panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ) + ) + + # Resolves the live snapshot and dispatches as one native tmux invocation. + # ``run`` returns ``None``; this asserts the dispatch raises nothing. + plan.run(runner) + + +def test_empty_plan_run_is_noop_against_live_session(session: Session) -> None: + """A plan that resolves to no commands is a live no-op.""" + runner = SessionPlanExecutor(session) + + # ``limit(0)`` yields no rows, so the plan resolves to no commands. + plan = ( + panes().limit(0).commands(lambda pane: pane.cmd.send_keys("echo x", enter=True)) + ) + + # No commands resolved -> a silent no-op (does not raise). + plan.run(runner) diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py new file mode 100644 index 000000000..652056fd8 --- /dev/null +++ b/tests/_experimental/chain/test_plan.py @@ -0,0 +1,249 @@ +"""Tests for the deferred query-command plan layer.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +import pytest +from typing_extensions import assert_type + +from libtmux._experimental.chain import plan as api +from libtmux._experimental.chain.ir import CommandChain + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + + +@dataclass +class _FakeResult: + """Minimal command result for plan runner tests.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _FakeRunner: + """Runner exposing a fixed snapshot and recording dispatches.""" + + snapshot_value: api.TmuxSnapshot + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + + def snapshot(self) -> api.TmuxSnapshot: + """Return the fixed tmux snapshot.""" + return self.snapshot_value + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one command dispatch.""" + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["ok"]) + + +def _snapshot() -> api.TmuxSnapshot: + return api.TmuxSnapshot( + panes=( + api.PaneRef( + pane_id=api.PaneTarget("%2"), + window_id=api.WindowTarget("@1"), + session_id=api.SessionTarget("$0"), + pane_index=2, + active=True, + title="shell", + ), + api.PaneRef( + pane_id=api.PaneTarget("%1"), + window_id=api.WindowTarget("@1"), + session_id=api.SessionTarget("$0"), + pane_index=1, + active=True, + title="editor", + ), + api.PaneRef( + pane_id=api.PaneTarget("%3"), + window_id=api.WindowTarget("@2"), + session_id=api.SessionTarget("$0"), + pane_index=3, + active=False, + title="logs", + ), + ), + ) + + +def test_typed_targets_and_bound_commands_render_targets() -> None: + """Bound command namespaces keep pane and window targets typed.""" + pane = _snapshot().panes[0] + + pane_call = pane.cmd.send_keys("clear", enter=True) + window_call = pane.window.select_layout("even-horizontal") + + assert_type(pane.pane_id, api.PaneTarget) + assert_type(pane.window_id, api.WindowTarget) + assert_type(pane.session_id, api.SessionTarget) + assert pane_call.argv() == ("send-keys", "-t", "%2", "clear", "Enter") + assert window_call.argv() == ("select-layout", "-t", "@1", "even-horizontal") + + +def test_commands_defers_mapper_until_sequence_resolution() -> None: + """``commands`` stores a plan node instead of eagerly calling the mapper.""" + mapper_calls: list[api.PaneRef] = [] + + def mapper(pane: api.PaneRef) -> api.CommandValue: + mapper_calls.append(pane) + return pane.cmd.resize_pane(height=20) + + plan = api.panes().filter(active=True).commands(mapper) + + assert_type(plan, api.CommandPlan[None]) + assert mapper_calls == [] + + sequence = plan.to_chain(_snapshot()) + + assert [pane.pane_id.value for pane in mapper_calls] == ["%2", "%1"] + assert sequence.argvs() == ( + ("resize-pane", "-t", "%2", "-y", "20"), + ("resize-pane", "-t", "%1", "-y", "20"), + ) + + +def test_snapshot_sequence_filters_orders_and_flattens_commands() -> None: + """Snapshot compilation gives pure assertions without touching tmux.""" + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands( + lambda pane: [ + pane.cmd.send_keys("clear", enter=True), + pane.cmd.resize_pane(height=20), + ], + ) + ) + + sequence = plan.to_chain(_snapshot()) + + assert_type(sequence, CommandChain) + assert sequence.argvs() == ( + ("send-keys", "-t", "%1", "clear", "Enter"), + ("resize-pane", "-t", "%1", "-y", "20"), + ("send-keys", "-t", "%2", "clear", "Enter"), + ("resize-pane", "-t", "%2", "-y", "20"), + ) + assert sequence.argv()[:6] == ( + "send-keys", + "-t", + "%1", + "clear", + "Enter", + ";", + ) + + +def test_commands_supports_multiple_commands_per_row() -> None: + """``commands`` exposes the explicit multi-command row expansion.""" + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: ( + pane.cmd.resize_pane(height=10), + pane.window.select_layout("even-horizontal"), + ), + ) + ) + + assert plan.to_chain(_snapshot()).argvs() == ( + ("resize-pane", "-t", "%2", "-y", "10"), + ("select-layout", "-t", "@1", "even-horizontal"), + ("resize-pane", "-t", "%1", "-y", "10"), + ("select-layout", "-t", "@1", "even-horizontal"), + ) + + +def test_map_transforms_rows_without_creating_commands() -> None: + """``map`` remains data-oriented and separate from command construction.""" + query = api.panes().filter(active=True).order_by("pane_index") + + titles = query.map(lambda pane: pane.title).all(_snapshot()) + + assert_type(titles, list[str]) + assert titles == ["editor", "shell"] + + +def test_run_resolves_live_snapshot_and_dispatches_once() -> None: + """``run`` resolves the query and executes one native tmux sequence.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands(lambda pane: pane.cmd.send_keys("clear", enter=True)) + ) + + plan.run(runner) + + assert runner.calls == [ + ( + "send-keys", + ( + "-t", + "%1", + "clear", + "Enter", + ";", + "send-keys", + "-t", + "%2", + "clear", + "Enter", + ), + None, + ), + ] + + +def test_to_chain_uses_runner_snapshot_without_dispatching() -> None: + """Resolving against a runner still keeps execution explicit.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands(lambda pane: pane.cmd.resize_pane(height=12)) + ) + + sequence = plan.to_chain(runner) + + assert sequence.argvs() == ( + ("resize-pane", "-t", "%2", "-y", "12"), + ("resize-pane", "-t", "%1", "-y", "12"), + ) + assert runner.calls == [] + + +def test_empty_query_to_chain_raises_but_run_is_noop() -> None: + """Empty query plans are inspectably empty and executable as no-ops.""" + runner = _FakeRunner(api.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=20)) + + with pytest.raises(api.NoCommandsResolved): + plan.to_chain(runner) + + plan.run(runner) + assert runner.calls == [] + + +def test_commands_rejects_string_iterable_command_results() -> None: + """String-like mapper results are not accepted as command iterables.""" + plan = api.panes().commands(lambda pane: t.cast("t.Any", pane.title)) + + with pytest.raises(TypeError, match="command mapper"): + plan.to_chain(_snapshot()) From 03be9fcc8b2c598028d2adc8e9767bb0b39e86e1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 12:26:05 -0500 Subject: [PATCH 03/42] Experimental(feat[chainable-commands]): add async facade and live async adapter why: An async host (e.g. an MCP server) is the real awaitable boundary for this design. Add an async facade so snapshot resolution and dispatch are awaitable while command construction stays synchronous, preserving the one-plan = one-native-dispatch guarantee, plus a live async adapter over the sync core. what: - Add aio.py: async PaneQuery/MappedPaneQuery/CommandPlan wrapping the sync engine; to_chain reuses the sync compile path so one plan still yields one ir.CommandChain; run() dispatches via an async runner - Add adapters.AsyncSessionPlanRunner (AsyncPlanRunner over a live Session via asyncio.to_thread) - pyproject: add pytest-asyncio (dev + testing), asyncio_mode="auto", and asyncio_default_fixture_loop_scope="function" (matching pytest-asyncio's own config and sibling projects) - pytest_plugin/test_pytest_plugin: the plugin's pytester-based doctests/tests spawn sync inner pytest sessions; pass `-p no:asyncio` so the now-installed pytest-asyncio plugin does not load there and emit its loop-scope deprecation - Add tests/_experimental/test_aio.py: async plan semantics (pytest-asyncio auto) plus live async snapshot/dispatch integration - Add aio autodoc page; grow docs/experiment/index with an async example, card, and toctree entry --- .../api/libtmux._experimental.chain._async.md | 13 ++ docs/experiment/index.md | 42 +++- pyproject.toml | 4 + src/libtmux/_experimental/chain/__init__.py | 10 +- src/libtmux/_experimental/chain/_async.py | 211 ++++++++++++++++++ .../_experimental/chain/_connection.py | 45 ++++ src/libtmux/pytest_plugin.py | 12 +- tests/_experimental/chain/test_async.py | 197 ++++++++++++++++ tests/test_pytest_plugin.py | 2 +- uv.lock | 29 ++- 10 files changed, 558 insertions(+), 7 deletions(-) create mode 100644 docs/experiment/api/libtmux._experimental.chain._async.md create mode 100644 src/libtmux/_experimental/chain/_async.py create mode 100644 tests/_experimental/chain/test_async.py diff --git a/docs/experiment/api/libtmux._experimental.chain._async.md b/docs/experiment/api/libtmux._experimental.chain._async.md new file mode 100644 index 000000000..ac4434548 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain._async.md @@ -0,0 +1,13 @@ +# Async facade - `libtmux._experimental.chain._async` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain._async + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md index aafb60129..1fe22908f 100644 --- a/docs/experiment/index.md +++ b/docs/experiment/index.md @@ -28,10 +28,15 @@ subprocess per command. It grows in layers: {class}`~libtmux._experimental.chain.plan.PaneQuery` resolves against a pure {class}`~libtmux._experimental.chain.plan.TmuxSnapshot`, maps each typed row to commands, and compiles to one sequence. +- **Async facade** -- {mod}`~libtmux._experimental.chain._async` wraps + the same engine so snapshot resolution and dispatch are awaitable, while + command construction stays sync and one plan still compiles to one dispatch. - **Adapters** -- the live-tmux bridge: {func}`~libtmux._experimental.chain._connection.snapshot_from_session` reads real panes, and {class}`~libtmux._experimental.chain._connection.SessionPlanExecutor` + (plus its async sibling + {class}`~libtmux._experimental.chain._connection.AsyncSessionPlanExecutor`) resolves and dispatches a plan against a real server in one invocation. ::::{grid} 1 2 2 2 @@ -49,10 +54,16 @@ Immutable argv primitives: `CommandCall`, `CommandChain`, `CommandSpec`. Typed target-safe queries that compile to one command sequence. ::: +:::{grid-item-card} Async facade +:link: api/libtmux._experimental.chain._async +:link-type: doc +Awaitable snapshot + dispatch over the same engine, one dispatch per plan. +::: + :::{grid-item-card} Live-tmux adapters :link: api/libtmux._experimental.chain._connection :link-type: doc -`snapshot_from_session` and `SessionPlanExecutor` for real dispatch. +`snapshot_from_session`, `SessionPlanExecutor`, `AsyncSessionPlanExecutor`. ::: :::: @@ -117,11 +128,40 @@ Against a live server, dispatch the same plan in one invocation with >>> live_plan.run(runner) ``` +The same plan can be authored and compiled asynchronously -- construction stays +sync, only snapshot resolution and dispatch await: + +```python +>>> import asyncio +>>> from libtmux._experimental.chain import aio +>>> from libtmux._experimental.chain.plan import ( +... PaneRef, +... PaneTarget, +... SessionTarget, +... TmuxSnapshot, +... WindowTarget, +... ) +>>> snapshot = TmuxSnapshot( +... panes=( +... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), +... pane_index=0, active=True, title="editor"), +... ), +... ) +>>> async def _resize() -> tuple[tuple[str, ...], ...]: +... plan = aio.panes().filter(active=True).commands( +... lambda pane: pane.cmd.resize_pane(height=20), +... ) +... return (await plan.to_chain(snapshot)).argvs() +>>> asyncio.run(_resize()) +(('resize-pane', '-t', '%1', '-y', '20'),) +``` + ```{toctree} :hidden: :maxdepth: 1 api/libtmux._experimental.chain.ir api/libtmux._experimental.chain.plan +api/libtmux._experimental.chain._async api/libtmux._experimental.chain._connection ``` diff --git a/pyproject.toml b/pyproject.toml index 2575789c4..c6110d266 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,6 +65,7 @@ dev = [ "pytest-mock", "pytest-watcher", "pytest-xdist", + "pytest-asyncio", # Coverage "codecov", "coverage", @@ -87,6 +88,7 @@ testing = [ "pytest-rerunfailures", "pytest-mock", "pytest-watcher", + "pytest-asyncio", ] coverage =[ "codecov", @@ -242,6 +244,8 @@ doctest_optionflags = [ "ELLIPSIS", "NORMALIZE_WHITESPACE" ] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" testpaths = [ "src/libtmux", "tests", diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index 4cd6eae86..a05af6c94 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -11,8 +11,12 @@ intermediate representation (``CommandCall``, ``CommandChain``). - :mod:`~libtmux._experimental.chain.plan` -- typed, target-safe deferred query-command plans (``panes()``, ``CommandPlan``). +- :mod:`~libtmux._experimental.chain._async` -- an async facade over + the same engine, exposed publicly as ``aio`` (``aio.panes()``), preserving + one dispatch per plan. - :mod:`~libtmux._experimental.chain._connection` -- live-tmux - connection helpers (``snapshot_from_session``, ``SessionPlanExecutor``). + connection helpers (``snapshot_from_session``, ``SessionPlanExecutor``, + ``AsyncSessionPlanExecutor``). Note ---- @@ -22,7 +26,9 @@ from __future__ import annotations +from libtmux._experimental.chain import _async as aio from libtmux._experimental.chain._connection import ( + AsyncSessionPlanExecutor, SessionPlanExecutor, snapshot_from_session, ) @@ -51,6 +57,7 @@ __all__ = [ "Arg", + "AsyncSessionPlanExecutor", "CommandCall", "CommandChain", "CommandPlan", @@ -68,6 +75,7 @@ "SessionTarget", "TmuxSnapshot", "WindowTarget", + "aio", "panes", "snapshot_from_session", ] diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py new file mode 100644 index 000000000..f6be2e44c --- /dev/null +++ b/src/libtmux/_experimental/chain/_async.py @@ -0,0 +1,211 @@ +r"""Asyncio facade over the deferred query-command plan. + +This is a thin wrapper over the sync +:mod:`~libtmux._experimental.chain.plan` engine: command +*construction* stays synchronous, and only snapshot resolution and command +dispatch become awaitable. A plan still compiles to exactly one +:class:`~libtmux._experimental.chain.ir.CommandChain`, so the +"one plan = one native ``\\;`` dispatch" guarantee is preserved -- it just runs +without blocking the event loop, and independent plans can resolve and dispatch +concurrently. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass + +from libtmux._experimental.chain import plan as sync_plan +from libtmux._experimental.chain.ir import ( + Arg, + CommandChain, + CommandResultLike, +) + +MappedT = t.TypeVar("MappedT") +ResultT = t.TypeVar("ResultT") + +NoCommandsResolved = sync_plan.NoCommandsResolved + + +class AsyncCommandRunner(t.Protocol): + """Object capable of asynchronously dispatching one tmux command argv.""" + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command asynchronously.""" + ... + + +class AsyncSnapshotProvider(t.Protocol): + """Object that can asynchronously provide a pure tmux snapshot.""" + + async def snapshot(self) -> sync_plan.TmuxSnapshot: + """Return a tmux snapshot asynchronously.""" + ... + + +class AsyncPlanRunner(AsyncCommandRunner, AsyncSnapshotProvider, t.Protocol): + """A runner that can asynchronously resolve snapshots and dispatch.""" + + +AsyncSnapshotSource: t.TypeAlias = "sync_plan.TmuxSnapshot | AsyncSnapshotProvider" + + +@dataclass(frozen=True, slots=True) +class PaneQuery: + """An async lazy pane query backed by the sync query object. + + Construction stays synchronous; only :meth:`all`/:meth:`first` await a + snapshot source. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import ( + ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, + ... ) + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... )) + >>> rows = asyncio.run(panes().filter(active=True).all(snapshot)) + >>> [row.pane_id.value for row in rows] + ['%1'] + """ + + query: sync_plan.PaneQuery + + def filter(self, *, active: bool) -> PaneQuery: + """Return a query filtered by active state.""" + return dataclasses.replace(self, query=self.query.filter(active=active)) + + def order_by(self, field: sync_plan.OrderField) -> PaneQuery: + """Return a query ordered by a known pane field.""" + return dataclasses.replace(self, query=self.query.order_by(field)) + + def limit(self, count: int) -> PaneQuery: + """Return a query capped to ``count`` rows.""" + return dataclasses.replace(self, query=self.query.limit(count)) + + async def all(self, source: AsyncSnapshotSource) -> list[sync_plan.PaneRef]: + """Evaluate the query against an async snapshot source.""" + snapshot = await _resolve_snapshot(source) + return self.query.all(snapshot) + + async def first(self, source: AsyncSnapshotSource) -> sync_plan.PaneRef | None: + """Evaluate the query and return its first row, or ``None``.""" + snapshot = await _resolve_snapshot(source) + return self.query.first(snapshot) + + def map( + self, + mapper: t.Callable[[sync_plan.PaneRef], MappedT], + ) -> MappedPaneQuery[MappedT]: + """Return a data-only transformation query (no commands).""" + return MappedPaneQuery(query=self, mapper=mapper) + + def commands(self, mapper: sync_plan.CommandMapper) -> CommandPlan[None]: + """Return a deferred async multi-command side-effect plan.""" + return CommandPlan(query=self, mapper=mapper) + + +@dataclass(frozen=True, slots=True) +class MappedPaneQuery(t.Generic[MappedT]): + """An async data-only query transformation over pane rows.""" + + query: PaneQuery + mapper: t.Callable[[sync_plan.PaneRef], MappedT] + + async def all(self, source: AsyncSnapshotSource) -> list[MappedT]: + """Evaluate the query and transform every row.""" + return [self.mapper(row) for row in await self.query.all(source)] + + async def first(self, source: AsyncSnapshotSource) -> MappedT | None: + """Evaluate the query and transform the first row, or ``None``.""" + row = await self.query.first(source) + if row is None: + return None + return self.mapper(row) + + +@dataclass(frozen=True, slots=True) +class CommandPlan(t.Generic[ResultT]): + """An async command plan that resolves a query into a command sequence. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import ( + ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, + ... ) + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=1, active=True, title="logs"), + ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... pane_index=0, active=True, title="editor"), + ... )) + >>> async def _demo(): + ... plan = ( + ... panes() + ... .filter(active=True) + ... .order_by("pane_index") + ... .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ... ) + ... sequence = await plan.to_chain(snapshot) + ... return sequence.argvs() + >>> asyncio.run(_demo()) + (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) + """ + + query: PaneQuery + mapper: sync_plan.CommandMapper + + async def to_chain(self, source: AsyncSnapshotSource) -> CommandChain: + """Resolve the async query and compile mapped commands. + + Reuses the sync compile path, so a plan still produces exactly one + :class:`~libtmux._experimental.chain.ir.CommandChain`. + """ + snapshot = await _resolve_snapshot(source) + return self.query.query.commands(self.mapper).to_chain(snapshot) + + async def run(self: CommandPlan[None], runner: AsyncPlanRunner) -> None: + """Resolve, compile, and dispatch the plan in one async invocation. + + An empty plan is a no-op (it does not raise). + """ + try: + sequence = await self.to_chain(runner) + except NoCommandsResolved: + return None + argv = sequence.argv() + await runner.cmd(argv[0], *argv[1:]) + return None + + +def panes() -> PaneQuery: + """Start an async lazy pane query. + + Examples + -------- + >>> panes().query + PaneQuery(active_filter=None, ordering=None, limit_count=None) + """ + return PaneQuery(sync_plan.panes()) + + +async def _resolve_snapshot(source: AsyncSnapshotSource) -> sync_plan.TmuxSnapshot: + if isinstance(source, sync_plan.TmuxSnapshot): + return source + return await source.snapshot() diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py index 0d7beddfc..eb89f3e1e 100644 --- a/src/libtmux/_experimental/chain/_connection.py +++ b/src/libtmux/_experimental/chain/_connection.py @@ -16,6 +16,7 @@ from __future__ import annotations +import asyncio import typing as t from libtmux._experimental.chain.ir import ( @@ -115,3 +116,47 @@ def cmd( def snapshot(self) -> TmuxSnapshot: """Return a fresh snapshot of the session's panes.""" return snapshot_from_session(self.session) + + +class AsyncSessionPlanExecutor: + """An ``AsyncPlanRunner`` backed by a live :class:`libtmux.Session`. + + libtmux dispatch is synchronous, so this offloads each blocking call to a + worker thread with :func:`asyncio.to_thread`. The plan still compiles to one + native sequence and dispatches once; it simply does not block the event + loop, so independent plans can resolve and dispatch concurrently. + + Examples + -------- + >>> import asyncio + >>> runner = AsyncSessionPlanExecutor(session) + >>> async def _demo() -> bool: + ... snapshot = await runner.snapshot() + ... return snapshot.panes[0].pane_id.value.startswith("%") + >>> asyncio.run(_demo()) + True + + A plan resolves and dispatches once, without blocking the loop: + + >>> from libtmux._experimental.chain import aio + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> asyncio.run(plan.run(runner)) + """ + + def __init__(self, session: Session) -> None: + self._sync = SessionPlanExecutor(session) + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command in a worker thread.""" + return await asyncio.to_thread(self._sync.cmd, cmd, *args, target=target) + + async def snapshot(self) -> TmuxSnapshot: + """Return a fresh snapshot, read in a worker thread.""" + return await asyncio.to_thread(self._sync.snapshot) diff --git a/src/libtmux/pytest_plugin.py b/src/libtmux/pytest_plugin.py index fcc3ce052..6726b3062 100644 --- a/src/libtmux/pytest_plugin.py +++ b/src/libtmux/pytest_plugin.py @@ -167,7 +167,9 @@ def server( >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) @@ -212,7 +214,9 @@ def session_params() -> dict[str, t.Any]: >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) @@ -246,7 +250,9 @@ def session( >>> pytester.makepyfile(**{'whatever.py': source}) PosixPath(...) - >>> result = pytester.runpytest('whatever.py', '--disable-warnings') + >>> result = pytester.runpytest( + ... 'whatever.py', '--disable-warnings', '-p', 'no:asyncio' + ... ) ===... >>> result.assert_outcomes(passed=1) diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py new file mode 100644 index 000000000..67215ba23 --- /dev/null +++ b/tests/_experimental/chain/test_async.py @@ -0,0 +1,197 @@ +"""Tests for the async facade over deferred command plans.""" + +from __future__ import annotations + +import asyncio +import typing as t +from dataclasses import dataclass, field + +import pytest +from typing_extensions import assert_type + +from libtmux._experimental.chain import _async as api, plan as sync_plan +from libtmux._experimental.chain._connection import AsyncSessionPlanExecutor +from libtmux._experimental.chain.ir import CommandChain + +if t.TYPE_CHECKING: + from libtmux._experimental.chain.ir import Arg + from libtmux.session import Session + + +@dataclass +class _FakeResult: + """Minimal async command result.""" + + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +@dataclass +class _AsyncFakeRunner: + """Async runner that exposes a snapshot and records dispatches.""" + + snapshot_value: sync_plan.TmuxSnapshot + calls: list[tuple[str, tuple[Arg, ...], str | int | None]] = field( + default_factory=list, + ) + snapshot_calls: int = 0 + + async def snapshot(self) -> sync_plan.TmuxSnapshot: + """Return the fixed tmux snapshot asynchronously.""" + await asyncio.sleep(0) + self.snapshot_calls += 1 + return self.snapshot_value + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> _FakeResult: + """Record one async command dispatch.""" + await asyncio.sleep(0) + self.calls.append((cmd, args, target)) + return _FakeResult(stdout=["async ok"]) + + +def _snapshot() -> sync_plan.TmuxSnapshot: + return sync_plan.TmuxSnapshot( + panes=( + sync_plan.PaneRef( + pane_id=sync_plan.PaneTarget("%2"), + window_id=sync_plan.WindowTarget("@1"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=2, + active=True, + title="shell", + ), + sync_plan.PaneRef( + pane_id=sync_plan.PaneTarget("%1"), + window_id=sync_plan.WindowTarget("@1"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=1, + active=True, + title="editor", + ), + sync_plan.PaneRef( + pane_id=sync_plan.PaneTarget("%3"), + window_id=sync_plan.WindowTarget("@2"), + session_id=sync_plan.SessionTarget("$0"), + pane_index=3, + active=False, + title="logs", + ), + ), + ) + + +async def test_async_to_chain_awaits_snapshot_without_dispatching() -> None: + """Async plan inspection preserves the pure command-assertion workflow.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands( + lambda pane: [ + pane.cmd.send_keys("clear", enter=True), + pane.window.select_layout("even-horizontal"), + ], + ) + ) + + sequence = await plan.to_chain(runner) + + assert_type(plan, api.CommandPlan[None]) + assert_type(sequence, CommandChain) + assert sequence.argvs() == ( + ("send-keys", "-t", "%1", "clear", "Enter"), + ("select-layout", "-t", "@1", "even-horizontal"), + ("send-keys", "-t", "%2", "clear", "Enter"), + ("select-layout", "-t", "@1", "even-horizontal"), + ) + assert runner.snapshot_calls == 1 + assert runner.calls == [] + + +async def test_async_run_dispatches_one_native_tmux_sequence() -> None: + """Async execution still batches concrete commands into one tmux call.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .order_by("pane_index") + .commands(lambda pane: pane.cmd.resize_pane(height=20)) + ) + + await plan.run(runner) + + assert runner.calls == [ + ( + "resize-pane", + ( + "-t", + "%1", + "-y", + "20", + ";", + "resize-pane", + "-t", + "%2", + "-y", + "20", + ), + None, + ), + ] + + +async def test_async_map_and_first_are_data_only() -> None: + """Async row transforms stay separate from command construction.""" + runner = _AsyncFakeRunner(_snapshot()) + query = api.panes().filter(active=True).order_by("pane_index") + + titles = await query.map(lambda pane: pane.title).all(runner) + first = await query.first(runner) + + assert_type(titles, list[str]) + assert titles == ["editor", "shell"] + assert first is not None + assert first.pane_id.value == "%1" + assert runner.calls == [] + + +async def test_async_empty_plan_to_chain_raises_but_run_is_noop() -> None: + """Async empty plans match the sync no-op execution behavior.""" + runner = _AsyncFakeRunner(sync_plan.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=20)) + + with pytest.raises(api.NoCommandsResolved): + await plan.to_chain(runner) + + await plan.run(runner) + assert runner.calls == [] + + +async def test_async_session_plan_runner_dispatches_against_live_tmux( + session: Session, +) -> None: + """The async live adapter resolves and dispatches against a real server.""" + session.active_window.split() + runner = AsyncSessionPlanExecutor(session) + + snapshot = await runner.snapshot() + assert len(snapshot.panes) >= 2 + + plan = api.panes().commands( + lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ) + sequence = await plan.to_chain(runner) + + # Every active and inactive pane in the snapshot is targeted. + targeted = {argv[2] for argv in sequence.argvs()} + assert targeted == {pane.pane_id.value for pane in snapshot.panes} + + # Dispatches once through the worker thread without raising. + await plan.run(runner) diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index 23acbcfe9..ce503cc8a 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -76,7 +76,7 @@ def test_repo_git_remote_checkout( ) # Test - result = pytester.runpytest(str(first_test_filename)) + result = pytester.runpytest(str(first_test_filename), "-p", "no:asyncio") result.assert_outcomes(passed=1) diff --git a/uv.lock b/uv.lock index 96f2ff9d7..39bd2f578 100644 --- a/uv.lock +++ b/uv.lock @@ -114,6 +114,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845, upload-time = "2026-02-01T12:30:53.445Z" }, ] +[[package]] +name = "backports-asyncio-runner" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/ff/70dca7d7cb1cbc0edb2c6cc0c38b65cba36cccc491eca64cabd5fe7f8670/backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162", size = 69893, upload-time = "2025-07-02T02:27:15.685Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" }, +] + [[package]] name = "beautifulsoup4" version = "4.15.0" @@ -407,7 +416,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -634,6 +643,7 @@ dev = [ { name = "gp-sphinx" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -662,6 +672,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -683,6 +694,7 @@ dev = [ { name = "gp-sphinx", specifier = "==0.0.1a31" }, { name = "mypy" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, @@ -709,6 +721,7 @@ lint = [ testing = [ { name = "gp-libs" }, { name = "pytest" }, + { name = "pytest-asyncio" }, { name = "pytest-mock" }, { name = "pytest-rerunfailures" }, { name = "pytest-watcher" }, @@ -1028,6 +1041,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, ] +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" }, + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + [[package]] name = "pytest-cov" version = "7.1.0" From f1c73f0429ea08b9982b4997335c5db256ff9010 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 13:12:15 -0500 Subject: [PATCH 04/42] Experimental(feat[chainable-commands]): add chainability contract why: A tmux command sequence is dispatched once, so a command may only fold into a chain when its output is not consumed mid-chain. Wire the static and dynamic halves of that rule together so a chain compiler has one place to decide what may merge. what: - Add chainability.py: COMMAND_SPECS registry + is_chainable() (static half, via CommandSpec.chainable); DeferredCommandResult raising DeferredOutputUnavailable on output access (dynamic half); ChainabilityError for non-chainable commands - Add tests/_experimental/test_chainability.py covering the static flags and deferred output rejection - Export the chainability surface from the package; add the chainability autodoc page, grid card, and toctree entry --- .../api/libtmux._experimental.chain.chain.md | 13 +++ docs/experiment/index.md | 12 ++ src/libtmux/_experimental/chain/__init__.py | 13 +++ src/libtmux/_experimental/chain/chain.py | 110 ++++++++++++++++++ tests/_experimental/chain/test_async.py | 2 +- tests/_experimental/chain/test_chain.py | 35 ++++++ 6 files changed, 184 insertions(+), 1 deletion(-) create mode 100644 docs/experiment/api/libtmux._experimental.chain.chain.md create mode 100644 src/libtmux/_experimental/chain/chain.py create mode 100644 tests/_experimental/chain/test_chain.py diff --git a/docs/experiment/api/libtmux._experimental.chain.chain.md b/docs/experiment/api/libtmux._experimental.chain.chain.md new file mode 100644 index 000000000..308abb56a --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.chain.md @@ -0,0 +1,13 @@ +# Chain - `libtmux._experimental.chain.chain` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.chain + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md index 1fe22908f..f89e3b05a 100644 --- a/docs/experiment/index.md +++ b/docs/experiment/index.md @@ -31,6 +31,11 @@ subprocess per command. It grows in layers: - **Async facade** -- {mod}`~libtmux._experimental.chain._async` wraps the same engine so snapshot resolution and dispatch are awaitable, while command construction stays sync and one plan still compiles to one dispatch. +- **Chainability contract** -- + {mod}`~libtmux._experimental.chain.chain` decides which commands + may fold into one dispatch: the static + {attr}`~libtmux._experimental.chain.ir.CommandSpec.chainable` + flag plus a deferred result that refuses output read before the chain runs. - **Adapters** -- the live-tmux bridge: {func}`~libtmux._experimental.chain._connection.snapshot_from_session` reads real panes, and @@ -66,6 +71,12 @@ Awaitable snapshot + dispatch over the same engine, one dispatch per plan. `snapshot_from_session`, `SessionPlanExecutor`, `AsyncSessionPlanExecutor`. ::: +:::{grid-item-card} Chainability contract +:link: api/libtmux._experimental.chain.chain +:link-type: doc +What may fold into one dispatch: `chainable` specs + deferred results. +::: + :::: ## At a glance @@ -164,4 +175,5 @@ api/libtmux._experimental.chain.ir api/libtmux._experimental.chain.plan api/libtmux._experimental.chain._async api/libtmux._experimental.chain._connection +api/libtmux._experimental.chain.chain ``` diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index a05af6c94..0f0318055 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -17,6 +17,9 @@ - :mod:`~libtmux._experimental.chain._connection` -- live-tmux connection helpers (``snapshot_from_session``, ``SessionPlanExecutor``, ``AsyncSessionPlanExecutor``). +- :mod:`~libtmux._experimental.chain.chain` -- the chainability + contract that decides which commands may fold into one dispatch + (``CommandSpec.chainable`` + ``DeferredCommandResult``). Note ---- @@ -32,6 +35,12 @@ SessionPlanExecutor, snapshot_from_session, ) +from libtmux._experimental.chain.chain import ( + ChainabilityError, + DeferredCommandResult, + DeferredOutputUnavailable, + is_chainable, +) from libtmux._experimental.chain.ir import ( Arg, CommandCall, @@ -58,6 +67,7 @@ __all__ = [ "Arg", "AsyncSessionPlanExecutor", + "ChainabilityError", "CommandCall", "CommandChain", "CommandPlan", @@ -66,6 +76,8 @@ "CommandScope", "CommandSpec", "CommandValue", + "DeferredCommandResult", + "DeferredOutputUnavailable", "NoCommandsResolved", "PaneQuery", "PaneRef", @@ -76,6 +88,7 @@ "TmuxSnapshot", "WindowTarget", "aio", + "is_chainable", "panes", "snapshot_from_session", ] diff --git a/src/libtmux/_experimental/chain/chain.py b/src/libtmux/_experimental/chain/chain.py new file mode 100644 index 000000000..078f9e222 --- /dev/null +++ b/src/libtmux/_experimental/chain/chain.py @@ -0,0 +1,110 @@ +"""The chainability contract: what may fold into one dispatch. + +A tmux command sequence is dispatched once, so a command may only join a chain +if its output is **not** consumed mid-chain. This module wires the two halves of +that rule together: + +- *static* -- a :class:`~libtmux._experimental.chain.ir.CommandSpec` + per command declares ``chainable`` (see :data:`COMMAND_SPECS` / + :func:`is_chainable`). Output commands such as ``show-option`` are + ``chainable=False``. +- *dynamic* -- :class:`DeferredCommandResult` stands in for a not-yet-dispatched + call and raises :class:`DeferredOutputUnavailable` if its output is read before + the chain runs. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandSpec, +) + +COMMAND_SPECS: dict[str, CommandSpec] = { + "new-window": CommandSpec("new-window", "session"), + "split-window": CommandSpec("split-window", "pane"), + "rename-window": CommandSpec("rename-window", "window"), + "select-layout": CommandSpec("select-layout", "window"), + "send-keys": CommandSpec("send-keys", "pane"), + "resize-pane": CommandSpec("resize-pane", "pane"), + "set-option": CommandSpec("set-option", "server"), + # Output commands cannot fold into a chain -- they need stdout immediately. + "show-option": CommandSpec("show-option", "server", chainable=False), + "capture-pane": CommandSpec("capture-pane", "pane", chainable=False), + "display-message": CommandSpec("display-message", "server", chainable=False), +} +"""Known command metadata, including each command's ``chainable`` flag.""" + + +class DeferredOutputUnavailable(RuntimeError): + """Raised when a deferred command result is inspected before dispatch.""" + + +class ChainabilityError(RuntimeError): + """Raised when a non-chainable command is added to a chain.""" + + +def is_chainable(name: str) -> bool: + """Return whether a command may fold into a one-dispatch chain. + + Unknown commands are treated as chainable; commands in :data:`COMMAND_SPECS` + use their declared ``chainable`` flag. + + Examples + -------- + >>> is_chainable("rename-window") + True + >>> is_chainable("show-option") + False + >>> is_chainable("some-unknown-command") + True + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + return True + return spec.chainable + + +@dataclass(frozen=True, slots=True) +class DeferredCommandResult: + """A placeholder result for a call that has not been dispatched yet. + + Reading output before the chain runs is a programming error: the value does + not exist yet. + + Examples + -------- + >>> result = DeferredCommandResult(CommandCall("rename-window", ("work",))) + >>> try: + ... result.stdout + ... except DeferredOutputUnavailable: + ... print("unavailable until the chain runs") + unavailable until the chain runs + """ + + call: CommandCall + + @property + def stdout(self) -> list[str]: + """Reject immediate stdout access.""" + msg = "deferred command output is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + + @property + def stderr(self) -> list[str]: + """Reject immediate stderr access.""" + msg = "deferred command errors are unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + + @property + def returncode(self) -> int: + """Reject immediate return-code access.""" + msg = "deferred command status is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py index 67215ba23..646b36684 100644 --- a/tests/_experimental/chain/test_async.py +++ b/tests/_experimental/chain/test_async.py @@ -116,7 +116,7 @@ async def test_async_to_chain_awaits_snapshot_without_dispatching() -> None: async def test_async_run_dispatches_one_native_tmux_sequence() -> None: - """Async execution still batches concrete commands into one tmux call.""" + """Async execution still chains concrete commands into one tmux call.""" runner = _AsyncFakeRunner(_snapshot()) plan = ( api.panes() diff --git a/tests/_experimental/chain/test_chain.py b/tests/_experimental/chain/test_chain.py new file mode 100644 index 000000000..d0b38d69e --- /dev/null +++ b/tests/_experimental/chain/test_chain.py @@ -0,0 +1,35 @@ +"""Tests for the chainability contract.""" + +from __future__ import annotations + +import pytest + +from libtmux._experimental.chain.chain import ( + DeferredCommandResult, + DeferredOutputUnavailable, + is_chainable, +) +from libtmux._experimental.chain.ir import CommandCall + + +def test_is_chainable_uses_static_spec() -> None: + """The static ``chainable`` flag decides what may fold into a chain.""" + assert is_chainable("rename-window") is True + assert is_chainable("select-layout") is True + # Output commands cannot join a one-dispatch chain. + assert is_chainable("show-option") is False + assert is_chainable("capture-pane") is False + # Unknown commands are treated as chainable. + assert is_chainable("some-unknown-command") is True + + +def test_deferred_result_rejects_output_access() -> None: + """A deferred result has no output until the chain runs.""" + result = DeferredCommandResult(CommandCall("rename-window", ("work",))) + + with pytest.raises(DeferredOutputUnavailable): + _ = result.stdout + with pytest.raises(DeferredOutputUnavailable): + _ = result.stderr + with pytest.raises(DeferredOutputUnavailable): + _ = result.returncode From 2edf54094c8726718b84441f69a13d9a20727061 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 13:18:10 -0500 Subject: [PATCH 05/42] Experimental(docs[chainable-commands]): polish landing copy and section names why: Use plain libtmux/Python vernacular for the experimental docs so the layers read clearly to newcomers, while keeping every link, example, and toctree entry. what: - Rename the five sections: Command IR -> Intermediate representation, Deferred plan -> Expressions, Async facade -> Async, Live-tmux adapters -> Connecting to live tmux sessions, Chainability contract -> Chainability - Reorder the layer bullets and grid cards to match the toctree; tighten the landing prose and adopt "expression" vocabulary in the worked examples - Update each api page heading to the new section name --- .../api/libtmux._experimental.chain._async.md | 2 +- ...libtmux._experimental.chain._connection.md | 2 +- .../api/libtmux._experimental.chain.ir.md | 2 +- .../api/libtmux._experimental.chain.plan.md | 2 +- docs/experiment/index.md | 85 ++++++++++--------- 5 files changed, 49 insertions(+), 44 deletions(-) diff --git a/docs/experiment/api/libtmux._experimental.chain._async.md b/docs/experiment/api/libtmux._experimental.chain._async.md index ac4434548..6cbda1af6 100644 --- a/docs/experiment/api/libtmux._experimental.chain._async.md +++ b/docs/experiment/api/libtmux._experimental.chain._async.md @@ -1,4 +1,4 @@ -# Async facade - `libtmux._experimental.chain._async` +# Async - `libtmux._experimental.chain._async` :::{warning} Experimental. This API is **not** covered by version policies and can break or diff --git a/docs/experiment/api/libtmux._experimental.chain._connection.md b/docs/experiment/api/libtmux._experimental.chain._connection.md index 6f291f668..07bfe4531 100644 --- a/docs/experiment/api/libtmux._experimental.chain._connection.md +++ b/docs/experiment/api/libtmux._experimental.chain._connection.md @@ -1,4 +1,4 @@ -# Live-tmux adapters - `libtmux._experimental.chain._connection` +# Connecting to live tmux sessions - `libtmux._experimental.chain._connection` :::{warning} Experimental. This API is **not** covered by version policies and can break or diff --git a/docs/experiment/api/libtmux._experimental.chain.ir.md b/docs/experiment/api/libtmux._experimental.chain.ir.md index 4aedf07b4..7d11ee5ee 100644 --- a/docs/experiment/api/libtmux._experimental.chain.ir.md +++ b/docs/experiment/api/libtmux._experimental.chain.ir.md @@ -1,4 +1,4 @@ -# Command IR - `libtmux._experimental.chain.ir` +# Intermediate representation - `libtmux._experimental.chain.ir` :::{warning} Experimental. This API is **not** covered by version policies and can break or diff --git a/docs/experiment/api/libtmux._experimental.chain.plan.md b/docs/experiment/api/libtmux._experimental.chain.plan.md index b518fb157..61bc53884 100644 --- a/docs/experiment/api/libtmux._experimental.chain.plan.md +++ b/docs/experiment/api/libtmux._experimental.chain.plan.md @@ -1,4 +1,4 @@ -# Deferred plan - `libtmux._experimental.chain.plan` +# Expressions - `libtmux._experimental.chain.plan` :::{warning} Experimental. This API is **not** covered by version policies and can break or diff --git a/docs/experiment/index.md b/docs/experiment/index.md index f89e3b05a..15e898a34 100644 --- a/docs/experiment/index.md +++ b/docs/experiment/index.md @@ -14,67 +14,72 @@ please [file an issue](https://github.com/tmux-python/libtmux/issues). ## Chainable commands -The `libtmux._experimental.chain` package lets you author an ordered -sequence of typed tmux commands that compiles to **one** native -`tmux ... \; ...` invocation and dispatches once -- instead of issuing one -subprocess per command. It grows in layers: - -- **IR** -- the immutable argv intermediate representation: a - {class}`~libtmux._experimental.chain.ir.CommandCall` is one typed - command; a {class}`~libtmux._experimental.chain.ir.CommandChain` - is an ordered group that renders to a single argv with standalone `;` - separators and dispatches once. -- **Plan** -- a typed, target-safe deferred query: a lazy +`libtmux._experimental.chain` lets you build an ordered sequence of +typed tmux commands that runs as **one** native `tmux ... \; ...` invocation, +instead of one subprocess per command. The pieces layer up, so you can reach for +as much or as little as you need: + +- **Intermediate representation** -- the typed argv layer beneath everything: a + {class}`~libtmux._experimental.chain.ir.CommandCall` is a single + command, and a + {class}`~libtmux._experimental.chain.ir.CommandChain` is an + ordered group that renders to one argv (with standalone `;` separators) and + dispatches once. +- **Expressions** -- compose commands from a lazy, target-safe pane query. A {class}`~libtmux._experimental.chain.plan.PaneQuery` resolves - against a pure {class}`~libtmux._experimental.chain.plan.TmuxSnapshot`, - maps each typed row to commands, and compiles to one sequence. -- **Async facade** -- {mod}`~libtmux._experimental.chain._async` wraps - the same engine so snapshot resolution and dispatch are awaitable, while - command construction stays sync and one plan still compiles to one dispatch. -- **Chainability contract** -- - {mod}`~libtmux._experimental.chain.chain` decides which commands - may fold into one dispatch: the static - {attr}`~libtmux._experimental.chain.ir.CommandSpec.chainable` - flag plus a deferred result that refuses output read before the chain runs. -- **Adapters** -- the live-tmux bridge: + against a pure + {class}`~libtmux._experimental.chain.plan.TmuxSnapshot`, maps each + typed row to commands, and compiles to one sequence -- so you can build and + assert the result without touching tmux. +- **Async** -- {mod}`~libtmux._experimental.chain._async` mirrors the + same query and dispatch API with `await`, while command construction stays + synchronous and one expression still compiles to one invocation. +- **Connecting to live tmux sessions** -- the bridge to a real server: {func}`~libtmux._experimental.chain._connection.snapshot_from_session` - reads real panes, and + reads live panes, and {class}`~libtmux._experimental.chain._connection.SessionPlanExecutor` - (plus its async sibling + (with its async counterpart {class}`~libtmux._experimental.chain._connection.AsyncSessionPlanExecutor`) - resolves and dispatches a plan against a real server in one invocation. + resolves and runs an expression against a live {class}`~libtmux.Session` in one + invocation. +- **Chainability** -- + {mod}`~libtmux._experimental.chain.chain` decides which commands + may share one invocation: the static + {attr}`~libtmux._experimental.chain.ir.CommandSpec.chainable` + flag, plus a deferred result that won't hand back output until the chain has + run. ::::{grid} 1 2 2 2 :gutter: 2 2 3 3 -:::{grid-item-card} Command IR +:::{grid-item-card} Intermediate representation :link: api/libtmux._experimental.chain.ir :link-type: doc -Immutable argv primitives: `CommandCall`, `CommandChain`, `CommandSpec`. +The typed argv layer: `CommandCall`, `CommandChain`, `CommandSpec`. ::: -:::{grid-item-card} Deferred plan +:::{grid-item-card} Expressions :link: api/libtmux._experimental.chain.plan :link-type: doc -Typed target-safe queries that compile to one command sequence. +Build commands from a lazy, target-safe pane query. ::: -:::{grid-item-card} Async facade +:::{grid-item-card} Async :link: api/libtmux._experimental.chain._async :link-type: doc -Awaitable snapshot + dispatch over the same engine, one dispatch per plan. +The same query and dispatch API, with `await`. ::: -:::{grid-item-card} Live-tmux adapters +:::{grid-item-card} Connecting to live tmux sessions :link: api/libtmux._experimental.chain._connection :link-type: doc -`snapshot_from_session`, `SessionPlanExecutor`, `AsyncSessionPlanExecutor`. +Read live panes and run an expression against a real session. ::: -:::{grid-item-card} Chainability contract +:::{grid-item-card} Chainability :link: api/libtmux._experimental.chain.chain :link-type: doc -What may fold into one dispatch: `chainable` specs + deferred results. +Which commands may share one invocation. ::: :::: @@ -97,8 +102,8 @@ Compose typed calls and dispatch them as one tmux invocation: ['2'] ``` -Resolve a typed query against a snapshot and compile it to one sequence -- pure, -no tmux required: +Build an expression from a query and compile it to one sequence -- pure, no tmux +required: ```python >>> from libtmux._experimental.chain.plan import ( @@ -127,7 +132,7 @@ no tmux required: (('resize-pane', '-t', '%1', '-y', '20'), ('resize-pane', '-t', '%2', '-y', '20')) ``` -Against a live server, dispatch the same plan in one invocation with +Against a live server, run the same expression in one invocation with {class}`~libtmux._experimental.chain._connection.SessionPlanExecutor`: ```python @@ -139,8 +144,8 @@ Against a live server, dispatch the same plan in one invocation with >>> live_plan.run(runner) ``` -The same plan can be authored and compiled asynchronously -- construction stays -sync, only snapshot resolution and dispatch await: +The same expression can be built and compiled asynchronously -- construction +stays synchronous; only resolution and dispatch await: ```python >>> import asyncio From 3ec018f5f4232d74d8b87dd7f3b07e32a429a9aa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 15:35:11 -0500 Subject: [PATCH 06/42] Experimental(test[chainable-commands]): cover minimal-install import why: Minimal installs should import the experimental chain package without the dev or testing dependency groups present. what: - Add a python -S subprocess import of libtmux._experimental.chain to test_chainability.py, asserting the package loads with only stdlib --- tests/_experimental/chain/test_chain.py | 45 +++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/tests/_experimental/chain/test_chain.py b/tests/_experimental/chain/test_chain.py index d0b38d69e..4dd27c038 100644 --- a/tests/_experimental/chain/test_chain.py +++ b/tests/_experimental/chain/test_chain.py @@ -2,6 +2,12 @@ from __future__ import annotations +import os +import pathlib +import subprocess +import sys +import typing as t + import pytest from libtmux._experimental.chain.chain import ( @@ -12,6 +18,45 @@ from libtmux._experimental.chain.ir import CommandCall +class MinimalImportCase(t.NamedTuple): + """A module import that must work without optional dependency groups.""" + + test_id: str + module: str + + +MINIMAL_IMPORT_CASES = ( + MinimalImportCase( + test_id="chain-package", + module="libtmux._experimental.chain", + ), +) + + +@pytest.mark.parametrize( + "case", + MINIMAL_IMPORT_CASES, + ids=[case.test_id for case in MINIMAL_IMPORT_CASES], +) +def test_minimal_import_without_dev_dependency_groups( + case: MinimalImportCase, +) -> None: + """The experimental chain package imports with only stdlib dependencies.""" + project_root = pathlib.Path(__file__).parents[3] + env = os.environ.copy() + env["PYTHONPATH"] = str(project_root / "src") + + proc = subprocess.run( + [sys.executable, "-S", "-c", f"import {case.module}"], + check=False, + capture_output=True, + env=env, + text=True, + ) + + assert proc.returncode == 0, proc.stderr + + def test_is_chainable_uses_static_spec() -> None: """The static ``chainable`` flag decides what may fold into a chain.""" assert is_chainable("rename-window") is True From 70b8a3057a1cb2d6f980f27adc80eedc3efb6379 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 14 Jun 2026 15:39:33 -0500 Subject: [PATCH 07/42] Experimental(docs[chainable-commands]): Clarify sequence runner boundary why: Object-level cmd wrappers add target context, so only Server should be documented as directly safe for raw command sequences. what: - Limit CommandRunner direct-dispatch guidance to Server - Point object-level usage to session.server or SessionPlanExecutor --- src/libtmux/_experimental/chain/ir.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py index 143880a05..1694c5301 100644 --- a/src/libtmux/_experimental/chain/ir.py +++ b/src/libtmux/_experimental/chain/ir.py @@ -39,10 +39,12 @@ class CommandResultLike(t.Protocol): class CommandRunner(t.Protocol): """Object capable of dispatching one tmux command argv. - A live :class:`libtmux.Server` (and :class:`libtmux.Session`, - :class:`libtmux.Window`, :class:`libtmux.Pane`) already matches this - protocol via its ``cmd()`` method, so sequences can be dispatched without - any adapter for the common case. + A live :class:`libtmux.Server` already matches this protocol via its + ``cmd()`` method, so sequences can be dispatched without an adapter for the + common case. Object-level ``cmd()`` wrappers such as ``Session.cmd()`` add + their own target context; use ``session.server`` or + :class:`~libtmux._experimental.chain._connection.SessionPlanExecutor` for + composed command sequences. """ def cmd( From 6ef26159cd1ad00a6bcea499006db71c7964cc48 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Tue, 16 Jun 2026 17:08:50 -0500 Subject: [PATCH 08/42] Pane,Session(fix[targets]): Scope window targets to owning session why: Bare tmux window targets resolve against the server's current session, which can differ from the Session or Pane object issuing the command. what: - Target break-pane destination windows at the source pane's session - Prefix bare Session.kill_window names and indexes with the owning session id - Preserve explicit window ids and fully qualified tmux targets --- src/libtmux/pane.py | 2 ++ src/libtmux/session.py | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/libtmux/pane.py b/src/libtmux/pane.py index e5833111f..3531d2a81 100644 --- a/src/libtmux/pane.py +++ b/src/libtmux/pane.py @@ -2078,6 +2078,8 @@ def break_pane( tmux_args += ("-n", window_name) tmux_args += ("-s", str(self.pane_id)) + if self.session_id is not None: + tmux_args += ("-t", f"{self.session_id}:") # Use server.cmd to avoid auto-adding -t from self.cmd proc = self.server.cmd("break-pane", *tmux_args) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index ff9a851e7..9640d7fc5 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -864,9 +864,11 @@ def kill_window(self, target_window: str | int | None = None) -> None: target: str | int | None = target_window if target_window is not None: if isinstance(target_window, int): - target = f"{self.session_name}:{target_window}" + target = f"{self.session_id}:{target_window}" + elif target_window.startswith("@") or ":" in target_window: + target = target_window else: - target = f"{target_window}" + target = f"{self.session_id}:{target_window}" proc = self.cmd("kill-window", target=target) From d5d571be132ddd809e3b8b5051092f2168b59584 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 19:58:10 -0500 Subject: [PATCH 09/42] Experimental(refactor[chain]): drop vestigial CommandPlan ResultT generic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: CommandPlan is only ever constructed as CommandPlan[None] — the ResultT type parameter advertised a result abstraction that does not exist. what: - Remove Generic[ResultT] from the sync and async CommandPlan; flatten to a plain CommandPlan - Drop the now-unused ResultT TypeVar from plan.py and _async.py - Point the assert_type call sites at the non-generic CommandPlan --- src/libtmux/_experimental/chain/_async.py | 7 +++---- src/libtmux/_experimental/chain/plan.py | 7 +++---- tests/_experimental/chain/test_async.py | 2 +- tests/_experimental/chain/test_plan.py | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py index f6be2e44c..0f73a0514 100644 --- a/src/libtmux/_experimental/chain/_async.py +++ b/src/libtmux/_experimental/chain/_async.py @@ -29,7 +29,6 @@ ) MappedT = t.TypeVar("MappedT") -ResultT = t.TypeVar("ResultT") NoCommandsResolved = sync_plan.NoCommandsResolved @@ -115,7 +114,7 @@ def map( """Return a data-only transformation query (no commands).""" return MappedPaneQuery(query=self, mapper=mapper) - def commands(self, mapper: sync_plan.CommandMapper) -> CommandPlan[None]: + def commands(self, mapper: sync_plan.CommandMapper) -> CommandPlan: """Return a deferred async multi-command side-effect plan.""" return CommandPlan(query=self, mapper=mapper) @@ -140,7 +139,7 @@ async def first(self, source: AsyncSnapshotSource) -> MappedT | None: @dataclass(frozen=True, slots=True) -class CommandPlan(t.Generic[ResultT]): +class CommandPlan: """An async command plan that resolves a query into a command sequence. Examples @@ -180,7 +179,7 @@ async def to_chain(self, source: AsyncSnapshotSource) -> CommandChain: snapshot = await _resolve_snapshot(source) return self.query.query.commands(self.mapper).to_chain(snapshot) - async def run(self: CommandPlan[None], runner: AsyncPlanRunner) -> None: + async def run(self, runner: AsyncPlanRunner) -> None: """Resolve, compile, and dispatch the plan in one async invocation. An empty plan is a no-op (it does not raise). diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index 354206159..c16a9b37a 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -35,7 +35,6 @@ OrderField: t.TypeAlias = t.Literal["pane_id", "pane_index", "title"] """A :class:`PaneRef` field a query may order by.""" -ResultT = t.TypeVar("ResultT") MappedT = t.TypeVar("MappedT") @@ -410,7 +409,7 @@ def map( """Return a data-only transformation query (no commands).""" return MappedPaneQuery(query=self, mapper=mapper) - def commands(self, mapper: CommandMapper) -> CommandPlan[None]: + def commands(self, mapper: CommandMapper) -> CommandPlan: """Return a deferred plan where each row maps to one or more commands. Examples @@ -476,7 +475,7 @@ class _CommandPlanNode: @dataclass(frozen=True, slots=True) -class CommandPlan(t.Generic[ResultT]): +class CommandPlan: """A lazy command plan that resolves a query into a command sequence. Examples @@ -526,7 +525,7 @@ def to_chain(self, source: SnapshotSource) -> CommandChain: raise NoCommandsResolved(msg) return CommandChain(tuple(calls)) - def run(self: CommandPlan[None], runner: PlanRunner) -> None: + def run(self, runner: PlanRunner) -> None: """Resolve, compile, and dispatch the plan in one tmux invocation. An empty plan is a no-op (it does not raise), mirroring libtmux's diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py index 646b36684..b5f966de9 100644 --- a/tests/_experimental/chain/test_async.py +++ b/tests/_experimental/chain/test_async.py @@ -103,7 +103,7 @@ async def test_async_to_chain_awaits_snapshot_without_dispatching() -> None: sequence = await plan.to_chain(runner) - assert_type(plan, api.CommandPlan[None]) + assert_type(plan, api.CommandPlan) assert_type(sequence, CommandChain) assert sequence.argvs() == ( ("send-keys", "-t", "%1", "clear", "Enter"), diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index 652056fd8..8a2ea15b6 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -103,7 +103,7 @@ def mapper(pane: api.PaneRef) -> api.CommandValue: plan = api.panes().filter(active=True).commands(mapper) - assert_type(plan, api.CommandPlan[None]) + assert_type(plan, api.CommandPlan) assert mapper_calls == [] sequence = plan.to_chain(_snapshot()) From 47e754fef1d0343e3d093a1292e130dc9f8b87b0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:00:52 -0500 Subject: [PATCH 10/42] Experimental(fix[chain]): fail closed on empty tmux targets why: An empty `-t ''` resolves to tmux's current/attached target, silently defeating the typed-target guarantee. The snapshot adapter coalesced a missing window/session id to "" and CommandCall accepted "" as a target, so a hand-built PaneRef/TmuxSnapshot could emit a mis-resolving command. what: - Reject an empty-string target in CommandCall.__post_init__ (None and integer targets such as 0 stay valid) - Skip panes missing a window/session id in snapshot_from_session rather than coalescing with `or ""` - Add a CommandCall empty-target rejection test --- .../_experimental/chain/_connection.py | 11 +++++++--- src/libtmux/_experimental/chain/ir.py | 20 +++++++++++++++++++ tests/_experimental/chain/test_ir.py | 14 +++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py index eb89f3e1e..7ed7c51fe 100644 --- a/src/libtmux/_experimental/chain/_connection.py +++ b/src/libtmux/_experimental/chain/_connection.py @@ -60,13 +60,18 @@ def snapshot_from_session(session: Session) -> TmuxSnapshot: rows: list[PaneRef] = [] for pane in session.panes: pane_id = pane.pane_id - if pane_id is None: + window_id = pane.window_id + session_id = pane.session_id + # Fail closed: a missing id would render an empty ``-t ''`` target, + # which tmux resolves to the current/attached target. Skip the row + # rather than emit a target that silently mis-resolves. + if pane_id is None or window_id is None or session_id is None: continue rows.append( PaneRef( pane_id=PaneTarget(pane_id), - window_id=WindowTarget(pane.window_id or ""), - session_id=SessionTarget(pane.session_id or ""), + window_id=WindowTarget(window_id), + session_id=SessionTarget(session_id), pane_index=int(pane.pane_index) if pane.pane_index is not None else 0, active=pane.pane_active == "1", title=pane.pane_title or "", diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py index 1694c5301..41588074f 100644 --- a/src/libtmux/_experimental/chain/ir.py +++ b/src/libtmux/_experimental/chain/ir.py @@ -114,6 +114,26 @@ class CommandCall: args: tuple[Arg, ...] = () target: str | int | None = None + def __post_init__(self) -> None: + """Reject an empty-string target (fail closed). + + An empty ``-t ''`` resolves to tmux's *current/attached* target, which + silently defeats the typed-target guarantee. ``None`` means "no target" + and is allowed; an integer target such as ``0`` is allowed. + + Examples + -------- + >>> CommandCall("kill-window", target="") + Traceback (most recent call last): + ... + ValueError: CommandCall target must be a non-empty string or None + >>> CommandCall("select-window", target=0).argv() + ('select-window', '-t', '0') + """ + if self.target == "": + msg = "CommandCall target must be a non-empty string or None" + raise ValueError(msg) + def argv(self) -> tuple[str, ...]: """Render this call as tmux argv tokens. diff --git a/tests/_experimental/chain/test_ir.py b/tests/_experimental/chain/test_ir.py index f1aa20b24..c878b5492 100644 --- a/tests/_experimental/chain/test_ir.py +++ b/tests/_experimental/chain/test_ir.py @@ -78,6 +78,20 @@ def test_command_call_renders_integer_arguments() -> None: assert call.argv() == ("resize-pane", "-t", "%1", "-y", "20") +def test_command_call_rejects_empty_string_target() -> None: + """An empty-string target is rejected; ``None`` and ints are allowed.""" + with pytest.raises(ValueError, match="non-empty string or None"): + CommandCall("kill-window", target="") + + # None (no target) and integer targets remain valid. + assert CommandCall("list-panes").argv() == ("list-panes",) + assert CommandCall("select-window", target=0).argv() == ( + "select-window", + "-t", + "0", + ) + + def test_command_sequence_renders_tmux_semicolon_sequence() -> None: """Composed calls render with standalone ``;`` separator tokens.""" sequence = CommandCall("new-window", ("-d",)) >> CommandCall( From 41959e57788c089eecffdcfc3a5ecefef8674138 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:06:31 -0500 Subject: [PATCH 11/42] Experimental(feat[chain]): enforce the chainability contract in to_chain MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: is_chainable/ChainabilityError were exported and documented as deciding which commands may share one invocation, yet no compile path enforced them — so a non-chainable command (show-option, capture-pane) could be silently folded into a one-dispatch chain and lose its output. what: - CommandPlan.to_chain raises ChainabilityError when a mapped command is non-chainable; raw CommandCall >> composition stays the explicit, unchecked escape hatch - Async to_chain reuses the sync compile path, so the rule applies there too - Add tests for the rejected (show-option) and allowed (rename-window) cases --- src/libtmux/_experimental/chain/plan.py | 13 +++++++++ tests/_experimental/chain/test_plan.py | 35 ++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index c16a9b37a..7e4e89634 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -25,6 +25,7 @@ import typing as t from dataclasses import dataclass +from libtmux._experimental.chain.chain import ChainabilityError, is_chainable from libtmux._experimental.chain.ir import ( Arg, CommandCall, @@ -516,6 +517,11 @@ def to_chain(self, source: SnapshotSource) -> CommandChain: ------ NoCommandsResolved If the resolved query produced no commands. + ChainabilityError + If a mapped command is non-chainable -- its output would be + consumed mid-chain (e.g. ``show-option``). Raw ``CommandCall`` + composition via ``>>`` is the explicit escape hatch and is not + checked. """ calls: list[CommandCall] = [] for row in self.node.query.all(source): @@ -523,6 +529,13 @@ def to_chain(self, source: SnapshotSource) -> CommandChain: if not calls: msg = "command plan resolved to no commands" raise NoCommandsResolved(msg) + for call in calls: + if not is_chainable(call.name): + msg = ( + f"command {call.name!r} is not chainable and cannot be " + f"folded into a one-dispatch sequence" + ) + raise ChainabilityError(msg) return CommandChain(tuple(calls)) def run(self, runner: PlanRunner) -> None: diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index 8a2ea15b6..8fc1f6198 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -9,7 +9,8 @@ from typing_extensions import assert_type from libtmux._experimental.chain import plan as api -from libtmux._experimental.chain.ir import CommandChain +from libtmux._experimental.chain.chain import ChainabilityError +from libtmux._experimental.chain.ir import CommandCall, CommandChain if t.TYPE_CHECKING: from libtmux._experimental.chain.ir import Arg @@ -247,3 +248,35 @@ def test_commands_rejects_string_iterable_command_results() -> None: with pytest.raises(TypeError, match="command mapper"): plan.to_chain(_snapshot()) + + +def test_to_chain_rejects_nonchainable_command() -> None: + """A plan mapping a row to a non-chainable command raises. + + ``show-option`` returns output that would be consumed mid-chain, so folding + it into a one-dispatch sequence is rejected at compile time -- the + chainability contract is enforced, not merely advertised. + """ + plan = api.panes().commands( + lambda pane: CommandCall( + "show-option", + ("-gv", "@x"), + target=pane.pane_id.value, + ), + ) + + with pytest.raises(ChainabilityError, match="not chainable"): + plan.to_chain(_snapshot()) + + +def test_to_chain_allows_chainable_command() -> None: + """A chainable raw command compiles without raising.""" + plan = ( + api.panes() + .limit(1) + .commands( + lambda pane: CommandCall("rename-window", ("work",)), + ) + ) + + assert plan.to_chain(_snapshot()).argvs() == (("rename-window", "work"),) From 6c69260af09a08184733ab23bb468d948f3c5cb0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:13:14 -0500 Subject: [PATCH 12/42] Experimental(test[chain]): use strict asyncio_mode with explicit markers why: asyncio_mode="auto" took suite-wide ownership of every coroutine test on a mostly-synchronous library. Strict mode plus an explicit marker on the single async test module removes that global blast radius without changing behavior. what: - pyproject: asyncio_mode "auto" -> "strict" - test_async.py: add module-level pytestmark = pytest.mark.asyncio - Keep -p no:asyncio in the isolated inner pytester runs and document why: those runs don't read this project's asyncio_default_fixture_loop_scope, so the suppression is independent of asyncio_mode -- removing it reintroduces pytest-asyncio's loop-scope deprecation warning regardless of strict/auto. --- pyproject.toml | 2 +- tests/_experimental/chain/test_async.py | 3 +++ tests/test_pytest_plugin.py | 3 +++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c6110d266..45cf8c701 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -244,7 +244,7 @@ doctest_optionflags = [ "ELLIPSIS", "NORMALIZE_WHITESPACE" ] -asyncio_mode = "auto" +asyncio_mode = "strict" asyncio_default_fixture_loop_scope = "function" testpaths = [ "src/libtmux", diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py index b5f966de9..f9b186b83 100644 --- a/tests/_experimental/chain/test_async.py +++ b/tests/_experimental/chain/test_async.py @@ -17,6 +17,9 @@ from libtmux._experimental.chain.ir import Arg from libtmux.session import Session +# Strict asyncio_mode: mark every coroutine test in this module explicitly. +pytestmark = pytest.mark.asyncio + @dataclass class _FakeResult: diff --git a/tests/test_pytest_plugin.py b/tests/test_pytest_plugin.py index ce503cc8a..eebc505f8 100644 --- a/tests/test_pytest_plugin.py +++ b/tests/test_pytest_plugin.py @@ -76,6 +76,9 @@ def test_repo_git_remote_checkout( ) # Test + # Inner pytester runs are isolated and don't read this project's + # asyncio_default_fixture_loop_scope, so pytest-asyncio would emit its + # loop-scope deprecation. Disable the plugin for these synchronous runs. result = pytester.runpytest(str(first_test_filename), "-p", "no:asyncio") result.assert_outcomes(passed=1) From 3178c24d68f196648457b5863368c0069d63de46 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:18:49 -0500 Subject: [PATCH 13/42] Experimental(feat[chain]): log the one-shot tmux dispatch why: A composed sequence dispatches once, so a failure surfaces as a single aggregate result with no record of which command broke. Per the logging standards, emit a structured debug record of the rendered argv at the dispatch point. what: - Add module loggers to ir and _async - CommandChain.run and async CommandPlan.run log "tmux command sequence dispatched" (tmux_cmd, tmux_subcommand) and "complete" (tmux_exit_code) - Add a caplog test asserting the structured record schema --- src/libtmux/_experimental/chain/_async.py | 13 ++++++++++++- src/libtmux/_experimental/chain/ir.py | 14 +++++++++++++- tests/_experimental/chain/test_ir.py | 22 ++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py index 0f73a0514..5e7970a99 100644 --- a/src/libtmux/_experimental/chain/_async.py +++ b/src/libtmux/_experimental/chain/_async.py @@ -18,6 +18,7 @@ from __future__ import annotations import dataclasses +import logging import typing as t from dataclasses import dataclass @@ -28,6 +29,8 @@ CommandResultLike, ) +logger = logging.getLogger(__name__) + MappedT = t.TypeVar("MappedT") NoCommandsResolved = sync_plan.NoCommandsResolved @@ -189,7 +192,15 @@ async def run(self, runner: AsyncPlanRunner) -> None: except NoCommandsResolved: return None argv = sequence.argv() - await runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence dispatched", + extra={"tmux_cmd": " ".join(argv), "tmux_subcommand": argv[0]}, + ) + result = await runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence complete", + extra={"tmux_exit_code": result.returncode}, + ) return None diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py index 41588074f..91b022346 100644 --- a/src/libtmux/_experimental/chain/ir.py +++ b/src/libtmux/_experimental/chain/ir.py @@ -14,9 +14,12 @@ from __future__ import annotations +import logging import typing as t from dataclasses import dataclass +logger = logging.getLogger(__name__) + Arg: t.TypeAlias = "str | int" """A single tmux argument token. Integers are rendered with :func:`str`.""" @@ -322,7 +325,16 @@ def run(self, runner: CommandRunner) -> CommandResultLike: ['2'] """ argv = self.argv() - return runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence dispatched", + extra={"tmux_cmd": " ".join(argv), "tmux_subcommand": argv[0]}, + ) + result = runner.cmd(argv[0], *argv[1:]) + logger.debug( + "tmux command sequence complete", + extra={"tmux_exit_code": result.returncode}, + ) + return result def _render_arg(arg: Arg) -> str: diff --git a/tests/_experimental/chain/test_ir.py b/tests/_experimental/chain/test_ir.py index c878b5492..4bfac2a00 100644 --- a/tests/_experimental/chain/test_ir.py +++ b/tests/_experimental/chain/test_ir.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import typing as t from dataclasses import dataclass, field @@ -145,6 +146,27 @@ def test_command_sequence_runs_as_single_runner_call() -> None: ] +def test_command_sequence_run_logs_structured_dispatch( + caplog: pytest.LogCaptureFixture, +) -> None: + """``run`` emits a debug record carrying the rendered tmux command.""" + runner = _FakeRunner() + sequence = CommandCall("new-window", ("-d",)) >> CommandCall("split-window") + + with caplog.at_level(logging.DEBUG, logger="libtmux._experimental.chain.ir"): + sequence.run(runner) + + dispatched = [r for r in caplog.records if hasattr(r, "tmux_cmd")] + assert dispatched + record = t.cast("t.Any", dispatched[0]) + assert record.tmux_cmd == "new-window -d ; split-window" + assert record.tmux_subcommand == "new-window" + + completed = [r for r in caplog.records if hasattr(r, "tmux_exit_code")] + assert completed + assert t.cast("t.Any", completed[0]).tmux_exit_code == 0 + + def test_command_spec_defaults_to_chainable() -> None: """Specs are chainable unless a command must return output immediately.""" assert CommandSpec(name="rename-window", scope="window").chainable is True From a1f037b5d8380889a19efd3f0e4f9e1eefe40f15 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:22:41 -0500 Subject: [PATCH 14/42] Pane,Session(test[targets]): cover session-scoped window targeting why: The targeting fix in 5ddf036c changed shipped break-pane and kill-window behavior but shipped no test. These regression tests fail on master and pass with the fix, locking in the cross-session behavior. what: - test_break_pane_targets_owning_session: a pane breaks out into its own session's window even when another session is the server's current one - test_kill_window_targets_owning_session: a bare window name is scoped to the owning session, leaving an identically-named window in another session intact --- tests/test_pane.py | 22 ++++++++++++++++++++++ tests/test_session.py | 22 ++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/tests/test_pane.py b/tests/test_pane.py index c77eb34f1..dcfc3f1a2 100644 --- a/tests/test_pane.py +++ b/tests/test_pane.py @@ -17,6 +17,7 @@ if t.TYPE_CHECKING: from libtmux._internal.types import StrPath from libtmux.pane import Pane + from libtmux.server import Server from libtmux.session import Session logger = logging.getLogger(__name__) @@ -1335,6 +1336,27 @@ def test_break_pane_basic(session: Session) -> None: assert new_window.window_id is not None +def test_break_pane_targets_owning_session(server: Server) -> None: + """``break_pane`` creates the new window in the pane's own session. + + Regression for the bundled targeting fix: a bare break-pane destination + resolves against the server's *current* session, which can differ from the + pane's session. A second session created afterwards becomes the current one, + so the broken-out window must still land in the owning session. + """ + owning = server.new_session(session_name="cc_break_owner") + server.new_session(session_name="cc_break_other") + + window = owning.new_window(window_name="to_break") + pane = window.active_pane + assert pane is not None + new_pane = pane.split(shell="sleep 1m") + + new_window = new_pane.break_pane() + + assert new_window.session_id == owning.session_id + + def test_break_pane_with_name(session: Session) -> None: """Test Pane.break_pane() with window_name.""" window = session.new_window(window_name="test_break_name") diff --git a/tests/test_session.py b/tests/test_session.py index d68d6d42c..153643830 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -103,6 +103,28 @@ def test_active_pane(session: Session) -> None: assert isinstance(session.active_pane, Pane) +def test_kill_window_targets_owning_session(server: Server) -> None: + """``kill_window`` scopes a bare window name to its owning session. + + Regression for the bundled targeting fix: a bare window name resolves + against the server's current session, so two sessions holding a window of + the same name could cross-target. Killing the owning session's window must + leave the other session's same-named window untouched. + """ + owning = server.new_session(session_name="cc_killwin_owner") + other = server.new_session(session_name="cc_killwin_other") + + owning.new_window(window_name="dup") + other.new_window(window_name="dup") + + owning.kill_window("dup") + + owning.refresh() + other.refresh() + assert "dup" not in [w.window_name for w in owning.windows] + assert "dup" in [w.window_name for w in other.windows] + + def test_session_rename(session: Session) -> None: """Session.rename_session renames session.""" session_name = session.session_name From 0812f6a5770b9665eeb7ae7c31c3fa8eecb2358d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:26:23 -0500 Subject: [PATCH 15/42] Experimental(docs[chain]): add per-method doctests why: The project requires a working doctest on every method; several chain query verbs, command compilers, and bound builders relied only on class-level doctests for coverage. what: - Add doctests to PaneQuery.filter/order_by/limit (sync and async facades) - Add doctests to the SendKeys/ResizePane/SelectLayout to_call compilers and the bound pane/window command builders - Add doctests to SessionPlanExecutor.cmd and snapshot --- src/libtmux/_experimental/chain/_async.py | 49 +++++++- .../_experimental/chain/_connection.py | 18 ++- src/libtmux/_experimental/chain/plan.py | 117 ++++++++++++++++-- 3 files changed, 168 insertions(+), 16 deletions(-) diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py index 5e7970a99..668f1a1c2 100644 --- a/src/libtmux/_experimental/chain/_async.py +++ b/src/libtmux/_experimental/chain/_async.py @@ -89,15 +89,33 @@ class PaneQuery: query: sync_plan.PaneQuery def filter(self, *, active: bool) -> PaneQuery: - """Return a query filtered by active state.""" + """Return a query filtered by active state. + + Examples + -------- + >>> panes().filter(active=True).query.active_filter + True + """ return dataclasses.replace(self, query=self.query.filter(active=active)) def order_by(self, field: sync_plan.OrderField) -> PaneQuery: - """Return a query ordered by a known pane field.""" + """Return a query ordered by a known pane field. + + Examples + -------- + >>> panes().order_by("pane_index").query.ordering + 'pane_index' + """ return dataclasses.replace(self, query=self.query.order_by(field)) def limit(self, count: int) -> PaneQuery: - """Return a query capped to ``count`` rows.""" + """Return a query capped to ``count`` rows. + + Examples + -------- + >>> panes().limit(3).query.limit_count + 3 + """ return dataclasses.replace(self, query=self.query.limit(count)) async def all(self, source: AsyncSnapshotSource) -> list[sync_plan.PaneRef]: @@ -178,6 +196,20 @@ async def to_chain(self, source: AsyncSnapshotSource) -> CommandChain: Reuses the sync compile path, so a plan still produces exactly one :class:`~libtmux._experimental.chain.ir.CommandChain`. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain.plan import PaneRef, TmuxSnapshot + >>> snapshot = TmuxSnapshot(panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... )) + >>> async def _to_chain(): + ... plan = panes().commands(lambda p: p.cmd.resize_pane(height=10)) + ... return (await plan.to_chain(snapshot)).argvs() + >>> asyncio.run(_to_chain()) + (('resize-pane', '-t', '%1', '-y', '10'),) """ snapshot = await _resolve_snapshot(source) return self.query.query.commands(self.mapper).to_chain(snapshot) @@ -186,6 +218,17 @@ async def run(self, runner: AsyncPlanRunner) -> None: """Resolve, compile, and dispatch the plan in one async invocation. An empty plan is a no-op (it does not raise). + + Examples + -------- + Resolve and dispatch against a live session in one async invocation: + + >>> import asyncio + >>> from libtmux._experimental.chain import aio, AsyncSessionPlanExecutor + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> asyncio.run(plan.run(AsyncSessionPlanExecutor(session))) """ try: sequence = await self.to_chain(runner) diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py index 7ed7c51fe..6f586ea48 100644 --- a/src/libtmux/_experimental/chain/_connection.py +++ b/src/libtmux/_experimental/chain/_connection.py @@ -111,7 +111,15 @@ def cmd( *args: Arg, target: str | int | None = None, ) -> CommandResultLike: - """Dispatch one tmux command through the live server.""" + """Dispatch one tmux command through the live server. + + Examples + -------- + >>> SessionPlanExecutor(session).cmd( + ... "set-option", "-g", "@cc_conn_demo", "1" + ... ).returncode + 0 + """ # A live Server already satisfies the CommandRunner protocol; the cast # keeps the variadic dispatch cleanly typed (mypy and ty both resolve # ``Server.cmd`` to a union otherwise). @@ -119,7 +127,13 @@ def cmd( return runner.cmd(cmd, *args, target=target) def snapshot(self) -> TmuxSnapshot: - """Return a fresh snapshot of the session's panes.""" + """Return a fresh snapshot of the session's panes. + + Examples + -------- + >>> SessionPlanExecutor(session).snapshot().panes[0].pane_id.value[0] + '%' + """ return snapshot_from_session(self.session) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index 7e4e89634..3b85332ff 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -178,7 +178,13 @@ class SendKeys(CommandValue): enter: bool = False def to_call(self) -> CommandCall: - """Compile to a shared command call.""" + """Compile to a shared command call. + + Examples + -------- + >>> SendKeys(PaneTarget("%1"), "clear", enter=True).to_call().argv() + ('send-keys', '-t', '%1', 'clear', 'Enter') + """ args: list[Arg] = [self.command] if self.enter: args.append("Enter") @@ -199,7 +205,13 @@ class ResizePane(CommandValue): height: int def to_call(self) -> CommandCall: - """Compile to a shared command call.""" + """Compile to a shared command call. + + Examples + -------- + >>> ResizePane(PaneTarget("%1"), height=20).to_call().argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ return CommandCall( "resize-pane", ("-y", self.height), @@ -221,7 +233,13 @@ class SelectLayout(CommandValue): layout: str def to_call(self) -> CommandCall: - """Compile to a shared command call.""" + """Compile to a shared command call. + + Examples + -------- + >>> SelectLayout(WindowTarget("@1"), "tiled").to_call().argv() + ('select-layout', '-t', '@1', 'tiled') + """ return CommandCall("select-layout", (self.layout,), target=self.target.value) @@ -238,11 +256,23 @@ def __init__(self, target: PaneTarget) -> None: self.target = target def send_keys(self, command: str, *, enter: bool = False) -> SendKeys: - """Build a target-bound ``send-keys`` command.""" + """Build a target-bound ``send-keys`` command. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).send_keys("clear").argv() + ('send-keys', '-t', '%1', 'clear') + """ return SendKeys(target=self.target, command=command, enter=enter) def resize_pane(self, *, height: int) -> ResizePane: - """Build a target-bound ``resize-pane`` command.""" + """Build a target-bound ``resize-pane`` command. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).resize_pane(height=20).argv() + ('resize-pane', '-t', '%1', '-y', '20') + """ return ResizePane(target=self.target, height=height) @@ -259,7 +289,13 @@ def __init__(self, target: WindowTarget) -> None: self.target = target def select_layout(self, layout: str) -> SelectLayout: - """Build a target-bound ``select-layout`` command.""" + """Build a target-bound ``select-layout`` command. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + """ return SelectLayout(target=self.target, layout=layout) @@ -359,15 +395,33 @@ class PaneQuery: limit_count: int | None = None def filter(self, *, active: bool) -> PaneQuery: - """Return a query filtered by active state.""" + """Return a query filtered by active state. + + Examples + -------- + >>> panes().filter(active=True) + PaneQuery(active_filter=True, ordering=None, limit_count=None) + """ return dataclasses.replace(self, active_filter=active) def order_by(self, field: OrderField) -> PaneQuery: - """Return a query ordered by a known pane field.""" + """Return a query ordered by a known pane field. + + Examples + -------- + >>> panes().order_by("pane_index") + PaneQuery(active_filter=None, ordering='pane_index', limit_count=None) + """ return dataclasses.replace(self, ordering=field) def limit(self, count: int) -> PaneQuery: - """Return a query capped to ``count`` rows.""" + """Return a query capped to ``count`` rows. + + Examples + -------- + >>> panes().limit(2) + PaneQuery(active_filter=None, ordering=None, limit_count=2) + """ return dataclasses.replace(self, limit_count=count) def all(self, source: SnapshotSource) -> list[PaneRef]: @@ -456,11 +510,39 @@ class MappedPaneQuery(t.Generic[MappedT]): mapper: cabc.Callable[[PaneRef], MappedT] def all(self, source: SnapshotSource) -> list[MappedT]: - """Evaluate the query and transform every row.""" + """Evaluate the query and transform every row. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", + ... pane_index=1, active=True, title="logs"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).all(snapshot) + ['editor', 'logs'] + """ return [self.mapper(row) for row in self.query.all(source)] def first(self, source: SnapshotSource) -> MappedT | None: - """Evaluate the query and transform the first row, or ``None``.""" + """Evaluate the query and transform the first row, or ``None``. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().map(lambda pane: pane.title).first(snapshot) + 'editor' + >>> panes().filter(active=False).map(lambda p: p.title).first(snapshot) is None + True + """ row = self.query.first(source) if row is None: return None @@ -522,6 +604,19 @@ def to_chain(self, source: SnapshotSource) -> CommandChain: consumed mid-chain (e.g. ``show-option``). Raw ``CommandCall`` composition via ``>>`` is the explicit escape hatch and is not checked. + + Examples + -------- + >>> snapshot = TmuxSnapshot( + ... panes=( + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor"), + ... ), + ... ) + >>> panes().commands( + ... lambda p: p.cmd.resize_pane(height=10) + ... ).to_chain(snapshot).argvs() + (('resize-pane', '-t', '%1', '-y', '10'),) """ calls: list[CommandCall] = [] for row in self.node.query.all(source): From 1772c07908cbf2f93181e5609d67a18009b1d659 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:31:30 -0500 Subject: [PATCH 16/42] Experimental(feat[chain]): add a typed raw-command escape hatch why: The bound vocabulary covered three commands with no way to issue an arbitrary tmux command without dropping out of the typed/target-safe layer. Consumers like tmuxp (per-session set-option loops) need to reach commands that have no first-class builder. what: - Add raw(name, *args) to the pane- and window-scoped bound namespaces, binding the namespace's typed target; raw commands still pass through the chainability check at compile time - Add a session-scoped namespace (PaneRef.session / BoundSessionCommands) so the most-looped per-session set-option calls are expressible - Add tests for target binding across the three scopes and for chainability enforcement on the escape hatch --- src/libtmux/_experimental/chain/plan.py | 49 +++++++++++++++++++++++++ tests/_experimental/chain/test_plan.py | 31 ++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index 3b85332ff..d462a674f 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -275,6 +275,20 @@ def resize_pane(self, *, height: int) -> ResizePane: """ return ResizePane(target=self.target, height=height) + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary pane-scoped command bound to this pane. + + The typed escape hatch: any tmux command, with the pane target + pre-bound, for commands without a first-class builder. Still subject to + the chainability check when compiled in a plan. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).raw("pipe-pane", "-o").argv() + ('pipe-pane', '-t', '%1', '-o') + """ + return CommandCall(name, args, target=self.target.value) + class BoundWindowCommands: """Window command namespace bound to one typed window target. @@ -298,6 +312,36 @@ def select_layout(self, layout: str) -> SelectLayout: """ return SelectLayout(target=self.target, layout=layout) + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary window-scoped command bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).raw("set-option", "@x", "1").argv() + ('set-option', '-t', '@1', '@x', '1') + """ + return CommandCall(name, args, target=self.target.value) + + +class BoundSessionCommands: + """Session command namespace bound to one typed session target. + + The session scope exists mainly for the ``raw`` escape hatch -- e.g. the + per-session ``set-option`` loops that workspace builders issue. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).raw("set-option", "@x", "1").argv() + ('set-option', '-t', '$0', '@x', '1') + """ + + def __init__(self, target: SessionTarget) -> None: + self.target = target + + def raw(self, name: str, *args: Arg) -> CommandCall: + """Build an arbitrary session-scoped command bound to this session.""" + return CommandCall(name, args, target=self.target.value) + @dataclass(frozen=True, slots=True) class PaneRef: @@ -339,6 +383,11 @@ def window(self) -> BoundWindowCommands: """Window-scoped commands bound to this pane's window.""" return BoundWindowCommands(self.window_id) + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this pane's session.""" + return BoundSessionCommands(self.session_id) + CommandMapper: t.TypeAlias = cabc.Callable[[PaneRef], IntoCommands] diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index 8fc1f6198..d4e173d42 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -280,3 +280,34 @@ def test_to_chain_allows_chainable_command() -> None: ) assert plan.to_chain(_snapshot()).argvs() == (("rename-window", "work"),) + + +def test_raw_escape_hatch_binds_typed_targets() -> None: + """``raw`` issues an arbitrary command bound to each scope's typed target.""" + pane = _snapshot().panes[0] + + assert pane.cmd.raw("pipe-pane", "-o").argv() == ("pipe-pane", "-t", "%2", "-o") + assert pane.window.raw("set-option", "@x", "1").argv() == ( + "set-option", + "-t", + "@1", + "@x", + "1", + ) + assert pane.session.raw("set-option", "automatic-rename", "on").argv() == ( + "set-option", + "-t", + "$0", + "automatic-rename", + "on", + ) + + +def test_raw_escape_hatch_still_enforces_chainability() -> None: + """A non-chainable command via the escape hatch is still rejected.""" + plan = ( + api.panes().limit(1).commands(lambda pane: pane.cmd.raw("capture-pane", "-p")) + ) + + with pytest.raises(ChainabilityError, match="not chainable"): + plan.to_chain(_snapshot()) From a546c80b13b696d33eb6c0ed3988f9b95156f073 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 19 Jun 2026 20:33:50 -0500 Subject: [PATCH 17/42] Session(docs[kill_window]): document the colon-name targeting limit why: A bare window name is scoped to the owning session, but a name containing ":" is ambiguous with tmux session:window target syntax and is passed through unchanged. Document the behavior and the @-window-id workaround. what: - Note that ":"/"@" targets are treated as already-qualified and pass through - Clarify in the parameter docs that bare names/indexes are session-scoped --- src/libtmux/session.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/libtmux/session.py b/src/libtmux/session.py index 9640d7fc5..70231cbd7 100644 --- a/src/libtmux/session.py +++ b/src/libtmux/session.py @@ -854,12 +854,22 @@ def kill_window(self, target_window: str | int | None = None) -> None: Parameters ---------- target_window : str | int, optional - Window to kill. + Window to kill. A bare window name or index is scoped to this + session so it does not resolve against the server's current + session. Raises ------ :exc:`libtmux.exc.LibTmuxException` If tmux returns an error. + + Notes + ----- + A ``target_window`` string that contains ``:`` (or starts with ``@``) is + treated as an already-qualified tmux target and passed through + unchanged. tmux target syntax cannot distinguish a ``session:window`` + specifier from a window *name* that itself contains ``:``, so a window + whose name contains a colon must be killed via its ``@`` window id. """ target: str | int | None = target_window if target_window is not None: From b582e3db62234cfb1076360daf952a337e5d4cdc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 05:35:18 -0500 Subject: [PATCH 18/42] Experimental(feat[chain]): finish the deferred-result half of the contract why: The chainability contract's static half (is_chainable) is wired into to_chain, but the dynamic half -- DeferredCommandResult, documented as "won't hand back output until the chain has run" -- was exported and self-tested yet never produced or resolved by any code path. what: - Make DeferredCommandResult a two-state handle: unresolved raises DeferredOutputUnavailable; resolve(result) returns a copy bound to the chain's merged result (a `\;` dispatch is not separable per command, so every handle reflects the same result) - Add CommandPlan.run_deferred (sync + async): dispatch once, return one resolved handle per command; empty plans return () - Cover the resolved state machine and run_deferred across sync and async --- src/libtmux/_experimental/chain/_async.py | 35 +++++++++++ src/libtmux/_experimental/chain/chain.py | 72 +++++++++++++++++------ src/libtmux/_experimental/chain/plan.py | 34 ++++++++++- tests/_experimental/chain/test_async.py | 18 ++++++ tests/_experimental/chain/test_chain.py | 25 +++++++- tests/_experimental/chain/test_plan.py | 31 +++++++++- 6 files changed, 195 insertions(+), 20 deletions(-) diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py index 668f1a1c2..09920b06d 100644 --- a/src/libtmux/_experimental/chain/_async.py +++ b/src/libtmux/_experimental/chain/_async.py @@ -23,6 +23,7 @@ from dataclasses import dataclass from libtmux._experimental.chain import plan as sync_plan +from libtmux._experimental.chain.chain import DeferredCommandResult from libtmux._experimental.chain.ir import ( Arg, CommandChain, @@ -246,6 +247,40 @@ async def run(self, runner: AsyncPlanRunner) -> None: ) return None + async def run_deferred( + self, + runner: AsyncPlanRunner, + ) -> tuple[DeferredCommandResult, ...]: + r"""Async: dispatch once and return a resolved deferred result per command. + + Mirrors :meth:`CommandPlan.run_deferred + `: one ``\\;`` + dispatch, each handle resolved with the chain's merged result. An empty + plan returns an empty tuple. + + Examples + -------- + >>> import asyncio + >>> from libtmux._experimental.chain import aio, AsyncSessionPlanExecutor + >>> plan = aio.panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> results = asyncio.run( + ... plan.run_deferred(AsyncSessionPlanExecutor(session)) + ... ) + >>> all(r.returncode == 0 for r in results) + True + """ + try: + sequence = await self.to_chain(runner) + except NoCommandsResolved: + return () + argv = sequence.argv() + result = await runner.cmd(argv[0], *argv[1:]) + return tuple( + DeferredCommandResult(call).resolve(result) for call in sequence.calls + ) + def panes() -> PaneQuery: """Start an async lazy pane query. diff --git a/src/libtmux/_experimental/chain/chain.py b/src/libtmux/_experimental/chain/chain.py index 078f9e222..afbdfd76f 100644 --- a/src/libtmux/_experimental/chain/chain.py +++ b/src/libtmux/_experimental/chain/chain.py @@ -8,9 +8,11 @@ per command declares ``chainable`` (see :data:`COMMAND_SPECS` / :func:`is_chainable`). Output commands such as ``show-option`` are ``chainable=False``. -- *dynamic* -- :class:`DeferredCommandResult` stands in for a not-yet-dispatched - call and raises :class:`DeferredOutputUnavailable` if its output is read before - the chain runs. +- *dynamic* -- :class:`DeferredCommandResult` stands in for a call folded into a + chain. It raises :class:`DeferredOutputUnavailable` if its output is read + before the chain runs, and resolves to the chain's merged result afterwards. + :meth:`~libtmux._experimental.chain.plan.CommandPlan.run_deferred` (and its + async counterpart) dispatch once and hand back resolved deferred results. Note ---- @@ -24,6 +26,7 @@ from libtmux._experimental.chain.ir import ( CommandCall, + CommandResultLike, CommandSpec, ) @@ -74,37 +77,72 @@ def is_chainable(name: str) -> bool: @dataclass(frozen=True, slots=True) class DeferredCommandResult: - """A placeholder result for a call that has not been dispatched yet. + r"""A result handle for a call folded into a one-dispatch chain. - Reading output before the chain runs is a programming error: the value does - not exist yet. + A chained call has no result of its own until the chain runs: a ``\\;`` + sequence dispatches once and returns a single merged result. While + ``result`` is ``None`` the handle is *unresolved* and reading output raises + :class:`DeferredOutputUnavailable`; once resolved it hands back the chain's + merged result -- the same result for every call in the chain, since a + ``\\;`` dispatch is not separable per command. Examples -------- - >>> result = DeferredCommandResult(CommandCall("rename-window", ("work",))) + Unresolved -- the value does not exist yet: + + >>> pending = DeferredCommandResult(CommandCall("rename-window", ("work",))) >>> try: - ... result.stdout + ... pending.stdout ... except DeferredOutputUnavailable: ... print("unavailable until the chain runs") unavailable until the chain runs + + Resolved -- the chain's merged result is handed back: + + >>> class _Merged: + ... stdout, stderr, returncode = [], [], 0 + >>> done = pending.resolve(_Merged()) + >>> done.returncode + 0 """ call: CommandCall + result: CommandResultLike | None = None + + def resolve(self, result: CommandResultLike) -> DeferredCommandResult: + """Return a resolved copy bound to the chain's merged result. + + Examples + -------- + >>> class _Merged: + ... stdout, stderr, returncode = ["ok"], [], 0 + >>> DeferredCommandResult( + ... CommandCall("rename-window", ("work",)) + ... ).resolve(_Merged()).stdout + ['ok'] + """ + return DeferredCommandResult(self.call, result) @property def stdout(self) -> list[str]: - """Reject immediate stdout access.""" - msg = "deferred command output is unavailable until the chain is run" - raise DeferredOutputUnavailable(msg) + """Chain stdout once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command output is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.stdout @property def stderr(self) -> list[str]: - """Reject immediate stderr access.""" - msg = "deferred command errors are unavailable until the chain is run" - raise DeferredOutputUnavailable(msg) + """Chain stderr once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command errors are unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.stderr @property def returncode(self) -> int: - """Reject immediate return-code access.""" - msg = "deferred command status is unavailable until the chain is run" - raise DeferredOutputUnavailable(msg) + """Chain return code once resolved; otherwise reject.""" + if self.result is None: + msg = "deferred command status is unavailable until the chain is run" + raise DeferredOutputUnavailable(msg) + return self.result.returncode diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index d462a674f..fb7952a31 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -25,7 +25,11 @@ import typing as t from dataclasses import dataclass -from libtmux._experimental.chain.chain import ChainabilityError, is_chainable +from libtmux._experimental.chain.chain import ( + ChainabilityError, + DeferredCommandResult, + is_chainable, +) from libtmux._experimental.chain.ir import ( Arg, CommandCall, @@ -706,6 +710,34 @@ def run(self, runner: PlanRunner) -> None: sequence.run(runner) return None + def run_deferred(self, runner: PlanRunner) -> tuple[DeferredCommandResult, ...]: + r"""Dispatch once and return a resolved deferred result per command. + + The chain dispatches a single time; each returned + :class:`~libtmux._experimental.chain.chain.DeferredCommandResult` is + resolved with the chain's merged result (a ``\\;`` dispatch is not + separable per command, so every handle reflects the same result). An + empty plan returns an empty tuple. + + Examples + -------- + >>> from libtmux._experimental.chain import SessionPlanExecutor + >>> plan = panes().filter(active=True).commands( + ... lambda pane: pane.cmd.send_keys("echo libtmux", enter=True), + ... ) + >>> results = plan.run_deferred(SessionPlanExecutor(session)) + >>> all(r.returncode == 0 for r in results) + True + """ + try: + sequence = self.to_chain(runner) + except NoCommandsResolved: + return () + result = sequence.run(runner) + return tuple( + DeferredCommandResult(call).resolve(result) for call in sequence.calls + ) + def panes() -> PaneQuery: """Start a lazy pane query. diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py index f9b186b83..620af0a26 100644 --- a/tests/_experimental/chain/test_async.py +++ b/tests/_experimental/chain/test_async.py @@ -198,3 +198,21 @@ async def test_async_session_plan_runner_dispatches_against_live_tmux( # Dispatches once through the worker thread without raising. await plan.run(runner) + + +async def test_async_run_deferred_resolves_one_handle_per_command() -> None: + """Async ``run_deferred`` dispatches once and resolves a handle per command.""" + runner = _AsyncFakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("clear", enter=True), + ) + ) + + results = await plan.run_deferred(runner) + + assert len(runner.calls) == 1 # the whole chain dispatched once + assert [r.returncode for r in results] == [0, 0] + assert results[0].stdout == ["async ok"] # the chain's merged result diff --git a/tests/_experimental/chain/test_chain.py b/tests/_experimental/chain/test_chain.py index 4dd27c038..caa0846d6 100644 --- a/tests/_experimental/chain/test_chain.py +++ b/tests/_experimental/chain/test_chain.py @@ -69,7 +69,7 @@ def test_is_chainable_uses_static_spec() -> None: def test_deferred_result_rejects_output_access() -> None: - """A deferred result has no output until the chain runs.""" + """An unresolved deferred result has no output until the chain runs.""" result = DeferredCommandResult(CommandCall("rename-window", ("work",))) with pytest.raises(DeferredOutputUnavailable): @@ -78,3 +78,26 @@ def test_deferred_result_rejects_output_access() -> None: _ = result.stderr with pytest.raises(DeferredOutputUnavailable): _ = result.returncode + + +class _MergedResult: + """Minimal merged chain result for resolution tests.""" + + def __init__(self) -> None: + self.stdout = ["ok"] + self.stderr: list[str] = [] + self.returncode = 0 + + +def test_deferred_result_resolves_to_chain_result() -> None: + """A resolved deferred result hands back the chain's merged result.""" + pending = DeferredCommandResult(CommandCall("rename-window", ("work",))) + + resolved = pending.resolve(_MergedResult()) + + assert resolved.returncode == 0 + assert resolved.stdout == ["ok"] + assert resolved.stderr == [] + # The original handle stays unresolved (immutable). + with pytest.raises(DeferredOutputUnavailable): + _ = pending.returncode diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index d4e173d42..8266acba3 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -9,7 +9,7 @@ from typing_extensions import assert_type from libtmux._experimental.chain import plan as api -from libtmux._experimental.chain.chain import ChainabilityError +from libtmux._experimental.chain.chain import ChainabilityError, DeferredCommandResult from libtmux._experimental.chain.ir import CommandCall, CommandChain if t.TYPE_CHECKING: @@ -311,3 +311,32 @@ def test_raw_escape_hatch_still_enforces_chainability() -> None: with pytest.raises(ChainabilityError, match="not chainable"): plan.to_chain(_snapshot()) + + +def test_run_deferred_resolves_one_handle_per_command() -> None: + """``run_deferred`` dispatches once and resolves a handle per command.""" + runner = _FakeRunner(_snapshot()) + plan = ( + api.panes() + .filter(active=True) + .commands( + lambda pane: pane.cmd.send_keys("clear", enter=True), + ) + ) + + results = plan.run_deferred(runner) + + assert len(runner.calls) == 1 # the whole chain dispatched once + assert len(results) == 2 # one handle per active pane + assert all(isinstance(r, DeferredCommandResult) for r in results) + assert [r.returncode for r in results] == [0, 0] + assert results[0].stdout == ["ok"] # the chain's merged result + + +def test_run_deferred_empty_plan_returns_empty() -> None: + """An empty plan dispatches nothing and returns no handles.""" + runner = _FakeRunner(api.TmuxSnapshot(panes=())) + plan = api.panes().commands(lambda pane: pane.cmd.resize_pane(height=10)) + + assert plan.run_deferred(runner) == () + assert runner.calls == [] From ac093c74ffa397717780291ef69d7dd553f9b8a2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 05:39:55 -0500 Subject: [PATCH 19/42] test(retry): make timing tests deterministic under load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The five wall-clock assertions (`0.9 <= elapsed <= 1.1`) flaked under load when run with --reruns 0 — nominal elapsed (~1.0s) sat ~100ms under the ceiling. The two "succeeds after 3 calls" tests also raced their 1s budget: under load only two of three calls fit, spuriously timing out. what: - Success cases: assert on behavior (call count + result) with a generous budget so all calls fit regardless of load; drop the wall-clock assertion - Timeout cases: keep the raises/returns assertion and the deterministic lower bound (retry_until only times out once elapsed >= budget); drop the fragile upper bound --- tests/test/test_retry.py | 79 +++++++++++++++++++++------------------- 1 file changed, 41 insertions(+), 38 deletions(-) diff --git a/tests/test/test_retry.py b/tests/test/test_retry.py index c2a0f9255..61b5134a4 100644 --- a/tests/test/test_retry.py +++ b/tests/test/test_retry.py @@ -11,13 +11,14 @@ def test_retry_three_times() -> None: - """Test retry_until().""" - ini = time() + """retry_until retries until the callable succeeds.""" + calls = 0 value = 0 def call_me_three_times() -> bool: - nonlocal value - sleep(0.3) # Sleep for 0.3 seconds to simulate work + nonlocal value, calls + calls += 1 + sleep(0.3) # simulate work if value == 2: return True @@ -25,73 +26,76 @@ def call_me_three_times() -> bool: value += 1 return False - retry_until(call_me_three_times, 1) - - end = time() - - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # Generous budget so all three calls fit even under load; assert on behavior + # (call count + success), not wall-clock, to stay deterministic. + assert retry_until(call_me_three_times, 5) is True + assert calls == 3 def test_function_times_out() -> None: - """Test time outs with retry_until().""" + """retry_until raises WaitTimeout after exhausting its budget.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False with pytest.raises(exc.WaitTimeout): retry_until(never_true, 1) - end = time() - - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # It retried for the full budget before timing out. The lower bound is + # deterministic (retry_until only times out once elapsed >= the budget); + # no fragile upper bound that load can blow past. + assert (time() - ini) >= 0.9 + assert calls > 1 def test_function_times_out_no_raise() -> None: - """Tests retry_until() with exception raising disabled.""" + """retry_until returns instead of raising when raises=False.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False retry_until(never_true, 1, raises=False) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert (time() - ini) >= 0.9 + assert calls > 1 def test_function_times_out_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, returning False.""" + """retry_until returns False on timeout when raises=False.""" ini = time() + calls = 0 def never_true() -> bool: - sleep( - 0.1, - ) # Sleep for 0.1 seconds to simulate work (called ~10 times in 1 second) + nonlocal calls + calls += 1 + sleep(0.1) # simulate work return False assert not retry_until(never_true, 1, raises=False) - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + assert (time() - ini) >= 0.9 + assert calls > 1 def test_retry_three_times_no_raise_assert() -> None: - """Tests retry_until() with exception raising disabled, with closure variable.""" - ini = time() + """retry_until returns True on success even with raises=False.""" + calls = 0 value = 0 def call_me_three_times() -> bool: - nonlocal value - sleep( - 0.3, - ) # Sleep for 0.3 seconds to simulate work (called 3 times in ~0.9 seconds) + nonlocal value, calls + calls += 1 + sleep(0.3) # simulate work if value == 2: return True @@ -99,7 +103,6 @@ def call_me_three_times() -> bool: value += 1 return False - assert retry_until(call_me_three_times, 1, raises=False) - - end = time() - assert 0.9 <= (end - ini) <= 1.1 # Allow for small timing variations + # Behavior-based, generous budget: deterministic even under load. + assert retry_until(call_me_three_times, 5, raises=False) is True + assert calls == 3 From c045af92d5f4c23b28b8e7dab4f9329d0fa321ab Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 06:19:39 -0500 Subject: [PATCH 20/42] py(deps[dev]) Bump dev packages --- uv.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uv.lock b/uv.lock index 39bd2f578..bf4ca3f5b 100644 --- a/uv.lock +++ b/uv.lock @@ -416,7 +416,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ From e8f8ef4fec81badd4bd22b53e73d76f2ba1d47e4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 06:29:32 -0500 Subject: [PATCH 21/42] Experimental(docs[chain]): correct the "touches a live server" claim why: run_deferred also dispatches to a live server, so the plan module docstring's "only CommandPlan.run touches a live server" was inaccurate. what: - Note that both run and run_deferred touch a live server --- src/libtmux/_experimental/chain/plan.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index fb7952a31..ae6dd7043 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -10,7 +10,8 @@ Compilation (:meth:`CommandPlan.to_chain`) is a pure function of the snapshot, so a plan can be inspected in memory -- no tmux required -- and only -:meth:`CommandPlan.run` touches a live server. +:meth:`CommandPlan.run` and :meth:`CommandPlan.run_deferred` touch a live +server. Note ---- From 58b9189ea0a543d3592e3fd788c756726da50df8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 07:59:15 -0500 Subject: [PATCH 22/42] Experimental(feat[chain]): add dual-purpose forward refs across pane/window/session why: A ref should work two ways -- concrete (a snapshot row) or forward (an object a creation verb will make, resolved lazily at dispatch) -- so a caller can split a pane, then split the pane that split just created, in one native `\;` invocation while reusing the existing command namespaces. what: - Add PendingTarget (active/marked runtime token) and the _target_arg seam: a forward target renders to a tmux token through the unchanged .cmd/.window/.session namespaces, with no parallel command vocabulary - Make PaneRef dual-purpose: a concrete() constructor plus guarded metadata (pane_index/active/title raise ForwardDataUnavailable on a forward ref), and split()/break_pane() creation verbs - Add WindowRef/SessionRef siblings and module-level new_session(); share do/to_chain/run via one _ForwardRef mixin (Self-typed) - Resolve forward refs at dispatch across all three scopes (live tests), and export the new public surface --- docs/experiment/index.md | 6 +- src/libtmux/_experimental/chain/__init__.py | 10 + src/libtmux/_experimental/chain/_async.py | 6 +- .../_experimental/chain/_connection.py | 2 +- src/libtmux/_experimental/chain/plan.py | 510 ++++++++++++++++-- tests/_experimental/chain/test_async.py | 6 +- tests/_experimental/chain/test_forward.py | 153 ++++++ tests/_experimental/chain/test_plan.py | 12 +- 8 files changed, 649 insertions(+), 56 deletions(-) create mode 100644 tests/_experimental/chain/test_forward.py diff --git a/docs/experiment/index.md b/docs/experiment/index.md index 15e898a34..84c1cb732 100644 --- a/docs/experiment/index.md +++ b/docs/experiment/index.md @@ -116,9 +116,9 @@ required: ... ) >>> snapshot = TmuxSnapshot( ... panes=( -... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), +... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), -... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), +... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", ... pane_index=1, active=True, title="logs"), ... ), ... ) @@ -159,7 +159,7 @@ stays synchronous; only resolution and dispatch await: ... ) >>> snapshot = TmuxSnapshot( ... panes=( -... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), +... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... ), ... ) diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index 0f0318055..d045845a5 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -53,14 +53,19 @@ from libtmux._experimental.chain.plan import ( CommandPlan, CommandValue, + ForwardDataUnavailable, NoCommandsResolved, PaneQuery, PaneRef, PaneTarget, + PendingTarget, PlanRunner, + SessionRef, SessionTarget, TmuxSnapshot, + WindowRef, WindowTarget, + new_session, panes, ) @@ -78,17 +83,22 @@ "CommandValue", "DeferredCommandResult", "DeferredOutputUnavailable", + "ForwardDataUnavailable", "NoCommandsResolved", "PaneQuery", "PaneRef", "PaneTarget", + "PendingTarget", "PlanRunner", "SessionPlanExecutor", + "SessionRef", "SessionTarget", "TmuxSnapshot", + "WindowRef", "WindowTarget", "aio", "is_chainable", + "new_session", "panes", "snapshot_from_session", ] diff --git a/src/libtmux/_experimental/chain/_async.py b/src/libtmux/_experimental/chain/_async.py index 09920b06d..dc187e9bc 100644 --- a/src/libtmux/_experimental/chain/_async.py +++ b/src/libtmux/_experimental/chain/_async.py @@ -79,7 +79,7 @@ class PaneQuery: ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, ... ) >>> snapshot = TmuxSnapshot(panes=( - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... )) >>> rows = asyncio.run(panes().filter(active=True).all(snapshot)) @@ -171,9 +171,9 @@ class CommandPlan: ... PaneRef, PaneTarget, SessionTarget, TmuxSnapshot, WindowTarget, ... ) >>> snapshot = TmuxSnapshot(panes=( - ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", ... pane_index=1, active=True, title="logs"), - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... )) >>> async def _demo(): diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py index 6f586ea48..2ce1313ee 100644 --- a/src/libtmux/_experimental/chain/_connection.py +++ b/src/libtmux/_experimental/chain/_connection.py @@ -68,7 +68,7 @@ def snapshot_from_session(session: Session) -> TmuxSnapshot: if pane_id is None or window_id is None or session_id is None: continue rows.append( - PaneRef( + PaneRef.concrete( pane_id=PaneTarget(pane_id), window_id=WindowTarget(window_id), session_id=SessionTarget(session_id), diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index ae6dd7043..890855273 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -35,9 +35,13 @@ Arg, CommandCall, CommandChain, + CommandResultLike, CommandRunner, ) +if t.TYPE_CHECKING: + from typing_extensions import Self + OrderField: t.TypeAlias = t.Literal["pane_id", "pane_index", "title"] """A :class:`PaneRef` field a query may order by.""" @@ -142,6 +146,143 @@ def __str__(self) -> str: return self.value +@dataclass(frozen=True, slots=True) +class PendingTarget: + """A tmux runtime token for an object that does not exist yet. + + A *forward* ref -- the pane/window/session a creation verb will make -- has + no id until tmux runs, so it is addressed at dispatch via a runtime token: + ``"active"`` renders to ``None`` (no ``-t``), since a non-detached + split/new-window/new-session activates what it creates and the next commands + hit it; ``"marked"`` renders to ``"{marked}"`` (the ``select-pane -m`` pane). + + Examples + -------- + >>> PendingTarget().render() is None + True + >>> PendingTarget("marked").render() + '{marked}' + """ + + slot: t.Literal["active", "marked"] = "active" + + def render(self) -> str | None: + """Render as the tmux runtime token (``None`` for active, else marked).""" + return None if self.slot == "active" else "{marked}" + + @property + def value(self) -> str: + """Display form (``""`` for active) so a ``*TargetT`` union is uniform.""" + return self.render() or "" + + +PaneTargetT: t.TypeAlias = "PaneTarget | PendingTarget" +WindowTargetT: t.TypeAlias = "WindowTarget | PendingTarget" +SessionTargetT: t.TypeAlias = "SessionTarget | PendingTarget" +AnyTarget: t.TypeAlias = "PaneTarget | WindowTarget | SessionTarget | PendingTarget" + + +def _target_arg(target: AnyTarget) -> str | int | None: + """Render a target as a concrete id or a pending token (the single seam). + + Examples + -------- + >>> _target_arg(PaneTarget("%1")) + '%1' + >>> _target_arg(PendingTarget()) is None + True + """ + if isinstance(target, PendingTarget): + return target.render() + return target.value + + +class ForwardDataUnavailable(RuntimeError): + """Raised when forward-ref metadata is read before tmux creates the object.""" + + +def _forward_data(field: str) -> t.NoReturn: + """Raise: a forward ref has no metadata until tmux creates the object.""" + msg = f"{field} is unavailable on a forward ref until tmux creates the object" + raise ForwardDataUnavailable(msg) + + +class _ForwardRef: + """Shared forward-chaining surface for the typed refs. + + A ref accumulates a one-dispatch ``_lineage`` of creation/decoration calls; + this mixin compiles and dispatches it. Defining it once keeps + :class:`PaneRef`/:class:`WindowRef`/:class:`SessionRef` free of repetition. + """ + + __slots__ = () + + if t.TYPE_CHECKING: + _lineage: tuple[CommandCall, ...] + + def do(self, build: cabc.Callable[[Self], IntoCommands]) -> Self: + """Append commands built from this ref via its own namespaces. + + The fluent way to act on a forward ref with no new vocabulary: ``build`` + uses the existing ``.cmd``/``.window``/``.session`` namespaces; the + cursor (this ref) is unchanged. + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> ref.do(lambda p: p.cmd.send_keys("vim", enter=True)).to_chain().argvs() + (('send-keys', '-t', '%1', 'vim', 'Enter'),) + """ + extra = _to_calls(build(self)) + return dataclasses.replace(self, _lineage=(*self._lineage, *extra)) # type: ignore[type-var] + + def to_chain(self) -> CommandChain: + """Fold the accumulated lineage into one chain (chainability-checked). + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> ref.split().to_chain().argvs() + (('split-window', '-t', '%1', '-v'),) + """ + return _compile_lineage(self._lineage) + + def run(self, runner: CommandRunner) -> CommandResultLike: + """Dispatch the accumulated lineage in one tmux invocation. + + Examples + -------- + Dispatch the lineage against a live server in one invocation: + + >>> ref = PaneRef.concrete( + ... pane_id=pane.pane_id, window_id=pane.window_id, + ... session_id=pane.session_id, pane_index=0, active=True, title="", + ... ) + >>> built = ref.do(lambda p: p.cmd.send_keys("echo hi", enter=True)) + >>> built.run(pane.server).returncode + 0 + """ + return self.to_chain().run(runner) + + +def _compile_lineage(calls: tuple[CommandCall, ...]) -> CommandChain: + """Chainability-check an accumulated lineage and fold it into one chain.""" + for call in calls: + if not is_chainable(call.name): + msg = ( + f"command {call.name!r} is not chainable and cannot be " + f"folded into a one-dispatch sequence" + ) + raise ChainabilityError(msg) + return CommandChain(calls) + + class CommandValue: """Base for typed command values produced by a deferred plan. @@ -178,7 +319,7 @@ class SendKeys(CommandValue): ('send-keys', '-t', '%1', 'clear', 'Enter') """ - target: PaneTarget + target: PaneTargetT command: str enter: bool = False @@ -193,7 +334,7 @@ def to_call(self) -> CommandCall: args: list[Arg] = [self.command] if self.enter: args.append("Enter") - return CommandCall("send-keys", tuple(args), target=self.target.value) + return CommandCall("send-keys", tuple(args), target=_target_arg(self.target)) @dataclass(frozen=True, slots=True) @@ -206,7 +347,7 @@ class ResizePane(CommandValue): ('resize-pane', '-t', '%1', '-y', '20') """ - target: PaneTarget + target: PaneTargetT height: int def to_call(self) -> CommandCall: @@ -220,7 +361,7 @@ def to_call(self) -> CommandCall: return CommandCall( "resize-pane", ("-y", self.height), - target=self.target.value, + target=_target_arg(self.target), ) @@ -234,7 +375,7 @@ class SelectLayout(CommandValue): ('select-layout', '-t', '@1', 'even-horizontal') """ - target: WindowTarget + target: WindowTargetT layout: str def to_call(self) -> CommandCall: @@ -245,7 +386,9 @@ def to_call(self) -> CommandCall: >>> SelectLayout(WindowTarget("@1"), "tiled").to_call().argv() ('select-layout', '-t', '@1', 'tiled') """ - return CommandCall("select-layout", (self.layout,), target=self.target.value) + return CommandCall( + "select-layout", (self.layout,), target=_target_arg(self.target) + ) class BoundPaneCommands: @@ -257,7 +400,7 @@ class BoundPaneCommands: ('send-keys', '-t', '%1', 'clear', 'Enter') """ - def __init__(self, target: PaneTarget) -> None: + def __init__(self, target: PaneTargetT) -> None: self.target = target def send_keys(self, command: str, *, enter: bool = False) -> SendKeys: @@ -292,7 +435,7 @@ def raw(self, name: str, *args: Arg) -> CommandCall: >>> BoundPaneCommands(PaneTarget("%1")).raw("pipe-pane", "-o").argv() ('pipe-pane', '-t', '%1', '-o') """ - return CommandCall(name, args, target=self.target.value) + return CommandCall(name, args, target=_target_arg(self.target)) class BoundWindowCommands: @@ -304,7 +447,7 @@ class BoundWindowCommands: ('select-layout', '-t', '@1', 'tiled') """ - def __init__(self, target: WindowTarget) -> None: + def __init__(self, target: WindowTargetT) -> None: self.target = target def select_layout(self, layout: str) -> SelectLayout: @@ -325,7 +468,7 @@ def raw(self, name: str, *args: Arg) -> CommandCall: >>> BoundWindowCommands(WindowTarget("@1")).raw("set-option", "@x", "1").argv() ('set-option', '-t', '@1', '@x', '1') """ - return CommandCall(name, args, target=self.target.value) + return CommandCall(name, args, target=_target_arg(self.target)) class BoundSessionCommands: @@ -340,47 +483,113 @@ class BoundSessionCommands: ('set-option', '-t', '$0', '@x', '1') """ - def __init__(self, target: SessionTarget) -> None: + def __init__(self, target: SessionTargetT) -> None: self.target = target def raw(self, name: str, *args: Arg) -> CommandCall: """Build an arbitrary session-scoped command bound to this session.""" - return CommandCall(name, args, target=self.target.value) + return CommandCall(name, args, target=_target_arg(self.target)) @dataclass(frozen=True, slots=True) -class PaneRef: - """A typed pane row returned by a pane query. +class PaneRef(_ForwardRef): + r"""A typed pane handle -- concrete (a snapshot row) or forward, one type. - The ``cmd`` and ``window`` namespaces are pre-bound to this row's typed - targets, so commands built from a row cannot mis-target. + A *concrete* ref comes from a query/snapshot: a real ``%id`` and metadata + (``pane_index``/``active``/``title``). A *forward* ref is declared before the + pane exists (``pane.split()``); its id is a :class:`PendingTarget` resolved + at dispatch and reading its metadata raises until tmux creates it. The + ``.cmd``/``.window``/``.session`` namespaces work identically on both. Examples -------- - >>> pane = PaneRef( - ... pane_id=PaneTarget("%1"), - ... window_id=WindowTarget("@1"), - ... session_id=SessionTarget("$0"), - ... pane_index=0, - ... active=True, - ... title="editor", + Concrete (from a snapshot): build a command bound to this pane's real ids. + + >>> pane = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", ... ) >>> pane.cmd.send_keys("clear", enter=True).argv() ('send-keys', '-t', '%1', 'clear', 'Enter') - >>> pane.window.select_layout("tiled").argv() - ('select-layout', '-t', '@1', 'tiled') + >>> pane.title + 'editor' + >>> pane.is_forward + False + + Forward: split it, then split the pane that split just created -- one chain. + + >>> pane.split(horizontal=True).split().to_chain().argvs() + (('split-window', '-t', '%1', '-h'), ('split-window', '-v')) """ - pane_id: PaneTarget - window_id: WindowTarget - session_id: SessionTarget - pane_index: int - active: bool - title: str + pane_id: PaneTargetT + window_id: WindowTargetT + session_id: SessionTargetT + _pane_index: int | None = None + _active: bool | None = None + _title: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, + *, + pane_id: str | PaneTarget, + window_id: str | WindowTarget, + session_id: str | SessionTarget, + pane_index: int, + active: bool, + title: str, + ) -> PaneRef: + """Build a concrete pane row (real ids and metadata). + + Examples + -------- + >>> ref = PaneRef.concrete( + ... pane_id="%1", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title="editor", + ... ) + >>> (ref.is_forward, ref.title) + (False, 'editor') + """ + return cls( + pane_id=PaneTarget.coerce(pane_id), + window_id=WindowTarget.coerce(window_id), + session_id=SessionTarget.coerce(session_id), + _pane_index=pane_index, + _active=active, + _title=title, + ) + + @property + def is_forward(self) -> bool: + """Whether this ref's id resolves at dispatch (vs. a known ``%id``).""" + return isinstance(self.pane_id, PendingTarget) + + @property + def pane_index(self) -> int: + """Pane index (raises on a forward ref -- the pane does not exist yet).""" + if self._pane_index is None: + _forward_data("pane_index") + return self._pane_index + + @property + def active(self) -> bool: + """Whether the pane is active (raises on a forward ref).""" + if self._active is None: + _forward_data("active") + return self._active + + @property + def title(self) -> str: + """Pane title (raises on a forward ref).""" + if self._title is None: + _forward_data("title") + return self._title @property def cmd(self) -> BoundPaneCommands: - """Pane-scoped commands bound to this pane.""" + """Pane-scoped commands bound to this pane (concrete id or pending token).""" return BoundPaneCommands(self.pane_id) @property @@ -393,10 +602,231 @@ def session(self) -> BoundSessionCommands: """Session-scoped commands bound to this pane's session.""" return BoundSessionCommands(self.session_id) + def split(self, *, horizontal: bool = False, shell: str | None = None) -> PaneRef: + r"""Split this pane; return a FORWARD ref to the new (active) pane. + + The new pane stays in this pane's window/session; its own id is pending + until dispatch -- a non-detached split activates it, so later commands + hit it with no ``-t``. + """ + args: list[Arg] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + call = CommandCall( + "split-window", tuple(args), target=_target_arg(self.pane_id) + ) + return PaneRef( + pane_id=PendingTarget("active"), + window_id=self.window_id, + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + def break_pane(self, *, name: str | None = None) -> WindowRef: + r"""Break this pane into a new window; return a FORWARD :class:`WindowRef`.""" + args: list[Arg] = ["-s", _require_id(self.pane_id)] + args += ["-t", f"{_require_id(self.session_id)}:"] # scope to owning session + if name is not None: + args += ["-n", name] + call = CommandCall("break-pane", tuple(args)) + return WindowRef( + window_id=PendingTarget("active"), + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + CommandMapper: t.TypeAlias = cabc.Callable[[PaneRef], IntoCommands] +def _require_id(target: AnyTarget) -> str: + """Return a concrete id, or raise -- creation verbs need a real source id.""" + if isinstance(target, PendingTarget): + msg = "a creation verb needs a concrete source id, not a forward ref" + raise ForwardDataUnavailable(msg) + return target.value + + +@dataclass(frozen=True, slots=True) +class WindowRef(_ForwardRef): + r"""A typed window handle -- concrete or forward, mirroring :class:`PaneRef`. + + Created by ``session.new_window()`` or ``pane.break_pane()``. Reuses the + ``.window``/``.session`` namespaces; ``.split()`` descends into a forward + :class:`PaneRef`. + + Examples + -------- + >>> win = WindowRef.concrete( + ... window_id="@1", session_id="$0", window_index=1, window_name="editor" + ... ) + >>> win.window.select_layout("tiled").argv() + ('select-layout', '-t', '@1', 'tiled') + >>> win.split().is_forward + True + """ + + window_id: WindowTargetT + session_id: SessionTargetT + _window_index: int | None = None + _window_name: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, + *, + window_id: str | WindowTarget, + session_id: str | SessionTarget, + window_index: int, + window_name: str, + ) -> WindowRef: + """Build a concrete window row (real ids and metadata). + + Examples + -------- + >>> ref = WindowRef.concrete( + ... window_id="@1", session_id="$0", window_index=1, window_name="editor" + ... ) + >>> (ref.is_forward, ref.window_name) + (False, 'editor') + """ + return cls( + window_id=WindowTarget.coerce(window_id), + session_id=SessionTarget.coerce(session_id), + _window_index=window_index, + _window_name=window_name, + ) + + @property + def is_forward(self) -> bool: + """Whether this window resolves at dispatch.""" + return isinstance(self.window_id, PendingTarget) + + @property + def window_index(self) -> int: + """Window index (raises on a forward ref).""" + if self._window_index is None: + _forward_data("window_index") + return self._window_index + + @property + def window_name(self) -> str: + """Window name (raises on a forward ref).""" + if self._window_name is None: + _forward_data("window_name") + return self._window_name + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands bound to this window.""" + return BoundWindowCommands(self.window_id) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this window's session.""" + return BoundSessionCommands(self.session_id) + + def split(self, *, horizontal: bool = False, shell: str | None = None) -> PaneRef: + r"""Split this window's active pane; return a forward :class:`PaneRef`.""" + args: list[Arg] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + call = CommandCall( + "split-window", tuple(args), target=_target_arg(self.window_id) + ) + return PaneRef( + pane_id=PendingTarget("active"), + window_id=self.window_id, + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +@dataclass(frozen=True, slots=True) +class SessionRef(_ForwardRef): + r"""A typed session handle -- concrete or forward, mirroring :class:`PaneRef`. + + Created by :func:`new_session`. Reuses the ``.session`` namespace; + ``.new_window()`` descends into a forward :class:`WindowRef`. + + Examples + -------- + >>> SessionRef.concrete(session_id="$0", session_name="ci").new_window( + ... name="build" + ... ).is_forward + True + """ + + session_id: SessionTargetT + _session_name: str | None = None + _lineage: tuple[CommandCall, ...] = () + + @classmethod + def concrete( + cls, *, session_id: str | SessionTarget, session_name: str + ) -> SessionRef: + """Build a concrete session row (real id and name). + + Examples + -------- + >>> ref = SessionRef.concrete(session_id="$0", session_name="ci") + >>> (ref.is_forward, ref.session_name) + (False, 'ci') + """ + return cls( + session_id=SessionTarget.coerce(session_id), + _session_name=session_name, + ) + + @property + def is_forward(self) -> bool: + """Whether this session resolves at dispatch.""" + return isinstance(self.session_id, PendingTarget) + + @property + def session_name(self) -> str: + """Session name (raises on a forward ref).""" + if self._session_name is None: + _forward_data("session_name") + return self._session_name + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this session.""" + return BoundSessionCommands(self.session_id) + + def new_window(self, *, name: str | None = None) -> WindowRef: + r"""Create a window in this session; return a forward :class:`WindowRef`.""" + args: list[Arg] = [] + target = _target_arg(self.session_id) + if target is not None: + args += ["-t", f"{target}:"] + if name is not None: + args += ["-n", name] + call = CommandCall("new-window", tuple(args)) + return WindowRef( + window_id=PendingTarget("active"), + session_id=self.session_id, + _lineage=(*self._lineage, call), + ) + + +def new_session(*, name: str | None = None) -> SessionRef: + r"""Create a detached session; return a forward :class:`SessionRef`. + + Examples + -------- + >>> new_session(name="ci").new_window(name="build").to_chain().argvs() + (('new-session', '-d', '-s', 'ci'), ('new-window', '-n', 'build')) + """ + args: list[Arg] = ["-d"] + if name is not None: + args += ["-s", name] + call = CommandCall("new-session", tuple(args)) + return SessionRef(session_id=PendingTarget("active"), _lineage=(call,)) + + @dataclass(frozen=True, slots=True) class TmuxSnapshot: """A pure snapshot of tmux pane state used to resolve plans. @@ -434,9 +864,9 @@ class PaneQuery: -------- >>> snapshot = TmuxSnapshot( ... panes=( - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), - ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", ... pane_index=1, active=False, title="logs"), ... ), ... ) @@ -485,9 +915,9 @@ def all(self, source: SnapshotSource) -> list[PaneRef]: -------- >>> snapshot = TmuxSnapshot( ... panes=( - ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", ... pane_index=1, active=True, title="logs"), - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... ), ... ) @@ -525,7 +955,7 @@ def commands(self, mapper: CommandMapper) -> CommandPlan: -------- >>> snapshot = TmuxSnapshot( ... panes=( - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... ), ... ) @@ -552,7 +982,7 @@ class MappedPaneQuery(t.Generic[MappedT]): -------- >>> snapshot = TmuxSnapshot( ... panes=( - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... ), ... ) @@ -619,9 +1049,9 @@ class CommandPlan: -------- >>> snapshot = TmuxSnapshot( ... panes=( - ... PaneRef(PaneTarget("%2"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%2", window_id="@1", session_id="$0", ... pane_index=1, active=True, title="logs"), - ... PaneRef(PaneTarget("%1"), WindowTarget("@1"), SessionTarget("$0"), + ... PaneRef.concrete(pane_id="%1", window_id="@1", session_id="$0", ... pane_index=0, active=True, title="editor"), ... ), ... ) diff --git a/tests/_experimental/chain/test_async.py b/tests/_experimental/chain/test_async.py index 620af0a26..9e397a687 100644 --- a/tests/_experimental/chain/test_async.py +++ b/tests/_experimental/chain/test_async.py @@ -61,7 +61,7 @@ async def cmd( def _snapshot() -> sync_plan.TmuxSnapshot: return sync_plan.TmuxSnapshot( panes=( - sync_plan.PaneRef( + sync_plan.PaneRef.concrete( pane_id=sync_plan.PaneTarget("%2"), window_id=sync_plan.WindowTarget("@1"), session_id=sync_plan.SessionTarget("$0"), @@ -69,7 +69,7 @@ def _snapshot() -> sync_plan.TmuxSnapshot: active=True, title="shell", ), - sync_plan.PaneRef( + sync_plan.PaneRef.concrete( pane_id=sync_plan.PaneTarget("%1"), window_id=sync_plan.WindowTarget("@1"), session_id=sync_plan.SessionTarget("$0"), @@ -77,7 +77,7 @@ def _snapshot() -> sync_plan.TmuxSnapshot: active=True, title="editor", ), - sync_plan.PaneRef( + sync_plan.PaneRef.concrete( pane_id=sync_plan.PaneTarget("%3"), window_id=sync_plan.WindowTarget("@2"), session_id=sync_plan.SessionTarget("$0"), diff --git a/tests/_experimental/chain/test_forward.py b/tests/_experimental/chain/test_forward.py new file mode 100644 index 000000000..dba3116da --- /dev/null +++ b/tests/_experimental/chain/test_forward.py @@ -0,0 +1,153 @@ +"""Forward (lazily-resolved) refs: the dual-purpose PaneRef/WindowRef/SessionRef.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._experimental.chain import ( + ForwardDataUnavailable, + PaneRef, + SessionPlanExecutor, + new_session, +) + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _seed(pane: t.Any) -> PaneRef: + return PaneRef.concrete( + pane_id=pane.pane_id, + window_id=pane.window_id, + session_id=pane.session_id, + pane_index=int(pane.pane_index or 0), + active=pane.pane_active == "1", + title=pane.pane_title or "", + ) + + +def test_forward_is_same_type_reuses_namespaces_and_guards_metadata() -> None: + """A forward ref is a PaneRef; .cmd is reused; metadata is guarded.""" + seed = PaneRef.concrete( + pane_id="%1", + window_id="@1", + session_id="$0", + pane_index=0, + active=True, + title="editor", + ) + child = seed.split(horizontal=True) + + assert isinstance(child, PaneRef) # SAME type, not a parallel object + assert child.is_forward and not seed.is_forward + # The pane id is pending (no -t = active), but the window is still the + # concrete @1 the split happened in -- propagated, so it stays addressable: + assert child.cmd.send_keys("htop").argv() == ("send-keys", "htop") + assert child.window.select_layout("tiled").argv() == ( + "select-layout", + "-t", + "@1", + "tiled", + ) + + assert seed.title == "editor" # concrete metadata: typed str + with pytest.raises(ForwardDataUnavailable): + _ = child.title # forward metadata: guarded (pending-attribute pattern) + + +def test_forward_do_threads_commands_and_compiles_one_chain() -> None: + """`.do()` reuses the namespaces fluently and compiles to one chain (pure).""" + seed = PaneRef.concrete( + pane_id="%1", + window_id="@1", + session_id="$0", + pane_index=0, + active=True, + title="x", + ) + plan = ( + seed.split(horizontal=True) # split %1 -> forward B + .split() # split B -> forward C + .do(lambda p: p.cmd.send_keys("htop", enter=True)) # reuse .cmd + ) + assert plan.to_chain().argvs() == ( + ("split-window", "-t", "%1", "-h"), + ("split-window", "-v"), + ("send-keys", "htop", "Enter"), + ) + + +def test_forward_pane_resolves_to_newly_created_pane(session: Session) -> None: + """Live: split a pane, split that pane, mark the deepest -- one dispatch.""" + window = session.new_window(window_name="fwd_pane") + seed_pane = window.active_pane + assert seed_pane is not None + + ( + _seed(seed_pane) + .split(horizontal=True) + .split() + .do(lambda p: p.cmd.raw("set-option", "-p", "@cc_mark", "DEEP")) + .run(session.server) + ) + + window.refresh() + assert len(window.panes) == 3 + marked = [ + p + for p in window.panes + if "DEEP" in p.cmd("display-message", "-p", "#{@cc_mark}").stdout + ] + assert len(marked) == 1 + assert marked[0].pane_active == "1" + assert marked[0].pane_id != seed_pane.pane_id # forward ref resolved + + +def test_forward_window_scope_creates_window_then_splits(session: Session) -> None: + """Live: session -> new_window (forward WindowRef) -> split inside it.""" + from libtmux._experimental.chain import SessionRef, SessionTarget + + assert session.session_id is not None + sref = SessionRef.concrete( + session_id=SessionTarget(session.session_id), + session_name=session.session_name or "", + ) + n_before = len(session.windows) + + sref.new_window(name="fwd_win").split(horizontal=True).run(session.server) + + session.refresh() + assert len(session.windows) == n_before + 1 + new_win = next(w for w in session.windows if w.window_name == "fwd_win") + new_win.refresh() + assert len(new_win.panes) == 2 # the new window's pane was split + + +def test_forward_session_scope_creates_session(session: Session) -> None: + """Live: new_session (forward SessionRef) -> new_window, one dispatch.""" + server = session.server + name = "cc_v2_fwd_sess" + try: + new_session(name=name).new_window(name="built").run(server) + + created = next((s for s in server.sessions if s.session_name == name), None) + assert created is not None # the forward session was created + assert "built" in {w.window_name for w in created.windows} + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +def test_forward_paneref_runs_through_executor(session: Session) -> None: + """A forward plan also dispatches through SessionPlanExecutor.""" + window = session.new_window(window_name="fwd_exec") + seed_pane = window.active_pane + assert seed_pane is not None + + _seed(seed_pane).split().split().run(SessionPlanExecutor(session)) + + window.refresh() + assert len(window.panes) == 3 diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index 8266acba3..b4499b686 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -52,7 +52,7 @@ def cmd( def _snapshot() -> api.TmuxSnapshot: return api.TmuxSnapshot( panes=( - api.PaneRef( + api.PaneRef.concrete( pane_id=api.PaneTarget("%2"), window_id=api.WindowTarget("@1"), session_id=api.SessionTarget("$0"), @@ -60,7 +60,7 @@ def _snapshot() -> api.TmuxSnapshot: active=True, title="shell", ), - api.PaneRef( + api.PaneRef.concrete( pane_id=api.PaneTarget("%1"), window_id=api.WindowTarget("@1"), session_id=api.SessionTarget("$0"), @@ -68,7 +68,7 @@ def _snapshot() -> api.TmuxSnapshot: active=True, title="editor", ), - api.PaneRef( + api.PaneRef.concrete( pane_id=api.PaneTarget("%3"), window_id=api.WindowTarget("@2"), session_id=api.SessionTarget("$0"), @@ -87,9 +87,9 @@ def test_typed_targets_and_bound_commands_render_targets() -> None: pane_call = pane.cmd.send_keys("clear", enter=True) window_call = pane.window.select_layout("even-horizontal") - assert_type(pane.pane_id, api.PaneTarget) - assert_type(pane.window_id, api.WindowTarget) - assert_type(pane.session_id, api.SessionTarget) + assert_type(pane.pane_id, api.PaneTargetT) + assert_type(pane.window_id, api.WindowTargetT) + assert_type(pane.session_id, api.SessionTargetT) assert pane_call.argv() == ("send-keys", "-t", "%2", "clear", "Enter") assert window_call.argv() == ("select-layout", "-t", "@1", "even-horizontal") From cc033faf609cdfa1f9501f7fe70d940741e17cb6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 08:38:37 -0500 Subject: [PATCH 23/42] Experimental(feat[chain]): resolve independent forward handles over N dispatches why: One `\;` invocation can address only the active or single marked object, so holding two independent forward handles needs N dispatches -- each creation run alone with `-P -F` to capture its real id, then substituted into downstream commands. The resolution must work for sync AND async without duplicating logic or blocking the event loop. what: - Add ir.SlotRef (an unresolved "id of slot N" target) plus the one-line _target_arg seam that passes it through to the resolver - Add _resolve.py: a sans-I/O resolution core -- a generator that yields a Dispatch/SnapshotRequest and resumes via .send(result), the same trampoline asyncio itself uses -- driven by ~10-line sync and async drivers that share it verbatim (the only divergence is await) - Add ForwardPlan/ForwardHandle: a builder handing out independent handles that reuse the .cmd/.window/.session namespaces via SlotRef, resolved over N dispatches (run_resolving / run_resolving_async); from_pane/from_query seeding - Capture each creation's stable id (#{pane_id}) and fold downstream commands into one trailing `\;` chain with ids substituted; live sync, async, and query-seed tests plus a pure sans-I/O core test --- src/libtmux/_experimental/chain/__init__.py | 8 + src/libtmux/_experimental/chain/_resolve.py | 335 ++++++++++++++++++++ src/libtmux/_experimental/chain/ir.py | 23 +- src/libtmux/_experimental/chain/plan.py | 23 +- tests/_experimental/chain/test_resolve.py | 140 ++++++++ 5 files changed, 519 insertions(+), 10 deletions(-) create mode 100644 src/libtmux/_experimental/chain/_resolve.py create mode 100644 tests/_experimental/chain/test_resolve.py diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index d045845a5..f6d5afa2c 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -35,6 +35,11 @@ SessionPlanExecutor, snapshot_from_session, ) +from libtmux._experimental.chain._resolve import ( + ForwardHandle, + ForwardPlan, + Resolved, +) from libtmux._experimental.chain.chain import ( ChainabilityError, DeferredCommandResult, @@ -84,12 +89,15 @@ "DeferredCommandResult", "DeferredOutputUnavailable", "ForwardDataUnavailable", + "ForwardHandle", + "ForwardPlan", "NoCommandsResolved", "PaneQuery", "PaneRef", "PaneTarget", "PendingTarget", "PlanRunner", + "Resolved", "SessionPlanExecutor", "SessionRef", "SessionTarget", diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py new file mode 100644 index 000000000..601f89224 --- /dev/null +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -0,0 +1,335 @@ +r"""Multi-dispatch resolution for independent forward handles (sans-I/O core). + +A *linear* forward chain folds into one ``tmux a \; b`` invocation: tmux +addresses the active (or single marked) object with no ``-t``. But it cannot +address two **independent** forward handles in one invocation -- ``-t`` is a +fixed argv token, and a freshly-created id escapes only as ``-P -F`` stdout. So +holding several independent handles needs **multiple dispatches**: each creation +runs on its own with ``-P -F '#{pane_id}'`` to capture its real id, which is +then substituted into the downstream commands. + +The resolution is a **sans-I/O generator** -- the same yield-request / +resume-with-result trampoline asyncio itself uses (``Future.__await__`` yields a +request, ``Task.__step`` ``.send()``\\s the result back). One core; two short +drivers (sync ``runner.cmd``, async ``await runner.cmd``). The N-dispatch logic +is never duplicated, and the generator is suspended at a ``yield`` between +dispatches, so it never blocks the event loop. + +Note +---- +This is an **experimental** prototype, not covered by the versioning policy. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass, field + +from libtmux._experimental.chain.ir import ( + CommandCall, + CommandChain, + CommandResultLike, + SlotRef, +) +from libtmux._experimental.chain.plan import ( + AnyTarget, + BoundPaneCommands, + BoundSessionCommands, + BoundWindowCommands, + PaneQuery, + PaneRef, + _target_arg, + _to_calls, +) + +if t.TYPE_CHECKING: + import collections.abc as cabc + + from libtmux._experimental.chain._async import AsyncPlanRunner + from libtmux._experimental.chain.plan import IntoCommands, PlanRunner + +Kind = t.Literal["pane", "window", "session"] +_CAPTURE: dict[Kind, str] = { + "pane": "#{pane_id}", + "window": "#{window_id}", + "session": "#{session_id}", +} +_SEED = -1 # reserved binding key for a query-resolved seed + + +class NoSeedResolved(RuntimeError): + """Raised when a query-seeded forward plan matches no pane.""" + + +# --- plan IR ---------------------------------------------------------------- +@dataclass(frozen=True, slots=True) +class _Create: + """A creation step; ``call.target`` is concrete, ``None``, or a parent SlotRef.""" + + slot: int + kind: Kind + call: CommandCall + + +@dataclass(frozen=True, slots=True) +class _Decorate: + """A decoration step; ``call.target`` may be a SlotRef into an earlier slot.""" + + call: CommandCall + + +_Step: t.TypeAlias = "_Create | _Decorate" + + +# --- the sans-I/O protocol -------------------------------------------------- +@dataclass(frozen=True, slots=True) +class SnapshotRequest: + """The driver must supply a tmux snapshot (sync or awaited).""" + + +@dataclass(frozen=True, slots=True) +class Dispatch: + """A tmux dispatch the driver runs, handing back its result. + + ``captures`` names the forward slot this dispatch creates an id for + (``None`` for a decorate- or cleanup-only dispatch); the core binds + ``stdout[0]`` to that slot once the driver returns the result. + """ + + argv: tuple[str, ...] + captures: int | None + + +Request: t.TypeAlias = "SnapshotRequest | Dispatch" + + +@dataclass(frozen=True, slots=True) +class Resolved: + """The outcome of a multi-dispatch resolution. + + ``bindings`` maps each forward slot to the concrete id tmux assigned + (``%N``/``@N``/``$N``); ``results`` holds each resolution dispatch's + result in order (a recovery ``select-pane -M`` after a failed marked + fold is not captured). + """ + + bindings: dict[int, str] = field(default_factory=dict) + results: tuple[CommandResultLike, ...] = () + + +def _with_capture(call: CommandCall, kind: Kind) -> tuple[str, ...]: + """Append ``-P -F '#{_id}'`` so the creation prints its stable id.""" + return (*call.argv(), "-P", "-F", _CAPTURE[kind]) + + +def _subst(call: CommandCall, bindings: dict[int, str]) -> CommandCall: + """Replace a SlotRef target with the captured concrete id.""" + if isinstance(call.target, SlotRef): + return dataclasses.replace(call, target=bindings[call.target.slot]) + return call + + +def drive( + steps: tuple[_Step, ...], + *, + seed_query: PaneQuery | None = None, +) -> t.Generator[Request, t.Any, Resolved]: + r"""Sans-I/O resolution core: yield a :class:`Request`, resume via ``.send``. + + Each :class:`_Create` is dispatched alone with ``-P -F`` id capture; a run of + :class:`_Decorate`\\s folds into one trailing ``\;`` chain with the captured + ids substituted. No awaits, no runner, no threads -- a pure state machine the + sync/async drivers feed results into. + """ + bindings: dict[int, str] = {} + results: list[CommandResultLike] = [] + tail: list[CommandCall] = [] + + if seed_query is not None: + snapshot = yield SnapshotRequest() + seed = seed_query.first(snapshot) + if seed is None: + msg = "query matched no pane to seed the forward plan" + raise NoSeedResolved(msg) + bindings[_SEED] = str(_target_arg(seed.pane_id)) + + for step in steps: + if isinstance(step, _Create): + if tail: + chain = CommandChain(tuple(_subst(c, bindings) for c in tail)) + results.append((yield Dispatch(chain.argv(), None))) + tail = [] + argv = _with_capture(_subst(step.call, bindings), step.kind) + result = yield Dispatch(argv, step.slot) + results.append(result) + bindings[step.slot] = result.stdout[0].strip() + else: + tail.append(step.call) + + if tail: + chain = CommandChain(tuple(_subst(c, bindings) for c in tail)) + results.append((yield Dispatch(chain.argv(), None))) + + return Resolved(bindings, tuple(results)) + + +# --- the two thin drivers (the only sync/async divergence) ------------------ +def run_sync( + gen: t.Generator[Request, t.Any, Resolved], runner: PlanRunner +) -> Resolved: + """Drive the resolution core with blocking calls.""" + try: + request = next(gen) + while True: + if isinstance(request, SnapshotRequest): + request = gen.send(runner.snapshot()) + else: + request = gen.send(runner.cmd(request.argv[0], *request.argv[1:])) + except StopIteration as stop: + return t.cast("Resolved", stop.value) + + +async def run_async( + gen: t.Generator[Request, t.Any, Resolved], + runner: AsyncPlanRunner, +) -> Resolved: + """Drive the *same* core with ``await`` -- no resolution logic duplicated.""" + try: + request = next(gen) + while True: + if isinstance(request, SnapshotRequest): + request = gen.send(await runner.snapshot()) + else: + result = await runner.cmd(request.argv[0], *request.argv[1:]) + request = gen.send(result) + except StopIteration as stop: + return t.cast("Resolved", stop.value) + + +# --- the builder + handles -------------------------------------------------- +class ForwardHandle: + """A reference to a forward pane (a slot) inside a :class:`ForwardPlan`. + + Reuses the existing ``.cmd``/``.window``/``.session`` namespaces -- bound to + this handle's :class:`~libtmux._experimental.chain.ir.SlotRef`, so each + command carries the slot and the resolver substitutes the captured id. + """ + + def __init__(self, plan: ForwardPlan, slot: int) -> None: + self._plan = plan + self._slot = slot + + @property + def cmd(self) -> BoundPaneCommands: + """Pane-scoped commands bound to this handle.""" + return BoundPaneCommands(SlotRef(self._slot)) + + @property + def window(self) -> BoundWindowCommands: + """Window-scoped commands (a pane id resolves up to its window).""" + return BoundWindowCommands(SlotRef(self._slot)) + + @property + def session(self) -> BoundSessionCommands: + """Session-scoped commands bound to this handle's session.""" + return BoundSessionCommands(SlotRef(self._slot)) + + def split( + self, *, horizontal: bool = False, shell: str | None = None + ) -> ForwardHandle: + """Split this handle's pane; return a handle to the new pane.""" + return self._plan._split(SlotRef(self._slot), horizontal, shell) + + def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: + """Decorate this handle via its namespaces (reused, no new vocabulary).""" + self._plan._steps.extend(_Decorate(call) for call in _to_calls(build(self))) + return self + + +class ForwardPlan: + r"""A builder for a multi-handle forward plan, resolved over N dispatches. + + Hand out independent handles (``split()``), decorate each through its reused + namespaces, then :meth:`run_resolving` (sync) or :meth:`run_resolving_async`: + one creation per dispatch (``-P -F`` capture), the downstream commands folded + into one trailing ``\;`` chain with the captured ids substituted in. + + Examples + -------- + Two independent panes, resolved over three dispatches against a fake server + that hands back fabricated pane ids: + + >>> from libtmux._experimental.chain.plan import PaneTarget + >>> class _FakeServer: + ... count = 6 + ... def cmd(self, *argv): + ... _FakeServer.count += 1 + ... line = [f"%{_FakeServer.count}"] + ... return type("R", (), {"stdout": line, "stderr": [], "returncode": 0})() + >>> plan = ForwardPlan(PaneTarget("%1")) + >>> left, right = plan.split(horizontal=True), plan.split() + >>> _ = left.do(lambda h: h.cmd.send_keys("vim", enter=True)) + >>> _ = right.do(lambda h: h.cmd.send_keys("htop", enter=True)) + >>> plan.run_resolving(_FakeServer()).bindings + {0: '%7', 1: '%8'} + """ + + def __init__(self, seed: AnyTarget | None = None) -> None: + self._steps: list[_Step] = [] + self._n = 0 + self._seed = seed + self._seed_query: PaneQuery | None = None + + @classmethod + def from_pane(cls, pane: PaneRef) -> ForwardPlan: + """Seed the plan from a concrete pane row.""" + return cls(seed=pane.pane_id) + + @classmethod + def from_query(cls, query: PaneQuery) -> ForwardPlan: + """Seed the plan from the first row of a live query (read at run time).""" + plan = cls(seed=None) + plan._seed_query = query + return plan + + def _seed_target(self) -> str | int | None | SlotRef: + if self._seed_query is not None: + return SlotRef(_SEED) + if self._seed is None: + return None + return _target_arg(self._seed) + + def _split( + self, + parent: str | int | None | SlotRef, + horizontal: bool, + shell: str | None, + ) -> ForwardHandle: + slot = self._n + self._n += 1 + args: list[str] = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + self._steps.append( + _Create( + slot, "pane", CommandCall("split-window", tuple(args), target=parent) + ) + ) + return ForwardHandle(self, slot) + + def split( + self, *, horizontal: bool = False, shell: str | None = None + ) -> ForwardHandle: + """Split the seed (root); return a handle to the new pane.""" + return self._split(self._seed_target(), horizontal, shell) + + def run_resolving(self, runner: PlanRunner) -> Resolved: + """Resolve over N dispatches against a live server (sync).""" + return run_sync(drive(tuple(self._steps), seed_query=self._seed_query), runner) + + async def run_resolving_async(self, runner: AsyncPlanRunner) -> Resolved: + """Resolve over N dispatches against a live server (async, same core).""" + return await run_async( + drive(tuple(self._steps), seed_query=self._seed_query), runner + ) diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py index 91b022346..76e119c1b 100644 --- a/src/libtmux/_experimental/chain/ir.py +++ b/src/libtmux/_experimental/chain/ir.py @@ -89,6 +89,24 @@ class CommandSpec: chainable: bool = True +@dataclass(frozen=True, slots=True) +class SlotRef: + r"""An unresolved target: "the id of forward slot N", filled at resolve time. + + Carried by a :class:`CommandCall` built against a forward handle in a + multi-dispatch plan. The resolver replaces it with the captured concrete id + (``%N``/``@N``/``$N``) before the call is rendered; rendering an unresolved + SlotRef is a planner bug and raises. + + Examples + -------- + >>> SlotRef(0) + SlotRef(slot=0) + """ + + slot: int + + @dataclass(frozen=True, slots=True) class CommandCall: """One typed tmux command call before subprocess dispatch. @@ -115,7 +133,7 @@ class CommandCall: name: str args: tuple[Arg, ...] = () - target: str | int | None = None + target: str | int | None | SlotRef = None def __post_init__(self) -> None: """Reject an empty-string target (fail closed). @@ -151,6 +169,9 @@ def argv(self) -> tuple[str, ...]: ('kill-window', '-t', '@1') """ rendered: list[str] = [self.name] + if isinstance(self.target, SlotRef): + msg = "cannot render an unresolved SlotRef; resolve the plan first" + raise TypeError(msg) if self.target is not None: rendered.extend(("-t", str(self.target))) rendered.extend(_render_arg(arg) for arg in self.args) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index 890855273..ff9870259 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -37,6 +37,7 @@ CommandChain, CommandResultLike, CommandRunner, + SlotRef, ) if t.TYPE_CHECKING: @@ -179,10 +180,12 @@ def value(self) -> str: PaneTargetT: t.TypeAlias = "PaneTarget | PendingTarget" WindowTargetT: t.TypeAlias = "WindowTarget | PendingTarget" SessionTargetT: t.TypeAlias = "SessionTarget | PendingTarget" -AnyTarget: t.TypeAlias = "PaneTarget | WindowTarget | SessionTarget | PendingTarget" +AnyTarget: t.TypeAlias = ( + "PaneTarget | WindowTarget | SessionTarget | PendingTarget | SlotRef" +) -def _target_arg(target: AnyTarget) -> str | int | None: +def _target_arg(target: AnyTarget) -> str | int | None | SlotRef: """Render a target as a concrete id or a pending token (the single seam). Examples @@ -194,6 +197,8 @@ def _target_arg(target: AnyTarget) -> str | int | None: """ if isinstance(target, PendingTarget): return target.render() + if isinstance(target, SlotRef): + return target # deferred; the multi-dispatch resolver substitutes it return target.value @@ -319,7 +324,7 @@ class SendKeys(CommandValue): ('send-keys', '-t', '%1', 'clear', 'Enter') """ - target: PaneTargetT + target: PaneTargetT | SlotRef command: str enter: bool = False @@ -347,7 +352,7 @@ class ResizePane(CommandValue): ('resize-pane', '-t', '%1', '-y', '20') """ - target: PaneTargetT + target: PaneTargetT | SlotRef height: int def to_call(self) -> CommandCall: @@ -375,7 +380,7 @@ class SelectLayout(CommandValue): ('select-layout', '-t', '@1', 'even-horizontal') """ - target: WindowTargetT + target: WindowTargetT | SlotRef layout: str def to_call(self) -> CommandCall: @@ -400,7 +405,7 @@ class BoundPaneCommands: ('send-keys', '-t', '%1', 'clear', 'Enter') """ - def __init__(self, target: PaneTargetT) -> None: + def __init__(self, target: PaneTargetT | SlotRef) -> None: self.target = target def send_keys(self, command: str, *, enter: bool = False) -> SendKeys: @@ -447,7 +452,7 @@ class BoundWindowCommands: ('select-layout', '-t', '@1', 'tiled') """ - def __init__(self, target: WindowTargetT) -> None: + def __init__(self, target: WindowTargetT | SlotRef) -> None: self.target = target def select_layout(self, layout: str) -> SelectLayout: @@ -483,7 +488,7 @@ class BoundSessionCommands: ('set-option', '-t', '$0', '@x', '1') """ - def __init__(self, target: SessionTargetT) -> None: + def __init__(self, target: SessionTargetT | SlotRef) -> None: self.target = target def raw(self, name: str, *args: Arg) -> CommandCall: @@ -641,7 +646,7 @@ def break_pane(self, *, name: str | None = None) -> WindowRef: def _require_id(target: AnyTarget) -> str: """Return a concrete id, or raise -- creation verbs need a real source id.""" - if isinstance(target, PendingTarget): + if isinstance(target, (PendingTarget, SlotRef)): msg = "a creation verb needs a concrete source id, not a forward ref" raise ForwardDataUnavailable(msg) return target.value diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py new file mode 100644 index 000000000..4f99eaef4 --- /dev/null +++ b/tests/_experimental/chain/test_resolve.py @@ -0,0 +1,140 @@ +"""Multi-dispatch resolution: independent forward handles, sync + async.""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +import pytest + +from libtmux._experimental.chain import ( + AsyncSessionPlanExecutor, + ForwardPlan, + SessionPlanExecutor, + panes, +) +from libtmux._experimental.chain._resolve import Dispatch, drive +from libtmux._experimental.chain.plan import PaneTarget + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +@dataclass +class _FakeResult: + stdout: list[str] = field(default_factory=list) + stderr: list[str] = field(default_factory=list) + returncode: int = 0 + + +def _mark(value: str) -> t.Callable[[t.Any], t.Any]: + return lambda h: h.cmd.raw("set-option", "-p", "@m", value) + + +def test_drive_core_is_sans_io_and_substitutes_ids() -> None: + """The core yields Dispatch, captures ids, and substitutes them -- no tmux. + + A hand-rolled driver feeds fake ids back, proving the resolution logic is a + pure generator independent of any I/O (just like the sync/async drivers). + """ + plan = ForwardPlan(PaneTarget("%1")) + left, right = plan.split(horizontal=True), plan.split() + left.do(_mark("L")) + right.do(_mark("R")) + + gen = drive(tuple(plan._steps)) + dispatched: list[tuple[str, ...]] = [] + fake_ids = iter(["%7", "%8"]) + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + dispatched.append(request.argv) + out = [next(fake_ids)] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=out)) + except StopIteration as stop: + resolved = stop.value + + assert dispatched == [ + # each creation dispatched alone with id capture (both split the seed %1): + ("split-window", "-t", "%1", "-h", "-P", "-F", "#{pane_id}"), + ("split-window", "-t", "%1", "-v", "-P", "-F", "#{pane_id}"), + # downstream commands fold into one trailing \; chain, ids substituted: + ( + "set-option", + "-t", + "%7", + "-p", + "@m", + "L", + ";", + "set-option", + "-t", + "%8", + "-p", + "@m", + "R", + ), + ] + assert resolved.bindings == {0: "%7", 1: "%8"} + + +def _mark_of(session: Session, pane_id: str) -> list[str]: + return session.server.cmd("display-message", "-p", "-t", pane_id, "#{@m}").stdout + + +def test_multidispatch_two_handles_sync(session: Session) -> None: + """Live: two independent forward panes, each captured + decorated correctly.""" + window = session.new_window(window_name="resolve_sync") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + left, right = plan.split(horizontal=True), plan.split() + left.do(_mark("LEFT")) + right.do(_mark("RIGHT")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert set(resolved.bindings) == {0, 1} + assert resolved.bindings[0] != resolved.bindings[1] + window.refresh() + assert len(window.panes) == 3 # seed + two independent panes + assert _mark_of(session, resolved.bindings[0]) == ["LEFT"] + assert _mark_of(session, resolved.bindings[1]) == ["RIGHT"] + + +@pytest.mark.asyncio +async def test_multidispatch_two_handles_async(session: Session) -> None: + """Live async: the same resolution core, driven with await.""" + window = session.new_window(window_name="resolve_async") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(_mark("AL")) + plan.split().do(_mark("AR")) + + resolved = await plan.run_resolving_async(AsyncSessionPlanExecutor(session)) + + window.refresh() + assert len(window.panes) == 3 + assert _mark_of(session, resolved.bindings[0]) == ["AL"] + assert _mark_of(session, resolved.bindings[1]) == ["AR"] + + +def test_multidispatch_from_query_seed(session: Session) -> None: + """Live: seed the plan from the first row of a query (resolved at run).""" + window = session.new_window(window_name="resolve_seed") + assert window.active_pane is not None + + plan = ForwardPlan.from_query(panes().filter(active=True)) + plan.split(horizontal=True).do(_mark("A")) + plan.split().do(_mark("B")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert _mark_of(session, resolved.bindings[0]) == ["A"] + assert _mark_of(session, resolved.bindings[1]) == ["B"] From 6aa7c05813d16fd73296f95beae1d7de0ac7b9a2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 08:57:11 -0500 Subject: [PATCH 24/42] Experimental(feat[chain]): span pane, window, and session forward scopes why: The multi-dispatch builder created panes only -- `split()`. A forward plan that wants two independent windows in a fresh session (or any session/window handle) had no vocabulary for it, even though the sans-I/O core already captures `#{window_id}`/`#{session_id}` per scope. Extending the builder makes the N-dispatch resolver address every tmux scope, not just panes. what: - Add ir.SlotRef.suffix: a captured id can render qualified -- `new-window -t $N:` reuses a plain `$N` capture without a second binding; _subst appends it - Generalize ForwardPlan._split into _create(parent, kind, name, args) and add ForwardPlan.new_session (session handle) + ForwardHandle.new_window (window handle, targets the session via SlotRef(slot, ":")) - Thread the handle's kind through ForwardHandle so it stays one type across scopes; guard creation verbs with _require so new_window()/split() fail fast at build time on the wrong scope instead of erroring in tmux - Factor _split_args shared by the plan- and handle-level split - Cover the new scopes: a pure scope-guard test plus live sync/async tests that build a session, two independent windows, and a split inside each --- src/libtmux/_experimental/chain/_resolve.py | 93 +++++++++++++++------ src/libtmux/_experimental/chain/ir.py | 11 ++- tests/_experimental/chain/test_resolve.py | 80 ++++++++++++++++++ 3 files changed, 154 insertions(+), 30 deletions(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 601f89224..6a0e34fae 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -124,9 +124,10 @@ def _with_capture(call: CommandCall, kind: Kind) -> tuple[str, ...]: def _subst(call: CommandCall, bindings: dict[int, str]) -> CommandCall: - """Replace a SlotRef target with the captured concrete id.""" + """Replace a SlotRef target with the captured concrete id (plus its suffix).""" if isinstance(call.target, SlotRef): - return dataclasses.replace(call, target=bindings[call.target.slot]) + resolved = bindings[call.target.slot] + call.target.suffix + return dataclasses.replace(call, target=resolved) return call @@ -208,17 +209,30 @@ async def run_async( # --- the builder + handles -------------------------------------------------- -class ForwardHandle: - """A reference to a forward pane (a slot) inside a :class:`ForwardPlan`. +def _split_args(horizontal: bool, shell: str | None) -> tuple[str, ...]: + """Render the ``split-window`` flags shared by the plan- and handle-level split.""" + args = ["-h" if horizontal else "-v"] + if shell is not None: + args.append(shell) + return tuple(args) + - Reuses the existing ``.cmd``/``.window``/``.session`` namespaces -- bound to - this handle's :class:`~libtmux._experimental.chain.ir.SlotRef`, so each - command carries the slot and the resolver substitutes the captured id. +class ForwardHandle: + """A reference to a forward object (a slot) inside a :class:`ForwardPlan`. + + One type spans all three tmux scopes: the handle knows its ``kind`` + (``pane``/``window``/``session``), so its creation verbs stay scope-correct + -- ``new_window()`` only on a session, ``split()`` only on a pane or window + -- while the ``.cmd``/``.window``/``.session`` command namespaces are reused + unchanged, each bound to this handle's + :class:`~libtmux._experimental.chain.ir.SlotRef` so the resolver substitutes + the captured id. """ - def __init__(self, plan: ForwardPlan, slot: int) -> None: + def __init__(self, plan: ForwardPlan, slot: int, kind: Kind) -> None: self._plan = plan self._slot = slot + self._kind = kind @property def cmd(self) -> BoundPaneCommands: @@ -238,22 +252,46 @@ def session(self) -> BoundSessionCommands: def split( self, *, horizontal: bool = False, shell: str | None = None ) -> ForwardHandle: - """Split this handle's pane; return a handle to the new pane.""" - return self._plan._split(SlotRef(self._slot), horizontal, shell) + """Split this handle's active pane; return a handle to the new pane.""" + self._require("split", "pane", "window") + return self._plan._create( + SlotRef(self._slot), "pane", "split-window", _split_args(horizontal, shell) + ) + + def new_window(self, *, name: str | None = None) -> ForwardHandle: + """Create a window in this session handle; return a window handle. + + Targets the session as ``-t $N:`` -- the captured session id with a + ``:`` suffix, so a plain ``$N`` capture addresses a new window in it. + """ + self._require("new_window", "session") + args = ("-n", name) if name is not None else () + return self._plan._create( + SlotRef(self._slot, ":"), "window", "new-window", args + ) def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: """Decorate this handle via its namespaces (reused, no new vocabulary).""" self._plan._steps.extend(_Decorate(call) for call in _to_calls(build(self))) return self + def _require(self, verb: str, *kinds: Kind) -> None: + """Reject a creation verb used on a handle of the wrong tmux scope.""" + if self._kind not in kinds: + allowed = " or ".join(kinds) + msg = f"{verb}() needs a {allowed} handle, not a {self._kind} handle" + raise TypeError(msg) + class ForwardPlan: r"""A builder for a multi-handle forward plan, resolved over N dispatches. - Hand out independent handles (``split()``), decorate each through its reused - namespaces, then :meth:`run_resolving` (sync) or :meth:`run_resolving_async`: - one creation per dispatch (``-P -F`` capture), the downstream commands folded - into one trailing ``\;`` chain with the captured ids substituted in. + Hand out independent handles across every tmux scope -- :meth:`new_session`, + then :meth:`ForwardHandle.new_window`, then :meth:`split` -- decorate each + through its reused namespaces, then :meth:`run_resolving` (sync) or + :meth:`run_resolving_async`: one creation per dispatch (``-P -F`` capture), + the downstream commands folded into one trailing ``\;`` chain with the + captured ids substituted in. Examples -------- @@ -300,29 +338,30 @@ def _seed_target(self) -> str | int | None | SlotRef: return None return _target_arg(self._seed) - def _split( + def _create( self, parent: str | int | None | SlotRef, - horizontal: bool, - shell: str | None, + kind: Kind, + name: str, + args: tuple[str, ...], ) -> ForwardHandle: slot = self._n self._n += 1 - args: list[str] = ["-h" if horizontal else "-v"] - if shell is not None: - args.append(shell) - self._steps.append( - _Create( - slot, "pane", CommandCall("split-window", tuple(args), target=parent) - ) - ) - return ForwardHandle(self, slot) + self._steps.append(_Create(slot, kind, CommandCall(name, args, target=parent))) + return ForwardHandle(self, slot, kind) def split( self, *, horizontal: bool = False, shell: str | None = None ) -> ForwardHandle: """Split the seed (root); return a handle to the new pane.""" - return self._split(self._seed_target(), horizontal, shell) + return self._create( + self._seed_target(), "pane", "split-window", _split_args(horizontal, shell) + ) + + def new_session(self, *, name: str | None = None) -> ForwardHandle: + """Create a detached session; return a session handle.""" + args = ("-d", "-s", name) if name is not None else ("-d",) + return self._create(None, "session", "new-session", args) def run_resolving(self, runner: PlanRunner) -> Resolved: """Resolve over N dispatches against a live server (sync).""" diff --git a/src/libtmux/_experimental/chain/ir.py b/src/libtmux/_experimental/chain/ir.py index 76e119c1b..8bf9d4f0f 100644 --- a/src/libtmux/_experimental/chain/ir.py +++ b/src/libtmux/_experimental/chain/ir.py @@ -95,16 +95,21 @@ class SlotRef: Carried by a :class:`CommandCall` built against a forward handle in a multi-dispatch plan. The resolver replaces it with the captured concrete id - (``%N``/``@N``/``$N``) before the call is rendered; rendering an unresolved - SlotRef is a planner bug and raises. + (``%N``/``@N``/``$N``) plus ``suffix`` before the call is rendered; rendering + an unresolved SlotRef is a planner bug and raises. ``suffix`` lets a command + that needs a qualified target -- e.g. ``new-window -t $N:`` -- reuse a plain + captured ``$N``. Examples -------- >>> SlotRef(0) - SlotRef(slot=0) + SlotRef(slot=0, suffix='') + >>> SlotRef(0, ":") + SlotRef(slot=0, suffix=':') """ slot: int + suffix: str = "" @dataclass(frozen=True, slots=True) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 4f99eaef4..6a6917be1 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -138,3 +138,83 @@ def test_multidispatch_from_query_seed(session: Session) -> None: assert _mark_of(session, resolved.bindings[0]) == ["A"] assert _mark_of(session, resolved.bindings[1]) == ["B"] + + +def test_handle_scope_guard() -> None: + """A creation verb on the wrong tmux scope fails fast at build time (pure).""" + plan = ForwardPlan() + sess = plan.new_session(name="g") + win = sess.new_window(name="w") + pane = win.split() # window -> split is allowed + + with pytest.raises(TypeError): + sess.split() # a session has no single pane to split + with pytest.raises(TypeError): + win.new_window() # only a session creates windows + with pytest.raises(TypeError): + pane.new_window() # ditto for a pane + + +def test_multidispatch_session_window_pane(session: Session) -> None: + """Live: one plan builds a session, two independent windows, a split in each. + + Five creations resolve over five dispatches -- a session (``$N``), two + windows captured via ``new-window -t $N:`` (``@M``), and a split inside each + window (``%K``) -- proving the builder spans all three tmux scopes. + """ + server = session.server + name = "cc_md_swp" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + w_left = sess.new_window(name="left") + w_right = sess.new_window(name="right") + w_left.split(horizontal=True).do(_mark("WL")) + w_right.split().do(_mark("WR")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert set(resolved.bindings) == {0, 1, 2, 3, 4} + assert resolved.bindings[0].startswith("$") # session + assert resolved.bindings[1].startswith("@") # left window + assert resolved.bindings[2].startswith("@") # right window + assert resolved.bindings[3].startswith("%") # pane split into left + assert resolved.bindings[4].startswith("%") # pane split into right + + created = next(s for s in server.sessions if s.session_name == name) + wins = {w.window_name: w for w in created.windows} + assert {"left", "right"} <= set(wins) + wins["left"].refresh() + wins["right"].refresh() + assert len(wins["left"].panes) == 2 # each window's pane was split + assert len(wins["right"].panes) == 2 + assert _mark_of(session, resolved.bindings[3]) == ["WL"] + assert _mark_of(session, resolved.bindings[4]) == ["WR"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + +@pytest.mark.asyncio +async def test_multidispatch_session_window_async(session: Session) -> None: + """Live async: the same session/window/pane span, driven with await.""" + server = session.server + name = "cc_md_swp_async" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + w_a = sess.new_window(name="a") + w_b = sess.new_window(name="b") + w_a.split().do(_mark("A")) + w_b.split().do(_mark("B")) + + resolved = await plan.run_resolving_async(AsyncSessionPlanExecutor(session)) + + assert resolved.bindings[0].startswith("$") + assert _mark_of(session, resolved.bindings[3]) == ["A"] + assert _mark_of(session, resolved.bindings[4]) == ["B"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() From b3a2eeb1bc29c07e1ab1fe89d315df5424163a33 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:16:14 -0500 Subject: [PATCH 25/42] Experimental(feat[chain]): fold a lone pane handle into one {marked} dispatch why: A single-handle forward plan (split one pane, decorate it) still cost two dispatches -- create+capture, then a substituted decorate chain -- because a freshly-created id can't be substituted mid-`\;`-chain. tmux's marked-pane register sidesteps that: the mark set by one command is visible to a later command in the SAME invocation, so a lone pane handle resolves in ONE dispatch. what: - Add a plan-shape analyzer (_marked_eligible): exactly one pane `_Create` is the one shape that folds to a single dispatch -- the marked register is a single server-wide slot (so >=2 independent handles still need N dispatches) and only a non-detached split leaves its result active to be marked (so a detached session create stays multi-dispatch) - Add _marked_invocation: ` \; select-pane -m \; ... \; select-pane -M` -- capture the id for bindings, mark the new active pane, address it via {marked} (resolves for window/session decorates too), and clear the register so no server-wide mark leaks drive() picks the strategy by shape; the multi-dispatch path is unchanged - Unify the `-P -F` capture into one _capturing helper shared by both strategies - Cover it: an analyzer-classification test, a pure single-dispatch fold test, and a live test asserting one invocation + correct {marked} decorate + that the mark is cleared afterward --- src/libtmux/_experimental/chain/_resolve.py | 73 ++++++++++++++-- tests/_experimental/chain/test_resolve.py | 92 ++++++++++++++++++++- 2 files changed, 159 insertions(+), 6 deletions(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 6a0e34fae..d4af576c2 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -56,6 +56,7 @@ "session": "#{session_id}", } _SEED = -1 # reserved binding key for a query-resolved seed +_MARKED = "{marked}" # tmux's single server-wide marked-pane target token class NoSeedResolved(RuntimeError): @@ -118,9 +119,14 @@ class Resolved: results: tuple[CommandResultLike, ...] = () -def _with_capture(call: CommandCall, kind: Kind) -> tuple[str, ...]: +def _capturing(call: CommandCall, kind: Kind) -> CommandCall: """Append ``-P -F '#{_id}'`` so the creation prints its stable id.""" - return (*call.argv(), "-P", "-F", _CAPTURE[kind]) + return dataclasses.replace(call, args=(*call.args, "-P", "-F", _CAPTURE[kind])) + + +def _with_capture(call: CommandCall, kind: Kind) -> tuple[str, ...]: + """Render a capturing creation as argv (the multi-dispatch per-step form).""" + return _capturing(call, kind).argv() def _subst(call: CommandCall, bindings: dict[int, str]) -> CommandCall: @@ -131,6 +137,53 @@ def _subst(call: CommandCall, bindings: dict[int, str]) -> CommandCall: return call +# --- strategy: a lone pane handle folds into one {marked} dispatch ---------- +def _to_marked(call: CommandCall) -> CommandCall: + """Retarget a SlotRef call to tmux's ``{marked}`` register (single-dispatch).""" + if isinstance(call.target, SlotRef): + return dataclasses.replace(call, target=_MARKED) + return call + + +def _marked_eligible(steps: tuple[_Step, ...]) -> _Create | None: + """Return the lone pane creation when the plan folds into one dispatch. + + The marked register is a single server-wide slot, and only a non-detached + pane creation (``split-window``) leaves its result active to be marked. So + exactly one pane :class:`_Create` is the one plan shape that resolves in a + single ``{marked}`` invocation; any other shape (two or more creations, or a + detached session creation) needs the multi-dispatch path. + """ + creates = [step for step in steps if isinstance(step, _Create)] + if len(creates) == 1 and creates[0].kind == "pane": + return creates[0] + return None + + +def _marked_invocation( + create: _Create, + decorates: tuple[CommandCall, ...], + bindings: dict[int, str], +) -> tuple[str, ...]: + r"""Fold a lone pane creation and its decorates into one ``\;`` invocation. + + Emits `` \; select-pane -m \; ... \; select-pane -M``: the split's new pane is active, ``-m`` + marks it, every decorate addresses it through tmux's ``{marked}`` register + (which resolves for window- and session-scoped decorates too), and a trailing + ``-M`` clears the register. Should the chain fail after the mark is set, + :func:`drive` issues a recovery ``-M``, so no server-wide mark leaks. With no + decorates only the capturing creation runs -- the mark would have no reader. + """ + capture = _capturing(_subst(create.call, bindings), create.kind) + if not decorates: + return capture.argv() + calls = [capture, CommandCall("select-pane", ("-m",))] + calls.extend(_to_marked(call) for call in decorates) + calls.append(CommandCall("select-pane", ("-M",))) + return CommandChain(tuple(calls)).argv() + + def drive( steps: tuple[_Step, ...], *, @@ -138,9 +191,11 @@ def drive( ) -> t.Generator[Request, t.Any, Resolved]: r"""Sans-I/O resolution core: yield a :class:`Request`, resume via ``.send``. - Each :class:`_Create` is dispatched alone with ``-P -F`` id capture; a run of - :class:`_Decorate`\\s folds into one trailing ``\;`` chain with the captured - ids substituted. No awaits, no runner, no threads -- a pure state machine the + The plan shape picks the cheapest correct strategy (see :func:`_marked_eligible`): + a lone pane creation folds into **one** ``{marked}`` invocation; otherwise each + :class:`_Create` is dispatched alone with ``-P -F`` id capture and a run of + :class:`_Decorate`\\s folds into one trailing ``\;`` chain with the captured ids + substituted. No awaits, no runner, no threads -- a pure state machine the sync/async drivers feed results into. """ bindings: dict[int, str] = {} @@ -155,6 +210,14 @@ def drive( raise NoSeedResolved(msg) bindings[_SEED] = str(_target_arg(seed.pane_id)) + solo = _marked_eligible(steps) + if solo is not None: + decorates = tuple(s.call for s in steps if isinstance(s, _Decorate)) + argv = _marked_invocation(solo, decorates, bindings) + result = yield Dispatch(argv, solo.slot) + bindings[solo.slot] = result.stdout[0].strip() + return Resolved(bindings, (result,)) + for step in steps: if isinstance(step, _Create): if tail: diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 6a6917be1..7a8829266 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -13,7 +13,7 @@ SessionPlanExecutor, panes, ) -from libtmux._experimental.chain._resolve import Dispatch, drive +from libtmux._experimental.chain._resolve import Dispatch, _marked_eligible, drive from libtmux._experimental.chain.plan import PaneTarget if t.TYPE_CHECKING: @@ -218,3 +218,93 @@ async def test_multidispatch_session_window_async(session: Session) -> None: for s in list(server.sessions): if s.session_name == name: s.kill() + + +def test_marked_eligible_classifies_plan_shapes() -> None: + """The analyzer picks single-dispatch only for a lone pane creation.""" + one_pane = ForwardPlan(PaneTarget("%1")) + one_pane.split().do(_mark("x")) + assert _marked_eligible(tuple(one_pane._steps)) is not None + + two_panes = ForwardPlan(PaneTarget("%1")) + two_panes.split() + two_panes.split() + assert _marked_eligible(tuple(two_panes._steps)) is None # one mark slot only + + one_session = ForwardPlan() + one_session.new_session(name="x") + # a detached session has no active pane to mark: + assert _marked_eligible(tuple(one_session._steps)) is None + + +def test_marked_single_dispatch_folds_one_handle() -> None: + """A lone pane handle resolves in ONE ``{marked}`` invocation (pure, no tmux). + + The split captures its id, marks the new (active) pane, the decorate + addresses it via ``{marked}``, and the mark is cleared -- all one dispatch. + """ + plan = ForwardPlan(PaneTarget("%1")) + plan.split(horizontal=True).do(_mark("L")) + + gen = drive(tuple(plan._steps)) + dispatched: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + dispatched.append(request.argv) + out = ["%7"] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=out)) + except StopIteration as stop: + resolved = stop.value + + assert dispatched == [ + ( + "split-window", + "-t", + "%1", + "-h", + "-P", + "-F", + "#{pane_id}", + ";", + "select-pane", + "-m", + ";", + "set-option", + "-t", + "{marked}", + "-p", + "@m", + "L", + ";", + "select-pane", + "-M", + ), + ] + assert len(dispatched) == 1 # one invocation, not create + decorate + assert resolved.bindings == {0: "%7"} + + +def test_marked_single_dispatch_live(session: Session) -> None: + """Live: a lone forward pane resolves in one dispatch and leaks no mark.""" + window = session.new_window(window_name="marked_solo") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(_mark("SOLO")) + + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert len(resolved.results) == 1 # single invocation, not create + decorate + new_pane_id = resolved.bindings[0] + window.refresh() + assert len(window.panes) == 2 + assert _mark_of(session, new_pane_id) == ["SOLO"] + # the server-wide mark register is cleared afterward, not leaked: + marked = session.server.cmd( + "display-message", "-p", "-t", new_pane_id, "#{pane_marked}" + ).stdout + assert marked == ["0"] From b21935c318afc69b3170f22cace83bf13afb409a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 09:53:43 -0500 Subject: [PATCH 26/42] docs(CHANGES): experimental chainable tmux command system why: Record the new libtmux._experimental.chain command system in the unreleased notes so downstream users learn it exists. what: - Add a sections-only ### What's new entry under the placeholder (no lead paragraph, no version) describing one-call command chains and lazy pane/window/session references --- CHANGES | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGES b/CHANGES index 3798c5eb3..b88fdc224 100644 --- a/CHANGES +++ b/CHANGES @@ -45,6 +45,17 @@ $ uvx --from 'libtmux' --prerelease allow python _Notes on the upcoming release will go here._ +### What's new + +#### New: chainable tmux commands (`libtmux._experimental.chain`) + +Build a sequence of tmux commands and run it in a single tmux call, +instead of one subprocess per command. References to panes, windows, +and sessions can be lazy — point at an object a command will create +and keep building against it — and they all resolve together when the +chain runs. Experimental and opt-in: import from +`libtmux._experimental.chain`, not the top-level `libtmux`. (#685) + ## libtmux 0.58.1 (2026-06-16) libtmux 0.58.1 restores compatibility with pytest 9.1. The bundled From 45eef7ae932e70dcf9b58a997dfbb516bc9e8ea5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 10:24:03 -0500 Subject: [PATCH 27/42] Experimental(fix[chain]): fail loudly when a forward creation dispatch fails why: A failed split/new-window/new-session prints nothing on stdout, so the resolver's `bindings[slot] = result.stdout[0].strip()` raised an opaque IndexError instead of surfacing tmux's actual error -- a downstream consumer hitting a bad target or an out-of-space split got a confusing stack trace. what: - Add ForwardDispatchError carrying the offending argv + tmux result, with a clear message including the exit code and stderr - Route both capture sites in drive() through _capture_id(), which raises on a nonzero exit or empty stdout rather than indexing into nothing - Export ForwardDispatchError; cover with a pure test (failed result) and a live test (split against a bogus target) --- src/libtmux/_experimental/chain/__init__.py | 2 + src/libtmux/_experimental/chain/_resolve.py | 42 ++++++++++++++++++++- tests/_experimental/chain/test_resolve.py | 29 +++++++++++++- 3 files changed, 70 insertions(+), 3 deletions(-) diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index f6d5afa2c..7b6b78c82 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -36,6 +36,7 @@ snapshot_from_session, ) from libtmux._experimental.chain._resolve import ( + ForwardDispatchError, ForwardHandle, ForwardPlan, Resolved, @@ -89,6 +90,7 @@ "DeferredCommandResult", "DeferredOutputUnavailable", "ForwardDataUnavailable", + "ForwardDispatchError", "ForwardHandle", "ForwardPlan", "NoCommandsResolved", diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index d4af576c2..d2c6f0f1f 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -63,6 +63,44 @@ class NoSeedResolved(RuntimeError): """Raised when a query-seeded forward plan matches no pane.""" +class ForwardDispatchError(RuntimeError): + r"""A forward creation dispatch failed -- nonzero exit or no captured id. + + Raised by the resolver when a ``split``/``new-window``/``new-session`` did + not print the id it was asked to capture (tmux rejected the target, ran out + of space, etc.). It turns what was an opaque ``IndexError`` on empty stdout + into a clear failure carrying the offending ``argv`` and the tmux result. + + Examples + -------- + >>> class _Result: + ... stdout: list[str] = [] + ... stderr = ["no space for new pane"] + ... returncode = 1 + >>> err = ForwardDispatchError(("split-window", "-t", "%1"), _Result()) + >>> err.argv + ('split-window', '-t', '%1') + >>> print(str(err)) + forward dispatch failed (exit 1): split-window -t %1: no space for new pane + """ + + def __init__(self, argv: tuple[str, ...], result: CommandResultLike) -> None: + self.argv = argv + self.result = result + stderr = " ".join(result.stderr).strip() + detail = f": {stderr}" if stderr else "" + cmd = " ".join(argv) + msg = f"forward dispatch failed (exit {result.returncode}): {cmd}{detail}" + super().__init__(msg) + + +def _capture_id(argv: tuple[str, ...], result: CommandResultLike) -> str: + """Read the id a creation dispatch printed, or fail loudly.""" + if result.returncode != 0 or not result.stdout: + raise ForwardDispatchError(argv, result) + return result.stdout[0].strip() + + # --- plan IR ---------------------------------------------------------------- @dataclass(frozen=True, slots=True) class _Create: @@ -215,7 +253,7 @@ def drive( decorates = tuple(s.call for s in steps if isinstance(s, _Decorate)) argv = _marked_invocation(solo, decorates, bindings) result = yield Dispatch(argv, solo.slot) - bindings[solo.slot] = result.stdout[0].strip() + bindings[solo.slot] = _capture_id(argv, result) return Resolved(bindings, (result,)) for step in steps: @@ -227,7 +265,7 @@ def drive( argv = _with_capture(_subst(step.call, bindings), step.kind) result = yield Dispatch(argv, step.slot) results.append(result) - bindings[step.slot] = result.stdout[0].strip() + bindings[step.slot] = _capture_id(argv, result) else: tail.append(step.call) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 7a8829266..6e6d6643c 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -13,7 +13,12 @@ SessionPlanExecutor, panes, ) -from libtmux._experimental.chain._resolve import Dispatch, _marked_eligible, drive +from libtmux._experimental.chain._resolve import ( + Dispatch, + ForwardDispatchError, + _marked_eligible, + drive, +) from libtmux._experimental.chain.plan import PaneTarget if t.TYPE_CHECKING: @@ -220,6 +225,28 @@ async def test_multidispatch_session_window_async(session: Session) -> None: s.kill() +def test_forward_dispatch_error_on_failed_create() -> None: + """A failed creation dispatch raises ForwardDispatchError, not IndexError.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split() + + gen = drive(tuple(plan._steps)) + next(gen) # the split's capturing dispatch + with pytest.raises(ForwardDispatchError) as excinfo: + gen.send(_FakeResult(stdout=[], stderr=["no space for new pane"], returncode=1)) + + assert "no space for new pane" in str(excinfo.value) + assert excinfo.value.argv[0] == "split-window" + + +def test_forward_dispatch_error_live(session: Session) -> None: + """Live: splitting against a bogus target fails loudly with the tmux error.""" + plan = ForwardPlan(PaneTarget("%nonexistent999")) + plan.split() + with pytest.raises(ForwardDispatchError): + plan.run_resolving(SessionPlanExecutor(session)) + + def test_marked_eligible_classifies_plan_shapes() -> None: """The analyzer picks single-dispatch only for a lone pane creation.""" one_pane = ForwardPlan(PaneTarget("%1")) From c7a593f3f108dc93fdd5a011f55a096866e2958e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 10:26:28 -0500 Subject: [PATCH 28/42] Experimental(feat[chain]): forward creates accept start_directory and environment why: A forward split/new-window/new-session could set only -h/-v + shell and a name -- a downstream workspace builder couldn't give a new pane its working directory or env, and these must ride the create command itself (a later decorate is too late), so the whole builder was forced onto a hybrid path. what: - Add start_directory= and environment= to split(), new_window(), new_session() (plus window_shell= on new_window and width=/height= on new_session) - Render them as libtmux does -- a concatenated `-c` and one `-e=` per var -- via a shared _location_args helper - Cover with a pure render test across all three verbs and a live test asserting a forward split lands in the requested start_directory --- src/libtmux/_experimental/chain/_resolve.py | 92 ++++++++++++++++++--- tests/_experimental/chain/test_resolve.py | 48 +++++++++++ 2 files changed, 128 insertions(+), 12 deletions(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index d2c6f0f1f..8dee73072 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -310,9 +310,34 @@ async def run_async( # --- the builder + handles -------------------------------------------------- -def _split_args(horizontal: bool, shell: str | None) -> tuple[str, ...]: +def _location_args( + start_directory: str | None, environment: dict[str, str] | None +) -> tuple[str, ...]: + """Render create-time ``-c`` / ``-e=`` flags (as libtmux renders them). + + Examples + -------- + >>> _location_args("/tmp", {"FOO": "bar"}) + ('-c/tmp', '-eFOO=bar') + >>> _location_args(None, None) + () + """ + args: list[str] = [] + if start_directory is not None: + args.append(f"-c{start_directory}") + if environment: + args.extend(f"-e{key}={value}" for key, value in environment.items()) + return tuple(args) + + +def _split_args( + horizontal: bool, + shell: str | None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, +) -> tuple[str, ...]: """Render the ``split-window`` flags shared by the plan- and handle-level split.""" - args = ["-h" if horizontal else "-v"] + args = ["-h" if horizontal else "-v", *_location_args(start_directory, environment)] if shell is not None: args.append(shell) return tuple(args) @@ -351,24 +376,44 @@ def session(self) -> BoundSessionCommands: return BoundSessionCommands(SlotRef(self._slot)) def split( - self, *, horizontal: bool = False, shell: str | None = None + self, + *, + horizontal: bool = False, + shell: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, ) -> ForwardHandle: """Split this handle's active pane; return a handle to the new pane.""" self._require("split", "pane", "window") return self._plan._create( - SlotRef(self._slot), "pane", "split-window", _split_args(horizontal, shell) + SlotRef(self._slot), + "pane", + "split-window", + _split_args(horizontal, shell, start_directory, environment), ) - def new_window(self, *, name: str | None = None) -> ForwardHandle: + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + window_shell: str | None = None, + ) -> ForwardHandle: """Create a window in this session handle; return a window handle. Targets the session as ``-t $N:`` -- the captured session id with a ``:`` suffix, so a plain ``$N`` capture addresses a new window in it. """ self._require("new_window", "session") - args = ("-n", name) if name is not None else () + args: list[str] = [] + if name is not None: + args += ["-n", name] + args.extend(_location_args(start_directory, environment)) + if window_shell is not None: + args.append(window_shell) return self._plan._create( - SlotRef(self._slot, ":"), "window", "new-window", args + SlotRef(self._slot, ":"), "window", "new-window", tuple(args) ) def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: @@ -452,17 +497,40 @@ def _create( return ForwardHandle(self, slot, kind) def split( - self, *, horizontal: bool = False, shell: str | None = None + self, + *, + horizontal: bool = False, + shell: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, ) -> ForwardHandle: """Split the seed (root); return a handle to the new pane.""" return self._create( - self._seed_target(), "pane", "split-window", _split_args(horizontal, shell) + self._seed_target(), + "pane", + "split-window", + _split_args(horizontal, shell, start_directory, environment), ) - def new_session(self, *, name: str | None = None) -> ForwardHandle: + def new_session( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + width: int | None = None, + height: int | None = None, + ) -> ForwardHandle: """Create a detached session; return a session handle.""" - args = ("-d", "-s", name) if name is not None else ("-d",) - return self._create(None, "session", "new-session", args) + args: list[str] = ["-d"] + if name is not None: + args += ["-s", name] + args.extend(_location_args(start_directory, environment)) + if width is not None: + args += ["-x", str(width)] + if height is not None: + args += ["-y", str(height)] + return self._create(None, "session", "new-session", tuple(args)) def run_resolving(self, runner: PlanRunner) -> Resolved: """Resolve over N dispatches against a live server (sync).""" diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 6e6d6643c..d6f7caf07 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -2,6 +2,7 @@ from __future__ import annotations +import pathlib import typing as t from dataclasses import dataclass, field @@ -225,6 +226,53 @@ async def test_multidispatch_session_window_async(session: Session) -> None: s.kill() +def test_creation_options_render() -> None: + """split/new_session/new_window render -c/-e/size onto the create argv.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split(start_directory="/tmp", environment={"FOO": "bar"}) + assert plan._steps[0].call.args == ("-v", "-c/tmp", "-eFOO=bar") + + plan2 = ForwardPlan() + sess = plan2.new_session( + name="x", start_directory="/tmp", environment={"A": "1"}, width=200, height=50 + ) + sess.new_window( + name="w", start_directory="/srv", environment={"B": "2"}, window_shell="zsh" + ) + assert plan2._steps[0].call.args == ( + "-d", + "-s", + "x", + "-c/tmp", + "-eA=1", + "-x", + "200", + "-y", + "50", + ) + assert plan2._steps[1].call.args == ("-n", "w", "-c/srv", "-eB=2", "zsh") + + +def test_creation_start_directory_live( + session: Session, tmp_path: pathlib.Path +) -> None: + """Live: a forward split honours start_directory.""" + window = session.new_window(window_name="cc_cwd") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(start_directory=str(tmp_path)) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + cwd = session.server.cmd( + "display-message", "-p", "-t", resolved.bindings[0], "#{pane_current_path}" + ).stdout + assert cwd + assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() + + def test_forward_dispatch_error_on_failed_create() -> None: """A failed creation dispatch raises ForwardDispatchError, not IndexError.""" plan = ForwardPlan(PaneTarget("%1")) From b2e2cd163966b54c0d53a3323fc7e54ddeae4846 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 10:35:05 -0500 Subject: [PATCH 29/42] Experimental(feat[chain]): seed forward plans from existing sessions and windows why: A ForwardPlan could only seed a pane (to split) or a query, and new_window lived only on a forward session handle -- so a downstream workspace builder could not add windows to a session it already created, and seeding forced the verbose six-field PaneRef.concrete(...) boilerplate. The pre-existing seed had no handle, so its first/existing pane could not be decorated in the plan. what: - Generalize ForwardHandle to bind either a SlotRef (forward) or a concrete id (an existing object), so one handle type spans created and seed objects - Add ForwardPlan.from_window / from_session (and let from_pane accept a live libtmux Pane, a chain ref, a typed target, or a bare id via _id_of) - Expose ForwardPlan.seed -- a handle to the existing seed -- so it can be .do()-decorated, split(), or new_window()'d by scope; add ForwardPlan.new_window to add a window to the seed session - Cover with pure render tests across all three scopes, a seed-decorate test, and a live test adding a window+split to an existing session --- src/libtmux/_experimental/chain/_resolve.py | 140 +++++++++++++++++--- tests/_experimental/chain/test_resolve.py | 47 +++++++ 2 files changed, 165 insertions(+), 22 deletions(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 8dee73072..4ccc566b7 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -39,6 +39,11 @@ BoundWindowCommands, PaneQuery, PaneRef, + PaneTarget, + SessionRef, + SessionTarget, + WindowRef, + WindowTarget, _target_arg, _to_calls, ) @@ -48,6 +53,14 @@ from libtmux._experimental.chain._async import AsyncPlanRunner from libtmux._experimental.chain.plan import IntoCommands, PlanRunner + from libtmux.pane import Pane + from libtmux.session import Session + from libtmux.window import Window + +# A live libtmux object, a chain ref, a typed target, or a bare id string. +PaneSeed: t.TypeAlias = "Pane | PaneRef | PaneTarget | str" +WindowSeed: t.TypeAlias = "Window | WindowRef | WindowTarget | str" +SessionSeed: t.TypeAlias = "Session | SessionRef | SessionTarget | str" Kind = t.Literal["pane", "window", "session"] _CAPTURE: dict[Kind, str] = { @@ -343,37 +356,81 @@ def _split_args( return tuple(args) +def _id_of(obj: object, attr: str) -> str: + """Read a tmux id string from a live libtmux object, a chain ref, or a str. + + Examples + -------- + >>> _id_of("%1", "pane_id") + '%1' + >>> _id_of(PaneTarget("%2"), "pane_id") + '%2' + >>> _id_of(PaneRef.concrete(pane_id="%3", window_id="@1", session_id="$0", + ... pane_index=0, active=True, title=""), "pane_id") + '%3' + """ + if isinstance(obj, str): + return obj + if isinstance(obj, (PaneTarget, WindowTarget, SessionTarget)): + return obj.value + value = getattr(obj, attr) + if isinstance(value, (PaneTarget, WindowTarget, SessionTarget)): + return value.value + return str(value) + + +def _kind_of(target: AnyTarget) -> Kind: + """Return the tmux scope a concrete seed target addresses.""" + if isinstance(target, PaneTarget): + return "pane" + if isinstance(target, WindowTarget): + return "window" + if isinstance(target, SessionTarget): + return "session" + msg = f"cannot seed a forward plan from {type(target).__name__}" + raise TypeError(msg) + + class ForwardHandle: - """A reference to a forward object (a slot) inside a :class:`ForwardPlan`. - - One type spans all three tmux scopes: the handle knows its ``kind`` - (``pane``/``window``/``session``), so its creation verbs stay scope-correct - -- ``new_window()`` only on a session, ``split()`` only on a pane or window - -- while the ``.cmd``/``.window``/``.session`` command namespaces are reused - unchanged, each bound to this handle's - :class:`~libtmux._experimental.chain.ir.SlotRef` so the resolver substitutes - the captured id. + """A reference to one object inside a :class:`ForwardPlan` -- forward or seed. + + One type spans all three tmux scopes and both lifetimes: a *forward* handle + is bound to a :class:`~libtmux._experimental.chain.ir.SlotRef` (a not-yet- + created object whose id the resolver substitutes); a *seed* handle is bound + to a concrete id string (an object that already exists). The handle knows its + ``kind`` so creation verbs stay scope-correct -- ``new_window()`` only on a + session, ``split()`` only on a pane or window -- while the ``.cmd``/ + ``.window``/``.session`` command namespaces are reused unchanged. """ - def __init__(self, plan: ForwardPlan, slot: int, kind: Kind) -> None: + def __init__(self, plan: ForwardPlan, ref: SlotRef | str, kind: Kind) -> None: self._plan = plan - self._slot = slot + self._ref = ref self._kind = kind @property def cmd(self) -> BoundPaneCommands: """Pane-scoped commands bound to this handle.""" - return BoundPaneCommands(SlotRef(self._slot)) + ref = self._ref if isinstance(self._ref, SlotRef) else PaneTarget(self._ref) + return BoundPaneCommands(ref) @property def window(self) -> BoundWindowCommands: """Window-scoped commands (a pane id resolves up to its window).""" - return BoundWindowCommands(SlotRef(self._slot)) + ref = self._ref if isinstance(self._ref, SlotRef) else WindowTarget(self._ref) + return BoundWindowCommands(ref) @property def session(self) -> BoundSessionCommands: """Session-scoped commands bound to this handle's session.""" - return BoundSessionCommands(SlotRef(self._slot)) + ref = self._ref if isinstance(self._ref, SlotRef) else SessionTarget(self._ref) + return BoundSessionCommands(ref) + + def _parent(self, suffix: str = "") -> str | int | None | SlotRef: + """Return the ``-t`` parent for a creation off this handle (slot or id).""" + if isinstance(self._ref, SlotRef): + return SlotRef(self._ref.slot, suffix) + return f"{self._ref}{suffix}" if suffix else self._ref def split( self, @@ -386,7 +443,7 @@ def split( """Split this handle's active pane; return a handle to the new pane.""" self._require("split", "pane", "window") return self._plan._create( - SlotRef(self._slot), + self._parent(), "pane", "split-window", _split_args(horizontal, shell, start_directory, environment), @@ -402,8 +459,8 @@ def new_window( ) -> ForwardHandle: """Create a window in this session handle; return a window handle. - Targets the session as ``-t $N:`` -- the captured session id with a - ``:`` suffix, so a plain ``$N`` capture addresses a new window in it. + Targets the session as ``-t $N:`` -- the (captured or concrete) session + id with a ``:`` suffix, so it addresses a new window in that session. """ self._require("new_window", "session") args: list[str] = [] @@ -413,7 +470,7 @@ def new_window( if window_shell is not None: args.append(window_shell) return self._plan._create( - SlotRef(self._slot, ":"), "window", "new-window", tuple(args) + self._parent(":"), "window", "new-window", tuple(args) ) def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: @@ -466,9 +523,19 @@ def __init__(self, seed: AnyTarget | None = None) -> None: self._seed_query: PaneQuery | None = None @classmethod - def from_pane(cls, pane: PaneRef) -> ForwardPlan: - """Seed the plan from a concrete pane row.""" - return cls(seed=pane.pane_id) + def from_pane(cls, pane: PaneSeed) -> ForwardPlan: + """Seed from an existing pane (a live ``Pane``, a ref, a target, or an id).""" + return cls(seed=PaneTarget(_id_of(pane, "pane_id"))) + + @classmethod + def from_window(cls, window: WindowSeed) -> ForwardPlan: + """Seed from an existing window -- ``split`` splits its active pane.""" + return cls(seed=WindowTarget(_id_of(window, "window_id"))) + + @classmethod + def from_session(cls, session: SessionSeed) -> ForwardPlan: + """Seed from an existing session -- ``new_window`` adds windows to it.""" + return cls(seed=SessionTarget(_id_of(session, "session_id"))) @classmethod def from_query(cls, query: PaneQuery) -> ForwardPlan: @@ -477,6 +544,19 @@ def from_query(cls, query: PaneQuery) -> ForwardPlan: plan._seed_query = query return plan + @property + def seed(self) -> ForwardHandle: + """A handle to the existing seed object -- decorate it or create off it. + + Lets the pre-existing seed take part in the plan like a created handle: + ``plan.seed.do(...)`` decorates it, and (by scope) ``plan.seed.split()`` / + ``plan.seed.new_window()`` create children of it. + """ + if self._seed is None: + msg = "plan has no concrete seed (use from_pane/from_window/from_session)" + raise ValueError(msg) + return ForwardHandle(self, str(_target_arg(self._seed)), _kind_of(self._seed)) + def _seed_target(self) -> str | int | None | SlotRef: if self._seed_query is not None: return SlotRef(_SEED) @@ -494,7 +574,7 @@ def _create( slot = self._n self._n += 1 self._steps.append(_Create(slot, kind, CommandCall(name, args, target=parent))) - return ForwardHandle(self, slot, kind) + return ForwardHandle(self, SlotRef(slot), kind) def split( self, @@ -532,6 +612,22 @@ def new_session( args += ["-y", str(height)] return self._create(None, "session", "new-session", tuple(args)) + def new_window( + self, + *, + name: str | None = None, + start_directory: str | None = None, + environment: dict[str, str] | None = None, + window_shell: str | None = None, + ) -> ForwardHandle: + """Create a window in the seed session (requires :meth:`from_session`).""" + return self.seed.new_window( + name=name, + start_directory=start_directory, + environment=environment, + window_shell=window_shell, + ) + def run_resolving(self, runner: PlanRunner) -> Resolved: """Resolve over N dispatches against a live server (sync).""" return run_sync(drive(tuple(self._steps), seed_query=self._seed_query), runner) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index d6f7caf07..e9837f1c2 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -273,6 +273,53 @@ def test_creation_start_directory_live( assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() +def test_seed_from_existing_scopes_render() -> None: + """from_session/from_window/from_pane build creates targeting the seed id.""" + splan = ForwardPlan.from_session("$0") + splan.new_window(name="w") + assert splan._steps[0].call.argv() == ("new-window", "-t", "$0:", "-n", "w") + + wplan = ForwardPlan.from_window("@1") + wplan.split(horizontal=True) + assert wplan._steps[0].call.argv() == ("split-window", "-t", "@1", "-h") + + pplan = ForwardPlan.from_pane("%5") + pplan.split() + assert pplan._steps[0].call.argv() == ("split-window", "-t", "%5", "-v") + + +def test_seed_handle_decorates_existing_object() -> None: + """plan.seed exposes the pre-existing seed as a decoratable handle.""" + plan = ForwardPlan.from_pane("%1") + assert plan.seed.cmd.send_keys("clear", enter=True).argv() == ( + "send-keys", + "-t", + "%1", + "clear", + "Enter", + ) + # a query-seeded plan has no concrete seed to hand back: + qplan = ForwardPlan.from_query(panes().filter(active=True)) + with pytest.raises(ValueError, match="no concrete seed"): + _ = qplan.seed + + +def test_seed_from_live_session_adds_window(session: Session) -> None: + """Live: from_session(live) adds a window + split to the existing session.""" + n_before = len(session.windows) + + plan = ForwardPlan.from_session(session) + plan.new_window(name="cc_seeded").split().do(_mark("SEED")) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + session.refresh() + assert len(session.windows) == n_before + 1 + new_win = next(w for w in session.windows if w.window_name == "cc_seeded") + new_win.refresh() + assert len(new_win.panes) == 2 # the window's pane was split + assert _mark_of(session, resolved.bindings[1]) == ["SEED"] # slot 1 = split pane + + def test_forward_dispatch_error_on_failed_create() -> None: """A failed creation dispatch raises ForwardDispatchError, not IndexError.""" plan = ForwardPlan(PaneTarget("%1")) From 3e4a335922f4ed7bdc3661cb293d002849acea6e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 10:38:31 -0500 Subject: [PATCH 30/42] Experimental(feat[chain]): map resolved slots back to live libtmux objects why: A resolved plan only handed back Resolved.bindings (slot -> id string), so a downstream consumer that needs a libtmux Pane/Window/Session (to focus a pane, attach a session, or keep building) had to hand-roll server.panes.get(pane_id=...) lookups for every slot. what: - Add Resolved.pane/window/session(slot, server) -- look the captured id up in the server's QueryLists and return the typed libtmux object - Cover with a live test building a session/window/pane and asserting each helper round-trips its slot to the matching object id --- src/libtmux/_experimental/chain/_resolve.py | 19 +++++++++++++++++++ tests/_experimental/chain/test_resolve.py | 20 ++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 4ccc566b7..9ccddc8a5 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -54,6 +54,7 @@ from libtmux._experimental.chain._async import AsyncPlanRunner from libtmux._experimental.chain.plan import IntoCommands, PlanRunner from libtmux.pane import Pane + from libtmux.server import Server from libtmux.session import Session from libtmux.window import Window @@ -169,6 +170,24 @@ class Resolved: bindings: dict[int, str] = field(default_factory=dict) results: tuple[CommandResultLike, ...] = () + def pane(self, slot: int, server: Server) -> Pane: + """Look up the live pane a forward slot resolved to (by captured id).""" + pane = server.panes.get(pane_id=self.bindings[slot]) + assert pane is not None # get() raises ObjectDoesNotExist rather than None + return pane + + def window(self, slot: int, server: Server) -> Window: + """Look up the live window a forward slot resolved to (by captured id).""" + window = server.windows.get(window_id=self.bindings[slot]) + assert window is not None + return window + + def session(self, slot: int, server: Server) -> Session: + """Look up the live session a forward slot resolved to (by captured id).""" + session = server.sessions.get(session_id=self.bindings[slot]) + assert session is not None + return session + def _capturing(call: CommandCall, kind: Kind) -> CommandCall: """Append ``-P -F '#{_id}'`` so the creation prints its stable id.""" diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index e9837f1c2..52ba22a65 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -273,6 +273,26 @@ def test_creation_start_directory_live( assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() +def test_resolved_maps_slots_to_live_objects(session: Session) -> None: + """Resolved.pane/window/session(slot, server) return the created libtmux objects.""" + server = session.server + name = "cc_resolved_objs" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) # slot 0 (session) + win = sess.new_window(name="w") # slot 1 (window) + win.split() # slot 2 (pane) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + assert resolved.session(0, server).session_id == resolved.bindings[0] + assert resolved.window(1, server).window_id == resolved.bindings[1] + assert resolved.pane(2, server).pane_id == resolved.bindings[2] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + def test_seed_from_existing_scopes_render() -> None: """from_session/from_window/from_pane build creates targeting the seed id.""" splan = ForwardPlan.from_session("$0") From 15aa343dc577eb8a01f62cc08513df9edba3ddc7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 10:41:30 -0500 Subject: [PATCH 31/42] Experimental(feat[chain]): add a server-level PlanRunner for create-from-scratch why: run_resolving needs a PlanRunner (cmd + snapshot), but the only runners were session-backed -- so a ForwardPlan().new_session(...) plan (which has no pre-existing session) had to borrow an unrelated session's executor just to reach server.cmd. A bare Server had no clean way to drive a creation plan. what: - Add ServerPlanRunner / AsyncServerPlanRunner backed by a live Server: cmd dispatches straight through server.cmd; snapshot() is empty since a server runner is for creation, not query seeding (a query-seeded plan still wants a SessionPlanExecutor) - Export both; cover with doctests and a live test creating a whole session -> window -> pane tree through ServerPlanRunner alone --- src/libtmux/_experimental/chain/__init__.py | 4 ++ .../_experimental/chain/_connection.py | 67 +++++++++++++++++++ tests/_experimental/chain/test_resolve.py | 19 ++++++ 3 files changed, 90 insertions(+) diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index 7b6b78c82..7c97a807e 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -31,7 +31,9 @@ from libtmux._experimental.chain import _async as aio from libtmux._experimental.chain._connection import ( + AsyncServerPlanRunner, AsyncSessionPlanExecutor, + ServerPlanRunner, SessionPlanExecutor, snapshot_from_session, ) @@ -77,6 +79,7 @@ __all__ = [ "Arg", + "AsyncServerPlanRunner", "AsyncSessionPlanExecutor", "ChainabilityError", "CommandCall", @@ -100,6 +103,7 @@ "PendingTarget", "PlanRunner", "Resolved", + "ServerPlanRunner", "SessionPlanExecutor", "SessionRef", "SessionTarget", diff --git a/src/libtmux/_experimental/chain/_connection.py b/src/libtmux/_experimental/chain/_connection.py index 2ce1313ee..b2237a50c 100644 --- a/src/libtmux/_experimental/chain/_connection.py +++ b/src/libtmux/_experimental/chain/_connection.py @@ -33,6 +33,7 @@ ) if t.TYPE_CHECKING: + from libtmux.server import Server from libtmux.session import Session @@ -179,3 +180,69 @@ async def cmd( async def snapshot(self) -> TmuxSnapshot: """Return a fresh snapshot, read in a worker thread.""" return await asyncio.to_thread(self._sync.snapshot) + + +class ServerPlanRunner: + """A ``PlanRunner`` backed by a live :class:`libtmux.Server`. + + For create-from-scratch plans (``ForwardPlan().new_session(...)``) that have + no -- and need no -- pre-existing session: it dispatches straight through + ``server.cmd`` instead of borrowing an unrelated session's executor. + ``snapshot()`` is empty, since a server-level runner is for creation, not + query seeding -- a query-seeded plan still wants a :class:`SessionPlanExecutor`. + + Examples + -------- + >>> runner = ServerPlanRunner(server) + >>> runner.snapshot().panes + () + >>> runner.cmd("set-option", "-g", "@cc_srv_demo", "1").returncode + 0 + """ + + def __init__(self, server: Server) -> None: + self.server = server + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command through the live server.""" + runner = t.cast("CommandRunner", self.server) + return runner.cmd(cmd, *args, target=target) + + def snapshot(self) -> TmuxSnapshot: + """Return an empty snapshot (a server runner is for creation, not queries).""" + return TmuxSnapshot(panes=()) + + +class AsyncServerPlanRunner: + """An ``AsyncPlanRunner`` backed by a live :class:`libtmux.Server`. + + The async companion to :class:`ServerPlanRunner`; offloads the blocking + dispatch via :func:`asyncio.to_thread`. + + Examples + -------- + >>> import asyncio + >>> asyncio.run(AsyncServerPlanRunner(server).snapshot()).panes + () + """ + + def __init__(self, server: Server) -> None: + self._sync = ServerPlanRunner(server) + + async def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> CommandResultLike: + """Dispatch one tmux command in a worker thread.""" + return await asyncio.to_thread(self._sync.cmd, cmd, *args, target=target) + + async def snapshot(self) -> TmuxSnapshot: + """Return an empty snapshot in a worker thread.""" + return await asyncio.to_thread(self._sync.snapshot) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 52ba22a65..550d28406 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -11,6 +11,7 @@ from libtmux._experimental.chain import ( AsyncSessionPlanExecutor, ForwardPlan, + ServerPlanRunner, SessionPlanExecutor, panes, ) @@ -273,6 +274,24 @@ def test_creation_start_directory_live( assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() +def test_server_plan_runner_creates_session_from_scratch(session: Session) -> None: + """ServerPlanRunner runs a create-from-scratch plan without a seed session.""" + server = session.server + name = "cc_server_runner" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) # slot 0 + sess.new_window(name="w").split().do(_mark("SR")) # slots 1, 2 + resolved = plan.run_resolving(ServerPlanRunner(server)) + + assert resolved.session(0, server).session_name == name + assert _mark_of(session, resolved.bindings[2]) == ["SR"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + def test_resolved_maps_slots_to_live_objects(session: Session) -> None: """Resolved.pane/window/session(slot, server) return the created libtmux objects.""" server = session.server From 0f9a3287b672d562998f3b80c1d488809a13f51e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 10:58:35 -0500 Subject: [PATCH 32/42] Experimental(feat[chain]): typed verbs for options, rename, select, environment why: The bound command namespaces only typed send_keys/resize_pane/select_layout, so a workspace builder setting window/session options, renaming, or selecting had to drop to .raw(...) for almost everything -- the common path, untyped. what: - Add set_option/select to the pane namespace; set_option/rename/select to the window namespace; set_option/set_environment/rename to the session namespace - Each builds the right tmux command with its scope flag (-p/-w / none) bound to the namespace target; doctest each render, plus a live test using a typed verb as a forward-plan decorate --- src/libtmux/_experimental/chain/plan.py | 81 +++++++++++++++++++++++ tests/_experimental/chain/test_resolve.py | 17 +++++ 2 files changed, 98 insertions(+) diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index ff9870259..de14b1dad 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -428,6 +428,26 @@ def resize_pane(self, *, height: int) -> ResizePane: """ return ResizePane(target=self.target, height=height) + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a pane-scoped ``set-option -p`` bound to this pane. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).set_option("@x", "1").argv() + ('set-option', '-t', '%1', '-p', '@x', '1') + """ + return self.raw("set-option", "-p", name, value) + + def select(self) -> CommandCall: + """Build a ``select-pane`` bound to this pane. + + Examples + -------- + >>> BoundPaneCommands(PaneTarget("%1")).select().argv() + ('select-pane', '-t', '%1') + """ + return self.raw("select-pane") + def raw(self, name: str, *args: Arg) -> CommandCall: """Build an arbitrary pane-scoped command bound to this pane. @@ -465,6 +485,36 @@ def select_layout(self, layout: str) -> SelectLayout: """ return SelectLayout(target=self.target, layout=layout) + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a window-scoped ``set-option -w`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).set_option("mode-keys", "vi").argv() + ('set-option', '-t', '@1', '-w', 'mode-keys', 'vi') + """ + return self.raw("set-option", "-w", name, value) + + def rename(self, name: str) -> CommandCall: + """Build a ``rename-window`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).rename("editor").argv() + ('rename-window', '-t', '@1', 'editor') + """ + return self.raw("rename-window", name) + + def select(self) -> CommandCall: + """Build a ``select-window`` bound to this window. + + Examples + -------- + >>> BoundWindowCommands(WindowTarget("@1")).select().argv() + ('select-window', '-t', '@1') + """ + return self.raw("select-window") + def raw(self, name: str, *args: Arg) -> CommandCall: """Build an arbitrary window-scoped command bound to this window. @@ -491,6 +541,37 @@ class BoundSessionCommands: def __init__(self, target: SessionTargetT | SlotRef) -> None: self.target = target + def set_option(self, name: str, value: Arg) -> CommandCall: + """Build a session-scoped ``set-option`` bound to this session. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).set_option("status", "on").argv() + ('set-option', '-t', '$0', 'status', 'on') + """ + return self.raw("set-option", name, value) + + def set_environment(self, name: str, value: str) -> CommandCall: + """Build a session-scoped ``set-environment`` bound to this session. + + Examples + -------- + >>> cmds = BoundSessionCommands(SessionTarget("$0")) + >>> cmds.set_environment("EDITOR", "vim").argv() + ('set-environment', '-t', '$0', 'EDITOR', 'vim') + """ + return self.raw("set-environment", name, value) + + def rename(self, name: str) -> CommandCall: + """Build a ``rename-session`` bound to this session. + + Examples + -------- + >>> BoundSessionCommands(SessionTarget("$0")).rename("work").argv() + ('rename-session', '-t', '$0', 'work') + """ + return self.raw("rename-session", name) + def raw(self, name: str, *args: Arg) -> CommandCall: """Build an arbitrary session-scoped command bound to this session.""" return CommandCall(name, args, target=_target_arg(self.target)) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 550d28406..b2ffb9407 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -274,6 +274,23 @@ def test_creation_start_directory_live( assert pathlib.Path(cwd[0]).resolve() == tmp_path.resolve() +def test_typed_verbs_in_forward_plan(session: Session) -> None: + """Live: the new typed bound-namespace verbs work as decorates in a plan.""" + window = session.new_window(window_name="cc_verbs") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split().do(lambda h: h.cmd.set_option("@cc_v", "ok")) + resolved = plan.run_resolving(SessionPlanExecutor(session)) + + val = session.server.cmd( + "display-message", "-p", "-t", resolved.bindings[0], "#{@cc_v}" + ).stdout + assert val == ["ok"] + + def test_server_plan_runner_creates_session_from_scratch(session: Session) -> None: """ServerPlanRunner runs a create-from-scratch plan without a seed session.""" server = session.server From a4319f6d3a6969a82ba4ede79797f140c97bba1c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 11:00:38 -0500 Subject: [PATCH 33/42] Experimental(feat[chain]): hand back a handle to a new session's default window why: new_session is born with one window and pane the plan couldn't address, so a pure forward build had to create all windows and then kill the orphan default. There was no way to decorate or build onto the session's initial pane/window. what: - Add ForwardHandle.initial_pane / initial_window (session handles only): pane- and window-kind handles bound to the session, which resolves to its active pane/window -- so the default window can be renamed, decorated, or split instead of orphaned - Cover with a pure scope-guard test and a live test that renames + splits a new session's default window (one window, two panes, no orphan) --- src/libtmux/_experimental/chain/_resolve.py | 24 +++++++++++++++ tests/_experimental/chain/test_resolve.py | 34 +++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 9ccddc8a5..f5ecc97ea 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -492,6 +492,30 @@ def new_window( self._parent(":"), "window", "new-window", tuple(args) ) + @property + def initial_pane(self) -> ForwardHandle: + """Return a pane handle on this session's initial (default) pane. + + A detached ``new-session`` is born with one window and pane that the + plan otherwise can't address; this hands back a pane handle bound to the + session (which resolves to its active pane), so the default window can be + decorated or split instead of orphaned. Session handles only. + """ + if self._kind != "session": + msg = f"initial_pane is only on a session handle, not a {self._kind} handle" + raise TypeError(msg) + return ForwardHandle(self._plan, self._ref, "pane") + + @property + def initial_window(self) -> ForwardHandle: + """Return a window handle on this session's initial (default) window.""" + if self._kind != "session": + msg = ( + f"initial_window is only on a session handle, not a {self._kind} handle" + ) + raise TypeError(msg) + return ForwardHandle(self._plan, self._ref, "window") + def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: """Decorate this handle via its namespaces (reused, no new vocabulary).""" self._plan._steps.extend(_Decorate(call) for call in _to_calls(build(self))) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index b2ffb9407..fa031b5c7 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -329,6 +329,40 @@ def test_resolved_maps_slots_to_live_objects(session: Session) -> None: s.kill() +def test_initial_handles_require_session() -> None: + """initial_pane/initial_window are only available on a session handle (pure).""" + pane = ForwardPlan(PaneTarget("%1")).split() # a pane handle + with pytest.raises(TypeError): + _ = pane.initial_pane + with pytest.raises(TypeError): + _ = pane.initial_window + + +def test_initial_pane_builds_onto_default_window(session: Session) -> None: + """Live: a new session's default window is built onto, not orphaned.""" + server = session.server + name = "cc_initial" + try: + plan = ForwardPlan() + sess = plan.new_session(name=name) + sess.initial_pane.do(lambda h: h.window.rename("main")) + sess.initial_pane.split().do(_mark("IP")) + resolved = plan.run_resolving(ServerPlanRunner(server)) + + created = resolved.session(0, server) + created.refresh() + assert len(created.windows) == 1 # no orphan window + main = created.windows[0] + assert main.window_name == "main" + main.refresh() + assert len(main.panes) == 2 # default pane was split + assert _mark_of(session, resolved.bindings[1]) == ["IP"] + finally: + for s in list(server.sessions): + if s.session_name == name: + s.kill() + + def test_seed_from_existing_scopes_render() -> None: """from_session/from_window/from_pane build creates targeting the seed id.""" splan = ForwardPlan.from_session("$0") From 54048597ac0a968846807f64b3f9949e721bff16 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 11:03:03 -0500 Subject: [PATCH 34/42] Experimental(feat[chain]): preserve_mark opt-out for the single-dispatch fold why: The lone-pane single-dispatch path marks the new pane with select-pane -m and clears it with -M, which unconditionally drops whatever pane the user had marked server-wide. A long-running consumer (an MCP against a user's live server) would silently clobber their mark. what: - Add preserve_mark to run_resolving / run_resolving_async (threaded to drive as allow_marked): when set, the resolver never takes the {marked} fold, so the server-wide mark is left untouched (at the cost of the lone-pane optimization) - Cover with a pure test (no select-pane emitted) and a live test asserting a pre-existing user mark survives a preserve_mark resolve --- src/libtmux/_experimental/chain/_resolve.py | 36 ++++++++++++++----- tests/_experimental/chain/test_resolve.py | 39 +++++++++++++++++++++ 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index f5ecc97ea..75d4930ed 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -258,6 +258,7 @@ def drive( steps: tuple[_Step, ...], *, seed_query: PaneQuery | None = None, + allow_marked: bool = True, ) -> t.Generator[Request, t.Any, Resolved]: r"""Sans-I/O resolution core: yield a :class:`Request`, resume via ``.send``. @@ -266,7 +267,9 @@ def drive( :class:`_Create` is dispatched alone with ``-P -F`` id capture and a run of :class:`_Decorate`\\s folds into one trailing ``\;`` chain with the captured ids substituted. No awaits, no runner, no threads -- a pure state machine the - sync/async drivers feed results into. + sync/async drivers feed results into. ``allow_marked=False`` forbids the + single-dispatch ``{marked}`` fold, so the resolver never touches tmux's + server-wide marked pane. """ bindings: dict[int, str] = {} results: list[CommandResultLike] = [] @@ -280,7 +283,7 @@ def drive( raise NoSeedResolved(msg) bindings[_SEED] = str(_target_arg(seed.pane_id)) - solo = _marked_eligible(steps) + solo = _marked_eligible(steps) if allow_marked else None if solo is not None: decorates = tuple(s.call for s in steps if isinstance(s, _Decorate)) argv = _marked_invocation(solo, decorates, bindings) @@ -671,12 +674,29 @@ def new_window( window_shell=window_shell, ) - def run_resolving(self, runner: PlanRunner) -> Resolved: - """Resolve over N dispatches against a live server (sync).""" - return run_sync(drive(tuple(self._steps), seed_query=self._seed_query), runner) + def run_resolving( + self, runner: PlanRunner, *, preserve_mark: bool = False + ) -> Resolved: + """Resolve over N dispatches against a live server (sync). - async def run_resolving_async(self, runner: AsyncPlanRunner) -> Resolved: + ``preserve_mark=True`` skips the single-dispatch ``{marked}`` fold (which + transiently sets then clears tmux's server-wide marked pane), so resolving + against a user's live server does not clobber a mark they had set. + """ + gen = drive( + tuple(self._steps), + seed_query=self._seed_query, + allow_marked=not preserve_mark, + ) + return run_sync(gen, runner) + + async def run_resolving_async( + self, runner: AsyncPlanRunner, *, preserve_mark: bool = False + ) -> Resolved: """Resolve over N dispatches against a live server (async, same core).""" - return await run_async( - drive(tuple(self._steps), seed_query=self._seed_query), runner + gen = drive( + tuple(self._steps), + seed_query=self._seed_query, + allow_marked=not preserve_mark, ) + return await run_async(gen, runner) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index fa031b5c7..3f800dbed 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -329,6 +329,45 @@ def test_resolved_maps_slots_to_live_objects(session: Session) -> None: s.kill() +def test_preserve_mark_skips_marked_fold() -> None: + """allow_marked=False forces multi-dispatch -- the mark is untouched (pure).""" + plan = ForwardPlan(PaneTarget("%1")) + plan.split().do(lambda h: h.cmd.raw("set-option", "-p", "@m", "X")) + + gen = drive(tuple(plan._steps), allow_marked=False) + argvs: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + argvs.append(request.argv) + request = gen.send(_FakeResult(stdout=["%9"])) + except StopIteration: + pass + + flat = [tok for argv in argvs for tok in argv] + assert "select-pane" not in flat # mark register untouched + assert len(argvs) == 2 # multi-dispatch: create + decorate + + +def test_preserve_mark_keeps_user_mark(session: Session) -> None: + """Live: preserve_mark=True leaves a pre-existing user mark intact.""" + window = session.new_window(window_name="cc_mark") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + session.server.cmd("select-pane", "-m", "-t", seed.pane_id) # user marks a pane + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split().do(_mark("M")) + plan.run_resolving(SessionPlanExecutor(session), preserve_mark=True) + + marked = session.server.cmd( + "display-message", "-p", "-t", seed.pane_id, "#{pane_marked}" + ).stdout + assert marked == ["1"] # default marked-fold would have cleared it + + def test_initial_handles_require_session() -> None: """initial_pane/initial_window are only available on a session handle (pure).""" pane = ForwardPlan(PaneTarget("%1")).split() # a pane handle From b8ecfed83e947f11da3823bf74595688f14bf4e9 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 11:05:12 -0500 Subject: [PATCH 35/42] Experimental(docs[chain]): clarify the two forward systems and result caveats why: The package exposes two forward-reference systems -- the linear dual-ref chain (PaneRef.split()...) and the multi-handle ForwardPlan -- with no guidance on which to reach for, and the merged-result / experimental caveats were only implicit. what: - Document the two forward shapes in the package docstring: a linear chain (single line of descent, folds to one dispatch) vs ForwardPlan (independent handles, minimum dispatches, ids captured), with a rule of thumb for choosing - Note the merged-result caveat (a `\;` sequence returns one result, so per-command output needs individual calls or run_deferred) alongside the experimental notice --- src/libtmux/_experimental/chain/__init__.py | 25 ++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index 7c97a807e..a02883597 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -21,10 +21,33 @@ contract that decides which commands may fold into one dispatch (``CommandSpec.chainable`` + ``DeferredCommandResult``). +Forward references come in two shapes; pick by whether the handles are +independent: + +- A **linear chain** -- ``PaneRef.split().split().do(...)`` in + :mod:`~libtmux._experimental.chain.plan`. Each step creates the object the next + step builds on (split a pane, then split *that* pane). Because tmux keeps the + freshly-created object active, the whole chain addresses it with no ``-t`` and + folds into **one** ``\\;`` invocation (``to_chain()`` / ``run()``). Use it when + the forward objects form a single line of descent. +- A **multi-handle plan** -- ``ForwardPlan``. Hands out + **independent** handles (two splits off one pane, two windows in a new + session) and resolves them over the minimum number of dispatches, capturing each + new id with ``-P -F`` and substituting it downstream. Use it when you hold more + than one forward object at once, or need a new id back + (``Resolved.bindings`` / ``Resolved.pane(...)``). + +A lone-pane ``ForwardPlan`` still folds to one dispatch (via the marked register), +so the two shapes overlap only there; reach for the linear chain for a pure line +of splits and ``ForwardPlan`` the moment the handles fan out. + Note ---- This is an **experimental** API, not covered by the project's versioning policy. -It may change or be removed between any releases without notice. +It may change or be removed between any releases without notice. A ``\\;`` +sequence returns one merged result, so per-command output is not separable; reach +for individual typed calls (or ``run_deferred``) when you need a command's own +output. """ from __future__ import annotations From c07f728cfac7a0756e3a20541805aab2e66b9b0e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 14:44:24 -0500 Subject: [PATCH 36/42] Chain(fix[chain]): Fail closed unknown commands why: Unknown tmux commands could previously enter native chains, hiding output-producing or blocking commands behind one merged subprocess result. what: - Treat unregistered commands as non-chainable - Add explicit specs for chain-layer commands used by typed plans - Raise clearer chainability errors during plan compilation --- src/libtmux/_experimental/chain/__init__.py | 2 + src/libtmux/_experimental/chain/chain.py | 44 +++++++++++++++-- src/libtmux/_experimental/chain/plan.py | 17 ++----- tests/_experimental/chain/test_chain.py | 52 +++++++++++++++++---- tests/_experimental/chain/test_plan.py | 15 +++++- 5 files changed, 103 insertions(+), 27 deletions(-) diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index a02883597..24413980d 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -70,6 +70,7 @@ ChainabilityError, DeferredCommandResult, DeferredOutputUnavailable, + ensure_chainable, is_chainable, ) from libtmux._experimental.chain.ir import ( @@ -134,6 +135,7 @@ "WindowRef", "WindowTarget", "aio", + "ensure_chainable", "is_chainable", "new_session", "panes", diff --git a/src/libtmux/_experimental/chain/chain.py b/src/libtmux/_experimental/chain/chain.py index afbdfd76f..253ce00fc 100644 --- a/src/libtmux/_experimental/chain/chain.py +++ b/src/libtmux/_experimental/chain/chain.py @@ -31,13 +31,19 @@ ) COMMAND_SPECS: dict[str, CommandSpec] = { + "new-session": CommandSpec("new-session", "server"), "new-window": CommandSpec("new-window", "session"), "split-window": CommandSpec("split-window", "pane"), + "break-pane": CommandSpec("break-pane", "pane"), "rename-window": CommandSpec("rename-window", "window"), + "rename-session": CommandSpec("rename-session", "session"), "select-layout": CommandSpec("select-layout", "window"), + "select-pane": CommandSpec("select-pane", "pane"), + "select-window": CommandSpec("select-window", "window"), "send-keys": CommandSpec("send-keys", "pane"), "resize-pane": CommandSpec("resize-pane", "pane"), "set-option": CommandSpec("set-option", "server"), + "set-environment": CommandSpec("set-environment", "session"), # Output commands cannot fold into a chain -- they need stdout immediately. "show-option": CommandSpec("show-option", "server", chainable=False), "capture-pane": CommandSpec("capture-pane", "pane", chainable=False), @@ -57,8 +63,8 @@ class ChainabilityError(RuntimeError): def is_chainable(name: str) -> bool: """Return whether a command may fold into a one-dispatch chain. - Unknown commands are treated as chainable; commands in :data:`COMMAND_SPECS` - use their declared ``chainable`` flag. + Unknown commands fail closed. Commands in :data:`COMMAND_SPECS` use their + declared ``chainable`` flag. Examples -------- @@ -67,14 +73,44 @@ def is_chainable(name: str) -> bool: >>> is_chainable("show-option") False >>> is_chainable("some-unknown-command") - True + False """ spec = COMMAND_SPECS.get(name) if spec is None: - return True + return False return spec.chainable +def ensure_chainable(name: str) -> None: + """Raise unless ``name`` is a known command that may fold into a chain. + + Examples + -------- + >>> ensure_chainable("rename-window") + >>> ensure_chainable("show-option") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...ChainabilityError: command 'show-option' is not chainable... + >>> ensure_chainable("some-unknown-command") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...ChainabilityError: unknown tmux command 'some-unknown-command'... + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + msg = ( + f"unknown tmux command {name!r} cannot be folded into " + "a one-dispatch sequence" + ) + raise ChainabilityError(msg) + if not spec.chainable: + msg = ( + f"command {name!r} is not chainable and cannot be " + f"folded into a one-dispatch sequence" + ) + raise ChainabilityError(msg) + + @dataclass(frozen=True, slots=True) class DeferredCommandResult: r"""A result handle for a call folded into a one-dispatch chain. diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index de14b1dad..5276fd4fc 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -27,9 +27,8 @@ from dataclasses import dataclass from libtmux._experimental.chain.chain import ( - ChainabilityError, DeferredCommandResult, - is_chainable, + ensure_chainable, ) from libtmux._experimental.chain.ir import ( Arg, @@ -279,12 +278,7 @@ def run(self, runner: CommandRunner) -> CommandResultLike: def _compile_lineage(calls: tuple[CommandCall, ...]) -> CommandChain: """Chainability-check an accumulated lineage and fold it into one chain.""" for call in calls: - if not is_chainable(call.name): - msg = ( - f"command {call.name!r} is not chainable and cannot be " - f"folded into a one-dispatch sequence" - ) - raise ChainabilityError(msg) + ensure_chainable(call.name) return CommandChain(calls) @@ -1195,12 +1189,7 @@ def to_chain(self, source: SnapshotSource) -> CommandChain: msg = "command plan resolved to no commands" raise NoCommandsResolved(msg) for call in calls: - if not is_chainable(call.name): - msg = ( - f"command {call.name!r} is not chainable and cannot be " - f"folded into a one-dispatch sequence" - ) - raise ChainabilityError(msg) + ensure_chainable(call.name) return CommandChain(tuple(calls)) def run(self, runner: PlanRunner) -> None: diff --git a/tests/_experimental/chain/test_chain.py b/tests/_experimental/chain/test_chain.py index caa0846d6..4c8813886 100644 --- a/tests/_experimental/chain/test_chain.py +++ b/tests/_experimental/chain/test_chain.py @@ -33,6 +33,43 @@ class MinimalImportCase(t.NamedTuple): ) +class ChainabilityCase(t.NamedTuple): + """A command name and expected chainability.""" + + test_id: str + command: str + expected: bool + + +CHAINABILITY_CASES = ( + ChainabilityCase( + test_id="known-chainable", + command="rename-window", + expected=True, + ), + ChainabilityCase( + test_id="layout-chainable", + command="select-layout", + expected=True, + ), + ChainabilityCase( + test_id="output-show-option", + command="show-option", + expected=False, + ), + ChainabilityCase( + test_id="output-capture-pane", + command="capture-pane", + expected=False, + ), + ChainabilityCase( + test_id="unknown-fail-closed", + command="some-unknown-command", + expected=False, + ), +) + + @pytest.mark.parametrize( "case", MINIMAL_IMPORT_CASES, @@ -57,15 +94,14 @@ def test_minimal_import_without_dev_dependency_groups( assert proc.returncode == 0, proc.stderr -def test_is_chainable_uses_static_spec() -> None: +@pytest.mark.parametrize( + "case", + CHAINABILITY_CASES, + ids=[case.test_id for case in CHAINABILITY_CASES], +) +def test_is_chainable_uses_static_spec(case: ChainabilityCase) -> None: """The static ``chainable`` flag decides what may fold into a chain.""" - assert is_chainable("rename-window") is True - assert is_chainable("select-layout") is True - # Output commands cannot join a one-dispatch chain. - assert is_chainable("show-option") is False - assert is_chainable("capture-pane") is False - # Unknown commands are treated as chainable. - assert is_chainable("some-unknown-command") is True + assert is_chainable(case.command) is case.expected def test_deferred_result_rejects_output_access() -> None: diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index b4499b686..38b32f998 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -9,7 +9,10 @@ from typing_extensions import assert_type from libtmux._experimental.chain import plan as api -from libtmux._experimental.chain.chain import ChainabilityError, DeferredCommandResult +from libtmux._experimental.chain.chain import ( + ChainabilityError, + DeferredCommandResult, +) from libtmux._experimental.chain.ir import CommandCall, CommandChain if t.TYPE_CHECKING: @@ -269,6 +272,16 @@ def test_to_chain_rejects_nonchainable_command() -> None: plan.to_chain(_snapshot()) +def test_to_chain_rejects_unknown_raw_command() -> None: + """The raw escape hatch may not fold unregistered commands.""" + plan = ( + api.panes().limit(1).commands(lambda pane: pane.cmd.raw("some-unknown-command")) + ) + + with pytest.raises(ChainabilityError, match="unknown tmux command"): + plan.to_chain(_snapshot()) + + def test_to_chain_allows_chainable_command() -> None: """A chainable raw command compiles without raising.""" plan = ( From c375cd27ef0f68e818d9c359c09d075516b76063 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 14:47:40 -0500 Subject: [PATCH 37/42] Chain(fix[scope]): Validate bound targets why: Command specs declared target scope, but typed raw builders did not reject known commands bound to the wrong object namespace. what: - Add a chain scope-validation helper and error - Validate known commands from pane/window/session raw builders - Cover wrong-scope command bindings with parametrized tests --- src/libtmux/_experimental/chain/__init__.py | 4 +++ src/libtmux/_experimental/chain/chain.py | 38 ++++++++++++++++++++ src/libtmux/_experimental/chain/plan.py | 4 +++ tests/_experimental/chain/test_plan.py | 39 +++++++++++++++++++++ 4 files changed, 85 insertions(+) diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index 24413980d..f960e712e 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -68,10 +68,12 @@ ) from libtmux._experimental.chain.chain import ( ChainabilityError, + CommandScopeError, DeferredCommandResult, DeferredOutputUnavailable, ensure_chainable, is_chainable, + validate_command_scope, ) from libtmux._experimental.chain.ir import ( Arg, @@ -112,6 +114,7 @@ "CommandResultLike", "CommandRunner", "CommandScope", + "CommandScopeError", "CommandSpec", "CommandValue", "DeferredCommandResult", @@ -140,4 +143,5 @@ "new_session", "panes", "snapshot_from_session", + "validate_command_scope", ] diff --git a/src/libtmux/_experimental/chain/chain.py b/src/libtmux/_experimental/chain/chain.py index 253ce00fc..f2319442c 100644 --- a/src/libtmux/_experimental/chain/chain.py +++ b/src/libtmux/_experimental/chain/chain.py @@ -27,6 +27,7 @@ from libtmux._experimental.chain.ir import ( CommandCall, CommandResultLike, + CommandScope, CommandSpec, ) @@ -52,6 +53,15 @@ """Known command metadata, including each command's ``chainable`` flag.""" +COMMAND_TARGET_SCOPES: dict[str, frozenset[CommandScope]] = { + "display-message": frozenset(("server", "session", "window", "pane")), + "set-option": frozenset(("server", "session", "window", "pane")), + "show-option": frozenset(("server", "session", "window", "pane")), + "split-window": frozenset(("window", "pane")), +} +"""Commands whose accepted typed target scopes differ from their primary scope.""" + + class DeferredOutputUnavailable(RuntimeError): """Raised when a deferred command result is inspected before dispatch.""" @@ -60,6 +70,10 @@ class ChainabilityError(RuntimeError): """Raised when a non-chainable command is added to a chain.""" +class CommandScopeError(RuntimeError): + """Raised when a known command is bound to the wrong typed target scope.""" + + def is_chainable(name: str) -> bool: """Return whether a command may fold into a one-dispatch chain. @@ -111,6 +125,30 @@ def ensure_chainable(name: str) -> None: raise ChainabilityError(msg) +def validate_command_scope(name: str, target_scope: CommandScope) -> None: + """Raise if a known command cannot target ``target_scope``. + + Unknown commands are left to :func:`ensure_chainable`; raw + :class:`CommandCall` targets are opaque and intentionally not parsed. + + Examples + -------- + >>> validate_command_scope("rename-window", "window") + >>> validate_command_scope("rename-window", "pane") # doctest: +ELLIPSIS + Traceback (most recent call last): + ... + libtmux...CommandScopeError: command 'rename-window' cannot target pane... + >>> validate_command_scope("some-unknown-command", "pane") + """ + spec = COMMAND_SPECS.get(name) + if spec is None: + return + allowed = COMMAND_TARGET_SCOPES.get(name, frozenset((spec.scope,))) + if target_scope not in allowed: + msg = f"command {name!r} cannot target {target_scope} scope" + raise CommandScopeError(msg) + + @dataclass(frozen=True, slots=True) class DeferredCommandResult: r"""A result handle for a call folded into a one-dispatch chain. diff --git a/src/libtmux/_experimental/chain/plan.py b/src/libtmux/_experimental/chain/plan.py index 5276fd4fc..4ebe229cf 100644 --- a/src/libtmux/_experimental/chain/plan.py +++ b/src/libtmux/_experimental/chain/plan.py @@ -29,6 +29,7 @@ from libtmux._experimental.chain.chain import ( DeferredCommandResult, ensure_chainable, + validate_command_scope, ) from libtmux._experimental.chain.ir import ( Arg, @@ -454,6 +455,7 @@ def raw(self, name: str, *args: Arg) -> CommandCall: >>> BoundPaneCommands(PaneTarget("%1")).raw("pipe-pane", "-o").argv() ('pipe-pane', '-t', '%1', '-o') """ + validate_command_scope(name, "pane") return CommandCall(name, args, target=_target_arg(self.target)) @@ -517,6 +519,7 @@ def raw(self, name: str, *args: Arg) -> CommandCall: >>> BoundWindowCommands(WindowTarget("@1")).raw("set-option", "@x", "1").argv() ('set-option', '-t', '@1', '@x', '1') """ + validate_command_scope(name, "window") return CommandCall(name, args, target=_target_arg(self.target)) @@ -568,6 +571,7 @@ def rename(self, name: str) -> CommandCall: def raw(self, name: str, *args: Arg) -> CommandCall: """Build an arbitrary session-scoped command bound to this session.""" + validate_command_scope(name, "session") return CommandCall(name, args, target=_target_arg(self.target)) diff --git a/tests/_experimental/chain/test_plan.py b/tests/_experimental/chain/test_plan.py index 38b32f998..19624e2f6 100644 --- a/tests/_experimental/chain/test_plan.py +++ b/tests/_experimental/chain/test_plan.py @@ -11,6 +11,7 @@ from libtmux._experimental.chain import plan as api from libtmux._experimental.chain.chain import ( ChainabilityError, + CommandScopeError, DeferredCommandResult, ) from libtmux._experimental.chain.ir import CommandCall, CommandChain @@ -316,6 +317,44 @@ def test_raw_escape_hatch_binds_typed_targets() -> None: ) +class ScopeRejectionCase(t.NamedTuple): + """A known command bound to a wrong typed target scope.""" + + test_id: str + build: t.Callable[[api.PaneRef], object] + + +SCOPE_REJECTION_CASES = ( + ScopeRejectionCase( + test_id="pane-target-window-command", + build=lambda pane: pane.cmd.raw("rename-window", "bad"), + ), + ScopeRejectionCase( + test_id="window-target-pane-command", + build=lambda pane: pane.window.raw("send-keys", "bad"), + ), + ScopeRejectionCase( + test_id="session-target-pane-command", + build=lambda pane: pane.session.raw("resize-pane", "-y", 20), + ), +) + + +@pytest.mark.parametrize( + "case", + SCOPE_REJECTION_CASES, + ids=[case.test_id for case in SCOPE_REJECTION_CASES], +) +def test_raw_escape_hatch_rejects_wrong_known_scope( + case: ScopeRejectionCase, +) -> None: + """Known commands cannot bind to the wrong typed target namespace.""" + pane = _snapshot().panes[0] + + with pytest.raises(CommandScopeError, match="cannot target"): + case.build(pane) + + def test_raw_escape_hatch_still_enforces_chainability() -> None: """A non-chainable command via the escape hatch is still rejected.""" plan = ( From e1c883fe86c5e40b64dcb2adb9cb7945c5071c36 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 20 Jun 2026 19:11:30 -0500 Subject: [PATCH 38/42] Chain(feat[control]): Add control runner why: MCP callers need per-command output from batched chain commands, which native semicolon dispatch cannot attribute after folding. what: - Add an experimental ControlModeRunner scoped to chain commands - Batch rendered command calls over one tmux -C client - Document and test per-command stdout and mid-batch error handling --- CHANGES | 5 +- .../libtmux._experimental.chain.control.md | 13 + docs/experiment/index.md | 11 + src/libtmux/_experimental/chain/__init__.py | 12 + src/libtmux/_experimental/chain/control.py | 467 ++++++++++++++++++ tests/_experimental/chain/test_control.py | 142 ++++++ 6 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 docs/experiment/api/libtmux._experimental.chain.control.md create mode 100644 src/libtmux/_experimental/chain/control.py create mode 100644 tests/_experimental/chain/test_control.py diff --git a/CHANGES b/CHANGES index b88fdc224..d157b5b2c 100644 --- a/CHANGES +++ b/CHANGES @@ -54,7 +54,10 @@ instead of one subprocess per command. References to panes, windows, and sessions can be lazy — point at an object a command will create and keep building against it — and they all resolve together when the chain runs. Experimental and opt-in: import from -`libtmux._experimental.chain`, not the top-level `libtmux`. (#685) +`libtmux._experimental.chain`, not the top-level `libtmux`. +When callers need per-command output, the experimental control-mode +runner batches command lines through one persistent `tmux -C` client +and returns one result per command. (#685) ## libtmux 0.58.1 (2026-06-16) diff --git a/docs/experiment/api/libtmux._experimental.chain.control.md b/docs/experiment/api/libtmux._experimental.chain.control.md new file mode 100644 index 000000000..abbaf2ce8 --- /dev/null +++ b/docs/experiment/api/libtmux._experimental.chain.control.md @@ -0,0 +1,13 @@ +# Control mode - `libtmux._experimental.chain.control` + +:::{warning} +Experimental. This API is **not** covered by version policies and can break or +be removed between minor versions. +::: + +```{eval-rst} +.. automodule:: libtmux._experimental.chain.control + :members: + :undoc-members: + :show-inheritance: +``` diff --git a/docs/experiment/index.md b/docs/experiment/index.md index 84c1cb732..226588834 100644 --- a/docs/experiment/index.md +++ b/docs/experiment/index.md @@ -42,6 +42,10 @@ as much or as little as you need: {class}`~libtmux._experimental.chain._connection.AsyncSessionPlanExecutor`) resolves and runs an expression against a live {class}`~libtmux.Session` in one invocation. +- **Control mode** -- + {class}`~libtmux._experimental.chain.control.ControlModeRunner` + batches command lines through one persistent `tmux -C` client and returns one + result per command when callers need per-command output. - **Chainability** -- {mod}`~libtmux._experimental.chain.chain` decides which commands may share one invocation: the static @@ -82,6 +86,12 @@ Read live panes and run an expression against a real session. Which commands may share one invocation. ::: +:::{grid-item-card} Control mode +:link: api/libtmux._experimental.chain.control +:link-type: doc +Batch command lines with per-command results. +::: + :::: ## At a glance @@ -181,4 +191,5 @@ api/libtmux._experimental.chain.plan api/libtmux._experimental.chain._async api/libtmux._experimental.chain._connection api/libtmux._experimental.chain.chain +api/libtmux._experimental.chain.control ``` diff --git a/src/libtmux/_experimental/chain/__init__.py b/src/libtmux/_experimental/chain/__init__.py index f960e712e..07b410a94 100644 --- a/src/libtmux/_experimental/chain/__init__.py +++ b/src/libtmux/_experimental/chain/__init__.py @@ -75,6 +75,13 @@ is_chainable, validate_command_scope, ) +from libtmux._experimental.chain.control import ( + ControlModeBlock, + ControlModeError, + ControlModeParser, + ControlModeResult, + ControlModeRunner, +) from libtmux._experimental.chain.ir import ( Arg, CommandCall, @@ -117,6 +124,11 @@ "CommandScopeError", "CommandSpec", "CommandValue", + "ControlModeBlock", + "ControlModeError", + "ControlModeParser", + "ControlModeResult", + "ControlModeRunner", "DeferredCommandResult", "DeferredOutputUnavailable", "ForwardDataUnavailable", diff --git a/src/libtmux/_experimental/chain/control.py b/src/libtmux/_experimental/chain/control.py new file mode 100644 index 000000000..e73d2d70a --- /dev/null +++ b/src/libtmux/_experimental/chain/control.py @@ -0,0 +1,467 @@ +"""Control-mode runner for experimental chain commands. + +This module keeps the control-mode surface scoped to +``libtmux._experimental.chain``. It borrows the persistent ``tmux -C`` + +batched-stdin shape from the protocol-engines experiments without installing a +general engine registry into this branch. + +Note +---- +This is an **experimental** API, not covered by the project's versioning policy. +It may change or be removed between any releases without notice. +""" + +from __future__ import annotations + +import contextlib +import dataclasses +import logging +import os +import selectors +import shlex +import shutil +import subprocess +import threading +import time +import typing as t + +from libtmux import exc +from libtmux._experimental.chain.ir import Arg, CommandCall, CommandChain + +if t.TYPE_CHECKING: + import types + from collections.abc import Sequence + + from libtmux.server import Server + +logger = logging.getLogger(__name__) + +_BEGIN_PREFIX = b"%begin " +_END_PREFIX = b"%end " +_ERROR_PREFIX = b"%error " +_READ_CHUNK = 65536 +_DEFAULT_TIMEOUT = 30.0 +_GRACEFUL_EXIT_TIMEOUT = 0.5 +_TERMINATE_TIMEOUT = 1.0 +_NOTIFICATION_PREFIXES = ( + b"%extended-output ", + b"%output ", + b"%pause ", + b"%continue ", + b"%session-changed ", + b"%client-session-changed ", + b"%session-renamed ", + b"%sessions-changed", + b"%session-window-changed ", + b"%window-add ", + b"%window-close ", + b"%window-renamed ", + b"%window-pane-changed ", + b"%pane-mode-changed ", + b"%unlinked-window-add ", + b"%unlinked-window-close ", + b"%unlinked-window-renamed ", + b"%paste-buffer-changed ", + b"%paste-buffer-deleted ", + b"%client-detached ", + b"%subscription-changed ", + b"%exit", + b"%message ", +) + + +class ControlModeError(exc.LibTmuxException): + """The experimental control-mode runner failed.""" + + +@dataclasses.dataclass(frozen=True, slots=True) +class ControlModeBlock: + """One ``%begin``/``%end`` or ``%error`` control-mode command block.""" + + number: int + flags: int + is_error: bool + body: tuple[bytes, ...] + + +@dataclasses.dataclass(slots=True) +class _PendingBlock: + number: int + flags: int + body: list[bytes] + + +class ControlModeParser: + """I/O-free parser for the subset of control mode needed by chains.""" + + __slots__ = ("_blocks", "_buffer", "_pending") + + def __init__(self) -> None: + self._buffer = bytearray() + self._blocks: list[ControlModeBlock] = [] + self._pending: _PendingBlock | None = None + + def feed(self, data: bytes) -> None: + """Consume bytes from tmux stdout.""" + if not data: + return + self._buffer.extend(data) + while True: + newline = self._buffer.find(b"\n") + if newline < 0: + return + line = bytes(self._buffer[:newline]) + del self._buffer[: newline + 1] + self._handle_line(line) + + def blocks(self) -> list[ControlModeBlock]: + """Drain parsed command blocks.""" + blocks, self._blocks = self._blocks, [] + return blocks + + def _handle_line(self, line: bytes) -> None: + if self._pending is not None: + if line.startswith(_END_PREFIX) or line.startswith(_ERROR_PREFIX): + self._close_block(line) + return + if any(line.startswith(prefix) for prefix in _NOTIFICATION_PREFIXES): + return + self._pending.body.append(line) + return + + if line.startswith(_BEGIN_PREFIX): + self._open_block(line) + + def _open_block(self, line: bytes) -> None: + number, flags = _parse_guard(line, _BEGIN_PREFIX) + if number is None: + return + self._pending = _PendingBlock(number=number, flags=flags or 0, body=[]) + + def _close_block(self, line: bytes) -> None: + pending = self._pending + self._pending = None + if pending is None: + return + + prefix = _ERROR_PREFIX if line.startswith(_ERROR_PREFIX) else _END_PREFIX + number, _flags = _parse_guard(line, prefix) + if number is not None and number != pending.number: + logger.warning( + "control-mode close guard number mismatch", + extra={ + "tmux_cm_block_id": pending.number, + "tmux_cm_close_id": number, + }, + ) + + self._blocks.append( + ControlModeBlock( + number=pending.number, + flags=pending.flags, + is_error=line.startswith(_ERROR_PREFIX), + body=tuple(pending.body), + ), + ) + + +@dataclasses.dataclass(slots=True) +class ControlModeResult: + """Result shape returned by :class:`ControlModeRunner`.""" + + stdout: list[str] + stderr: list[str] + returncode: int + + +class ControlModeRunner: + """Persistent ``tmux -C`` runner for experimental command chains. + + The runner batches independent command lines over one control-mode + connection. Unlike a native ``;`` chain, each line returns its own + ``%begin``/``%end`` block, so callers can keep per-command stdout and + return codes while avoiding per-command subprocess startup. + """ + + def __init__( + self, + server: Server, + *, + timeout: float = _DEFAULT_TIMEOUT, + ) -> None: + self.server = server + self.timeout = timeout + self._lock = threading.Lock() + self._parser = ControlModeParser() + self._proc: subprocess.Popen[bytes] | None = None + self._selector: selectors.DefaultSelector | None = None + self._startup_ack_pending = True + + def cmd( + self, + cmd: str, + *args: Arg, + target: str | int | None = None, + ) -> ControlModeResult: + """Dispatch one command through the persistent control client.""" + call = CommandCall(name=cmd, args=tuple(args), target=target) + return self.run_calls([call])[0] + + def run_calls(self, calls: Sequence[CommandCall]) -> list[ControlModeResult]: + """Dispatch command calls as one control-mode batch.""" + return self.run_argvs([call.argv() for call in calls]) + + def run_chain(self, chain: CommandChain) -> list[ControlModeResult]: + """Dispatch a ``CommandChain`` with one result per contained call.""" + return self.run_calls(chain.calls) + + def run_argvs(self, argvs: Sequence[Sequence[Arg]]) -> list[ControlModeResult]: + """Dispatch rendered tmux argv rows as one control-mode batch.""" + if not argvs: + return [] + + rendered = [tuple(str(arg) for arg in argv) for argv in argvs] + with self._lock: + self._ensure_started() + payload = b"".join( + (shlex.join(argv) + "\n").encode("utf-8") for argv in rendered + ) + self._write(payload) + blocks = self._read_blocks(len(rendered)) + + return [_result_from_block(block) for block in blocks] + + def close(self) -> None: + """Close the control-mode subprocess. + + Acquires the run lock so teardown never closes the selector out from + under an in-flight :meth:`run_argvs` reading on another thread. + """ + with self._lock: + proc = self._proc + selector = self._selector + self._proc = None + self._selector = None + self._parser = ControlModeParser() + self._startup_ack_pending = True + + if selector is not None: + with contextlib.suppress(Exception): + selector.close() + if proc is None: + return + + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.write(b"\n") + proc.stdin.flush() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + if proc.stdin is not None and not proc.stdin.closed: + with contextlib.suppress(OSError): + proc.stdin.close() + if not _wait_for_exit(proc, _GRACEFUL_EXIT_TIMEOUT): + with contextlib.suppress(OSError): + proc.terminate() + if not _wait_for_exit(proc, _TERMINATE_TIMEOUT): + with contextlib.suppress(OSError): + proc.kill() + with contextlib.suppress(subprocess.TimeoutExpired): + proc.wait(timeout=_TERMINATE_TIMEOUT) + + def __enter__(self) -> ControlModeRunner: + """Return this runner.""" + return self + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: types.TracebackType | None, + ) -> None: + """Close the control-mode subprocess.""" + self.close() + + def _ensure_started(self) -> None: + if self._proc is not None: + if self._proc.poll() is not None: + msg = f"tmux -C exited with code {self._proc.returncode}" + raise ControlModeError(msg) + return + + tmux_bin = self.server.tmux_bin or shutil.which("tmux") + if tmux_bin is None: + raise exc.TmuxCommandNotFound + + cmd = [tmux_bin, *self._server_args(), "-C"] + try: + proc = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=0, + ) + except FileNotFoundError: + raise exc.TmuxCommandNotFound from None + + if proc.stdin is None or proc.stdout is None or proc.stderr is None: + with contextlib.suppress(OSError): + proc.kill() + msg = "tmux -C subprocess pipes are unavailable" + raise ControlModeError(msg) + + os.set_blocking(proc.stdout.fileno(), False) + os.set_blocking(proc.stderr.fileno(), False) + selector = selectors.DefaultSelector() + selector.register(proc.stdout, selectors.EVENT_READ, "stdout") + selector.register(proc.stderr, selectors.EVENT_READ, "stderr") + self._proc = proc + self._selector = selector + self._startup_ack_pending = True + + def _server_args(self) -> list[str]: + args: list[str] = [] + if self.server.socket_name: + args.extend(("-L", str(self.server.socket_name))) + if self.server.socket_path: + args.extend(("-S", str(self.server.socket_path))) + if self.server.config_file: + args.extend(("-f", str(self.server.config_file))) + if self.server.colors == 256: + args.append("-2") + elif self.server.colors == 88: + args.append("-8") + elif self.server.colors is not None: + raise exc.UnknownColorOption + return args + + def _write(self, payload: bytes) -> None: + proc = self._proc + if proc is None or proc.stdin is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + try: + proc.stdin.write(payload) + proc.stdin.flush() + except (BrokenPipeError, OSError) as error: + msg = f"tmux control-mode write failed: {error}" + raise ControlModeError(msg) from error + + def _read_blocks(self, count: int) -> list[ControlModeBlock]: + proc = self._proc + selector = self._selector + if proc is None or selector is None: + msg = "control-mode subprocess is not connected" + raise ControlModeError(msg) + + blocks: list[ControlModeBlock] = [] + deadline = time.monotonic() + self.timeout + while len(blocks) < count: + remaining = deadline - time.monotonic() + if remaining <= 0: + msg = ( + "tmux control-mode timed out after " + f"{self.timeout}s waiting for {count} result blocks" + ) + raise ControlModeError(msg) + + ready = selector.select(min(remaining, 0.1)) + if not ready: + if proc.poll() is not None: + msg = f"tmux -C exited with code {proc.returncode}" + raise ControlModeError(msg) + continue + + for key, _events in ready: + if key.data == "stdout": + self._read_stdout() + elif key.data == "stderr": + self._read_stderr() + + for block in self._parser.blocks(): + if self._startup_ack_pending: + self._startup_ack_pending = False + if block.flags == 0: + continue + blocks.append(block) + if len(blocks) == count: + break + + return blocks + + def _read_stdout(self) -> None: + proc = self._proc + assert proc is not None + assert proc.stdout is not None + try: + chunk = os.read(proc.stdout.fileno(), _READ_CHUNK) + except BlockingIOError: + return + except OSError as error: + msg = f"tmux control-mode stdout read failed: {error}" + raise ControlModeError(msg) from error + if not chunk: + msg = "tmux -C closed stdout" + raise ControlModeError(msg) + self._parser.feed(chunk) + + def _read_stderr(self) -> None: + proc = self._proc + assert proc is not None + assert proc.stderr is not None + try: + chunk = os.read(proc.stderr.fileno(), _READ_CHUNK) + except (BlockingIOError, OSError): + return + if chunk: + logger.debug( + "tmux control-mode stderr", + extra={"tmux_stderr": [chunk.decode("utf-8", errors="replace")]}, + ) + + +def _parse_guard( + line: bytes, + prefix: bytes, +) -> tuple[int | None, int | None]: + rest = line[len(prefix) :] + parts = rest.split() + if len(parts) < 3: + return (None, None) + try: + number = int(parts[1]) + flags = int(parts[2]) + except ValueError: + return (None, None) + return (number, flags) + + +def _result_from_block(block: ControlModeBlock) -> ControlModeResult: + lines = [line.decode("utf-8", errors="replace") for line in block.body] + if block.is_error: + return ControlModeResult(stdout=[], stderr=_trim_lines(lines), returncode=1) + return ControlModeResult(stdout=_trim_lines(lines), stderr=[], returncode=0) + + +def _trim_lines(lines: list[str]) -> list[str]: + trimmed = list(lines) + while trimmed and not trimmed[-1].strip(): + trimmed.pop() + return trimmed + + +def _wait_for_exit(proc: subprocess.Popen[bytes], timeout: float) -> bool: + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + return False + return True + + +__all__ = [ + "ControlModeBlock", + "ControlModeError", + "ControlModeParser", + "ControlModeResult", + "ControlModeRunner", +] diff --git a/tests/_experimental/chain/test_control.py b/tests/_experimental/chain/test_control.py new file mode 100644 index 000000000..540e5eeee --- /dev/null +++ b/tests/_experimental/chain/test_control.py @@ -0,0 +1,142 @@ +"""Tests for the experimental chain control-mode runner.""" + +from __future__ import annotations + +import typing as t + +import pytest + +from libtmux._experimental.chain.control import ( + ControlModeBlock, + ControlModeParser, + ControlModeRunner, +) +from libtmux._experimental.chain.ir import CommandCall + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +class ParserCase(t.NamedTuple): + """A control-mode wire payload and expected blocks.""" + + test_id: str + wire: bytes + expected: tuple[ControlModeBlock, ...] + + +PARSER_CASES = ( + ParserCase( + test_id="success-block", + wire=b"%begin 1 7 1\nhello\n%end 1 7 1\n", + expected=( + ControlModeBlock( + number=7, + flags=1, + is_error=False, + body=(b"hello",), + ), + ), + ), + ParserCase( + test_id="error-block", + wire=b"%begin 1 8 1\nbad command\n%error 1 8 1\n", + expected=( + ControlModeBlock( + number=8, + flags=1, + is_error=True, + body=(b"bad command",), + ), + ), + ), + ParserCase( + test_id="pane-id-output", + wire=b"%begin 1 9 1\n%42\n%end 1 9 1\n", + expected=( + ControlModeBlock( + number=9, + flags=1, + is_error=False, + body=(b"%42",), + ), + ), + ), +) + + +@pytest.mark.parametrize( + "case", + PARSER_CASES, + ids=[case.test_id for case in PARSER_CASES], +) +def test_control_mode_parser_emits_blocks(case: ParserCase) -> None: + """The parser preserves block bodies and error status.""" + parser = ControlModeParser() + + parser.feed(case.wire) + + assert tuple(parser.blocks()) == case.expected + + +def test_control_mode_runner_empty_batch_does_not_spawn(session: Session) -> None: + """An empty control-mode batch is a no-op.""" + runner = ControlModeRunner(session.server) + try: + assert runner.run_argvs([]) == [] + assert runner._proc is None + finally: + runner.close() + + +def test_control_mode_runner_batch_returns_per_command_stdout( + session: Session, +) -> None: + """A control-mode batch returns one output result per command.""" + with ControlModeRunner(session.server) as runner: + results = runner.run_argvs( + [ + ("display-message", "-p", "first"), + ("display-message", "-p", "second"), + ], + ) + + assert [result.returncode for result in results] == [0, 0] + assert [result.stdout for result in results] == [["first"], ["second"]] + assert [result.stderr for result in results] == [[], []] + + +def test_control_mode_runner_chain_returns_per_call_stdout( + session: Session, +) -> None: + """A ``CommandChain`` can run over control mode without merged output.""" + chain = CommandCall("display-message", ("-p", "left")).then( + CommandCall("display-message", ("-p", "right")), + ) + + with ControlModeRunner(session.server) as runner: + results = runner.run_chain(chain) + + assert [result.stdout for result in results] == [["left"], ["right"]] + + +def test_control_mode_runner_mid_batch_error_keeps_later_results( + session: Session, +) -> None: + """A bad command in a control-mode batch does not consume later results.""" + with ControlModeRunner(session.server) as runner: + before, bad, after = runner.run_argvs( + [ + ("display-message", "-p", "before"), + ("no-such-command",), + ("display-message", "-p", "after"), + ], + ) + + assert before.returncode == 0 + assert before.stdout == ["before"] + assert bad.returncode == 1 + assert bad.stdout == [] + assert bad.stderr + assert after.returncode == 0 + assert after.stdout == ["after"] From 302e346337185b765d3659758c32ae4638e0527e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 07:04:31 -0500 Subject: [PATCH 39/42] Chain(fix[control]): Keep percent output why: Control-mode command output can legitimately begin with tmux control tokens. Treating those lines as notifications truncated stdout and could desynchronize a pending command block. what: - Preserve token-shaped output while a command block is pending - Close pending blocks only on the matching guard number - Add parser regressions for event-shaped and mismatched guard output --- src/libtmux/_experimental/chain/control.py | 14 ++++++++++--- tests/_experimental/chain/test_control.py | 24 ++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/libtmux/_experimental/chain/control.py b/src/libtmux/_experimental/chain/control.py index e73d2d70a..6ffb007ba 100644 --- a/src/libtmux/_experimental/chain/control.py +++ b/src/libtmux/_experimental/chain/control.py @@ -121,11 +121,9 @@ def blocks(self) -> list[ControlModeBlock]: def _handle_line(self, line: bytes) -> None: if self._pending is not None: - if line.startswith(_END_PREFIX) or line.startswith(_ERROR_PREFIX): + if _matches_pending_close(line, self._pending.number): self._close_block(line) return - if any(line.startswith(prefix) for prefix in _NOTIFICATION_PREFIXES): - return self._pending.body.append(line) return @@ -436,6 +434,16 @@ def _parse_guard( return (number, flags) +def _matches_pending_close(line: bytes, pending_number: int) -> bool: + if line.startswith(_END_PREFIX): + number, _flags = _parse_guard(line, _END_PREFIX) + return number == pending_number + if line.startswith(_ERROR_PREFIX): + number, _flags = _parse_guard(line, _ERROR_PREFIX) + return number == pending_number + return False + + def _result_from_block(block: ControlModeBlock) -> ControlModeResult: lines = [line.decode("utf-8", errors="replace") for line in block.body] if block.is_error: diff --git a/tests/_experimental/chain/test_control.py b/tests/_experimental/chain/test_control.py index 540e5eeee..56219f3da 100644 --- a/tests/_experimental/chain/test_control.py +++ b/tests/_experimental/chain/test_control.py @@ -62,6 +62,30 @@ class ParserCase(t.NamedTuple): ), ), ), + ParserCase( + test_id="event-shaped-output", + wire=b"%begin 1 10 1\n%output literal\n%message literal\n%end 1 10 1\n", + expected=( + ControlModeBlock( + number=10, + flags=1, + is_error=False, + body=(b"%output literal", b"%message literal"), + ), + ), + ), + ParserCase( + test_id="mismatched-end-shaped-output", + wire=b"%begin 1 11 1\n%end 1 12 1\nstill here\n%end 1 11 1\n", + expected=( + ControlModeBlock( + number=11, + flags=1, + is_error=False, + body=(b"%end 1 12 1", b"still here"), + ), + ), + ), ) From 7275ce0a50d8c1ec8310eed3456e4b5cc7fe112c Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 07:08:10 -0500 Subject: [PATCH 40/42] Chain(fix[plan]): Guard forward decorates why: Forward decorations enter folded dispatches too, so they must obey the same chainability contract as other chain compilation paths. what: - Check ForwardHandle.do decorations with ensure_chainable - Cover output-command and unknown-command raw decoration bypasses --- src/libtmux/_experimental/chain/_resolve.py | 6 +++- tests/_experimental/chain/test_resolve.py | 39 +++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 75d4930ed..a202fabf5 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -26,6 +26,7 @@ import typing as t from dataclasses import dataclass, field +from libtmux._experimental.chain.chain import ensure_chainable from libtmux._experimental.chain.ir import ( CommandCall, CommandChain, @@ -521,7 +522,10 @@ def initial_window(self) -> ForwardHandle: def do(self, build: cabc.Callable[[ForwardHandle], IntoCommands]) -> ForwardHandle: """Decorate this handle via its namespaces (reused, no new vocabulary).""" - self._plan._steps.extend(_Decorate(call) for call in _to_calls(build(self))) + calls = _to_calls(build(self)) + for call in calls: + ensure_chainable(call.name) + self._plan._steps.extend(_Decorate(call) for call in calls) return self def _require(self, verb: str, *kinds: Kind) -> None: diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index 3f800dbed..f6fe23ddb 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -10,6 +10,7 @@ from libtmux._experimental.chain import ( AsyncSessionPlanExecutor, + ChainabilityError, ForwardPlan, ServerPlanRunner, SessionPlanExecutor, @@ -38,6 +39,28 @@ def _mark(value: str) -> t.Callable[[t.Any], t.Any]: return lambda h: h.cmd.raw("set-option", "-p", "@m", value) +class ForwardDecorateChainabilityCase(t.NamedTuple): + """A forward decoration that must fail chainability validation.""" + + test_id: str + build: t.Callable[[t.Any], t.Any] + match: str + + +FORWARD_DECORATE_CHAINABILITY_CASES = ( + ForwardDecorateChainabilityCase( + test_id="output-command", + build=lambda h: h.cmd.raw("capture-pane", "-p"), + match="not chainable", + ), + ForwardDecorateChainabilityCase( + test_id="unknown-command", + build=lambda h: h.cmd.raw("some-unknown-command"), + match="unknown tmux command", + ), +) + + def test_drive_core_is_sans_io_and_substitutes_ids() -> None: """The core yields Dispatch, captures ids, and substitutes them -- no tmux. @@ -162,6 +185,22 @@ def test_handle_scope_guard() -> None: pane.new_window() # ditto for a pane +@pytest.mark.parametrize( + "case", + FORWARD_DECORATE_CHAINABILITY_CASES, + ids=[case.test_id for case in FORWARD_DECORATE_CHAINABILITY_CASES], +) +def test_forward_handle_do_enforces_chainability( + case: ForwardDecorateChainabilityCase, +) -> None: + """Forward decorations cannot bypass the chainability contract.""" + plan = ForwardPlan(PaneTarget("%1")) + handle = plan.split() + + with pytest.raises(ChainabilityError, match=case.match): + handle.do(case.build) + + def test_multidispatch_session_window_pane(session: Session) -> None: """Live: one plan builds a session, two independent windows, a split in each. From 2b77da065779ab58e8fe914e060db1bbbbe95373 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 07:11:12 -0500 Subject: [PATCH 41/42] Chain(fix[plan]): Keep seed order why: The marked-pane fast path must not change the order callers authored. Seed decorations before a split need to execute before the created pane exists. what: - Skip the marked fast path when decorations precede the lone pane create - Cover seed-decoration, split, and child-decoration dispatch ordering --- src/libtmux/_experimental/chain/_resolve.py | 18 ++++++++++----- tests/_experimental/chain/test_resolve.py | 25 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index a202fabf5..2a32e740c 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -221,14 +221,20 @@ def _marked_eligible(steps: tuple[_Step, ...]) -> _Create | None: The marked register is a single server-wide slot, and only a non-detached pane creation (``split-window``) leaves its result active to be marked. So - exactly one pane :class:`_Create` is the one plan shape that resolves in a - single ``{marked}`` invocation; any other shape (two or more creations, or a - detached session creation) needs the multi-dispatch path. + exactly one pane :class:`_Create` with no preceding decoration is the one + plan shape that resolves in a single ``{marked}`` invocation; any other shape + needs the multi-dispatch path. """ creates = [step for step in steps if isinstance(step, _Create)] - if len(creates) == 1 and creates[0].kind == "pane": - return creates[0] - return None + if len(creates) != 1 or creates[0].kind != "pane": + return None + create = creates[0] + for step in steps: + if step is create: + return create + if isinstance(step, _Decorate): + return None + return create def _marked_invocation( diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index f6fe23ddb..a14c95d2b 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -389,6 +389,31 @@ def test_preserve_mark_skips_marked_fold() -> None: assert len(argvs) == 2 # multi-dispatch: create + decorate +def test_marked_fold_preserves_pre_create_decorate_order() -> None: + """Seed decorations authored before a split must run before the split.""" + plan = ForwardPlan(PaneTarget("%1")) + plan.seed.do(_mark("seed")) + plan.split().do(_mark("child")) + + gen = drive(tuple(plan._steps)) + argvs: list[tuple[str, ...]] = [] + request = next(gen) + try: + while True: + assert isinstance(request, Dispatch) + argvs.append(request.argv) + stdout = ["%9"] if request.captures is not None else [] + request = gen.send(_FakeResult(stdout=stdout)) + except StopIteration: + pass + + assert argvs == [ + ("set-option", "-t", "%1", "-p", "@m", "seed"), + ("split-window", "-t", "%1", "-v", "-P", "-F", "#{pane_id}"), + ("set-option", "-t", "%9", "-p", "@m", "child"), + ] + + def test_preserve_mark_keeps_user_mark(session: Session) -> None: """Live: preserve_mark=True leaves a pre-existing user mark intact.""" window = session.new_window(window_name="cc_mark") From b34c0065aca35faeef4fc8e706ddb2e099a3fcb3 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 21 Jun 2026 07:14:43 -0500 Subject: [PATCH 42/42] Chain(fix[plan]): Clear mark on failure why: A marked fast-path dispatch can fail after marking the newly created pane but before the final unmark command runs, leaving tmux server state changed after the resolver raises. what: - Issue a best-effort unmark dispatch after failed marked chains with a captured id - Preserve the original forward dispatch error - Add a live regression that verifies no pane remains marked after failure --- src/libtmux/_experimental/chain/_resolve.py | 2 ++ tests/_experimental/chain/test_resolve.py | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/libtmux/_experimental/chain/_resolve.py b/src/libtmux/_experimental/chain/_resolve.py index 2a32e740c..c290c927f 100644 --- a/src/libtmux/_experimental/chain/_resolve.py +++ b/src/libtmux/_experimental/chain/_resolve.py @@ -295,6 +295,8 @@ def drive( decorates = tuple(s.call for s in steps if isinstance(s, _Decorate)) argv = _marked_invocation(solo, decorates, bindings) result = yield Dispatch(argv, solo.slot) + if decorates and result.returncode != 0 and result.stdout: + yield Dispatch(("select-pane", "-M"), None) bindings[solo.slot] = _capture_id(argv, result) return Resolved(bindings, (result,)) diff --git a/tests/_experimental/chain/test_resolve.py b/tests/_experimental/chain/test_resolve.py index a14c95d2b..33491e2bd 100644 --- a/tests/_experimental/chain/test_resolve.py +++ b/tests/_experimental/chain/test_resolve.py @@ -623,3 +623,24 @@ def test_marked_single_dispatch_live(session: Session) -> None: "display-message", "-p", "-t", new_pane_id, "#{pane_marked}" ).stdout assert marked == ["0"] + + +def test_marked_single_dispatch_failure_clears_mark(session: Session) -> None: + """Live: a failed marked dispatch still clears tmux's mark register.""" + window = session.new_window(window_name="marked_fail") + seed = window.active_pane + assert seed is not None + assert seed.pane_id is not None + assert window.window_id is not None + + plan = ForwardPlan(PaneTarget(seed.pane_id)) + plan.split(horizontal=True).do(lambda h: h.window.select_layout("not-a-layout")) + + with pytest.raises(ForwardDispatchError): + plan.run_resolving(SessionPlanExecutor(session)) + + marked = session.server.cmd( + "list-panes", "-t", window.window_id, "-F", "#{pane_id}:#{pane_marked}" + ).stdout + assert marked + assert all(row.endswith(":0") for row in marked)