From 443006873a2975f3351a5ff6967d0416e0da4fca Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 12:30:23 -0500 Subject: [PATCH 01/21] exc(feat): add builder resolution errors why: Resolving a workspace builder by dotted path, entry point, or trusted import path needs actionable errors that do not leak machine-specific paths. what: - Add WorkspaceBuilderError base plus WorkspaceBuilderNotFound, WorkspaceBuilderImportError, InvalidWorkspaceBuilder, and WorkspaceBuilderPathError under WorkspaceError --- src/tmuxp/exc.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 545038d7ca..59b69cbb7d 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -94,6 +94,85 @@ def __init__(self, *args: object, **kwargs: object) -> None: return super().__init__("No session active.", *args, **kwargs) +class WorkspaceBuilderError(WorkspaceError): + """Base error for resolving and validating a workspace builder.""" + + +class WorkspaceBuilderNotFound(WorkspaceBuilderError): + """Configured ``workspace_builder`` could not be resolved.""" + + def __init__( + self, + target: str, + available: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Workspace builder {target!r} could not be found." + if available: + names = ", ".join(sorted(available)) + msg += f" Available entry point builders: {names}." + else: + msg += ( + " Provide a Python dotted path (e.g. 'package.module:Builder') " + "or register an entry point in the 'tmuxp.workspace_builders' group." + ) + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderImportError(WorkspaceBuilderError): + """Configured ``workspace_builder`` failed to import.""" + + def __init__( + self, + target: str, + reason: str | None = None, + paths: list[str] | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"Could not import workspace builder {target!r}." + if reason: + msg += f" {reason}" + if paths: + msg += f" Searched trusted paths: {', '.join(paths)}." + msg += ( + " Confirm the builder is importable, or add its directory to " + "'workspace_builder_paths'." + ) + return super().__init__(msg, *args, **kwargs) + + +class InvalidWorkspaceBuilder(WorkspaceBuilderError): + """Resolved ``workspace_builder`` object is not a usable builder.""" + + def __init__( + self, + target: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"{target!r} is not a valid workspace builder" + msg += f": {reason}" if reason else "." + return super().__init__(msg, *args, **kwargs) + + +class WorkspaceBuilderPathError(WorkspaceBuilderError): + """A ``workspace_builder_paths`` entry is not a usable directory.""" + + def __init__( + self, + path: str, + reason: str | None = None, + *args: object, + **kwargs: object, + ) -> None: + msg = f"workspace_builder_paths entry is invalid: {path}." + msg += f" {reason}" if reason else " Each entry must be an existing directory." + return super().__init__(msg, *args, **kwargs) + + class TmuxpPluginException(TmuxpException): """Base Exception for Tmuxp Errors.""" From 8503fc9e5965ee3720b48bd77c2c552f82a44c92 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 12:30:29 -0500 Subject: [PATCH 02/21] workspace(feat): add builder options catalog why: Builder behavior (starting with pane readiness) needs a config home separate from tmux's options/global_options/environment catalogs, and extensible for future builders. what: - Add workspace/options.py with the PaneReadiness policy (auto/always/never plus truthy/falsy aliases), WorkspaceBuilderOptions, and zsh-aware shell detection helpers - Add unit tests for parsing and shell resolution --- src/tmuxp/workspace/options.py | 225 ++++++++++++++++++++++++++++++++ tests/workspace/test_options.py | 115 ++++++++++++++++ 2 files changed, 340 insertions(+) create mode 100644 src/tmuxp/workspace/options.py create mode 100644 tests/workspace/test_options.py diff --git a/src/tmuxp/workspace/options.py b/src/tmuxp/workspace/options.py new file mode 100644 index 0000000000..0719b4dc09 --- /dev/null +++ b/src/tmuxp/workspace/options.py @@ -0,0 +1,225 @@ +"""Behavior options for tmuxp workspace builders. + +The ``workspace_builder_options`` config catalog holds settings that tune how a +workspace builder runs, independent of *which* builder is selected. It is a +sibling to the tmux ``options`` / ``global_options`` / ``environment`` catalogs +and is the home for builder-behavior knobs (today: pane readiness; later: +parallel/async builder settings). + +Example +------- +.. code-block:: yaml + + workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +""" + +from __future__ import annotations + +import dataclasses +import enum +import os +import typing as t + +_AUTO_ALIASES = frozenset({"auto"}) +_ALWAYS_ALIASES = frozenset({"always", "true", "on", "yes", "1"}) +_NEVER_ALIASES = frozenset({"never", "false", "off", "no", "0"}) + + +class PaneReadiness(enum.Enum): + """Policy for whether the builder waits for a pane's shell prompt. + + tmuxp waits for each default-shell pane to draw its prompt before + dispatching layout and commands, which avoids a zsh prompt-redraw artifact + (see :func:`tmuxp.workspace.builder.classic._wait_for_pane_ready`). The wait + is only needed for zsh, so the default :attr:`AUTO` policy waits only when + the session's interactive shell is zsh. + """ + + AUTO = "auto" + ALWAYS = "always" + NEVER = "never" + + @classmethod + def from_config(cls, value: t.Any) -> PaneReadiness: + """Parse a ``pane_readiness`` config value into a policy. + + Accepts the canonical ``auto`` / ``always`` / ``never`` strings, plus + the truthy/falsy aliases users expect from boolean-like config keys. + + Parameters + ---------- + value : Any + value from ``workspace_builder_options.pane_readiness``; ``None`` + (key absent) resolves to :attr:`AUTO` + + Returns + ------- + PaneReadiness + + Examples + -------- + >>> PaneReadiness.from_config(None) is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config("auto") is PaneReadiness.AUTO + True + >>> PaneReadiness.from_config(True) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config("on") is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(1) is PaneReadiness.ALWAYS + True + >>> PaneReadiness.from_config(False) is PaneReadiness.NEVER + True + >>> PaneReadiness.from_config("never") is PaneReadiness.NEVER + True + + Unknown values raise an actionable error: + + >>> PaneReadiness.from_config("sometimes") + Traceback (most recent call last): + ... + ValueError: invalid pane_readiness value: 'sometimes'; expected one of: + auto, always/true/on/yes/1, never/false/off/no/0 + """ + if value is None: + return cls.AUTO + if isinstance(value, cls): + return value + if isinstance(value, bool): + return cls.ALWAYS if value else cls.NEVER + normalized = str(value).strip().lower() + if normalized in _AUTO_ALIASES: + return cls.AUTO + if normalized in _ALWAYS_ALIASES: + return cls.ALWAYS + if normalized in _NEVER_ALIASES: + return cls.NEVER + msg = ( + f"invalid pane_readiness value: {value!r}; expected one of: " + "auto, always/true/on/yes/1, never/false/off/no/0" + ) + raise ValueError(msg) + + +@dataclasses.dataclass(frozen=True) +class WorkspaceBuilderOptions: + """Parsed ``workspace_builder_options`` catalog. + + An absent ``workspace_builder_options`` catalog uses the defaults, whose + :attr:`PaneReadiness.AUTO` policy waits for a pane's prompt only when the + session shell is zsh: zsh workspaces build as before, while bash, sh, and + other shells skip the wait. Set ``pane_readiness: always`` to restore the + previous wait-everywhere behavior. + """ + + pane_readiness: PaneReadiness = PaneReadiness.AUTO + """pane-prompt wait policy; defaults to :attr:`PaneReadiness.AUTO`""" + + @classmethod + def from_config(cls, session_config: dict[str, t.Any]) -> WorkspaceBuilderOptions: + """Build options from a full workspace ``session_config`` dict. + + Reads the optional ``workspace_builder_options`` catalog; an absent or + empty catalog yields the defaults (see the class docstring for how the + default :attr:`PaneReadiness.AUTO` policy affects non-zsh shells). + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + WorkspaceBuilderOptions + + Examples + -------- + >>> WorkspaceBuilderOptions.from_config({}).pane_readiness + + + >>> cfg = {"workspace_builder_options": {"pane_readiness": "always"}} + >>> WorkspaceBuilderOptions.from_config(cfg).pane_readiness + + """ + catalog = session_config.get("workspace_builder_options") or {} + if not isinstance(catalog, dict): + msg = ( + "workspace_builder_options must be a mapping, got " + f"{type(catalog).__name__}" + ) + raise TypeError(msg) + return cls( + pane_readiness=PaneReadiness.from_config(catalog.get("pane_readiness")), + ) + + +def shell_is_zsh(shell: str | None) -> bool: + """Return ``True`` when ``shell`` names the zsh shell. + + Parameters + ---------- + shell : str or None + a shell path or name (e.g. ``/usr/bin/zsh``) + + Returns + ------- + bool + + Examples + -------- + >>> shell_is_zsh("/usr/bin/zsh") + True + >>> shell_is_zsh("/bin/bash") + False + >>> shell_is_zsh(None) + False + """ + return "zsh" in (shell or "") + + +def resolve_session_shell( + session: t.Any, + env: t.Mapping[str, str] | None = None, +) -> str: + """Resolve the effective interactive shell for a tmux session. + + Prefers tmux's ``default-shell`` option (which reflects a workspace's + ``options.default-shell`` once applied, otherwise tmux's global default), + and falls back to the ``SHELL`` environment variable. + + Parameters + ---------- + session : :class:`libtmux.Session` + live session exposing ``show_option("default-shell")`` + env : Mapping, optional + environment mapping for the ``SHELL`` fallback; defaults to + :data:`os.environ` + + Returns + ------- + str + resolved shell path/name, or ``""`` when undeterminable + + Examples + -------- + >>> class FakeSession: + ... def __init__(self, shell): + ... self._shell = shell + ... def show_option(self, name, **kwargs): + ... return self._shell + + The tmux ``default-shell`` wins when set: + + >>> resolve_session_shell(FakeSession("/usr/bin/zsh"), env={}) + '/usr/bin/zsh' + + The ``SHELL`` env var is the fallback: + + >>> resolve_session_shell(FakeSession(None), env={"SHELL": "/bin/bash"}) + '/bin/bash' + """ + default_shell = session.show_option("default-shell") + environ = os.environ if env is None else env + env_shell = environ.get("SHELL", "") + return str(default_shell or env_shell or "") diff --git a/tests/workspace/test_options.py b/tests/workspace/test_options.py new file mode 100644 index 0000000000..c6eaecf39f --- /dev/null +++ b/tests/workspace/test_options.py @@ -0,0 +1,115 @@ +"""Tests for workspace builder options (:mod:`tmuxp.workspace.options`).""" + +from __future__ import annotations + +import typing as t + +import pytest + +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) + + +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, PaneReadiness.AUTO), + ("auto", PaneReadiness.AUTO), + ("AUTO", PaneReadiness.AUTO), + (True, PaneReadiness.ALWAYS), + ("always", PaneReadiness.ALWAYS), + ("on", PaneReadiness.ALWAYS), + ("yes", PaneReadiness.ALWAYS), + ("1", PaneReadiness.ALWAYS), + (1, PaneReadiness.ALWAYS), + (False, PaneReadiness.NEVER), + ("never", PaneReadiness.NEVER), + ("off", PaneReadiness.NEVER), + ("no", PaneReadiness.NEVER), + ("0", PaneReadiness.NEVER), + (0, PaneReadiness.NEVER), + (PaneReadiness.ALWAYS, PaneReadiness.ALWAYS), + ], +) +def test_pane_readiness_from_config(value: t.Any, expected: PaneReadiness) -> None: + """from_config maps canonical values and truthy/falsy aliases.""" + assert PaneReadiness.from_config(value) is expected + + +@pytest.mark.parametrize("value", ["sometimes", "maybe", "2", ""]) +def test_pane_readiness_from_config_invalid(value: str) -> None: + """from_config rejects unknown values with an actionable error.""" + with pytest.raises(ValueError, match="pane_readiness"): + PaneReadiness.from_config(value) + + +def test_workspace_builder_options_defaults() -> None: + """An absent catalog yields AUTO readiness.""" + assert WorkspaceBuilderOptions.from_config({}).pane_readiness is PaneReadiness.AUTO + cfg = {"session_name": "x", "windows": []} + assert WorkspaceBuilderOptions.from_config(cfg).pane_readiness is PaneReadiness.AUTO + + +def test_workspace_builder_options_reads_catalog() -> None: + """from_config reads pane_readiness from the catalog.""" + cfg = {"workspace_builder_options": {"pane_readiness": "never"}} + options = WorkspaceBuilderOptions.from_config(cfg) + assert options.pane_readiness is PaneReadiness.NEVER + + +def test_workspace_builder_options_invalid_catalog_type() -> None: + """A non-mapping catalog is rejected.""" + with pytest.raises(TypeError, match="must be a mapping"): + WorkspaceBuilderOptions.from_config({"workspace_builder_options": ["nope"]}) + + +@pytest.mark.parametrize( + ("shell", "expected"), + [ + ("/usr/bin/zsh", True), + ("/bin/zsh", True), + ("zsh", True), + ("/bin/bash", False), + ("/bin/sh", False), + ("", False), + (None, False), + ], +) +def test_shell_is_zsh(shell: str | None, expected: bool) -> None: + """shell_is_zsh detects zsh by name.""" + assert shell_is_zsh(shell) is expected + + +class _FakeSession: + """Minimal stand-in exposing ``show_option`` for shell resolution.""" + + def __init__(self, shell: str | None) -> None: + self._shell = shell + + def show_option(self, name: str, **kwargs: t.Any) -> str | None: + """Return the canned default-shell value.""" + return self._shell + + +def test_resolve_session_shell_prefers_default_shell() -> None: + """The tmux default-shell wins over $SHELL.""" + shell = resolve_session_shell( + _FakeSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" + + +def test_resolve_session_shell_falls_back_to_env() -> None: + """$SHELL is the fallback when default-shell is empty.""" + shell = resolve_session_shell(_FakeSession(None), env={"SHELL": "/bin/bash"}) + assert shell == "/bin/bash" + + +def test_resolve_session_shell_empty_when_unknown() -> None: + """Returns an empty string when neither source resolves.""" + assert resolve_session_shell(_FakeSession(None), env={}) == "" From 0afcbb93682de55bbc97da607e719d60656b5123 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 16:10:51 -0500 Subject: [PATCH 03/21] workspace(feat): builder package + protocol why: Separate builder selection from build behavior. Make the classic builder explicit so a workspace can later choose a different builder, and define a public contract third-party builders implement. what: - Convert workspace/builder.py into a package; rename WorkspaceBuilder to ClassicWorkspaceBuilder in builder/classic.py, keeping a backwards-compatible WorkspaceBuilder alias - Add builder/protocol.py (WorkspaceBuilderProtocol, async-extensible) - Gate pane readiness on WorkspaceBuilderOptions: auto waits only for zsh, always/never force the choice - Point doctest tmux fixtures at builder.classic --- conftest.py | 2 +- src/tmuxp/workspace/builder/__init__.py | 31 +++++ .../{builder.py => builder/classic.py} | 63 ++++++++-- src/tmuxp/workspace/builder/protocol.py | 108 ++++++++++++++++++ 4 files changed, 192 insertions(+), 12 deletions(-) create mode 100644 src/tmuxp/workspace/builder/__init__.py rename src/tmuxp/workspace/{builder.py => builder/classic.py} (92%) create mode 100644 src/tmuxp/workspace/builder/protocol.py diff --git a/conftest.py b/conftest.py index 1f0583439e..ad606b5342 100644 --- a/conftest.py +++ b/conftest.py @@ -114,7 +114,7 @@ def socket_name(request: pytest.FixtureRequest) -> str: # Modules that actually need tmux fixtures in their doctests DOCTEST_NEEDS_TMUX = { - "tmuxp.workspace.builder", + "tmuxp.workspace.builder.classic", } diff --git a/src/tmuxp/workspace/builder/__init__.py b/src/tmuxp/workspace/builder/__init__.py new file mode 100644 index 0000000000..d7158e978c --- /dev/null +++ b/src/tmuxp/workspace/builder/__init__.py @@ -0,0 +1,31 @@ +"""Workspace builders: turn an expanded workspace ``dict`` into a tmux session. + +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the default +implementation. A workspace may select a different builder with the +``workspace_builder`` config key, resolved by +:mod:`tmuxp.workspace.builder.registry`. + +``WorkspaceBuilder`` remains importable here as a backwards-compatible alias of +:class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. +""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ( + ClassicWorkspaceBuilder, + get_default_columns, + get_default_rows, +) +from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + +# Backwards-compatible alias: the classic builder was historically named +# ``WorkspaceBuilder`` and imported from ``tmuxp.workspace.builder``. +WorkspaceBuilder = ClassicWorkspaceBuilder + +__all__ = [ + "ClassicWorkspaceBuilder", + "WorkspaceBuilder", + "WorkspaceBuilderProtocol", + "get_default_columns", + "get_default_rows", +] diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder/classic.py similarity index 92% rename from src/tmuxp/workspace/builder.py rename to src/tmuxp/workspace/builder/classic.py index 728b477963..f4d209bfd3 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder/classic.py @@ -17,6 +17,12 @@ from tmuxp import exc from tmuxp.log import TmuxpLoggerAdapter from tmuxp.util import get_current_pane, run_before_script +from tmuxp.workspace.options import ( + PaneReadiness, + WorkspaceBuilderOptions, + resolve_session_shell, + shell_is_zsh, +) if t.TYPE_CHECKING: from collections.abc import Iterator @@ -101,7 +107,7 @@ def get_default_rows() -> int: return int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", ROWS_FALLBACK))) -class WorkspaceBuilder: +class ClassicWorkspaceBuilder: """Load workspace from workspace :py:obj:`dict` object. Build tmux workspace from a configuration. Creates and names windows, sets options, @@ -134,7 +140,7 @@ class WorkspaceBuilder: ... - cmd: htop ... ''', Loader=yaml.Loader) - >>> builder = WorkspaceBuilder(session_config=session_config, server=server) + >>> builder = ClassicWorkspaceBuilder(session_config=session_config, server=server) **New session:** @@ -176,7 +182,7 @@ class WorkspaceBuilder: ... "session_name": "progress-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=progress_cfg, ... server=server, ... on_progress=calls.append, @@ -192,7 +198,7 @@ class WorkspaceBuilder: ... "session_name": "hook-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=no_script_cfg, ... server=server, ... on_before_script=lambda: hook_calls.append(True), @@ -208,7 +214,7 @@ class WorkspaceBuilder: ... "session_name": "script-output-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=no_script_cfg2, ... server=server, ... on_script_output=script_lines.append, @@ -224,7 +230,7 @@ class WorkspaceBuilder: ... "session_name": "events-demo", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=event_cfg, ... server=server, ... on_build_event=events.append, @@ -247,7 +253,7 @@ class WorkspaceBuilder: ... "before_script": "echo hello", ... "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], ... } - >>> builder = WorkspaceBuilder( + >>> builder = ClassicWorkspaceBuilder( ... session_config=script_event_cfg, ... server=server, ... on_build_event=script_events.append, @@ -297,7 +303,9 @@ class WorkspaceBuilder: their panes, returning full :class:`libtmux.Window` and :class:`libtmux.Pane` objects each step of the way:: - workspace = WorkspaceBuilder(session_config=session_config, server=server) + workspace = ClassicWorkspaceBuilder( + session_config=session_config, server=server + ) It handles the magic of cases where the user may want to start a session inside tmux (when `$TMUX` is in the env variables). @@ -310,6 +318,8 @@ class WorkspaceBuilder: on_before_script: t.Callable[[], None] | None on_script_output: t.Callable[[str], None] | None on_build_event: t.Callable[[dict[str, t.Any]], None] | None + _builder_options: WorkspaceBuilderOptions + _pane_readiness_wait: bool def __init__( self, @@ -373,6 +383,14 @@ def __init__( self.on_script_output = on_script_output self.on_build_event = on_build_event + # Builder-behavior catalog (e.g. pane readiness). Parsed eagerly so an + # invalid value fails fast; the readiness decision itself needs a live + # session and is resolved in build(). + self._builder_options = WorkspaceBuilderOptions.from_config(session_config) + # Safe default for direct iter_create_panes() use that bypasses build(); + # build() replaces this with the policy-resolved value. + self._pane_readiness_wait = True + if self.server is not None and self.session_exists( session_name=self.session_config["session_name"], ): @@ -425,7 +443,7 @@ def build(self, session: Session | None = None, append: bool = False) -> None: if not session: if not self.server: msg = ( - "WorkspaceBuilder.build requires server to be passed " + "ClassicWorkspaceBuilder.build requires server to be passed " "on initialization, or pass in session object to here." ) raise exc.TmuxpException( @@ -538,6 +556,27 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for option, value in self.session_config["environment"].items(): self.session.set_environment(option, value) + # Resolve the pane-readiness decision once, now that the session exists + # and any config `options.default-shell` has been applied above. AUTO + # waits only for zsh (the shell the prompt-redraw wait was added for); + # ALWAYS/NEVER force the choice. + readiness = self._builder_options.pane_readiness + if readiness is PaneReadiness.ALWAYS: + self._pane_readiness_wait = True + elif readiness is PaneReadiness.NEVER: + self._pane_readiness_wait = False + else: # PaneReadiness.AUTO + self._pane_readiness_wait = shell_is_zsh( + resolve_session_shell(self.session), + ) + _log.debug( + "pane readiness resolved", + extra={ + "pane_readiness": readiness.value, + "pane_readiness_wait": self._pane_readiness_wait, + }, + ) + for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) @@ -781,9 +820,11 @@ def get_pane_shell( # Skip readiness wait when a custom shell/command launcher is set. # The shell/window_shell key runs a command (e.g. "top", "sleep 999") # that replaces the default shell — the pane exits when the command - # exits, so there is no interactive prompt to wait for. + # exits, so there is no interactive prompt to wait for. The + # pane_readiness policy (resolved in build()) further gates whether + # default-shell panes wait at all. pane_shell = pane_config.get("shell", window_config.get("window_shell")) - if pane_shell is None: + if pane_shell is None and self._pane_readiness_wait: _wait_for_pane_ready(pane) if "layout" in window_config: diff --git a/src/tmuxp/workspace/builder/protocol.py b/src/tmuxp/workspace/builder/protocol.py new file mode 100644 index 0000000000..0557bf7849 --- /dev/null +++ b/src/tmuxp/workspace/builder/protocol.py @@ -0,0 +1,108 @@ +"""Public contract for tmuxp workspace builders. + +A workspace builder turns an expanded workspace ``dict`` into a live tmux +session. :class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` is the +default implementation; third parties may ship their own and select them with +the ``workspace_builder`` config key (see +:mod:`tmuxp.workspace.builder.registry`). + +This module is intentionally dependency-light (typing only) so builder authors +can import the contract without pulling in tmuxp's resolution machinery. + +The contract is synchronous today. Async builders are an *additive* future +extension: a subclass protocol can declare an ``async`` build entry point and a +capability flag, discovered through the same entry-point group, without +changing this sync surface. +""" + +from __future__ import annotations + +import typing as t + +if t.TYPE_CHECKING: + from libtmux.server import Server + from libtmux.session import Session + + +@t.runtime_checkable +class WorkspaceBuilderProtocol(t.Protocol): + """Contract a builder must satisfy to be driven by ``tmuxp load``. + + A conforming builder is constructed with at least ``session_config`` and + ``server`` (plus the optional ``plugins`` and ``on_*`` callbacks the classic + builder accepts). It exposes :meth:`build`, surfaces the populated tmux + session via :attr:`session`, and honors the plugin/progress integration + points the CLI drives. + + Because the protocol carries data members (``session``, ``plugins``, the + ``on_*`` callbacks), validate *instances* with :func:`isinstance`; + class-level ``issubclass`` checks are not supported for runtime-checkable + protocols with non-method members. + + Examples + -------- + A builder implementing the full contract satisfies the protocol: + + >>> from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + >>> class MiniBuilder: + ... plugins: list = [] + ... on_progress = None + ... on_before_script = None + ... on_script_output = None + ... on_build_event = None + ... def __init__(self, session_config, server, **kwargs): + ... self._session_config = session_config + ... def build(self, session=None, append=False): + ... ... + ... def session_exists(self, session_name): + ... ... + ... def find_current_attached_session(self): + ... ... + ... @property + ... def session(self): + ... ... + >>> builder = MiniBuilder(session_config={}, server=None) + >>> isinstance(builder, WorkspaceBuilderProtocol) + True + + An object missing the contract does not: + + >>> isinstance(object(), WorkspaceBuilderProtocol) + False + """ + + plugins: list[t.Any] + on_progress: t.Callable[[str], None] | None + on_before_script: t.Callable[[], None] | None + on_script_output: t.Callable[[str], None] | None + on_build_event: t.Callable[[dict[str, t.Any]], None] | None + + def __init__( + self, + session_config: dict[str, t.Any], + server: Server, + plugins: list[t.Any] | None = None, + on_progress: t.Callable[[str], None] | None = None, + on_before_script: t.Callable[[], None] | None = None, + on_script_output: t.Callable[[str], None] | None = None, + on_build_event: t.Callable[[dict[str, t.Any]], None] | None = None, + ) -> None: + """Construct a builder from an expanded workspace and a tmux server.""" + ... + + @property + def session(self) -> Session: + """Return the tmux session the builder created or populated.""" + ... + + def build(self, session: Session | None = None, append: bool = False) -> None: + """Build the workspace, creating or populating a tmux session.""" + ... + + def session_exists(self, session_name: str) -> bool: + """Return ``True`` if a session with ``session_name`` already exists.""" + ... + + def find_current_attached_session(self) -> Session: + """Return the session currently attached within ``$TMUX``.""" + ... From 33eb1b698a702deb6cb2a1e73ec945e87ba8a8a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 16:11:13 -0500 Subject: [PATCH 04/21] workspace(feat): builder registry + sys.path sandbox why: A workspace selects a builder by name or import path, and a builder may live outside tmuxp's environment. Resolution and trusted imports belong in one place, separate from the builder itself. what: - Add builder/registry.py: resolve_builder_class (module:attr, dotted path, or tmuxp.workspace_builders entry point), resolve_builder_paths, available_builders, and a prepended_sys_path sandbox (no site.addsitedir) - Export the registry surface from the builder package --- src/tmuxp/workspace/builder/__init__.py | 12 + src/tmuxp/workspace/builder/registry.py | 325 ++++++++++++++++++++++++ 2 files changed, 337 insertions(+) create mode 100644 src/tmuxp/workspace/builder/registry.py diff --git a/src/tmuxp/workspace/builder/__init__.py b/src/tmuxp/workspace/builder/__init__.py index d7158e978c..469a5308e9 100644 --- a/src/tmuxp/workspace/builder/__init__.py +++ b/src/tmuxp/workspace/builder/__init__.py @@ -17,15 +17,27 @@ get_default_rows, ) from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol +from tmuxp.workspace.builder.registry import ( + WORKSPACE_BUILDERS_GROUP, + available_builders, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) # Backwards-compatible alias: the classic builder was historically named # ``WorkspaceBuilder`` and imported from ``tmuxp.workspace.builder``. WorkspaceBuilder = ClassicWorkspaceBuilder __all__ = [ + "WORKSPACE_BUILDERS_GROUP", "ClassicWorkspaceBuilder", "WorkspaceBuilder", "WorkspaceBuilderProtocol", + "available_builders", "get_default_columns", "get_default_rows", + "prepended_sys_path", + "resolve_builder_class", + "resolve_builder_paths", ] diff --git a/src/tmuxp/workspace/builder/registry.py b/src/tmuxp/workspace/builder/registry.py new file mode 100644 index 0000000000..595f134d13 --- /dev/null +++ b/src/tmuxp/workspace/builder/registry.py @@ -0,0 +1,325 @@ +"""Resolve and sandbox workspace builders selected by a workspace config. + +A workspace may point tmuxp at a builder other than the classic +:class:`tmuxp.workspace.builder.WorkspaceBuilder` via the ``workspace_builder`` +key (a Python dotted path, ``module:attr`` reference, or an entry-point name in +the ``tmuxp.workspace_builders`` group). When the builder lives outside the +active environment, ``workspace_builder_paths`` lists trusted directories that +are temporarily added to ``sys.path`` for the import and build. + +Security note: only literal directories are prepended to ``sys.path``. This +deliberately avoids :func:`site.addsitedir`, which executes ``.pth`` startup +files and is broader than making a module importable. The trust boundary is the +author of the workspace file. +""" + +from __future__ import annotations + +import contextlib +import importlib +import inspect +import os +import pathlib +import sys +import typing as t +from importlib import metadata + +from tmuxp import exc +from tmuxp._internal.private_path import PrivatePath +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder +from tmuxp.workspace.loader import expandshell + +if t.TYPE_CHECKING: + from collections.abc import Iterator + + from tmuxp.workspace.builder.protocol import WorkspaceBuilderProtocol + +WORKSPACE_BUILDERS_GROUP = "tmuxp.workspace_builders" +"""Entry-point group packaged builders register under.""" + + +def resolve_builder_paths( + session_config: dict[str, t.Any], + workspace_file: str | os.PathLike[str] | None = None, +) -> list[pathlib.Path]: + """Resolve and validate trusted ``workspace_builder_paths`` import roots. + + Each entry is shell-expanded (``~`` and ``$VARS``), resolved relative to the + workspace file's directory when not absolute, and required to be an existing + directory. Returns ``[]`` when the key is absent, so existing workspaces add + nothing to ``sys.path``. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + workspace_file : str or os.PathLike, optional + path to the workspace file; its parent anchors relative entries + + Returns + ------- + list of pathlib.Path + + Examples + -------- + Absent key resolves to an empty list (no-op for existing workspaces): + + >>> resolve_builder_paths({}, None) + [] + + An existing directory is resolved: + + >>> root = tmp_path / "roots" + >>> root.mkdir() + >>> resolve_builder_paths({"workspace_builder_paths": [str(root)]}, None) == [ + ... root.resolve() + ... ] + True + + A missing directory is rejected: + + >>> resolve_builder_paths( + ... {"workspace_builder_paths": [str(tmp_path / "missing")]}, None + ... ) + Traceback (most recent call last): + ... + tmuxp.exc.WorkspaceBuilderPathError: ... + """ + raw = session_config.get("workspace_builder_paths") or [] + if isinstance(raw, (str, os.PathLike)): + raw = [raw] + + base = ( + pathlib.Path(workspace_file).parent + if workspace_file is not None + else pathlib.Path.cwd() + ) + + resolved: list[pathlib.Path] = [] + for entry in raw: + if not isinstance(entry, (str, os.PathLike)): + raise exc.WorkspaceBuilderPathError( + str(entry), + reason="entries must be path strings", + ) + candidate = pathlib.Path(expandshell(str(entry))) + if not candidate.is_absolute(): + candidate = base / candidate + candidate = candidate.resolve() + if not candidate.is_dir(): + raise exc.WorkspaceBuilderPathError(str(PrivatePath(candidate))) + resolved.append(candidate) + return resolved + + +@contextlib.contextmanager +def prepended_sys_path( + paths: list[pathlib.Path] | None, +) -> Iterator[None]: + """Temporarily prepend directories to ``sys.path``, restoring it on exit. + + Paths are prepended in order (first entry ends up at ``sys.path[0]``). An + empty or falsy ``paths`` is a no-op. Uses only literal directory entries; it + avoids :func:`site.addsitedir` (which runs ``.pth`` startup code). + + Parameters + ---------- + paths : list of pathlib.Path or None + directories to prepend + + Examples + -------- + >>> import sys + >>> ext = tmp_path / "ext" + >>> ext.mkdir() + >>> before = list(sys.path) + >>> with prepended_sys_path([ext]): + ... sys.path[0] == str(ext) + True + >>> sys.path == before + True + """ + if not paths: + yield + return + saved = list(sys.path) + for path in reversed(paths): + sys.path.insert(0, str(path)) + try: + yield + finally: + sys.path[:] = saved + + +def available_builders() -> list[str]: + """Return the names of builders registered via entry points. + + Examples + -------- + >>> isinstance(available_builders(), list) + True + """ + return [ep.name for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP)] + + +def _load_entry_point(name: str) -> t.Any | None: + """Load a builder registered under entry-point ``name``, else ``None``. + + Examples + -------- + >>> _load_entry_point("definitely-not-a-registered-builder") is None + True + """ + for ep in metadata.entry_points(group=WORKSPACE_BUILDERS_GROUP): + if ep.name == name: + try: + return ep.load() + except (ImportError, AttributeError) as e: + raise exc.WorkspaceBuilderImportError(name, reason=str(e)) from e + return None + + +def _import_target(target: str) -> t.Any: + """Import an object from a ``module:attr`` or dotted ``module.attr`` path. + + Examples + -------- + >>> _import_target( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ).__name__ + 'ClassicWorkspaceBuilder' + + The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` alias resolves + to the same class: + + >>> _import_target("tmuxp.workspace.builder:WorkspaceBuilder").__name__ + 'ClassicWorkspaceBuilder' + """ + if ":" in target: + module_name, _, attr = target.partition(":") + else: + module_name, _, attr = target.rpartition(".") + if not module_name or not attr: + raise exc.WorkspaceBuilderNotFound(target) + try: + module = importlib.import_module(module_name) + except ImportError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + obj: t.Any = module + try: + for part in attr.split("."): + obj = getattr(obj, part) + except AttributeError as e: + raise exc.WorkspaceBuilderImportError(target, reason=str(e)) from e + return obj + + +def _validate_builder(obj: t.Any, target: str) -> None: + """Validate that ``obj`` is a usable workspace builder. + + A class must expose a callable ``build`` method and a constructor accepting + ``session_config`` and ``server`` (or ``**kwargs``). Non-class callables + (factories) are trusted and validated at instantiation. + + Examples + -------- + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> _validate_builder(ClassicWorkspaceBuilder, "classic") + + >>> _validate_builder(object, "object") + Traceback (most recent call last): + ... + tmuxp.exc.InvalidWorkspaceBuilder: 'object' is not a valid workspace builder: ... + """ + if inspect.isclass(obj): + if not callable(getattr(obj, "build", None)): + raise exc.InvalidWorkspaceBuilder( + target, + reason="class has no callable 'build' method", + ) + try: + params = inspect.signature(obj).parameters + except (TypeError, ValueError): + return + has_var_kw = any( + p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() + ) + missing = {"session_config", "server"} - set(params) + if not has_var_kw and missing: + joined = ", ".join(sorted(missing)) + raise exc.InvalidWorkspaceBuilder( + target, + reason=f"constructor missing parameter(s): {joined}", + ) + elif not callable(obj): + raise exc.InvalidWorkspaceBuilder(target, reason="not a class or callable") + + +def resolve_builder_class( + session_config: dict[str, t.Any], +) -> type[WorkspaceBuilderProtocol]: + """Resolve the workspace builder class selected by ``session_config``. + + Resolution of the ``workspace_builder`` value: + + 1. absent/empty → the classic + :class:`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` (no + import, no entry-point scan); + 2. contains ``:`` → a ``module:attr`` object reference; + 3. no ``.`` and no ``:`` → an entry-point name in the + ``tmuxp.workspace_builders`` group; + 4. dotted, no ``:`` → an entry-point name if registered, otherwise a + ``module.attr`` dotted path. + + Parameters + ---------- + session_config : dict + the expanded workspace configuration + + Returns + ------- + type + a workspace builder class (or builder factory) satisfying + :class:`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` + + Examples + -------- + The default resolves to the classic builder without importing anything: + + >>> from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + >>> resolve_builder_class({}) is ClassicWorkspaceBuilder + True + + A dotted ``module:attr`` reference is imported and validated: + + >>> resolve_builder_class( + ... { + ... "workspace_builder": ( + ... "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + ... ) + ... } + ... ) is ClassicWorkspaceBuilder + True + """ + target = session_config.get("workspace_builder") + if not target: + return ClassicWorkspaceBuilder + target_str = str(target).strip() + + obj: t.Any + if ":" in target_str: + obj = _import_target(target_str) + elif "." not in target_str: + obj = _load_entry_point(target_str) + if obj is None: + raise exc.WorkspaceBuilderNotFound( + target_str, + available=available_builders(), + ) + else: + obj = _load_entry_point(target_str) + if obj is None: + obj = _import_target(target_str) + + _validate_builder(obj, target_str) + return t.cast("type[WorkspaceBuilderProtocol]", obj) From 12762d56cff1821a619b6d86693a99911bb6ad85 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 16:11:23 -0500 Subject: [PATCH 05/21] build(deps): register classic builder entry point why: Let the classic builder be selected by the short name `classic`, and seed the tmuxp.workspace_builders entry-point group that third-party builders register under. what: - Register classic = ClassicWorkspaceBuilder under the tmuxp.workspace_builders entry points --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e3360098f0..b5f88abe5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,6 +52,9 @@ Changes = "https://github.com/tmux-python/tmuxp/blob/master/CHANGES" [project.scripts] tmuxp = 'tmuxp:cli.cli' +[project.entry-points."tmuxp.workspace_builders"] +classic = "tmuxp.workspace.builder.classic:ClassicWorkspaceBuilder" + [dependency-groups] dev = [ # Docs From e406e34ca8035461dc4e88119e226a740c9be06a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 16:11:33 -0500 Subject: [PATCH 06/21] test(builder): registry and readiness coverage why: Cover builder resolution and the pane-readiness policy, including custom builders resolved by entry-point name, module:attr, and import path. what: - Add tests/workspace/test_builder_registry.py and readiness cases in test_builder.py - Add tests/fixtures/workspace_builders (valid and invalid custom builders) --- tests/fixtures/workspace_builders/__init__.py | 3 + tests/fixtures/workspace_builders/invalid.py | 7 + tests/fixtures/workspace_builders/valid.py | 9 + tests/workspace/test_builder.py | 162 +++++++++++++++- tests/workspace/test_builder_registry.py | 176 ++++++++++++++++++ 5 files changed, 353 insertions(+), 4 deletions(-) create mode 100644 tests/fixtures/workspace_builders/__init__.py create mode 100644 tests/fixtures/workspace_builders/invalid.py create mode 100644 tests/fixtures/workspace_builders/valid.py create mode 100644 tests/workspace/test_builder_registry.py diff --git a/tests/fixtures/workspace_builders/__init__.py b/tests/fixtures/workspace_builders/__init__.py new file mode 100644 index 0000000000..4599a8613e --- /dev/null +++ b/tests/fixtures/workspace_builders/__init__.py @@ -0,0 +1,3 @@ +"""Custom workspace builder fixtures for resolution tests.""" + +from __future__ import annotations diff --git a/tests/fixtures/workspace_builders/invalid.py b/tests/fixtures/workspace_builders/invalid.py new file mode 100644 index 0000000000..af668c751a --- /dev/null +++ b/tests/fixtures/workspace_builders/invalid.py @@ -0,0 +1,7 @@ +"""Objects that are not valid workspace builders, used by resolution tests.""" + +from __future__ import annotations + + +class NotABuilder: + """A class missing the required ``build`` method.""" diff --git a/tests/fixtures/workspace_builders/valid.py b/tests/fixtures/workspace_builders/valid.py new file mode 100644 index 0000000000..bb3fb154f9 --- /dev/null +++ b/tests/fixtures/workspace_builders/valid.py @@ -0,0 +1,9 @@ +"""A minimal valid custom workspace builder used by resolution tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """Trivial subclass that satisfies the workspace builder contract.""" diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 007a489c16..32cbdffcf4 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -25,8 +25,9 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader from tmuxp.cli.load import load_plugins -from tmuxp.workspace import builder as builder_module, loader -from tmuxp.workspace.builder import WorkspaceBuilder, _wait_for_pane_ready +from tmuxp.workspace import loader +from tmuxp.workspace.builder import WorkspaceBuilder, classic as builder_classic +from tmuxp.workspace.builder.classic import _wait_for_pane_ready if t.TYPE_CHECKING: from libtmux.server import Server @@ -1563,6 +1564,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - panes: - shell_command: @@ -1578,6 +1581,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - panes: - shell_command: @@ -1592,6 +1597,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - panes: - shell_command: @@ -1608,6 +1615,8 @@ class PaneReadinessFixture(t.NamedTuple): yaml=textwrap.dedent( """\ session_name: readiness-test +workspace_builder_options: + pane_readiness: always windows: - window_shell: top panes: @@ -1635,7 +1644,7 @@ def test_pane_readiness_call_count( ) -> None: """Verify _wait_for_pane_ready is called only for appropriate panes.""" call_count = 0 - original = builder_module._wait_for_pane_ready + original = builder_classic._wait_for_pane_ready def counting_wait( pane: Pane, @@ -1646,7 +1655,7 @@ def counting_wait( call_count += 1 return original(pane, timeout=timeout, interval=interval) - monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait) + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) yaml_workspace = tmp_path / "readiness.yaml" yaml_workspace.write_text(yaml, encoding="utf-8") @@ -1659,6 +1668,151 @@ def counting_wait( assert call_count == expected_wait_count +def _count_readiness_waits( + *, + server: Server, + monkeypatch: pytest.MonkeyPatch, + tmp_path: pathlib.Path, + yaml: str, + shell: str | None = None, +) -> int: + """Build a workspace from ``yaml`` and count ``_wait_for_pane_ready`` calls. + + When ``shell`` is given, the resolved session shell is forced so ``auto`` + detection is deterministic regardless of the shell the suite runs under. + """ + call_count = 0 + original = builder_classic._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_classic, "_wait_for_pane_ready", counting_wait) + if shell is not None: + monkeypatch.setattr( + builder_classic, + "resolve_session_shell", + lambda session, env=None: shell, + ) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = loader.trickle(loader.expand(ConfigReader._from_file(yaml_workspace))) + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + return call_count + + +_TWO_DEFAULT_PANES = """\ +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""" + + +def _readiness_yaml(windows: str, policy: str | None = None) -> str: + """Compose a readiness workspace with an optional pane_readiness policy.""" + catalog = ( + f"workspace_builder_options:\n pane_readiness: {policy}\n" if policy else "" + ) + return f"session_name: readiness-test\n{catalog}windows:\n{windows}" + + +def test_pane_readiness_auto_waits_for_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under auto when the session shell is zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/usr/bin/zsh", + ) + assert count == 2 + + +def test_pane_readiness_auto_skips_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under auto for non-zsh shells (the intended speedup).""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES), + shell="/bin/bash", + ) + assert count == 0 + + +def test_pane_readiness_always_waits_non_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Wait for default-shell panes under always even for non-zsh shells.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="always"), + shell="/bin/bash", + ) + assert count == 2 + + +def test_pane_readiness_never_skips_zsh( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Skip the wait under never even for zsh.""" + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(_TWO_DEFAULT_PANES, policy="never"), + shell="/usr/bin/zsh", + ) + assert count == 0 + + +def test_pane_readiness_custom_shell_skips_under_always( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A window_shell pane skips the wait even when policy is always.""" + windows = textwrap.dedent( + """\ +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ) + count = _count_readiness_waits( + server=server, + monkeypatch=monkeypatch, + tmp_path=tmp_path, + yaml=_readiness_yaml(windows, policy="always"), + ) + assert count == 0 + + def test_select_layout_not_called_after_yield( tmp_path: pathlib.Path, server: Server, diff --git a/tests/workspace/test_builder_registry.py b/tests/workspace/test_builder_registry.py new file mode 100644 index 0000000000..ad7dd949fb --- /dev/null +++ b/tests/workspace/test_builder_registry.py @@ -0,0 +1,176 @@ +"""Tests for workspace builder resolution (:mod:`tmuxp.workspace.builder.registry`).""" + +from __future__ import annotations + +import sys +import textwrap +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.classic import ClassicWorkspaceBuilder + +if t.TYPE_CHECKING: + import pathlib + + from libtmux.server import Server + +VALID = "tests.fixtures.workspace_builders.valid" +INVALID = "tests.fixtures.workspace_builders.invalid" + + +def test_resolve_default_returns_classic() -> None: + """An absent workspace_builder resolves to the classic builder.""" + assert registry.resolve_builder_class({}) is ClassicWorkspaceBuilder + + +def test_resolve_entry_point_classic() -> None: + """The 'classic' entry point resolves to the classic builder.""" + resolved = registry.resolve_builder_class({"workspace_builder": "classic"}) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_dotted_object_reference() -> None: + """A ``module:attr`` reference resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}:CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_dotted_path_reference() -> None: + """A dotted ``module.attr`` path resolves and validates.""" + from tests.fixtures.workspace_builders.valid import CustomBuilder + + resolved = registry.resolve_builder_class( + {"workspace_builder": f"{VALID}.CustomBuilder"}, + ) + assert resolved is CustomBuilder + + +def test_resolve_compat_alias_path() -> None: + """The historical ``tmuxp.workspace.builder:WorkspaceBuilder`` still resolves.""" + resolved = registry.resolve_builder_class( + {"workspace_builder": "tmuxp.workspace.builder:WorkspaceBuilder"}, + ) + assert resolved is ClassicWorkspaceBuilder + + +def test_resolve_not_found_bare_name() -> None: + """An unknown bare name raises WorkspaceBuilderNotFound.""" + with pytest.raises(exc.WorkspaceBuilderNotFound): + registry.resolve_builder_class({"workspace_builder": "nonexistent-builder"}) + + +def test_resolve_import_error() -> None: + """An unimportable dotted path raises WorkspaceBuilderImportError.""" + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class( + {"workspace_builder": "tmuxp.does_not_exist:Thing"}, + ) + + +def test_resolve_invalid_builder() -> None: + """A class without a build method raises InvalidWorkspaceBuilder.""" + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:NotABuilder"}, + ) + + +def test_available_builders_includes_classic() -> None: + """The classic entry point is discoverable.""" + assert "classic" in registry.available_builders() + + +def test_resolve_builder_paths_absent() -> None: + """No workspace_builder_paths resolves to an empty list.""" + assert registry.resolve_builder_paths({}, None) == [] + + +def test_resolve_builder_paths_requires_directory(tmp_path: pathlib.Path) -> None: + """A missing directory raises WorkspaceBuilderPathError.""" + with pytest.raises(exc.WorkspaceBuilderPathError): + registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path / "missing")]}, + None, + ) + + +def test_resolve_builder_paths_relative_to_workspace_file( + tmp_path: pathlib.Path, +) -> None: + """Relative entries resolve against the workspace file's directory.""" + builders_dir = tmp_path / "builders" + builders_dir.mkdir() + workspace_file = tmp_path / "workspace.yaml" + workspace_file.write_text("session_name: x\n", encoding="utf-8") + + resolved = registry.resolve_builder_paths( + {"workspace_builder_paths": ["builders"]}, + workspace_file, + ) + assert resolved == [builders_dir.resolve()] + + +def test_prepended_sys_path_restores(tmp_path: pathlib.Path) -> None: + """The sandbox restores sys.path exactly on exit.""" + before = list(sys.path) + with registry.prepended_sys_path([tmp_path]): + assert sys.path[0] == str(tmp_path) + assert sys.path == before + + +def test_trusted_path_enables_import(tmp_path: pathlib.Path) -> None: + """A builder in a trusted path imports only while the path is active.""" + sys.modules.pop("ext_builder_trusted", None) + module = tmp_path / "ext_builder_trusted.py" + module.write_text( + textwrap.dedent( + '''\ +"""External builder for trusted-path tests.""" + +from __future__ import annotations + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class ExternalBuilder(ClassicWorkspaceBuilder): + """External custom builder.""" +''', + ), + encoding="utf-8", + ) + config = {"workspace_builder": "ext_builder_trusted:ExternalBuilder"} + + with pytest.raises(exc.WorkspaceBuilderImportError): + registry.resolve_builder_class(config) + + paths = registry.resolve_builder_paths( + {"workspace_builder_paths": [str(tmp_path)]}, + None, + ) + with registry.prepended_sys_path(paths): + resolved = registry.resolve_builder_class(config) + assert resolved.__name__ == "ExternalBuilder" + + +def test_resolve_and_build_selected_builder(server: Server) -> None: + """A config selecting a builder builds through the resolved class.""" + config = loader.expand( + { + "session_name": "registry-build", + "workspace_builder": "classic", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "registry-build" + builder.session.kill() From 684aba9c4dfac85ab078998dc549e75dc7857f3a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 12:30:58 -0500 Subject: [PATCH 07/21] cli(feat): route load through builder resolver why: tmuxp load must honor the workspace_builder selection and trusted import paths instead of always constructing the classic builder. what: - Resolve the builder class via resolve_builder_class and construct it inside a prepended_sys_path sandbox built from resolve_builder_paths, keeping trusted paths active for both import and build - Type the CLI build helpers against WorkspaceBuilderProtocol --- src/tmuxp/cli/load.py | 77 ++++++++++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 30 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..f60e7477ec 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -18,7 +18,12 @@ from tmuxp._internal import config_reader from tmuxp._internal.private_path import PrivatePath from tmuxp.workspace import loader -from tmuxp.workspace.builder import WorkspaceBuilder +from tmuxp.workspace.builder import ( + WorkspaceBuilderProtocol, + prepended_sys_path, + resolve_builder_class, + resolve_builder_paths, +) from tmuxp.workspace.finders import find_workspace_file, get_workspace_dir from ._colors import ColorMode, Colors, build_description, get_color_mode @@ -195,7 +200,7 @@ def load_plugins( return plugins -def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: +def _reattach(builder: WorkspaceBuilderProtocol, colors: Colors | None = None) -> None: """ Reattach session (depending on env being inside tmux already or not). @@ -232,7 +237,7 @@ def _reattach(builder: WorkspaceBuilder, colors: Colors | None = None) -> None: def _load_attached( - builder: WorkspaceBuilder, + builder: WorkspaceBuilderProtocol, detached: bool, pre_attach_hook: t.Callable[[], None] | None = None, ) -> None: @@ -265,7 +270,7 @@ def _load_attached( def _load_detached( - builder: WorkspaceBuilder, + builder: WorkspaceBuilderProtocol, colors: Colors | None = None, pre_output_hook: t.Callable[[], None] | None = None, ) -> None: @@ -292,7 +297,9 @@ def _load_detached( logger.info("session created in detached state") -def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: +def _load_append_windows_to_current_session( + builder: WorkspaceBuilderProtocol, +) -> None: """ Load workspace as new windows in current session. @@ -305,7 +312,7 @@ def _load_append_windows_to_current_session(builder: WorkspaceBuilder) -> None: assert builder.session is not None -def _setup_plugins(builder: WorkspaceBuilder) -> Session: +def _setup_plugins(builder: WorkspaceBuilderProtocol) -> Session: """Execute hooks for plugins running after ``before_script``. Parameters @@ -320,7 +327,7 @@ def _setup_plugins(builder: WorkspaceBuilder) -> Session: def _dispatch_build( - builder: WorkspaceBuilder, + builder: WorkspaceBuilderProtocol, detached: bool, append: bool, answer_yes: bool, @@ -577,13 +584,21 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - # WorkspaceBuilder creation — outside spinner so plugin prompts are safe + # Resolve any trusted import roots for the builder. Absent config -> [] -> + # a no-op sandbox, so existing workspaces add nothing to sys.path. + builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) + + # Builder resolution + creation — outside spinner so plugin prompts are safe. + # Trusted paths stay on sys.path for the import and instantiation; each + # build dispatch re-enters the sandbox so build-time lazy imports resolve. try: - builder = WorkspaceBuilder( - session_config=expanded_workspace, - plugins=load_plugins(expanded_workspace, colors=cli_colors), - server=t, - ) + with prepended_sys_path(builder_paths): + builder_cls = resolve_builder_class(expanded_workspace) + builder = builder_cls( + session_config=expanded_workspace, + plugins=load_plugins(expanded_workspace, colors=cli_colors), + server=t, + ) except exc.EmptyWorkspaceException: logger.warning( "workspace file is empty", @@ -612,13 +627,14 @@ def load_workspace( if _progress_disabled: _private_path = str(PrivatePath(workspace_file)) - result = _dispatch_build( - builder, - detached, - append, - answer_yes, - cli_colors, - ) + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + ) if result is not None: summary = "" try: @@ -687,16 +703,17 @@ def _emit_success() -> None: ) if _resolved_panel != 0: builder.on_script_output = spinner.add_output_line - result = _dispatch_build( - builder, - detached, - append, - answer_yes, - cli_colors, - pre_attach_hook=_emit_success, - on_error_hook=spinner.stop, - pre_prompt_hook=spinner.stop, - ) + with prepended_sys_path(builder_paths): + result = _dispatch_build( + builder, + detached, + append, + answer_yes, + cli_colors, + pre_attach_hook=_emit_success, + on_error_hook=spinner.stop, + pre_prompt_hook=spinner.stop, + ) if result is not None: _emit_success() return result From c06763c01d47bbe42cd1dca6c35edf302f643a41 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 12:32:50 -0500 Subject: [PATCH 08/21] docs(builder): document custom workspace builders why: The pluggable builder, readiness policy, and new modules need a user guide and API reference entries. what: - Add topics/custom-workspace-builders.md covering selection, trusted paths, the builder contract, and readiness tuning - Add options autodoc; document the builder package (classic, protocol, registry) and refresh workspace API toctrees - Update the stale WorkspaceBuilder cross-reference --- docs/configuration/examples.md | 2 +- docs/internals/api/workspace/builder.md | 30 +++- docs/internals/api/workspace/index.md | 1 + docs/internals/api/workspace/options.md | 8 + docs/topics/custom-workspace-builders.md | 186 +++++++++++++++++++++++ docs/topics/index.md | 7 + 6 files changed, 232 insertions(+), 2 deletions(-) create mode 100644 docs/internals/api/workspace/options.md create mode 100644 docs/topics/custom-workspace-builders.md diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 9651341309..2d463e4ba5 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -791,7 +791,7 @@ windows: tmuxp sessions can be scripted in python. The first way is to use the ORM in the {ref}`API`. The second is to pass a {py:obj}`dict` into -{class}`~tmuxp.workspace.builder.WorkspaceBuilder` with a correct schema. +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` with a correct schema. See: {meth}`tmuxp.validation.validate_schema`. ::: diff --git a/docs/internals/api/workspace/builder.md b/docs/internals/api/workspace/builder.md index 24525e1efb..012ed26eea 100644 --- a/docs/internals/api/workspace/builder.md +++ b/docs/internals/api/workspace/builder.md @@ -1,7 +1,35 @@ # Builder - `tmuxp.workspace.builder` +`tmuxp.workspace.builder` is a package. The classic, default builder lives in +{mod}`tmuxp.workspace.builder.classic`; the public contract and the selection +machinery live alongside it. + +`WorkspaceBuilder` remains importable from `tmuxp.workspace.builder` as a +backwards-compatible alias of +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. + +## Classic builder - `tmuxp.workspace.builder.classic` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.classic + :members: + :show-inheritance: + :undoc-members: +``` + +## Builder protocol - `tmuxp.workspace.builder.protocol` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.protocol + :members: + :show-inheritance: + :undoc-members: +``` + +## Builder registry - `tmuxp.workspace.builder.registry` + ```{eval-rst} -.. automodule:: tmuxp.workspace.builder +.. automodule:: tmuxp.workspace.builder.registry :members: :show-inheritance: :undoc-members: diff --git a/docs/internals/api/workspace/index.md b/docs/internals/api/workspace/index.md index 4c2d6241ad..373aadc72e 100644 --- a/docs/internals/api/workspace/index.md +++ b/docs/internals/api/workspace/index.md @@ -15,5 +15,6 @@ finders freezer importers loader +options validation ``` diff --git a/docs/internals/api/workspace/options.md b/docs/internals/api/workspace/options.md new file mode 100644 index 0000000000..e492ce6728 --- /dev/null +++ b/docs/internals/api/workspace/options.md @@ -0,0 +1,8 @@ +# Options - `tmuxp.workspace.options` + +```{eval-rst} +.. automodule:: tmuxp.workspace.options + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/topics/custom-workspace-builders.md b/docs/topics/custom-workspace-builders.md new file mode 100644 index 0000000000..598ba2a532 --- /dev/null +++ b/docs/topics/custom-workspace-builders.md @@ -0,0 +1,186 @@ +(custom-workspace-builders)= + +# Custom workspace builders + +A *workspace builder* turns an expanded workspace ``dict`` into a live tmux +session. tmuxp ships one builder, +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, and uses it +by default. Advanced users can point tmuxp at a different builder, and packagers +can distribute builders that users select by name. + +This is an advanced, opt-in feature. Existing workspace files keep using the +classic builder with no changes. + +## How a workspace is built + +1. `tmuxp load` reads the YAML/JSON file and expands it (shorthand, environment + variables, trickle-down defaults). +2. tmuxp resolves which builder to use from the `workspace_builder` config key + (default: the classic builder). +3. tmuxp constructs the builder with the expanded workspace and a + {class}`libtmux.Server`, then calls `.build()`. +4. The builder creates the session, windows, and panes, honoring plugin hooks + and progress callbacks. + +## Selecting a builder + +### By dotted path + +Point `workspace_builder` at an importable class. Both a +`module:attr` object reference and a dotted `module.attr` path are accepted: + +```yaml +session_name: my-session +workspace_builder: my_tmuxp_builders.builders:CustomBuilder +windows: + - window_name: editor + panes: + - vim +``` + +### By entry-point name + +Packaged builders register under the `tmuxp.workspace_builders` entry-point +group, letting users select them by a short name instead of an internal module +path: + +```yaml +workspace_builder: classic +``` + +The built-in classic builder is registered this way. A distribution registers +its own builder in `pyproject.toml`: + +```toml +[project.entry-points."tmuxp.workspace_builders"] +mybuilder = "my_tmuxp_builders.builders:CustomBuilder" +``` + +### Trusted import paths + +When a builder lives outside tmuxp's runtime environment (for example, a script +in your config directory), list trusted directories in +`workspace_builder_paths`. tmuxp expands `~` and environment variables, +resolves relative entries against the workspace file's directory, requires each +entry to be an existing directory, and temporarily prepends them to `sys.path` +for the import and build: + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +:::{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. tmuxp deliberately does **not** use +`site.addsitedir()` for these paths — that would execute `.pth` startup files +and is broader than making a module importable. +::: + +## Writing a builder + +The simplest custom builder subclasses the classic builder and overrides what it +needs: + +```python +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + + +class CustomBuilder(ClassicWorkspaceBuilder): + """A builder that renames the session after building.""" + + def build(self, session=None, append=False): + super().build(session=session, append=append) + self.session.rename_session(f"{self.session.name}-custom") +``` + +A builder written from scratch must satisfy +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`. The contract +covers what `tmuxp load` drives: + +- **Constructor** accepting `session_config`, `server`, and the optional + `plugins` list and `on_progress` / `on_before_script` / `on_script_output` / + `on_build_event` callbacks. +- **`build(session=None, append=False)`** — create or populate the session; the + `append` path adds windows to an existing session. +- **`session`** — the populated {class}`libtmux.Session`. +- **`session_exists()`** and **`find_current_attached_session()`** — used by the + CLI for attach/append decisions. +- **`plugins`** — the list of plugin instances; honor the plugin lifecycle hooks + (`before_workspace_builder`, `on_window_create`, `after_window_finished`). +- The **`on_*` callbacks** — call them at the documented milestones so the CLI's + progress display and `before_script` output stay accurate. + +The contract is synchronous today. It is shaped so an async builder can be added +later as an additive extension without changing this surface. + +## Pane readiness + +tmuxp waits for a pane's shell prompt before dispatching layout and commands, +which avoids a zsh prompt-redraw artifact. That wait is only needed for zsh, so +it is configurable independently of which builder you use, through the +`workspace_builder_options` catalog: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +``` + +- **`auto`** (default) — wait only when the session's interactive shell is zsh. +- **`always`** (or `true`/`on`/`yes`/`1`) — always wait for default-shell panes. +- **`never`** (or `false`/`off`/`no`/`0`) — never wait; fastest, but accepts the + prompt/layout race for shells that need it. + +Panes with a custom `shell` or `window_shell` never wait, regardless of policy — +those run a command in place of an interactive shell, so there is no prompt to +wait for. + +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Testing a builder + +Resolve, construct, and build against a real tmux server fixture: + +```python +from tmuxp.workspace import loader +from tmuxp.workspace.builder import registry + + +def test_custom_builder(server): + config = loader.expand( + { + "session_name": "demo", + "workspace_builder": "my_tmuxp_builders.builders:CustomBuilder", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + }, + ) + builder_cls = registry.resolve_builder_class(config) + builder = builder_cls(session_config=config, server=server) + builder.build() + assert builder.session.name == "demo" + builder.session.kill() +``` + +For builders that live in a trusted directory, build the `sys.path` sandbox with +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths` and +{func}`~tmuxp.workspace.builder.registry.prepended_sys_path`. + +## Choosing an approach + +- **Classic builder** — the default. Use it for any workspace that depends on + strict, pane-by-pane side effects (`start_directory`, `shell`, `window_shell`, + pane environment). +- **Readiness tuning** — set `pane_readiness` to trade prompt-safety for speed + without swapping builders. +- **A custom builder** — when you need behavior the classic builder doesn't + provide. Keep dependency-sensitive setup in `before_script` or + `shell_command_before` if your builder relaxes ordering guarantees. + +## Reference + +- {class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` +- {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +- {func}`~tmuxp.workspace.builder.registry.resolve_builder_class` +- {class}`~tmuxp.workspace.options.PaneReadiness` diff --git a/docs/topics/index.md b/docs/topics/index.md index 050ddb9d58..8dd1d70866 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -19,6 +19,12 @@ CI integration, scripting, and automation patterns. Plugin system for custom behavior. ::: +:::{grid-item-card} Custom workspace builders +:link: custom-workspace-builders +:link-type: doc +Select or ship a custom builder; tune pane readiness. +::: + :::{grid-item-card} Library vs CLI :link: library-vs-cli :link-type: doc @@ -47,6 +53,7 @@ instead of raw shell commands, supports JSON and YAML, and can workflows plugins +custom-workspace-builders library-vs-cli troubleshooting ``` From b7f5eabfcd146bebbb416f49d560bcb0ca33dd3e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 12:32:57 -0500 Subject: [PATCH 09/21] docs(CHANGES): pluggable builders, pane readiness why: Record the user-facing 1.72.0 changes for the upcoming release notes. what: - Document pluggable workspace builders: dotted-path / entry-point selection, trusted import paths, and the builder protocol - Document the configurable pane_readiness policy and its zsh-only auto default - Note the custom workspace builder guide --- CHANGES | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/CHANGES b/CHANGES index 8c1a395d00..7378276623 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,52 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### What's new + +#### Pluggable workspace builders (#1065) + +tmuxp can now load a workspace with a builder other than the built-in one. Set +`workspace_builder` in a workspace file to a Python dotted path +(`package.module:Builder`), a `module.attr` path, or the name of a builder +registered under the `tmuxp.workspace_builders` entry-point group. When a builder +lives outside tmuxp's environment, `workspace_builder_paths` lists trusted +directories that are temporarily added to `sys.path` for the import and build — +without the broader, code-executing behavior of site-directory processing. + +The built-in builder is now the explicit classic builder, available as the +`classic` entry point and as +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. A public +contract, {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`, +documents what a builder must provide and is shaped to allow async builders as a +future additive extension. `WorkspaceBuilder` remains importable from +`tmuxp.workspace.builder` as a backwards-compatible alias. See +{ref}`custom-workspace-builders` for details. + +#### Configurable pane readiness (#1065) + +Whether tmuxp waits for a pane's shell prompt before sending layout and commands +is now configurable through a new `workspace_builder_options` catalog: + +```yaml +workspace_builder_options: + pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) +``` + +The prompt wait exists to avoid a zsh prompt-redraw artifact, so the new default, +`auto`, waits only when the session's interactive shell is zsh. zsh users see no +change. Sessions on other shells (bash, sh, …) skip a wait they did not need and +load slightly faster. To restore the previous always-wait behavior set +`pane_readiness: always`; to skip the wait entirely set `never`. See +{class}`~tmuxp.workspace.options.PaneReadiness`. + +### Documentation + +#### Custom workspace builder guide (#1065) + +A new {ref}`custom-workspace-builders` topic explains how tmuxp hands a workspace +to a builder, how to select or package a builder, the trust boundary for +importing builder code, how to tune pane readiness, and how to test a builder. + ## tmuxp 1.72.0 (2026-06-28) tmuxp 1.72.0 bumps libtmux to 0.60.0, completing tmux 3.7 feature parity at the library layer tmuxp builds on. The new libtmux capabilities — floating panes, typed tmux 3.7 options, new pane format variables, and new command flags — are reachable through tmuxp's Python API and `tmuxp shell`, while the YAML workspace format and tmux 3.2a-3.6 support stay unchanged. From 64fe5658c3b20357b2278654e1a5d88473d0fb10 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 13:11:34 -0500 Subject: [PATCH 10/21] cli(fix[load]): friendly builder errors why: An invalid workspace_builder key raised a builder-resolution exception that propagated as a raw traceback, leaking the local stack and diverging from the styled handling load_plugins already gives an unimportable plugin. what: - Catch exc.WorkspaceBuilderError in load_workspace and emit a styled [Builder Error] message + sys.exit(1), mirroring the plugin path - Move resolve_builder_paths into the try so a bad workspace_builder_paths entry is handled too - Add a parametrized test covering unknown name, import error, invalid object, and missing trusted-path cases --- src/tmuxp/cli/load.py | 14 ++++---- tests/cli/test_load.py | 82 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index f60e7477ec..ed85f67d32 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -584,14 +584,12 @@ def load_workspace( shutil.which("tmux") # raise exception if tmux not found - # Resolve any trusted import roots for the builder. Absent config -> [] -> - # a no-op sandbox, so existing workspaces add nothing to sys.path. - builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) - # Builder resolution + creation — outside spinner so plugin prompts are safe. - # Trusted paths stay on sys.path for the import and instantiation; each - # build dispatch re-enters the sandbox so build-time lazy imports resolve. + # Trusted import roots (absent config -> [] -> a no-op sandbox) stay on + # sys.path for the import and instantiation; each build dispatch re-enters + # the sandbox so build-time lazy imports resolve. try: + builder_paths = resolve_builder_paths(expanded_workspace, workspace_file) with prepended_sys_path(builder_paths): builder_cls = resolve_builder_class(expanded_workspace) builder = builder_cls( @@ -609,6 +607,10 @@ def load_workspace( + f" {PrivatePath(workspace_file)} is empty or parsed no workspace data", ) return None + except exc.WorkspaceBuilderError as e: + logger.debug("workspace builder resolution failed", exc_info=True) + tmuxp_echo(cli_colors.error("[Builder Error]") + f" {e}") + sys.exit(1) session_name = expanded_workspace["session_name"] diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..4588a526cb 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -653,6 +653,88 @@ def test_load_plugins_plugin_missing( assert "[Plugin Error]" in result.out +class WorkspaceBuilderErrorFixture(t.NamedTuple): + """Test fixture for invalid ``workspace_builder`` selections.""" + + test_id: str + yaml: str + expected_substring: str + + +WORKSPACE_BUILDER_ERROR_FIXTURES: list[WorkspaceBuilderErrorFixture] = [ + WorkspaceBuilderErrorFixture( + test_id="unknown_entry_point_name", + yaml="""\ +session_name: builder-error +workspace_builder: nonexistent-builder +windows: +- panes: + - echo hi +""", + expected_substring="could not be found", + ), + WorkspaceBuilderErrorFixture( + test_id="dotted_import_error", + yaml="""\ +session_name: builder-error +workspace_builder: tmuxp.does_not_exist:Thing +windows: +- panes: + - echo hi +""", + expected_substring="Could not import", + ), + WorkspaceBuilderErrorFixture( + test_id="invalid_object", + yaml="""\ +session_name: builder-error +workspace_builder: tests.fixtures.workspace_builders.invalid:NotABuilder +windows: +- panes: + - echo hi +""", + expected_substring="not a valid workspace builder", + ), + WorkspaceBuilderErrorFixture( + test_id="missing_builder_path", + yaml="""\ +session_name: builder-error +workspace_builder: any:Builder +workspace_builder_paths: +- /nonexistent/tmuxp/builder/dir +windows: +- panes: + - echo hi +""", + expected_substring="workspace_builder_paths entry is invalid", + ), +] + + +@pytest.mark.parametrize( + list(WorkspaceBuilderErrorFixture._fields), + WORKSPACE_BUILDER_ERROR_FIXTURES, + ids=[test.test_id for test in WORKSPACE_BUILDER_ERROR_FIXTURES], +) +def test_load_workspace_builder_error_exits( + tmp_path: pathlib.Path, + server: Server, + capsys: pytest.CaptureFixture[str], + test_id: str, + yaml: str, + expected_substring: str, +) -> None: + """Invalid workspace_builder exits with a styled error, not a traceback.""" + config_file = tmp_path / ".tmuxp.yaml" + config_file.write_text(yaml, encoding="utf-8") + + with pytest.raises(SystemExit) as excinfo: + load_workspace(config_file, socket_name=server.socket_name, detached=True) + + assert excinfo.value.code == 1 + assert expected_substring in capsys.readouterr().out + + def test_plugin_system_before_script( monkeypatch_plugin_test_packages: None, server: Server, From eb44ed5b83b60b2854f82a3cfe7bc595ffec8f45 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 13:13:05 -0500 Subject: [PATCH 11/21] builder(fix[logging]): prefix readiness extra keys why: Structured log extra keys must carry the tmux_ prefix per the project logging standards so downstream aggregators can filter them consistently. what: - Rename the pane-readiness debug extra keys to tmux_pane_readiness and tmux_pane_readiness_wait --- src/tmuxp/workspace/builder/classic.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder/classic.py b/src/tmuxp/workspace/builder/classic.py index f4d209bfd3..324dfdd69d 100644 --- a/src/tmuxp/workspace/builder/classic.py +++ b/src/tmuxp/workspace/builder/classic.py @@ -572,8 +572,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: _log.debug( "pane readiness resolved", extra={ - "pane_readiness": readiness.value, - "pane_readiness_wait": self._pane_readiness_wait, + "tmux_pane_readiness": readiness.value, + "tmux_pane_readiness_wait": self._pane_readiness_wait, }, ) From 49039bbb8cffae905274f4ae43393875ec8cdcd6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 13:15:33 -0500 Subject: [PATCH 12/21] docs(builder): correct readiness/registry comments why: Two comments introduced on this branch were imprecise: the readiness comment named only options.default-shell (global_options can set it too), and the registry module docstring referenced the backwards-compat alias rather than the canonical class. what: - Reword the readiness comment to "its options (including default-shell)" - Point the registry docstring at ClassicWorkspaceBuilder --- src/tmuxp/workspace/builder/classic.py | 6 +++--- src/tmuxp/workspace/builder/registry.py | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/workspace/builder/classic.py b/src/tmuxp/workspace/builder/classic.py index 324dfdd69d..03d2fba87c 100644 --- a/src/tmuxp/workspace/builder/classic.py +++ b/src/tmuxp/workspace/builder/classic.py @@ -557,9 +557,9 @@ def build(self, session: Session | None = None, append: bool = False) -> None: self.session.set_environment(option, value) # Resolve the pane-readiness decision once, now that the session exists - # and any config `options.default-shell` has been applied above. AUTO - # waits only for zsh (the shell the prompt-redraw wait was added for); - # ALWAYS/NEVER force the choice. + # and its options (including `default-shell`) have been applied above. + # AUTO waits only for zsh (the shell the prompt-redraw wait was added + # for); ALWAYS/NEVER force the choice. readiness = self._builder_options.pane_readiness if readiness is PaneReadiness.ALWAYS: self._pane_readiness_wait = True diff --git a/src/tmuxp/workspace/builder/registry.py b/src/tmuxp/workspace/builder/registry.py index 595f134d13..b1744fdcbe 100644 --- a/src/tmuxp/workspace/builder/registry.py +++ b/src/tmuxp/workspace/builder/registry.py @@ -1,11 +1,11 @@ """Resolve and sandbox workspace builders selected by a workspace config. A workspace may point tmuxp at a builder other than the classic -:class:`tmuxp.workspace.builder.WorkspaceBuilder` via the ``workspace_builder`` -key (a Python dotted path, ``module:attr`` reference, or an entry-point name in -the ``tmuxp.workspace_builders`` group). When the builder lives outside the -active environment, ``workspace_builder_paths`` lists trusted directories that -are temporarily added to ``sys.path`` for the import and build. +:class:`tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` via the +``workspace_builder`` key (a Python dotted path, ``module:attr`` reference, or +an entry-point name in the ``tmuxp.workspace_builders`` group). When the builder +lives outside the active environment, ``workspace_builder_paths`` lists trusted +directories that are temporarily added to ``sys.path`` for the import and build. Security note: only literal directories are prepended to ``sys.path``. This deliberately avoids :func:`site.addsitedir`, which executes ``.pth`` startup From 45802a4446c3323f6c30b551a8661d8c222c6eee Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 13:24:32 -0500 Subject: [PATCH 13/21] options(fix): detect inherited default-shell why: Auto pane-readiness mis-detected the shell when default-shell was set globally (e.g. `set -g default-shell` in tmux.conf) rather than at the session level: tmux returned None for the session-level lookup and resolution fell back to $SHELL, so a global zsh default could be read as a non-zsh shell and skip the prompt wait. what: - Pass include_inherited=True to show_option so an inherited default-shell is resolved before the $SHELL fallback - Add a regression test with a session that exposes default-shell only via include_inherited --- src/tmuxp/workspace/options.py | 5 ++++- tests/workspace/test_options.py | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/options.py b/src/tmuxp/workspace/options.py index 0719b4dc09..32ad20d1d9 100644 --- a/src/tmuxp/workspace/options.py +++ b/src/tmuxp/workspace/options.py @@ -219,7 +219,10 @@ def resolve_session_shell( >>> resolve_session_shell(FakeSession(None), env={"SHELL": "/bin/bash"}) '/bin/bash' """ - default_shell = session.show_option("default-shell") + # include_inherited resolves a globally-set default-shell (e.g. + # ``set -g default-shell`` in tmux.conf) that the session inherits rather + # than overrides; without it tmux returns None and we'd fall back to $SHELL. + default_shell = session.show_option("default-shell", include_inherited=True) environ = os.environ if env is None else env env_shell = environ.get("SHELL", "") return str(default_shell or env_shell or "") diff --git a/tests/workspace/test_options.py b/tests/workspace/test_options.py index c6eaecf39f..cfd266a212 100644 --- a/tests/workspace/test_options.py +++ b/tests/workspace/test_options.py @@ -113,3 +113,29 @@ def test_resolve_session_shell_falls_back_to_env() -> None: def test_resolve_session_shell_empty_when_unknown() -> None: """Returns an empty string when neither source resolves.""" assert resolve_session_shell(_FakeSession(None), env={}) == "" + + +class _InheritedShellSession: + """Session whose default-shell is only visible with include_inherited.""" + + def __init__(self, shell: str) -> None: + self._shell = shell + + def show_option( + self, + name: str, + *, + include_inherited: bool = False, + **kwargs: t.Any, + ) -> str | None: + """Return the shell only when inherited options are requested.""" + return self._shell if include_inherited else None + + +def test_resolve_session_shell_reads_inherited_default_shell() -> None: + """A globally-inherited default-shell wins over the $SHELL fallback.""" + shell = resolve_session_shell( + _InheritedShellSession("/usr/bin/zsh"), + env={"SHELL": "/bin/bash"}, + ) + assert shell == "/usr/bin/zsh" From 3a2f8866b659291d5b07ea11c3e19f2f58f86505 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 13:43:05 -0500 Subject: [PATCH 14/21] registry(fix): require plugins in builder validation why: load_workspace always constructs the builder with plugins=..., so a custom builder whose __init__ accepts only session_config and server passed validation and then raised a raw TypeError at instantiation, bypassing the styled [Builder Error] path. what: - Require plugins (alongside session_config and server, or **kwargs) in _validate_builder so an incompatible constructor is rejected as InvalidWorkspaceBuilder before instantiation - Add a MissingPluginsBuilder fixture and a test --- src/tmuxp/workspace/builder/registry.py | 7 ++++--- tests/fixtures/workspace_builders/invalid.py | 12 ++++++++++++ tests/workspace/test_builder_registry.py | 12 ++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/workspace/builder/registry.py b/src/tmuxp/workspace/builder/registry.py index b1744fdcbe..e29a58ec70 100644 --- a/src/tmuxp/workspace/builder/registry.py +++ b/src/tmuxp/workspace/builder/registry.py @@ -218,8 +218,9 @@ def _validate_builder(obj: t.Any, target: str) -> None: """Validate that ``obj`` is a usable workspace builder. A class must expose a callable ``build`` method and a constructor accepting - ``session_config`` and ``server`` (or ``**kwargs``). Non-class callables - (factories) are trusted and validated at instantiation. + ``session_config``, ``server``, and ``plugins`` (or ``**kwargs``) — the + arguments ``tmuxp load`` always passes. Non-class callables (factories) are + trusted and validated at instantiation. Examples -------- @@ -244,7 +245,7 @@ def _validate_builder(obj: t.Any, target: str) -> None: has_var_kw = any( p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values() ) - missing = {"session_config", "server"} - set(params) + missing = {"session_config", "server", "plugins"} - set(params) if not has_var_kw and missing: joined = ", ".join(sorted(missing)) raise exc.InvalidWorkspaceBuilder( diff --git a/tests/fixtures/workspace_builders/invalid.py b/tests/fixtures/workspace_builders/invalid.py index af668c751a..020839315f 100644 --- a/tests/fixtures/workspace_builders/invalid.py +++ b/tests/fixtures/workspace_builders/invalid.py @@ -2,6 +2,18 @@ from __future__ import annotations +import typing as t + class NotABuilder: """A class missing the required ``build`` method.""" + + +class MissingPluginsBuilder: + """A builder whose constructor omits the ``plugins`` parameter.""" + + def __init__(self, session_config: t.Any, server: t.Any) -> None: + self.session_config = session_config + + def build(self, session: t.Any = None, append: bool = False) -> None: + """No-op build for validation tests.""" diff --git a/tests/workspace/test_builder_registry.py b/tests/workspace/test_builder_registry.py index ad7dd949fb..09dce0b143 100644 --- a/tests/workspace/test_builder_registry.py +++ b/tests/workspace/test_builder_registry.py @@ -83,6 +83,18 @@ def test_resolve_invalid_builder() -> None: ) +def test_resolve_rejects_constructor_without_plugins() -> None: + """A builder whose __init__ lacks plugins is rejected before instantiation. + + The CLI always calls the builder with ``plugins=...``, so such a constructor + would otherwise raise a raw TypeError instead of a styled error. + """ + with pytest.raises(exc.InvalidWorkspaceBuilder): + registry.resolve_builder_class( + {"workspace_builder": f"{INVALID}:MissingPluginsBuilder"}, + ) + + def test_available_builders_includes_classic() -> None: """The classic entry point is discoverable.""" assert "classic" in registry.available_builders() From 8de4227e57f0dcd07aea4978874670bf40680b49 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 13:48:24 -0500 Subject: [PATCH 15/21] options(fix): styled error for bad readiness config why: An invalid workspace_builder_options value (e.g. a typo'd pane_readiness: sometimes, or a non-mapping catalog) raised a bare ValueError/TypeError while constructing the default builder, which escaped load_workspace's WorkspaceBuilderError handler and surfaced as a raw traceback. what: - Add exc.InvalidWorkspaceBuilderOption (a WorkspaceBuilderError) and raise it from WorkspaceBuilderOptions.from_config for a non-mapping catalog or an invalid pane_readiness, so the error exits through the styled [Builder Error] path; PaneReadiness.from_config keeps its plain ValueError for the pure-parser unit/doctest - Add unit tests plus a load case covering the typo'd pane_readiness --- src/tmuxp/exc.py | 11 +++++++++++ src/tmuxp/workspace/options.py | 17 +++++++++-------- tests/cli/test_load.py | 12 ++++++++++++ tests/workspace/test_options.py | 13 +++++++++++-- 4 files changed, 43 insertions(+), 10 deletions(-) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 59b69cbb7d..5908d48c5e 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -173,6 +173,17 @@ def __init__( return super().__init__(msg, *args, **kwargs) +class InvalidWorkspaceBuilderOption(WorkspaceBuilderError): + """A ``workspace_builder_options`` value is invalid.""" + + def __init__(self, reason: str, *args: object, **kwargs: object) -> None: + return super().__init__( + f"Invalid workspace_builder_options: {reason}", + *args, + **kwargs, + ) + + class TmuxpPluginException(TmuxpException): """Base Exception for Tmuxp Errors.""" diff --git a/src/tmuxp/workspace/options.py b/src/tmuxp/workspace/options.py index 32ad20d1d9..cc2fad0401 100644 --- a/src/tmuxp/workspace/options.py +++ b/src/tmuxp/workspace/options.py @@ -21,6 +21,8 @@ import os import typing as t +from tmuxp import exc + _AUTO_ALIASES = frozenset({"auto"}) _ALWAYS_ALIASES = frozenset({"always", "true", "on", "yes", "1"}) _NEVER_ALIASES = frozenset({"never", "false", "off", "no", "0"}) @@ -144,14 +146,13 @@ def from_config(cls, session_config: dict[str, t.Any]) -> WorkspaceBuilderOption """ catalog = session_config.get("workspace_builder_options") or {} if not isinstance(catalog, dict): - msg = ( - "workspace_builder_options must be a mapping, got " - f"{type(catalog).__name__}" - ) - raise TypeError(msg) - return cls( - pane_readiness=PaneReadiness.from_config(catalog.get("pane_readiness")), - ) + msg = f"must be a mapping, got {type(catalog).__name__}" + raise exc.InvalidWorkspaceBuilderOption(msg) + try: + pane_readiness = PaneReadiness.from_config(catalog.get("pane_readiness")) + except ValueError as e: + raise exc.InvalidWorkspaceBuilderOption(str(e)) from e + return cls(pane_readiness=pane_readiness) def shell_is_zsh(shell: str | None) -> bool: diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index 4588a526cb..275e798d68 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -708,6 +708,18 @@ class WorkspaceBuilderErrorFixture(t.NamedTuple): """, expected_substring="workspace_builder_paths entry is invalid", ), + WorkspaceBuilderErrorFixture( + test_id="invalid_pane_readiness", + yaml="""\ +session_name: builder-error +workspace_builder_options: + pane_readiness: sometimes +windows: +- panes: + - echo hi +""", + expected_substring="pane_readiness", + ), ] diff --git a/tests/workspace/test_options.py b/tests/workspace/test_options.py index cfd266a212..c7c9aa369b 100644 --- a/tests/workspace/test_options.py +++ b/tests/workspace/test_options.py @@ -6,6 +6,7 @@ import pytest +from tmuxp import exc from tmuxp.workspace.options import ( PaneReadiness, WorkspaceBuilderOptions, @@ -62,11 +63,19 @@ def test_workspace_builder_options_reads_catalog() -> None: def test_workspace_builder_options_invalid_catalog_type() -> None: - """A non-mapping catalog is rejected.""" - with pytest.raises(TypeError, match="must be a mapping"): + """A non-mapping catalog is rejected as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="must be a mapping"): WorkspaceBuilderOptions.from_config({"workspace_builder_options": ["nope"]}) +def test_workspace_builder_options_invalid_pane_readiness() -> None: + """An invalid pane_readiness is wrapped as a builder-option error.""" + with pytest.raises(exc.InvalidWorkspaceBuilderOption, match="pane_readiness"): + WorkspaceBuilderOptions.from_config( + {"workspace_builder_options": {"pane_readiness": "sometimes"}}, + ) + + @pytest.mark.parametrize( ("shell", "expected"), [ From bd8da8141701cda95bf22360b886ce1c34c1a351 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 14:19:11 -0500 Subject: [PATCH 16/21] docs(CHANGES) split builder notes into user and dev why: The 1.72.0 entry mixed user-facing capability with the builder API surface in What's new. Separate the two so the release lead reads as an upgrade decision and builder authors get one linked API entry. what: - Trim the What's new entries to high-level user prose (selection, trusted paths, the pane_readiness policy and its zsh-only default) - Add a Development entry linking the builder package, protocol, registry resolvers, options/PaneReadiness, and error hierarchy - Fold the guide cross-reference into both sections --- CHANGES | 69 ++++++++++++++++++++++++++++----------------------------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/CHANGES b/CHANGES index 7378276623..e7cf731dd8 100644 --- a/CHANGES +++ b/CHANGES @@ -46,49 +46,48 @@ _Notes on the upcoming release will go here._ ### What's new -#### Pluggable workspace builders (#1065) - -tmuxp can now load a workspace with a builder other than the built-in one. Set -`workspace_builder` in a workspace file to a Python dotted path -(`package.module:Builder`), a `module.attr` path, or the name of a builder -registered under the `tmuxp.workspace_builders` entry-point group. When a builder -lives outside tmuxp's environment, `workspace_builder_paths` lists trusted -directories that are temporarily added to `sys.path` for the import and build — -without the broader, code-executing behavior of site-directory processing. - -The built-in builder is now the explicit classic builder, available as the -`classic` entry point and as -{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. A public -contract, {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol`, -documents what a builder must provide and is shaped to allow async builders as a -future additive extension. `WorkspaceBuilder` remains importable from -`tmuxp.workspace.builder` as a backwards-compatible alias. See -{ref}`custom-workspace-builders` for details. - -#### Configurable pane readiness (#1065) - -Whether tmuxp waits for a pane's shell prompt before sending layout and commands -is now configurable through a new `workspace_builder_options` catalog: +#### Pluggable workspace builders (#1066) + +tmuxp can now build a workspace with a builder other than its built-in one. Point +a workspace file's `workspace_builder` key at an installed builder — by registered +name or by Python import path — and tmuxp loads the session through it. Builders +that ship outside tmuxp's environment can be imported from directories listed in +`workspace_builder_paths`, which tmuxp trusts only for that load. The built-in +builder stays the default, so existing workspaces are unaffected. See +{ref}`custom-workspace-builders`. + +#### Configurable pane readiness (#1066) + +Before sending a pane's layout and commands, tmuxp can wait for the shell to draw +its prompt — a guard against a zsh prompt-redraw artifact. That wait is now a +policy you set per workspace: ```yaml workspace_builder_options: - pane_readiness: auto # auto | always | never (+ truthy/falsy aliases) + pane_readiness: auto # auto | always | never (also true/false) ``` -The prompt wait exists to avoid a zsh prompt-redraw artifact, so the new default, -`auto`, waits only when the session's interactive shell is zsh. zsh users see no -change. Sessions on other shells (bash, sh, …) skip a wait they did not need and -load slightly faster. To restore the previous always-wait behavior set -`pane_readiness: always`; to skip the wait entirely set `never`. See -{class}`~tmuxp.workspace.options.PaneReadiness`. +The new default, `auto`, waits only when the session's shell is zsh, so zsh users +see no change while bash, sh, and other shells skip a wait they never needed. Set +`always` to keep the previous wait-everywhere behavior, or `never` to skip it +entirely. See {ref}`custom-workspace-builders`. -### Documentation +### Development -#### Custom workspace builder guide (#1065) +#### Workspace builder API (#1066) -A new {ref}`custom-workspace-builders` topic explains how tmuxp hands a workspace -to a builder, how to select or package a builder, the trust boundary for -importing builder code, how to tune pane readiness, and how to test a builder. +`tmuxp.workspace.builder` is now a package. The default builder is +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`, with +`WorkspaceBuilder` kept as a backwards-compatible alias. Third-party builders +implement {class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +(shaped to allow async builders later) and are resolved by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` from an entry +point or import path, with trusted import directories handled by +{func}`~tmuxp.workspace.builder.registry.resolve_builder_paths`. Builder behavior +is configured through {class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` +and {class}`~tmuxp.workspace.options.PaneReadiness`, and resolution failures raise +{exc}`~tmuxp.exc.WorkspaceBuilderError` and its subclasses. See +{ref}`custom-workspace-builders` for the guide. ## tmuxp 1.72.0 (2026-06-28) From 7437d1f386ed912ed762c491314b6c8f5325fbdf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 15:26:11 -0500 Subject: [PATCH 17/21] docs(config) workspace builder keys reference why: The workspace_builder, workspace_builder_paths, and workspace_builder_options keys were documented only in the Topics guide. A reader browsing the Configuration section could not discover them, and top-level.md was a stub that dead-ended the "Top-level Options" card. what: - Add configuration/workspace-builders.md: a config-section reference with a summary table, per-key sections, the pane_readiness values, and a minimal YAML/JSON example; volatile detail (full alias list, trust-boundary rationale) stays single-sourced in the topics guide - Surface it via a "Workspace builders" card in both index grids and the configuration toctree - Bridge the top-level.md stub and add a reverse seealso from the topics guide --- docs/configuration/index.md | 13 +++ docs/configuration/top-level.md | 9 ++ docs/configuration/workspace-builders.md | 142 +++++++++++++++++++++++ docs/topics/custom-workspace-builders.md | 5 + 4 files changed, 169 insertions(+) create mode 100644 docs/configuration/workspace-builders.md diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 619cb62710..a3e82e39c8 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -27,6 +27,12 @@ TMUXP_CONFIGDIR and other env vars. Sample workspace configurations. ::: +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + :::: tmuxp loads your terminal workspace into tmux using workspace files. @@ -211,6 +217,12 @@ TMUXP_CONFIGDIR and other env vars. Sample workspace configurations. ::: +:::{grid-item-card} Workspace builders +:link: workspace-builders +:link-type: doc +Select a builder and tune pane readiness. +::: + :::: ```{toctree} @@ -219,4 +231,5 @@ Sample workspace configurations. top-level environmental-variables examples +workspace-builders ``` diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 72eb24f32f..b8e480522e 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,12 @@ Notes: ``` Above: Use `tmux` directly to attach _banana_. + +## Workspace builder keys + +A workspace file can also select a custom builder and tune builder behavior with +`workspace_builder`, `workspace_builder_paths`, and `workspace_builder_options`. + +```{seealso} +{ref}`workspace-builders` +``` diff --git a/docs/configuration/workspace-builders.md b/docs/configuration/workspace-builders.md new file mode 100644 index 0000000000..5354d66900 --- /dev/null +++ b/docs/configuration/workspace-builders.md @@ -0,0 +1,142 @@ +(workspace-builders)= + +# Workspace builders + +```{versionadded} 1.72.0 +``` + +Most workspaces never need these keys. By default tmuxp builds your session with +its built-in *classic* builder and waits for a pane's shell prompt only when that +shell is zsh — existing workspace files keep working unchanged. Set the keys below +to swap in a different builder or to tune the prompt wait. **Omit a key (or remove +it) to restore the default.** + +| Key | Type | Default | Purpose | +| --- | --- | --- | --- | +| `workspace_builder` | string | `classic` | Which builder turns the workspace into a session. | +| `workspace_builder_paths` | string or list of strings | _(none)_ | Trusted directories to import a builder from. | +| `workspace_builder_options` | mapping | _(all defaults)_ | Builder-behavior knobs, such as `pane_readiness`. | + +For the narrative — writing a builder, packaging one, the trust boundary, and +testing — see {ref}`custom-workspace-builders`. + +(workspace-builder-key)= + +## `workspace_builder` + +Selects the builder. The default, `classic`, is tmuxp's built-in builder. A value +is resolved in this order: + +1. absent or empty → the built-in classic builder (nothing is imported); +2. contains `:` → a `module:attr` object reference; +3. no `.` and no `:` → a builder registered under the `tmuxp.workspace_builders` + entry-point group, selected by name; +4. dotted with no `:` → an entry-point name if one is registered, otherwise a + `module.attr` import path. + +```yaml +session_name: my-session +workspace_builder: classic +windows: + - panes: + - vim +``` + +See {ref}`custom-workspace-builders` for selecting and packaging builders, and +{func}`~tmuxp.workspace.builder.registry.resolve_builder_class` for the resolver. + +(workspace-builder-paths-key)= + +## `workspace_builder_paths` + +Directories to import a builder from when it lives outside tmuxp's environment — +for example, a script in your config directory. Accepts a single string or a list +of strings. tmuxp expands `~` and environment variables, resolves relative entries +against the workspace file's directory, and requires each entry to be an existing +directory; the paths are added to `sys.path` only for the import and build. + +```yaml +workspace_builder: my_local_builder:CustomBuilder +workspace_builder_paths: + - ~/.config/tmuxp/builders +``` + +```{warning} +A workspace file that names a builder runs that builder's Python code. Only load +workspace files you trust. See the security note in {ref}`custom-workspace-builders`. +``` + +(workspace-builder-options-key)= + +## `workspace_builder_options` + +A catalog of builder-behavior settings, independent of which builder you use. +Today it holds a single key, `pane_readiness`, which controls whether tmuxp waits +for a pane's shell prompt before sending its layout and commands — a guard against +a zsh prompt-redraw artifact: + +```yaml +workspace_builder_options: + pane_readiness: auto +``` + +| Value | Behavior | +| --- | --- | +| `auto` _(default)_ | Wait only when the session's shell is zsh. | +| `always` | Always wait for default-shell panes. | +| `never` | Never wait; fastest, but accepts the prompt/layout race for shells that need it. | + +`pane_readiness` also accepts truthy/falsy aliases — `true`/`on`/`yes`/`1` map to +`always`, and `false`/`off`/`no`/`0` map to `never` (full list in +{ref}`custom-workspace-builders`). An unrecognized value fails the load with: + +```text +invalid pane_readiness value: 'sometimes'; expected one of: auto, always/true/on/yes/1, never/false/off/no/0 +``` + +Panes that run a custom `shell` or `window_shell` never wait, regardless of policy. +See {class}`~tmuxp.workspace.options.PaneReadiness` and +{class}`~tmuxp.workspace.options.WorkspaceBuilderOptions` for the parsing rules. + +## Minimal complete example + +````{tab} YAML +```yaml +session_name: my-session +workspace_builder: classic +workspace_builder_paths: + - ~/.config/tmuxp/builders +workspace_builder_options: + pane_readiness: auto +windows: + - window_name: editor + panes: + - vim +``` +```` + +````{tab} JSON +```json +{ + "session_name": "my-session", + "workspace_builder": "classic", + "workspace_builder_paths": ["~/.config/tmuxp/builders"], + "workspace_builder_options": { + "pane_readiness": "auto" + }, + "windows": [ + { + "window_name": "editor", + "panes": ["vim"] + } + ] +} +``` +```` + +```{seealso} +{ref}`custom-workspace-builders` — narrative guide to selecting, packaging, +writing, and testing builders · +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder` · +{class}`~tmuxp.workspace.builder.protocol.WorkspaceBuilderProtocol` +``` diff --git a/docs/topics/custom-workspace-builders.md b/docs/topics/custom-workspace-builders.md index 598ba2a532..26f6f38ff7 100644 --- a/docs/topics/custom-workspace-builders.md +++ b/docs/topics/custom-workspace-builders.md @@ -11,6 +11,11 @@ can distribute builders that users select by name. This is an advanced, opt-in feature. Existing workspace files keep using the classic builder with no changes. +```{seealso} +If you only want to set these keys in a workspace file, see +{ref}`workspace-builders` in the configuration reference. +``` + ## How a workspace is built 1. `tmuxp load` reads the YAML/JSON file and expands it (shorthand, environment From 4d58e4896715acc32696ede20076a73fbae41866 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 15:46:19 -0500 Subject: [PATCH 18/21] docs(api) split builder reference into a grid of pages why: The builder API page stacked the classic, protocol, and registry modules on one long page. As a package, builder reads better as its own section with a landing grid, matching the cli/ and workspace/ package layout. what: - Split internals/api/workspace/builder.md into a builder/ package doc: an index.md landing with a card grid + toctree over classic, protocol, and registry sub-pages, one automodule each - Point the workspace toctree at builder/index - Repoint the legacy api/workspace/builder redirect at builder/index --- docs/internals/api/workspace/builder.md | 36 ----------------- .../api/workspace/builder/classic.md | 8 ++++ docs/internals/api/workspace/builder/index.md | 40 +++++++++++++++++++ .../api/workspace/builder/protocol.md | 8 ++++ .../api/workspace/builder/registry.md | 8 ++++ docs/internals/api/workspace/index.md | 2 +- docs/redirects.txt | 2 +- 7 files changed, 66 insertions(+), 38 deletions(-) delete mode 100644 docs/internals/api/workspace/builder.md create mode 100644 docs/internals/api/workspace/builder/classic.md create mode 100644 docs/internals/api/workspace/builder/index.md create mode 100644 docs/internals/api/workspace/builder/protocol.md create mode 100644 docs/internals/api/workspace/builder/registry.md diff --git a/docs/internals/api/workspace/builder.md b/docs/internals/api/workspace/builder.md deleted file mode 100644 index 012ed26eea..0000000000 --- a/docs/internals/api/workspace/builder.md +++ /dev/null @@ -1,36 +0,0 @@ -# Builder - `tmuxp.workspace.builder` - -`tmuxp.workspace.builder` is a package. The classic, default builder lives in -{mod}`tmuxp.workspace.builder.classic`; the public contract and the selection -machinery live alongside it. - -`WorkspaceBuilder` remains importable from `tmuxp.workspace.builder` as a -backwards-compatible alias of -{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. - -## Classic builder - `tmuxp.workspace.builder.classic` - -```{eval-rst} -.. automodule:: tmuxp.workspace.builder.classic - :members: - :show-inheritance: - :undoc-members: -``` - -## Builder protocol - `tmuxp.workspace.builder.protocol` - -```{eval-rst} -.. automodule:: tmuxp.workspace.builder.protocol - :members: - :show-inheritance: - :undoc-members: -``` - -## Builder registry - `tmuxp.workspace.builder.registry` - -```{eval-rst} -.. automodule:: tmuxp.workspace.builder.registry - :members: - :show-inheritance: - :undoc-members: -``` diff --git a/docs/internals/api/workspace/builder/classic.md b/docs/internals/api/workspace/builder/classic.md new file mode 100644 index 0000000000..856ebc63d4 --- /dev/null +++ b/docs/internals/api/workspace/builder/classic.md @@ -0,0 +1,8 @@ +# Classic builder - `tmuxp.workspace.builder.classic` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.classic + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/index.md b/docs/internals/api/workspace/builder/index.md new file mode 100644 index 0000000000..0b3ddbfabe --- /dev/null +++ b/docs/internals/api/workspace/builder/index.md @@ -0,0 +1,40 @@ +# Builder - `tmuxp.workspace.builder` + +`tmuxp.workspace.builder` is a package. The classic, default builder lives in +{mod}`tmuxp.workspace.builder.classic`; the public contract and the selection +machinery live alongside it. + +`WorkspaceBuilder` remains importable from `tmuxp.workspace.builder` as a +backwards-compatible alias of +{class}`~tmuxp.workspace.builder.classic.ClassicWorkspaceBuilder`. + +::::{grid} 1 1 2 2 +:gutter: 2 2 3 3 + +:::{grid-item-card} Classic builder +:link: classic +:link-type: doc +The built-in, default builder — `tmuxp.workspace.builder.classic`. +::: + +:::{grid-item-card} Builder protocol +:link: protocol +:link-type: doc +The contract a builder must satisfy — `tmuxp.workspace.builder.protocol`. +::: + +:::{grid-item-card} Builder registry +:link: registry +:link-type: doc +Builder selection and trusted import paths — `tmuxp.workspace.builder.registry`. +::: + +:::: + +```{toctree} +:hidden: + +classic +protocol +registry +``` diff --git a/docs/internals/api/workspace/builder/protocol.md b/docs/internals/api/workspace/builder/protocol.md new file mode 100644 index 0000000000..1c980b33b3 --- /dev/null +++ b/docs/internals/api/workspace/builder/protocol.md @@ -0,0 +1,8 @@ +# Builder protocol - `tmuxp.workspace.builder.protocol` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.protocol + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/builder/registry.md b/docs/internals/api/workspace/builder/registry.md new file mode 100644 index 0000000000..3a9fa4f7f3 --- /dev/null +++ b/docs/internals/api/workspace/builder/registry.md @@ -0,0 +1,8 @@ +# Builder registry - `tmuxp.workspace.builder.registry` + +```{eval-rst} +.. automodule:: tmuxp.workspace.builder.registry + :members: + :show-inheritance: + :undoc-members: +``` diff --git a/docs/internals/api/workspace/index.md b/docs/internals/api/workspace/index.md index 373aadc72e..73c844cf27 100644 --- a/docs/internals/api/workspace/index.md +++ b/docs/internals/api/workspace/index.md @@ -9,7 +9,7 @@ If you need an internal API stabilized please [file an issue](https://github.com ::: ```{toctree} -builder +builder/index constants finders freezer diff --git a/docs/redirects.txt b/docs/redirects.txt index be9e1bdd10..53b041de9d 100644 --- a/docs/redirects.txt +++ b/docs/redirects.txt @@ -23,7 +23,7 @@ "api/cli/shell.md" "internals/api/cli/shell.md" "api/cli/utils.md" "internals/api/cli/utils.md" "api/workspace/index.md" "internals/api/workspace/index.md" -"api/workspace/builder.md" "internals/api/workspace/builder.md" +"api/workspace/builder.md" "internals/api/workspace/builder/index.md" "api/workspace/constants.md" "internals/api/workspace/constants.md" "api/workspace/finders.md" "internals/api/workspace/finders.md" "api/workspace/freezer.md" "internals/api/workspace/freezer.md" From b0f4144e436822d61b448f57afa4486dd1a192bd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:32:14 -0500 Subject: [PATCH 19/21] docs(CHANGES): place builder API under What's new why: AGENTS.md reserves ### Development for dev tooling (helper scripts, internal automation). The workspace builder API is a public extension surface for builder authors, so it belongs under ### What's new. what: - Drop the ### Development heading so "Workspace builder API" sits as a third deliverable under ### What's new --- CHANGES | 2 -- 1 file changed, 2 deletions(-) diff --git a/CHANGES b/CHANGES index e7cf731dd8..2c271d91e3 100644 --- a/CHANGES +++ b/CHANGES @@ -72,8 +72,6 @@ see no change while bash, sh, and other shells skip a wait they never needed. Se `always` to keep the previous wait-everywhere behavior, or `never` to skip it entirely. See {ref}`custom-workspace-builders`. -### Development - #### Workspace builder API (#1066) `tmuxp.workspace.builder` is now a package. The default builder is From 389a178db2cdc5db8e1578b2e9df4894ecdf2dbd Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:34:30 -0500 Subject: [PATCH 20/21] exc(test): doctest new workspace-builder errors why: CLAUDE.md requires working doctests on all functions and methods; the new workspace-builder exception classes added in this branch had none. what: - Add a concise print()-based doctest to each new builder exception (WorkspaceBuilderNotFound, WorkspaceBuilderImportError, InvalidWorkspaceBuilder, WorkspaceBuilderPathError, InvalidWorkspaceBuilderOption) covering message construction --- src/tmuxp/exc.py | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/exc.py b/src/tmuxp/exc.py index 5908d48c5e..8e76e531d5 100644 --- a/src/tmuxp/exc.py +++ b/src/tmuxp/exc.py @@ -99,7 +99,11 @@ class WorkspaceBuilderError(WorkspaceError): class WorkspaceBuilderNotFound(WorkspaceBuilderError): - """Configured ``workspace_builder`` could not be resolved.""" + """Configured ``workspace_builder`` could not be resolved. + + >>> print(WorkspaceBuilderNotFound("nope")) + Workspace builder 'nope' could not be found.... + """ def __init__( self, @@ -121,7 +125,11 @@ def __init__( class WorkspaceBuilderImportError(WorkspaceBuilderError): - """Configured ``workspace_builder`` failed to import.""" + """Configured ``workspace_builder`` failed to import. + + >>> print(WorkspaceBuilderImportError("pkg:B")) + Could not import workspace builder 'pkg:B'.... + """ def __init__( self, @@ -144,7 +152,11 @@ def __init__( class InvalidWorkspaceBuilder(WorkspaceBuilderError): - """Resolved ``workspace_builder`` object is not a usable builder.""" + """Resolved ``workspace_builder`` object is not a usable builder. + + >>> print(InvalidWorkspaceBuilder("pkg:B")) + 'pkg:B' is not a valid workspace builder. + """ def __init__( self, @@ -159,7 +171,11 @@ def __init__( class WorkspaceBuilderPathError(WorkspaceBuilderError): - """A ``workspace_builder_paths`` entry is not a usable directory.""" + """A ``workspace_builder_paths`` entry is not a usable directory. + + >>> print(WorkspaceBuilderPathError("/x")) + workspace_builder_paths entry is invalid: /x.... + """ def __init__( self, @@ -174,7 +190,11 @@ def __init__( class InvalidWorkspaceBuilderOption(WorkspaceBuilderError): - """A ``workspace_builder_options`` value is invalid.""" + """A ``workspace_builder_options`` value is invalid. + + >>> print(InvalidWorkspaceBuilderOption("bad")) + Invalid workspace_builder_options: bad + """ def __init__(self, reason: str, *args: object, **kwargs: object) -> None: return super().__init__( From 812dcca01a04c8612c33c30cb46ef5c97449011d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 28 Jun 2026 18:50:40 -0500 Subject: [PATCH 21/21] Tag v1.73.0 Minor release making the workspace build step pluggable and tunable. A workspace can build through a third-party builder selected by entry-point name or import path, and a new workspace_builder_options catalog controls the pane-readiness wait per workspace (#1066). Behavior change: the new pane_readiness=auto default skips the pane prompt wait on non-zsh shells; set pane_readiness: always to restore the previous wait-everywhere behavior. --- CHANGES | 6 +++++- pyproject.toml | 2 +- src/tmuxp/__about__.py | 2 +- uv.lock | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 2c271d91e3..1c006c2e5d 100644 --- a/CHANGES +++ b/CHANGES @@ -36,7 +36,7 @@ Run the developmental install with: $ tmuxp@next load yoursession ``` -## tmuxp 1.73.0 (Yet to be released) +## tmuxp 1.74.0 (Yet to be released) @@ -44,6 +44,10 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +## 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. + ### What's new #### Pluggable workspace builders (#1066) diff --git a/pyproject.toml b/pyproject.toml index b5f88abe5e..7abefdeaf8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tmuxp" -version = "1.72.0" +version = "1.73.0" description = "Session manager for tmux, which allows users to save and load tmux sessions through simple configuration files." requires-python = ">=3.10,<4.0" authors = [ diff --git a/src/tmuxp/__about__.py b/src/tmuxp/__about__.py index 2aa569d6cf..4bfc1730c9 100644 --- a/src/tmuxp/__about__.py +++ b/src/tmuxp/__about__.py @@ -8,7 +8,7 @@ __title__ = "tmuxp" __package_name__ = "tmuxp" -__version__ = "1.72.0" +__version__ = "1.73.0" __description__ = "tmux session manager" __email__ = "tony@git-pull.com" __author__ = "Tony Narlock" diff --git a/uv.lock b/uv.lock index d366553884..11ede46c19 100644 --- a/uv.lock +++ b/uv.lock @@ -1608,7 +1608,7 @@ wheels = [ [[package]] name = "tmuxp" -version = "1.72.0" +version = "1.73.0" source = { editable = "." } dependencies = [ { name = "libtmux" },