From ff8a5af2b6cfd8eeab182dee1a2ae0b99598313e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 19:55:44 -0500 Subject: [PATCH 1/3] Builder(feat[chain]): scaffold chain builder why: Land the experimental ChainWorkspaceBuilder from #1054 on the new builder package. It builds the window/pane tree via libtmux's chain API (libtmux#685), which is unreleased and absent from published libtmux, so the scaffold must import cleanly and fail with an actionable error rather than crash on import. what: - Add src/tmuxp/workspace/builder/chain.py: ChainWorkspaceBuilder subclasses ClassicWorkspaceBuilder; guard the experimental import behind _HAVE_CHAIN so the module imports without the API - build() raises exc.WorkspaceBuilderImportError when the chain API is absent; when present, port #1054's single-ForwardPlan build - Register the `chain` entry point in tmuxp.workspace_builders - Export ChainWorkspaceBuilder from the builder package - Add tests: test_builder_chain.py importorskips the chain API; the new test_builder_chain_unavailable.py asserts selecting `chain` without the API resolves the class yet raises on build() --- pyproject.toml | 1 + src/tmuxp/workspace/builder/__init__.py | 7 + src/tmuxp/workspace/builder/chain.py | 482 ++++++++++++++++++ tests/workspace/test_builder_chain.py | 120 +++++ .../test_builder_chain_unavailable.py | 85 +++ 5 files changed, 695 insertions(+) create mode 100644 src/tmuxp/workspace/builder/chain.py create mode 100644 tests/workspace/test_builder_chain.py create mode 100644 tests/workspace/test_builder_chain_unavailable.py diff --git a/pyproject.toml b/pyproject.toml index 7abefdeaf8..7077a15c86 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ tmuxp = 'tmuxp:cli.cli' [project.entry-points."tmuxp.workspace_builders"] classic = "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" +chain = "tmuxp.workspace.builder.chain:ChainWorkspaceBuilder" [dependency-groups] dev = [ diff --git a/src/tmuxp/workspace/builder/__init__.py b/src/tmuxp/workspace/builder/__init__.py index 469a5308e9..d639c58374 100644 --- a/src/tmuxp/workspace/builder/__init__.py +++ b/src/tmuxp/workspace/builder/__init__.py @@ -7,10 +7,16 @@ ``WorkspaceBuilder`` remains importable here as a backwards-compatible alias of :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. + +The experimental +:class:`~tmuxp.workspace.builder.chain.ChainWorkspaceBuilder` is also exported. +Its module imports cleanly even when libtmux's unreleased chain API +(libtmux#685) is absent; the missing API surfaces only when its ``build()`` runs. """ from __future__ import annotations +from tmuxp.workspace.builder.chain import ChainWorkspaceBuilder from tmuxp.workspace.builder.classic import ( ClassicWorkspaceBuilder, get_default_columns, @@ -31,6 +37,7 @@ __all__ = [ "WORKSPACE_BUILDERS_GROUP", + "ChainWorkspaceBuilder", "ClassicWorkspaceBuilder", "WorkspaceBuilder", "WorkspaceBuilderProtocol", diff --git a/src/tmuxp/workspace/builder/chain.py b/src/tmuxp/workspace/builder/chain.py new file mode 100644 index 0000000000..20f0325e76 --- /dev/null +++ b/src/tmuxp/workspace/builder/chain.py @@ -0,0 +1,482 @@ +"""Build a tmux workspace through libtmux's experimental chain API. + +:class:`ChainWorkspaceBuilder` is an experimental alternative to +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` that describes +the whole window/pane tree in one +:class:`~libtmux._experimental.chain.ForwardPlan` and resolves it in the fewest +tmux invocations, instead of one subprocess per ``new-window`` / +``split-window``. Shell commands are delivered either readiness-aware (the +default, matching the classic builder) or batched into the plan. + +The chain API (libtmux#685) is **unreleased**: it is absent from published +libtmux builds. This module imports cleanly without it -- the experimental +import is guarded and :data:`_HAVE_CHAIN` records whether the API is present. +When it is absent, :meth:`ChainWorkspaceBuilder.build` raises +:exc:`~tmuxp.exc.WorkspaceBuilderImportError` so selecting ``workspace_builder: +chain`` fails with an actionable message rather than an import crash. +""" + +from __future__ import annotations + +import typing as t + +from libtmux.pane import Pane +from libtmux.session import Session +from libtmux.window import Window + +from tmuxp import exc +from tmuxp.util import run_before_script +from tmuxp.workspace.builder.classic import ( + ClassicWorkspaceBuilder, + _wait_for_pane_ready, + get_default_columns, + get_default_rows, +) + +if t.TYPE_CHECKING: + # The chain API is unreleased (libtmux#685); model the symbols as ``Any`` + # and the feature as absent so this module type-checks on released libtmux. + ForwardPlan = t.Any + ServerPlanRunner = t.Any + _HAVE_CHAIN = False +else: + try: + from libtmux._experimental.chain import ForwardPlan, ServerPlanRunner + + _HAVE_CHAIN = True + except ImportError: + ForwardPlan = None + ServerPlanRunner = None + _HAVE_CHAIN = False + + +Decorate: t.TypeAlias = t.Callable[[t.Any], t.Any] +SendKeysMode: t.TypeAlias = t.Literal["readiness", "batched"] + +_CHAIN_UNAVAILABLE = ( + "ChainWorkspaceBuilder requires the libtmux experimental chain API " + "(libtmux#685), not available in this libtmux build" +) + + +def _str_or_none(value: t.Any) -> str | None: + """Narrow an ``Any`` config value to a ``str`` or ``None``. + + Examples + -------- + >>> _str_or_none("~/project") + '~/project' + >>> _str_or_none(None) is None + True + >>> _str_or_none(123) is None + True + """ + return value if isinstance(value, str) else None + + +def _window_start_directory(window_config: dict[str, t.Any]) -> str | None: + """Resolve a window's start_directory (window, or its first pane). + + Examples + -------- + >>> _window_start_directory({"start_directory": "/tmp"}) + '/tmp' + >>> _window_start_directory({"panes": [{"start_directory": "/srv"}]}) + '/srv' + >>> _window_start_directory({}) is None + True + """ + panes = window_config.get("panes") or [{}] + return _str_or_none( + panes[0].get("start_directory", window_config.get("start_directory")), + ) + + +def _window_shell(window_config: dict[str, t.Any]) -> str | None: + """Resolve a window's launch command (window_shell, or first pane shell). + + Examples + -------- + >>> _window_shell({"window_shell": "htop"}) + 'htop' + >>> _window_shell({"panes": [{"shell": "top"}]}) + 'top' + >>> _window_shell({}) is None + True + """ + panes = window_config.get("panes") or [{}] + return _str_or_none(panes[0].get("shell") or window_config.get("window_shell")) + + +def _pane_start_directory( + pane_config: dict[str, t.Any], + window_config: dict[str, t.Any], +) -> str | None: + """Resolve a pane's start_directory (pane, then window). + + Examples + -------- + >>> _pane_start_directory({"start_directory": "/a"}, {}) + '/a' + >>> _pane_start_directory({}, {"start_directory": "/b"}) + '/b' + >>> _pane_start_directory({}, {}) is None + True + """ + return _str_or_none( + pane_config.get("start_directory", window_config.get("start_directory")), + ) + + +def _pane_shell( + pane_config: dict[str, t.Any], + window_config: dict[str, t.Any], +) -> str | None: + """Resolve a pane's launch command (pane shell, then window_shell). + + Examples + -------- + >>> _pane_shell({"shell": "fish"}, {}) + 'fish' + >>> _pane_shell({}, {"window_shell": "zsh"}) + 'zsh' + >>> _pane_shell({}, {}) is None + True + """ + return _str_or_none(pane_config.get("shell", window_config.get("window_shell"))) + + +def _environment( + config: dict[str, t.Any], + window_config: dict[str, t.Any], +) -> dict[str, str] | None: + """Resolve environment for a pane/window (own, then window default). + + Examples + -------- + >>> _environment({"environment": {"A": "1"}}, {}) + {'A': '1'} + >>> _environment({}, {"environment": {"B": "2"}}) + {'B': '2'} + >>> _environment({}, {}) is None + True + """ + value = config.get("environment", window_config.get("environment")) + return value if isinstance(value, dict) else None + + +def _rename(name: str) -> Decorate: + """Return a decorate that renames a window. + + Examples + -------- + >>> class _Window: + ... def rename(self, name): + ... return ("rename", name) + >>> class _Handle: + ... window = _Window() + >>> _rename("editor")(_Handle()) + ('rename', 'editor') + """ + return lambda handle: handle.window.rename(name) + + +def _set_window_option(key: str, value: t.Any) -> Decorate: + """Return a decorate that sets one window option. + + Examples + -------- + >>> class _Window: + ... def set_option(self, key, value): + ... return (key, value) + >>> class _Handle: + ... window = _Window() + >>> _set_window_option("automatic-rename", False)(_Handle()) + ('automatic-rename', 'False') + """ + return lambda handle: handle.window.set_option(key, str(value)) + + +def _select_layout(layout: str) -> Decorate: + """Return a decorate that applies a window layout. + + Examples + -------- + >>> class _Window: + ... def select_layout(self, layout): + ... return layout + >>> class _Handle: + ... window = _Window() + >>> _select_layout("main-vertical")(_Handle()) + 'main-vertical' + """ + return lambda handle: handle.window.select_layout(layout) + + +def _send_keys(command: str, *, enter: bool) -> Decorate: + """Return a decorate that sends keys into a pane (batched mode). + + Examples + -------- + >>> class _Cmd: + ... def send_keys(self, command, enter): + ... return (command, enter) + >>> class _Handle: + ... cmd = _Cmd() + >>> _send_keys("echo hi", enter=True)(_Handle()) + ('echo hi', True) + """ + return lambda handle: handle.cmd.send_keys(command, enter=enter) + + +class ChainWorkspaceBuilder(ClassicWorkspaceBuilder): + """A :class:`ClassicWorkspaceBuilder` that builds the tree via the chain API. + + The session, then every window and pane, is described in one + :class:`~libtmux._experimental.chain.ForwardPlan` seeded from the live + session (reusing the session's default window as window 1, so nothing is + orphaned) and resolved in the fewest tmux invocations. Shell commands are + delivered per ``send_keys_mode``. + + This builder is experimental and depends on the unreleased + ``libtmux._experimental.chain`` API (libtmux#685). Until that ships, + :meth:`build` raises :exc:`~tmuxp.exc.WorkspaceBuilderImportError`. + + Examples + -------- + The class imports and subclasses the classic builder even when the chain + API is absent, so ``workspace_builder: chain`` resolves cleanly: + + >>> from tmuxp.workspace.builder.chain import ChainWorkspaceBuilder + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> issubclass(ChainWorkspaceBuilder, ClassicWorkspaceBuilder) + True + """ + + send_keys_mode: SendKeysMode + + def __init__( + self, + *args: t.Any, + send_keys_mode: SendKeysMode = "readiness", + **kwargs: t.Any, + ) -> None: + """Initialize the chain builder. + + Parameters + ---------- + *args, **kwargs + forwarded to + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` + (``session_config``, ``server``, ``plugins``, and the ``on_*`` + callbacks). + send_keys_mode : {"readiness", "batched"} + ``"readiness"`` (default) delivers each pane's shell commands after + the plan resolves, waiting for the prompt like the classic builder; + ``"batched"`` folds ``send_keys`` into the plan itself. + """ + super().__init__(*args, **kwargs) + self.send_keys_mode = send_keys_mode + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build the workspace, constructing the window/pane tree via the chain. + + Parameters + ---------- + session : :class:`libtmux.Session`, optional + session to build the workspace in; created from ``self.server`` when + omitted + append : bool + append windows to an existing active session + + Raises + ------ + tmuxp.exc.WorkspaceBuilderImportError + when the unreleased ``libtmux._experimental.chain`` API + (libtmux#685) is not importable in the current libtmux build + """ + if not _HAVE_CHAIN: + target = "chain" + raise exc.WorkspaceBuilderImportError(target, reason=_CHAIN_UNAVAILABLE) + + session = self._chain_create_session(session) + self._chain_setup_session(session) + + windows = self.session_config["windows"] + plan = ForwardPlan.from_session(session) + seed = plan.seed + for index, window_config in enumerate(windows): + first_window = index == 0 and not append + self._plan_window(plan, seed, window_config, first_window=first_window) + + plan.run_resolving(ServerPlanRunner(self.server)) + self._finalize(session, windows, append=append) + + # -- session creation + setup ------------------------------------------ + def _chain_create_session(self, session: Session | None) -> Session: + """Return the session to build in, creating one when not provided.""" + if session is not None: + self._session = session + return session + if not self.server: + msg = "ChainWorkspaceBuilder.build requires a server or a session" + raise exc.TmuxpException(msg) + + kwargs: dict[str, t.Any] = { + "x": get_default_columns(), + "y": get_default_rows(), + } + if "start_directory" in self.session_config: + kwargs["start_directory"] = self.session_config["start_directory"] + session = self.server.new_session( + session_name=self.session_config["session_name"], + **kwargs, + ) + assert session is not None + self._session = session + self.server = session.server + return session + + def _chain_setup_session(self, session: Session) -> None: + """Run plugin/before_script hooks and apply session-level options.""" + for plugin in self.plugins: + plugin.before_workspace_builder(session) + + if "before_script" in self.session_config: + cwd = self.session_config.get("start_directory") + try: + run_before_script( + self.session_config["before_script"], + cwd=cwd, + on_line=self.on_script_output, + ) + except Exception: + session.kill() + raise + + for option, value in self.session_config.get("options", {}).items(): + session.set_option(option, value) + for option, value in self.session_config.get("global_options", {}).items(): + session.set_option(option, value, global_=True) + for key, value in self.session_config.get("environment", {}).items(): + session.set_environment(key, value) + + # -- plan the window/pane tree ----------------------------------------- + def _plan_window( + self, + plan: t.Any, + seed: t.Any, + window_config: dict[str, t.Any], + *, + first_window: bool, + ) -> None: + """Describe one window and its panes on the forward plan.""" + name = window_config.get("window_name") + if first_window: + window = seed.initial_window + first_pane = seed.initial_pane + if name: + window.do(_rename(name)) + else: + window = seed.new_window( + name=name, + start_directory=_window_start_directory(window_config), + environment=_environment(window_config, window_config), + window_shell=_window_shell(window_config), + ) + first_pane = window + + for key, value in window_config.get("options", {}).items(): + window.do(_set_window_option(key, value)) + + panes = window_config["panes"] + pane_handles = [first_pane] + previous = first_pane + for pane_config in panes[1:]: + handle = previous.split( + start_directory=_pane_start_directory(pane_config, window_config), + shell=_pane_shell(pane_config, window_config), + environment=_environment(pane_config, window_config), + ) + pane_handles.append(handle) + previous = handle + + if "layout" in window_config: + window.do(_select_layout(window_config["layout"])) + + if self.send_keys_mode == "batched": + for handle, pane_config in zip(pane_handles, panes, strict=False): + self._plan_pane_keys(handle, pane_config) + + def _plan_pane_keys(self, handle: t.Any, pane_config: dict[str, t.Any]) -> None: + """Fold a pane's shell commands into the plan (batched mode).""" + enter = pane_config.get("enter", True) + for command in pane_config.get("shell_command", []): + handle.do(_send_keys(command["cmd"], enter=command.get("enter", enter))) + + # -- recover objects + deliver commands + focus ------------------------ + def _finalize( + self, + session: Session, + windows: list[dict[str, t.Any]], + *, + append: bool, + ) -> None: + """Recover libtmux objects, run hooks, deliver commands, apply focus.""" + session.refresh() + # Windows in index order == config creation order (default window first, + # new windows appended); same for a window's panes. + built = ( + list(session.windows)[-len(windows) :] if append else list(session.windows) + ) + + focus_window: Window | None = None + for window, window_config in zip(built, windows, strict=False): + window.refresh() + for plugin in self.plugins: + plugin.on_window_create(window) + + focus_pane: Pane | None = None + for pane, pane_config in zip( + window.panes, + window_config["panes"], + strict=False, + ): + if self.send_keys_mode == "readiness": + self._send_pane_commands(pane, pane_config, window_config) + if pane_config.get("focus"): + focus_pane = pane + + self.config_after_window(window, window_config) + for plugin in self.plugins: + plugin.after_window_finished(window) + if focus_pane is not None: + focus_pane.select() + if window_config.get("focus"): + focus_window = window + + if focus_window is not None: + focus_window.select() + + def _send_pane_commands( + self, + pane: Pane, + pane_config: dict[str, t.Any], + window_config: dict[str, t.Any], + ) -> None: + """Deliver a pane's shell commands readiness-aware (classic semantics).""" + if _pane_shell(pane_config, window_config) is None: + _wait_for_pane_ready(pane) + + if "suppress_history" in pane_config: + suppress = pane_config["suppress_history"] + else: + suppress = window_config.get("suppress_history", True) + + enter = pane_config.get("enter", True) + for command in pane_config.get("shell_command", []): + pane.send_keys( + command["cmd"], + suppress_history=command.get("suppress_history", suppress), + enter=command.get("enter", enter), + ) diff --git a/tests/workspace/test_builder_chain.py b/tests/workspace/test_builder_chain.py new file mode 100644 index 0000000000..631ffd07c0 --- /dev/null +++ b/tests/workspace/test_builder_chain.py @@ -0,0 +1,120 @@ +"""Tests for the chain-based workspace builder. + +These exercise the live build path and require libtmux's unreleased chain API +(libtmux#685). The whole module is skipped on released libtmux, where the API is +absent. The import-guarded fallback (selecting ``chain`` without the API) is +covered by :mod:`tests.workspace.test_builder_chain_unavailable`. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +pytest.importorskip("libtmux._experimental.chain") + +from libtmux.test.retry import retry_until +from libtmux.window import Window + +from tests.fixtures import utils as test_utils +from tmuxp._internal.config_reader import ConfigReader +from tmuxp.workspace import loader +from tmuxp.workspace.builder.chain import ChainWorkspaceBuilder + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _load(name: str) -> dict[str, t.Any]: + workspace = ConfigReader._from_file( + test_utils.get_workspace_file(f"workspace/builder/{name}"), + ) + workspace = loader.expand(workspace) + return loader.trickle(workspace) + + +def test_chain_split_windows(session: Session) -> None: + """The chain builder creates the windows and panes of a two-pane workspace.""" + workspace = _load("two_pane.yaml") + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + assert builder.session is session + assert [w.name for w in session.windows] == ["editor", "logging", "test"] + + editor = session.windows.get(window_name="editor") + assert isinstance(editor, Window) + editor.refresh() + assert len(editor.panes) == 2 + + +def test_chain_three_pane(session: Session) -> None: + """The chain builder splits a window into three panes.""" + workspace = _load("three_pane.yaml") + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + test_window = session.windows.get(window_name="test") + assert isinstance(test_window, Window) + test_window.refresh() + assert len(test_window.panes) == 3 + + +def test_chain_focus(session: Session) -> None: + """The chain builder honours window and pane focus.""" + workspace = _load("focus_and_pane.yaml") + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + + builder.build(session=session) + + assert session.active_window.name == "focused window" + + +def test_chain_commands_landed(session: Session) -> None: + """Readiness mode delivers each pane's shell command.""" + workspace = loader.trickle( + loader.expand( + { + "session_name": "cc_cmd", + "windows": [ + { + "window_name": "marker", + "panes": [{"shell_command": "echo CC_LANDED"}], + }, + ], + }, + ), + ) + builder = ChainWorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows.get(window_name="marker") + assert isinstance(window, Window) + pane = window.active_pane + assert pane is not None + + def _landed() -> bool: + return any("CC_LANDED" in line for line in pane.capture_pane()) + + assert retry_until(_landed, 2) + + +def test_chain_batched_mode(session: Session) -> None: + """Batched mode folds send_keys into the plan and still builds the tree.""" + workspace = _load("two_pane.yaml") + builder = ChainWorkspaceBuilder( + session_config=workspace, + server=session.server, + send_keys_mode="batched", + ) + + builder.build(session=session) + + assert [w.name for w in session.windows] == ["editor", "logging", "test"] + editor = session.windows.get(window_name="editor") + assert isinstance(editor, Window) + editor.refresh() + assert len(editor.panes) == 2 diff --git a/tests/workspace/test_builder_chain_unavailable.py b/tests/workspace/test_builder_chain_unavailable.py new file mode 100644 index 0000000000..5d5ae02480 --- /dev/null +++ b/tests/workspace/test_builder_chain_unavailable.py @@ -0,0 +1,85 @@ +"""Tests for ChainWorkspaceBuilder when the libtmux chain API is absent. + +Unlike :mod:`tests.workspace.test_builder_chain`, this module does not require +the unreleased chain API (libtmux#685): it asserts the import-guarded fallback. +On released libtmux the chain API is absent, so selecting ``workspace_builder: +chain`` must resolve cleanly and only fail with +:exc:`~tmuxp.exc.WorkspaceBuilderImportError` when ``build()`` runs. The module +self-skips on the rare build where the chain API is present. +""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp import exc +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry +from tmuxp.workspace.builder.chain import _HAVE_CHAIN, ChainWorkspaceBuilder + +if t.TYPE_CHECKING: + from libtmux.server import Server + +pytestmark = pytest.mark.skipif( + _HAVE_CHAIN, + reason="libtmux chain API present; the unavailable path cannot be exercised", +) + + +class SelectionCase(t.NamedTuple): + """A way of selecting the chain builder from a workspace config.""" + + test_id: str + workspace_builder: str + + +SELECTION_CASES: list[SelectionCase] = [ + SelectionCase(test_id="entry_point_name", workspace_builder="chain"), + SelectionCase( + test_id="dotted_reference", + workspace_builder="tmuxp.workspace.builder.chain:ChainWorkspaceBuilder", + ), +] + + +@pytest.mark.parametrize( + "case", + SELECTION_CASES, + ids=[c.test_id for c in SELECTION_CASES], +) +def test_chain_selection_resolves(case: SelectionCase) -> None: + """Selecting the chain builder resolves to the class without the chain API.""" + config = loader.expand( + { + "session_name": "chain-unavailable", + "workspace_builder": case.workspace_builder, + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + + resolved = registry.resolve_builder_class(config) + + assert resolved is ChainWorkspaceBuilder + + +@pytest.mark.parametrize( + "case", + SELECTION_CASES, + ids=[c.test_id for c in SELECTION_CASES], +) +def test_chain_build_raises_import_error(case: SelectionCase, server: Server) -> None: + """Building with the chain API absent raises WorkspaceBuilderImportError.""" + config = loader.expand( + { + "session_name": "chain-unavailable", + "workspace_builder": case.workspace_builder, + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + + with pytest.raises(exc.WorkspaceBuilderImportError): + builder.build() From 4e03f4491d8cd21ef2dd6ce2a462c2236aac4c2f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 19:55:49 -0500 Subject: [PATCH 2/3] docs(builder[chain]): document experimental chain builder why: Tell users the chain builder exists, how to select it, and that it is non-functional until libtmux's unreleased chain API ships. what: - Add docs/internals/api/workspace/builder/chain.md autodoc page with an unreleased-API warning; add it to the builder index grid and toctree - Add an "Experimental chain builder" section to the custom-workspace- builders topic and list it under Reference - Add a CHANGES entry under 1.74.0 noting the opt-in builder requires libtmux#685 and is non-functional until that ships --- CHANGES | 14 +++++++++++++ docs/internals/api/workspace/builder/chain.md | 16 ++++++++++++++ docs/internals/api/workspace/builder/index.md | 8 +++++++ docs/topics/custom-workspace-builders.md | 21 +++++++++++++++++++ 4 files changed, 59 insertions(+) create mode 100644 docs/internals/api/workspace/builder/chain.md diff --git a/CHANGES b/CHANGES index 1c006c2e5d..1258dd7f6b 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,20 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### What's new + +#### Experimental chain builder (opt-in) (#1054) + +tmuxp now registers an experimental +{class}`~tmuxp.workspace.builder.chain.ChainWorkspaceBuilder`, selectable with +`workspace_builder: chain`, that describes the whole window/pane tree in one plan +and resolves it in the fewest tmux invocations. It depends on libtmux's chain API +(libtmux#685), which is unreleased and absent from published libtmux builds, so +the builder is non-functional until that API ships: loading a workspace that +selects it raises {exc}`~tmuxp.exc.WorkspaceBuilderImportError`. The builder +module imports cleanly without the API. See {ref}`custom-workspace-builders` for +details. + ## tmuxp 1.73.0 (2026-06-28) tmuxp 1.73.0 makes the workspace build step pluggable and tunable. A workspace can now build through a third-party builder selected by registered entry-point name or Python import path, and a new `workspace_builder_options` catalog controls the pane-readiness wait per workspace. The built-in builder stays the default, so existing workspaces keep working — though the new `pane_readiness: auto` default skips the prompt wait on non-zsh shells. See {ref}`custom-workspace-builders` for the guide. diff --git a/docs/internals/api/workspace/builder/chain.md b/docs/internals/api/workspace/builder/chain.md new file mode 100644 index 0000000000..bd9ee4f18a --- /dev/null +++ b/docs/internals/api/workspace/builder/chain.md @@ -0,0 +1,16 @@ +# Chain builder - `tmuxp.workspace.builder.chain` + +:::{warning} +The chain builder is **experimental** and depends on libtmux's unreleased chain +API (libtmux#685). That API is absent from published libtmux builds, so +selecting `workspace_builder: chain` raises +{exc}`~tmuxp.exc.WorkspaceBuilderImportError` until it ships. The module itself +imports cleanly without the API. +::: + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.chain + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/index.md b/docs/internals/api/workspace/builder/index.md index 0b3ddbfabe..8cb6d459dd 100644 --- a/docs/internals/api/workspace/builder/index.md +++ b/docs/internals/api/workspace/builder/index.md @@ -29,6 +29,13 @@ The contract a builder must satisfy — `tmuxp.workspace.builder.protocol`. Builder selection and trusted import paths — `tmuxp.workspace.builder.registry`. ::: +:::{grid-item-card} Chain builder (experimental) +:link: chain +:link-type: doc +Experimental builder over libtmux's unreleased chain API — +`tmuxp.workspace.builder.chain`. +::: + :::: ```{toctree} @@ -37,4 +44,5 @@ Builder selection and trusted import paths — `tmuxp.workspace.builder.registry classic protocol registry +chain ``` diff --git a/docs/topics/custom-workspace-builders.md b/docs/topics/custom-workspace-builders.md index 26f6f38ff7..ef2b593586 100644 --- a/docs/topics/custom-workspace-builders.md +++ b/docs/topics/custom-workspace-builders.md @@ -120,6 +120,26 @@ covers what `tmuxp load` drives: The contract is synchronous today. It is shaped so an async builder can be added later as an additive extension without changing this surface. +## Experimental chain builder + +tmuxp ships an experimental +{class}`~tmuxp.workspace.builder.chain.ChainWorkspaceBuilder` that describes the +whole window/pane tree in one plan and resolves it in the fewest tmux +invocations, instead of one subprocess per `new-window` / `split-window`. Select +it by name like any other registered builder: + +```yaml +workspace_builder: chain +``` + +:::{warning} +The chain builder depends on libtmux's chain API (libtmux#685), which is +**unreleased** and absent from published libtmux builds. The builder module +imports cleanly without it, but selecting `workspace_builder: chain` and loading +the workspace raises {exc}`~tmuxp.exc.WorkspaceBuilderImportError` until that API +ships. It is non-functional today and provided as scaffolding only. +::: + ## Pane readiness tmuxp waits for a pane's shell prompt before dispatching layout and commands, @@ -186,6 +206,7 @@ For builders that live in a trusted directory, build the `sys.path` sandbox with ## Reference - {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` +- {class}`~tmuxp.workspace.builder.chain.ChainWorkspaceBuilder` (experimental) - {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` - {func}`~tmuxp.workspace.builder.registry.resolve_builder_class` - {class}`~tmuxp.workspace.options.PaneReadiness` From 9200dc3aff5641fdaad9ac2e2ea5fdc6cfc65f29 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 20:54:46 -0500 Subject: [PATCH 3/3] chore(coverage): omit experimental chain builder why: the chain builder's plan-building methods require the unreleased libtmux._experimental.chain API (libtmux#685) and cannot execute on released libtmux, so codecov flagged the unmeasurable lines as a project-coverage drop. what: - Omit src/tmuxp/workspace/builder/chain.py from coverage until the experimental API ships and the builder becomes testable --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 7077a15c86..4917a846fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -155,6 +155,11 @@ omit = [ "pkg/*", "*/log.py", "docs/_ext/*", + # Experimental chain builder: its plan-building methods require the + # unreleased libtmux._experimental.chain API (libtmux#685) and cannot + # execute on released libtmux, so they are not measurable yet. Remove + # this once that API ships and the builder becomes testable. + "*/workspace/builder/chain.py", ] [tool.coverage.report]