From cab36d5b14e89abe5fd4808521d2f220dd7f790b Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 18:35:19 -0500 Subject: [PATCH 01/26] builder(perf[readiness]) parallelize prompt wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: PR #1018 waits for each pane's shell to draw its prompt before the next pane is created, so workspace load time grew linearly with pane count (issue #1053: 12 windows / 18 panes took ~25s). The shells already initialize concurrently once tmux spawns them; only the observation was serial. what: - Add _wait_for_panes_ready(), a concurrent readiness barrier that polls every pane under one shared timeout instead of one per pane. - Restructure build() into phases: create all windows and panes, wait for every default-shell pane once, then lay out and send commands. - Create panes by splitting the newest pane only and defer the layout until after the barrier, so no pane is resized mid-prompt — keeping the zsh partial-line '%' fix (issue #365) intact. - Add _split_pane_reclaiming_space(): on a no-space split failure, wait for existing panes then select_layout and retry. - Keep iter_create_panes() as a per-window back-compat wrapper. - Reduce _wait_for_pane_ready() to a single-pane delegate. - Update readiness/layout tests for the barrier; add tests for the shared barrier, whole-session wait, and layout-after-barrier order. --- src/tmuxp/workspace/builder.py | 452 ++++++++++++++++++++++++++++---- tests/workspace/test_builder.py | 176 +++++++++++-- 2 files changed, 562 insertions(+), 66 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..bc17ac85d4 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -9,6 +9,7 @@ import typing as t from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.exc import LibTmuxException from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -24,15 +25,112 @@ logger = logging.getLogger(__name__) +def _pane_has_drawn_prompt(pane: Pane) -> bool: + """Return whether a pane's shell has drawn its prompt. + + The cursor leaving the origin ``(0, 0)`` is the signal that the shell has + finished initializing and rendered its first prompt. Resizing a pane before + this point races zsh's prompt redraw and surfaces its partial-line ``%`` + marker (issue #365), so the build waits for this to become true before + applying layouts or sending keys. + + Parameters + ---------- + pane : :class:`libtmux.Pane` + pane whose freshly refreshed cursor position to inspect + + Returns + ------- + bool + True once the cursor has moved away from ``(0, 0)`` + + Examples + -------- + >>> pane = session.active_window.active_pane + >>> _wait_for_pane_ready(pane, timeout=5.0) + True + >>> _pane_has_drawn_prompt(pane) + True + """ + return pane.cursor_x != "0" or pane.cursor_y != "0" + + +def _wait_for_panes_ready( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, +) -> dict[str, bool]: + """Wait for many panes to draw their prompts, sharing one timeout budget. + + tmux spawns each pane's shell the moment the pane is created, so the shells + initialize concurrently. Polling every pane in a single loop — rather than + blocking on each one to completion before starting the next — observes that + concurrency, collapsing the worst case from ``len(panes) * timeout`` of + serial waiting into a single shared ``timeout`` window. + + Parameters + ---------- + panes : list of :class:`libtmux.Pane` + panes to wait for; panes without an id are ignored + timeout : float + maximum seconds to wait for the whole set before giving up + interval : float + seconds between polling sweeps + + Returns + ------- + dict of str to bool + maps each pane id to whether it became ready before the timeout + + Examples + -------- + >>> pane = session.active_window.active_pane + >>> _wait_for_panes_ready([pane], timeout=5.0) # doctest: +ELLIPSIS + {'%...': True} + """ + pending = {p.pane_id: p for p in panes if p.pane_id is not None} + ready: dict[str, bool] = {} + start = time.monotonic() + while pending and time.monotonic() - start < timeout: + for pane_id, pane in list(pending.items()): + try: + pane.refresh() + except Exception: + logger.debug( + "pane refresh failed during readiness check", + exc_info=True, + extra={"tmux_pane": str(pane_id)}, + ) + ready[pane_id] = False + del pending[pane_id] + continue + if _pane_has_drawn_prompt(pane): + logger.debug( + "pane ready, cursor moved from origin", + extra={"tmux_pane": str(pane_id)}, + ) + ready[pane_id] = True + del pending[pane_id] + if pending: + time.sleep(interval) + for pane_id in pending: + logger.debug( + "pane readiness check timed out after %.1f seconds", + timeout, + extra={"tmux_pane": str(pane_id)}, + ) + ready[pane_id] = False + return ready + + def _wait_for_pane_ready( pane: Pane, timeout: float = 2.0, interval: float = 0.05, ) -> bool: - """Wait for pane shell to draw its prompt. + """Wait for a single pane's shell to draw its prompt. - Polls the pane's cursor position until it moves from origin (0, 0), - indicating the shell has finished initializing and drawn its prompt. + Convenience wrapper over :func:`_wait_for_panes_ready` for the one-pane case. Parameters ---------- @@ -57,30 +155,10 @@ def _wait_for_pane_ready( >>> _wait_for_pane_ready(pane, timeout=5.0) True """ - start = time.monotonic() - while time.monotonic() - start < timeout: - try: - pane.refresh() - except Exception: - logger.debug( - "pane refresh failed during readiness check", - exc_info=True, - extra={"tmux_pane": str(pane.pane_id)}, - ) - return False - if pane.cursor_x != "0" or pane.cursor_y != "0": - logger.debug( - "pane ready, cursor moved from origin", - extra={"tmux_pane": str(pane.pane_id)}, - ) - return True - time.sleep(interval) - logger.debug( - "pane readiness check timed out after %.1f seconds", - timeout, - extra={"tmux_pane": str(pane.pane_id)}, - ) - return False + if pane.pane_id is None: + return False + results = _wait_for_panes_ready([pane], timeout=timeout, interval=interval) + return results.get(pane.pane_id, False) COLUMNS_FALLBACK = 80 @@ -101,6 +179,19 @@ def get_default_rows() -> int: return int(os.getenv("TMUXP_DEFAULT_ROWS", os.getenv("ROWS", ROWS_FALLBACK))) +class _PaneEntry(t.NamedTuple): + """A created pane awaiting the readiness barrier, layout, and its commands. + + Carries the bits the second build phase needs once every shell is ready: + the pane, its config section, and the resolved custom ``shell`` (``None`` for + a default-shell pane, which is the only kind whose prompt the build waits on). + """ + + pane: Pane + config: dict[str, t.Any] + shell: str | None + + class WorkspaceBuilder: """Load workspace from workspace :py:obj:`dict` object. @@ -538,16 +629,34 @@ 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) + # Phase one — structure: create every window and its panes first, so all + # of their shells start initializing concurrently. Nothing is resized and + # no keys are sent yet. + window_layout: list[tuple[Window, dict[str, t.Any], list[_PaneEntry]]] = [] for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) for plugin in self.plugins: plugin.on_window_create(window) + entries = self._create_window_panes(window, window_config) + window_layout.append((window, window_config, entries)) + + # Barrier — wait once for every default-shell pane to draw its prompt. + # Because the shells warmed up in parallel during phase one, this single + # shared wait replaces what used to be one blocking wait per pane. + self._wait_for_workspace_ready(window_layout) + + # Phase two — finish: lay each window out (a single resize with all shells + # ready, so no zsh ``%`` marker) and send its commands. + for window, window_config, entries in window_layout: focus_pane = None - for pane, pane_config in self.iter_create_panes(window, window_config): + for pane, pane_config in self._dispatch_window_commands( + window, + window_config, + entries, + ): assert isinstance(pane, Pane) - pane = pane if pane_config.get("focus"): focus_pane = pane @@ -679,14 +788,23 @@ def iter_create_windows( yield window, window_config - def iter_create_panes( + def _create_window_panes( self, window: Window, window_config: dict[str, t.Any], - ) -> Iterator[t.Any]: - """Return :class:`libtmux.Pane` iterating through window config dict. + ) -> list[_PaneEntry]: + """Create a window's panes without waiting, laying out, or sending keys. - Run ``shell_command`` with ``$ tmux send-keys``. + This is the first build phase. Splitting the panes back-to-back lets + their shells initialize concurrently while the build moves on; readiness, + layout, and commands are deferred to later phases. + + Each split resizes the pane it targets, and the build only ever splits + the newest pane — one created microseconds earlier, still sourcing its rc + and therefore safe to resize. The exception is running out of room: when + a split fails for space, the existing panes are first waited on (so they + are past their prompt and safe to resize), then ``select_layout`` + reclaims space, and the split is retried. Parameters ---------- @@ -697,16 +815,34 @@ def iter_create_panes( Returns ------- - tuple of (:class:`libtmux.Pane`, ``pane_config``) - Newly created pane, and the section from the tmuxp configuration - that was used to create the pane. + list of :class:`_PaneEntry` + one entry per created pane, in config order + + Examples + -------- + >>> session = server.new_session('create-window-panes') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> entries = builder._create_window_panes( + ... session.active_window, + ... {'window_name': 'main', 'panes': [{'shell_command': []}, + ... {'shell_command': []}]}, + ... ) + >>> len(entries) + 2 + >>> entries[0].shell is None + True """ assert isinstance(window, Window) pane_base_index = window.show_option("pane-base-index", global_=True) assert pane_base_index is not None + layout = window_config.get("layout") pane = None + entries: list[_PaneEntry] = [] for pane_index, pane_config in enumerate( window_config["panes"], @@ -754,17 +890,25 @@ def get_pane_shell( assert pane is not None - pane = pane.split( - attach=True, - start_directory=get_pane_start_directory( + split_kwargs: dict[str, t.Any] = { + "attach": True, + "start_directory": get_pane_start_directory( pane_config=pane_config, window_config=window_config, ), - shell=get_pane_shell( + "shell": get_pane_shell( pane_config=pane_config, window_config=window_config, ), - environment=environment, + "environment": environment, + } + + pane = self._split_pane_reclaiming_space( + window, + pane, + split_kwargs, + layout, + entries, ) assert isinstance(pane, Pane) @@ -778,16 +922,179 @@ def get_pane_shell( ) pane_log.debug("pane created") - # 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. + # A pane with a custom shell/command launcher (e.g. "top", "sleep + # 999") replaces the default shell and never draws an interactive + # prompt, so it is recorded but excluded from the readiness barrier. pane_shell = pane_config.get("shell", window_config.get("window_shell")) - if pane_shell is None: - _wait_for_pane_ready(pane) + entries.append(_PaneEntry(pane=pane, config=pane_config, shell=pane_shell)) + + return entries - if "layout" in window_config: - window.select_layout(window_config["layout"]) + def _split_pane_reclaiming_space( + self, + window: Window, + pane: Pane, + split_kwargs: dict[str, t.Any], + layout: str | None, + entries: list[_PaneEntry], + ) -> Pane: + """Split ``pane``; on a space failure, reclaim room once and retry. + + Without an intermediate ``select_layout`` after every split, a window + with many panes eventually has no room for the next split. Reclaiming + space means resizing the panes already created, which is only safe once + their shells are past their prompts — so the readiness barrier runs + first, then the layout, then the split is retried. + + Parameters + ---------- + window : :class:`libtmux.Window` + window the panes belong to + pane : :class:`libtmux.Pane` + pane to split + split_kwargs : dict + keyword arguments forwarded to :meth:`libtmux.Pane.split` + layout : str or None + window layout used to reclaim space, if configured + entries : list of :class:`_PaneEntry` + panes created so far, waited on before reclaiming space + + Returns + ------- + :class:`libtmux.Pane` + the newly split pane + + Examples + -------- + >>> session = server.new_session('reclaim-space') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> first = session.active_window.active_pane + >>> second = builder._split_pane_reclaiming_space( + ... session.active_window, first, {'attach': True}, 'tiled', [], + ... ) + >>> second is not None + True + """ + try: + return pane.split(**split_kwargs) + except LibTmuxException: + _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) + if layout is not None: + window.select_layout(layout) + return pane.split(**split_kwargs) + + def _wait_for_workspace_ready( + self, + window_layout: list[tuple[Window, dict[str, t.Any], list[_PaneEntry]]], + ) -> dict[str, bool]: + """Wait for every default-shell pane across the workspace, concurrently. + + Collects the panes that draw an interactive prompt from all windows and + waits for them in a single shared barrier (:func:`_wait_for_panes_ready`). + + Parameters + ---------- + window_layout : list of tuple + ``(window, window_config, entries)`` triples from phase one + + Returns + ------- + dict of str to bool + maps each waited pane id to whether it became ready + + Examples + -------- + >>> session = server.new_session('workspace-ready') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> window_config = {'window_name': 'main', 'panes': [{'shell_command': []}]} + >>> entries = builder._create_window_panes( + ... session.active_window, window_config, + ... ) + >>> sorted( + ... builder._wait_for_workspace_ready( + ... [(session.active_window, window_config, entries)], + ... ).values(), + ... ) + [True] + """ + panes = [ + entry.pane + for (_window, _window_config, entries) in window_layout + for entry in entries + if entry.shell is None + ] + return _wait_for_panes_ready(panes) + + def _dispatch_window_commands( + self, + window: Window, + window_config: dict[str, t.Any], + entries: list[_PaneEntry], + ) -> Iterator[t.Any]: + """Lay the window out, then send each pane its ``shell_command``. + + The final build phase, run only after the readiness barrier. Applying the + layout here is a single resize with every shell already past its prompt, + which is what keeps zsh from printing its partial-line ``%`` marker + (issue #365). + + Parameters + ---------- + window : :class:`libtmux.Window` + window to finish + window_config : dict + config section for window + entries : list of :class:`_PaneEntry` + panes created for this window in phase one + + Yields + ------ + tuple of (:class:`libtmux.Pane`, ``pane_config``) + each pane and the config section used to create it + + Examples + -------- + >>> session = server.new_session('dispatch-window') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> window_config = { + ... 'window_name': 'main', 'layout': 'tiled', + ... 'panes': [{'shell_command': []}], + ... } + >>> entries = builder._create_window_panes( + ... session.active_window, window_config, + ... ) + >>> _ = builder._wait_for_workspace_ready( + ... [(session.active_window, window_config, entries)], + ... ) + >>> dispatched = list( + ... builder._dispatch_window_commands( + ... session.active_window, window_config, entries, + ... ), + ... ) + >>> len(dispatched) + 1 + """ + if "layout" in window_config: + window.select_layout(window_config["layout"]) + + for pane, pane_config, _pane_shell in entries: + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) if "suppress_history" in pane_config: suppress = pane_config["suppress_history"] @@ -819,6 +1126,53 @@ def get_pane_shell( yield pane, pane_config + def iter_create_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> Iterator[t.Any]: + """Return :class:`libtmux.Pane` iterating through window config dict. + + Run ``shell_command`` with ``$ tmux send-keys``. + + Creates the window's panes, waits for their shells concurrently, then + lays out and sends commands. :meth:`build` drives these phases across the + whole workspace for one shared wait; this per-window form is kept for + direct callers. + + Parameters + ---------- + window : :class:`libtmux.Window` + window to create panes for + window_config : dict + config section for window + + Returns + ------- + tuple of (:class:`libtmux.Pane`, ``pane_config``) + Newly created pane, and the section from the tmuxp configuration + that was used to create the pane. + + Examples + -------- + >>> session = server.new_session('iter-create-panes') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> panes = list( + ... builder.iter_create_panes( + ... session.active_window, + ... {'window_name': 'main', 'panes': [{'shell_command': []}]}, + ... ), + ... ) + >>> len(panes) + 1 + """ + entries = self._create_window_panes(window, window_config) + _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) + yield from self._dispatch_window_commands(window, window_config, entries) + def config_after_window( self, window: Window, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 007a489c16..9cf8e58195 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -26,7 +26,11 @@ 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.builder import ( + WorkspaceBuilder, + _wait_for_pane_ready, + _wait_for_panes_ready, +) if t.TYPE_CHECKING: from libtmux.server import Server @@ -1549,6 +1553,35 @@ def test_wait_for_pane_ready_timeout(session: Session) -> None: assert result is False +def test_wait_for_panes_ready_all_ready(session: Session) -> None: + """The shared barrier reports every default-shell pane ready.""" + window = session.active_window + first = window.active_pane + assert first is not None + second = first.split() + assert second is not None + + result = _wait_for_panes_ready([first, second], timeout=5.0) + + assert result == {first.pane_id: True, second.pane_id: True} + + +def test_wait_for_panes_ready_mixed(session: Session) -> None: + """The shared barrier times out only the pane that never draws a prompt.""" + window = session.active_window + first = window.active_pane + assert first is not None + sleeper = first.split(shell="sleep 999") + assert sleeper is not None + assert first.pane_id is not None + assert sleeper.pane_id is not None + + result = _wait_for_panes_ready([first, sleeper], timeout=0.5) + + assert result[first.pane_id] is True + assert result[sleeper.pane_id] is False + + class PaneReadinessFixture(t.NamedTuple): """Test fixture for pane readiness call count verification.""" @@ -1625,7 +1658,7 @@ class PaneReadinessFixture(t.NamedTuple): PANE_READINESS_FIXTURES, ids=[t.test_id for t in PANE_READINESS_FIXTURES], ) -def test_pane_readiness_call_count( +def test_pane_readiness_waits_for_default_shell_panes( tmp_path: pathlib.Path, server: Server, monkeypatch: pytest.MonkeyPatch, @@ -1633,20 +1666,19 @@ def test_pane_readiness_call_count( yaml: str, expected_wait_count: int, ) -> None: - """Verify _wait_for_pane_ready is called only for appropriate panes.""" - call_count = 0 - original = builder_module._wait_for_pane_ready + """Only default-shell panes are submitted to the readiness barrier.""" + waited: list[str] = [] + original = builder_module._wait_for_panes_ready - def counting_wait( - pane: Pane, + def recording_barrier( + panes: list[Pane], timeout: float = 2.0, interval: float = 0.05, - ) -> bool: - nonlocal call_count - call_count += 1 - return original(pane, timeout=timeout, interval=interval) + ) -> dict[str, bool]: + waited.extend(p.pane_id for p in panes if p.pane_id is not None) + return original(panes, timeout=timeout, interval=interval) - monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait) + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) yaml_workspace = tmp_path / "readiness.yaml" yaml_workspace.write_text(yaml, encoding="utf-8") @@ -1656,15 +1688,15 @@ def counting_wait( builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - assert call_count == expected_wait_count + assert len(waited) == expected_wait_count -def test_select_layout_not_called_after_yield( +def test_select_layout_called_once_per_window( tmp_path: pathlib.Path, server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """Verify select_layout is called once per pane, not duplicated in build().""" + """select_layout runs once per window, after the readiness barrier.""" call_count = 0 original_select_layout = Window.select_layout @@ -1695,8 +1727,118 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 - assert call_count == 3 + # One window, one layout pass — no per-pane and no duplicate build() pass. + assert call_count == 1 + + +def test_build_waits_for_whole_workspace_in_one_barrier( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """build() creates every pane up front, then waits for all in one barrier.""" + barrier_sizes: list[int] = [] + original = builder_module._wait_for_panes_ready + + def recording_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + barrier_sizes.append(len(panes)) + return original(panes, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) + + yaml_config = textwrap.dedent( + """\ +session_name: barrier-test +windows: +- window_name: one + layout: tiled + panes: + - shell_command: [] + - shell_command: [] +- window_name: two + layout: tiled + panes: + - shell_command: [] + - shell_command: [] +""", + ) + yaml_workspace = tmp_path / "barrier.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + # All four panes (across both windows) are awaited together, not per window. + assert barrier_sizes == [4] + + +def test_layout_runs_after_readiness_barrier( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """No window is laid out until the shared readiness barrier has completed. + + This is the issue #365 safety invariant under the parallel structure: a + pane must draw its prompt before it is resized, so every ``select_layout`` + must follow the barrier. + """ + events: list[str] = [] + original_barrier = builder_module._wait_for_panes_ready + original_select_layout = Window.select_layout + + def traced_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + result = original_barrier(panes, timeout=timeout, interval=interval) + events.append("barrier") + return result + + def traced_layout(self: Window, layout: str | None = None) -> Window: + events.append("layout") + return original_select_layout(self, layout) + + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", traced_barrier) + monkeypatch.setattr(Window, "select_layout", traced_layout) + + yaml_config = textwrap.dedent( + """\ +session_name: ordering-test +windows: +- window_name: one + layout: tiled + panes: + - shell_command: [] + - shell_command: [] +- window_name: two + layout: tiled + panes: + - shell_command: [] +""", + ) + yaml_workspace = tmp_path / "ordering.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + + assert "barrier" in events + assert "layout" in events + # The single workspace barrier precedes every layout pass. + assert events.count("barrier") == 1 + assert events.index("barrier") < events.index("layout") def test_builder_logs_session_created( From 103f9cf3af0b7adbded9d6e2d05434af908f4984 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:03:53 -0500 Subject: [PATCH 02/26] builder(fix[readiness]) wait before pane split why: split-window resizes the pane it targets, so the branch's create-all-panes phase could still resize a fresh default-shell pane before its prompt had moved away from the origin. what: - Wait for the current default-shell split target before calling split - Add an ordering regression for the pre-split readiness wait - Update readiness barrier expectations for the extra one-pane wait --- src/tmuxp/workspace/builder.py | 17 +++---- tests/workspace/test_builder.py | 89 ++++++++++++++++++++++++++++++--- 2 files changed, 89 insertions(+), 17 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index bc17ac85d4..47ae8f01b2 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -799,12 +799,10 @@ def _create_window_panes( their shells initialize concurrently while the build moves on; readiness, layout, and commands are deferred to later phases. - Each split resizes the pane it targets, and the build only ever splits - the newest pane — one created microseconds earlier, still sourcing its rc - and therefore safe to resize. The exception is running out of room: when - a split fails for space, the existing panes are first waited on (so they - are past their prompt and safe to resize), then ``select_layout`` - reclaims space, and the split is retried. + Each split resizes the pane it targets, so default-shell targets are + waited on before splitting. If a split fails for space, the existing + panes are waited on, ``select_layout`` reclaims space, and the split is + retried. Parameters ---------- @@ -903,6 +901,9 @@ def get_pane_shell( "environment": environment, } + if entries and entries[-1].shell is None: + _wait_for_panes_ready([entries[-1].pane]) + pane = self._split_pane_reclaiming_space( window, pane, @@ -942,9 +943,7 @@ def _split_pane_reclaiming_space( Without an intermediate ``select_layout`` after every split, a window with many panes eventually has no room for the next split. Reclaiming - space means resizing the panes already created, which is only safe once - their shells are past their prompts — so the readiness barrier runs - first, then the layout, then the split is retried. + space resizes created panes, so default-shell panes are waited on first. Parameters ---------- diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 9cf8e58195..05961cdb93 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1604,7 +1604,7 @@ class PaneReadinessFixture(t.NamedTuple): - cmd: echo world """, ), - expected_wait_count=2, + expected_wait_count=3, ), PaneReadinessFixture( test_id="waits_for_pane_without_commands", @@ -1618,7 +1618,7 @@ class PaneReadinessFixture(t.NamedTuple): - shell_command: [] """, ), - expected_wait_count=2, + expected_wait_count=3, ), PaneReadinessFixture( test_id="skips_pane_with_custom_shell", @@ -1634,7 +1634,7 @@ class PaneReadinessFixture(t.NamedTuple): - cmd: echo world """, ), - expected_wait_count=1, + expected_wait_count=2, ), PaneReadinessFixture( test_id="skips_all_panes_with_window_shell", @@ -1691,6 +1691,77 @@ def recording_barrier( assert len(waited) == expected_wait_count +class SplitReadinessFixture(t.NamedTuple): + """Pane split readiness ordering fixture.""" + + test_id: str + yaml: str + expected_events: list[str] + + +SPLIT_READINESS_FIXTURES: list[SplitReadinessFixture] = [ + SplitReadinessFixture( + test_id="default_shell_target", + yaml=textwrap.dedent( + """\ +session_name: split-readiness-test +windows: +- panes: + - shell_command: [] + - shell_command: [] +""", + ), + expected_events=["wait:1", "split"], + ), +] + + +@pytest.mark.parametrize( + list(SplitReadinessFixture._fields), + SPLIT_READINESS_FIXTURES, + ids=[t.test_id for t in SPLIT_READINESS_FIXTURES], +) +def test_default_shell_pane_waits_before_split_resize( + tmp_path: pathlib.Path, + session: Session, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + expected_events: list[str], +) -> None: + """Default-shell panes are ready before split-window resizes them.""" + assert test_id == "default_shell_target" + events: list[str] = [] + original_split = Pane.split + + def recording_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + events.append(f"wait:{len(panes)}") + return {pane.pane_id: True for pane in panes if pane.pane_id is not None} + + def recording_split(self: Pane, *args: t.Any, **kwargs: t.Any) -> Pane: + events.append("split") + return original_split(self, *args, **kwargs) + + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) + monkeypatch.setattr(Pane, "split", recording_split) + + yaml_workspace = tmp_path / "split_readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + window_config = workspace["windows"][0] + builder._create_window_panes(session.active_window, window_config) + + assert events[:2] == expected_events + + def test_select_layout_called_once_per_window( tmp_path: pathlib.Path, server: Server, @@ -1775,8 +1846,9 @@ def recording_barrier( builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - # All four panes (across both windows) are awaited together, not per window. - assert barrier_sizes == [4] + # The final barrier waits all panes together; earlier one-pane waits protect + # split-window resize targets. + assert barrier_sizes == [1, 1, 4] def test_layout_runs_after_readiness_barrier( @@ -1836,9 +1908,10 @@ def traced_layout(self: Window, layout: str | None = None) -> Window: assert "barrier" in events assert "layout" in events - # The single workspace barrier precedes every layout pass. - assert events.count("barrier") == 1 - assert events.index("barrier") < events.index("layout") + # All readiness barriers precede every layout pass. + assert events.count("barrier") == 2 + assert events.index("layout") > events.index("barrier") + assert events[: events.index("layout")] == ["barrier", "barrier"] def test_builder_logs_session_created( From ce0279d13c67ec6f363270ef7091c0872ba527c8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:14:05 -0500 Subject: [PATCH 03/26] builder(fix[commands]) isolate synchronize-panes --- src/tmuxp/workspace/builder.py | 92 +++++++++++++++--------- tests/workspace/test_builder.py | 123 ++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 33 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 47ae8f01b2..4e27523d0f 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -9,6 +9,7 @@ import typing as t from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.constants import OptionScope from libtmux.exc import LibTmuxException from libtmux.pane import Pane from libtmux.server import Server @@ -1082,48 +1083,73 @@ def _dispatch_window_commands( >>> len(dispatched) 1 """ - if "layout" in window_config: - window.select_layout(window_config["layout"]) + sync_option = "synchronize-panes" + window_sync = window.show_option(sync_option, scope=OptionScope.Window) + pane_sync = [ + (entry.pane, entry.pane.show_option(sync_option, scope=OptionScope.Pane)) + for entry in entries + ] - for pane, pane_config, _pane_shell in entries: - pane_log = TmuxpLoggerAdapter( - logger, - { - "tmux_session": window.session.name or "", - "tmux_window": window.name or "", - "tmux_pane": pane.pane_id or "", - }, + def restore_sync(target: Window | Pane, value: t.Any | None) -> None: + if value is None: + target.unset_option(sync_option) + return + target.set_option( + sync_option, + "on" if value is True or value == "on" else "off", ) - if "suppress_history" in pane_config: - suppress = pane_config["suppress_history"] - elif "suppress_history" in window_config: - suppress = window_config["suppress_history"] - else: - suppress = True + window.set_option(sync_option, "off", scope=OptionScope.Window) + for pane, _value in pane_sync: + pane.set_option(sync_option, "off", scope=OptionScope.Pane) + + try: + if "layout" in window_config: + window.select_layout(window_config["layout"]) + + for pane, pane_config, _pane_shell in entries: + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) - enter = pane_config.get("enter", True) - sleep_before = pane_config.get("sleep_before", None) - sleep_after = pane_config.get("sleep_after", None) - for cmd in pane_config["shell_command"]: - enter = cmd.get("enter", enter) - sleep_before = cmd.get("sleep_before", sleep_before) - sleep_after = cmd.get("sleep_after", sleep_after) + if "suppress_history" in pane_config: + suppress = pane_config["suppress_history"] + elif "suppress_history" in window_config: + suppress = window_config["suppress_history"] + else: + suppress = True - if sleep_before is not None: - time.sleep(sleep_before) + enter = pane_config.get("enter", True) + sleep_before = pane_config.get("sleep_before", None) + sleep_after = pane_config.get("sleep_after", None) + for cmd in pane_config["shell_command"]: + enter = cmd.get("enter", enter) + sleep_before = cmd.get("sleep_before", sleep_before) + sleep_after = cmd.get("sleep_after", sleep_after) - pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) - pane_log.debug("sent command %s", cmd["cmd"]) + if sleep_before is not None: + time.sleep(sleep_before) - if sleep_after is not None: - time.sleep(sleep_after) + pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + pane_log.debug("sent command %s", cmd["cmd"]) - if pane_config.get("focus"): - assert pane.pane_id is not None - window.select_pane(pane.pane_id) + if sleep_after is not None: + time.sleep(sleep_after) - yield pane, pane_config + if pane_config.get("focus"): + assert pane.pane_id is not None + window.select_pane(pane.pane_id) + + yield pane, pane_config + finally: + restore_sync(window, window_sync) + for pane, value in pane_sync: + restore_sync(pane, value) def iter_create_panes( self, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 05961cdb93..953d09388e 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -13,6 +13,7 @@ import libtmux import pytest from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.constants import OptionScope from libtmux.exc import LibTmuxException from libtmux.pane import Pane from libtmux.session import Session @@ -363,6 +364,128 @@ def f() -> bool: ), "Synchronized command did not execute properly" +class SynchronizePanesFixture(t.NamedTuple): + """Synchronize-panes command isolation fixture.""" + + test_id: str + yaml: str + enable_global_sync: bool + expected_local_sync: bool | None + + +SYNCHRONIZE_PANES_FIXTURES: list[SynchronizePanesFixture] = [ + SynchronizePanesFixture( + test_id="window_option", + yaml=textwrap.dedent( + """\ +session_name: sync-command-test +windows: +- window_name: sync + options: + synchronize-panes: on + panes: + - shell_command: + - cmd: echo tmuxp-left-sync-marker + - shell_command: + - cmd: echo tmuxp-right-sync-marker +""", + ), + enable_global_sync=False, + expected_local_sync=True, + ), + SynchronizePanesFixture( + test_id="global_default", + yaml=textwrap.dedent( + """\ +session_name: sync-command-test +windows: +- window_name: sync + panes: + - shell_command: + - cmd: echo tmuxp-left-sync-marker + - shell_command: + - cmd: echo tmuxp-right-sync-marker +""", + ), + enable_global_sync=True, + expected_local_sync=None, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizePanesFixture._fields), + SYNCHRONIZE_PANES_FIXTURES, + ids=[t.test_id for t in SYNCHRONIZE_PANES_FIXTURES], +) +def test_synchronize_panes_disabled_during_pane_commands( + tmp_path: pathlib.Path, + session: Session, + test_id: str, + yaml: str, + enable_global_sync: bool, + expected_local_sync: bool | None, +) -> None: + """Per-pane shell commands do not broadcast when synchronize-panes is on.""" + yaml_workspace = tmp_path / f"{test_id}.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + if enable_global_sync: + session.active_window.set_option( + "synchronize-panes", + "on", + global_=True, + scope=OptionScope.Window, + ) + + try: + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = session.active_window + left, right = window.panes + + def capture(pane: Pane) -> str: + return "\n".join(pane.cmd("capture-pane", "-p", "-J").stdout) + + def commands_stayed_per_pane() -> bool: + left_text = capture(left) + right_text = capture(right) + return ( + "tmuxp-left-sync-marker" in left_text + and "tmuxp-right-sync-marker" not in left_text + and "tmuxp-right-sync-marker" in right_text + and "tmuxp-left-sync-marker" not in right_text + ) + + assert retry_until(commands_stayed_per_pane, 5, interval=0.1), ( + capture(left), + capture(right), + ) + assert ( + window.show_option("synchronize-panes", scope=OptionScope.Window) + is expected_local_sync + ) + assert ( + window.show_option( + "synchronize-panes", + scope=OptionScope.Window, + include_inherited=True, + ) + is True + ) + finally: + if enable_global_sync: + session.active_window.set_option( + "synchronize-panes", + "off", + global_=True, + scope=OptionScope.Window, + ) + + def test_window_shell( session: Session, ) -> None: From ee17d41412770cdd5863c0c573acc66f840261a4 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:44:18 -0500 Subject: [PATCH 04/26] builder(fix[reclaim]) fail fast without a layout why: When a split runs out of space and the window has no layout, there is nothing to redistribute, so waiting for panes and retrying just repeats the same failure after a needless delay. what: - Re-raise the split failure immediately when layout is None. - Only wait for panes and select_layout when a layout can reclaim space. - Correct the docstring summary to match (reclaim is layout-gated). --- src/tmuxp/workspace/builder.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 4e27523d0f..591e377d5c 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -940,11 +940,12 @@ def _split_pane_reclaiming_space( layout: str | None, entries: list[_PaneEntry], ) -> Pane: - """Split ``pane``; on a space failure, reclaim room once and retry. + """Split ``pane``; if it fails for space and a layout is set, retry. Without an intermediate ``select_layout`` after every split, a window with many panes eventually has no room for the next split. Reclaiming space resizes created panes, so default-shell panes are waited on first. + With no layout to redistribute with, the split failure propagates. Parameters ---------- @@ -981,9 +982,10 @@ def _split_pane_reclaiming_space( try: return pane.split(**split_kwargs) except LibTmuxException: + if layout is None: + raise _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) - if layout is not None: - window.select_layout(layout) + window.select_layout(layout) return pane.split(**split_kwargs) def _wait_for_workspace_ready( From d985f7cb170dadba3aa3a800a10f52485cb65a3e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:45:44 -0500 Subject: [PATCH 05/26] builder(docs[build]) trim barrier comment narration why: The barrier comment narrated the removed per-pane wait, which the commit history already records and a present-day reader cannot act on. what: - Keep the load-bearing rationale (shells warm up in parallel, so one shared wait covers them) and drop the "what used to be" clause. --- src/tmuxp/workspace/builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 591e377d5c..7690c2bc53 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -644,8 +644,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: window_layout.append((window, window_config, entries)) # Barrier — wait once for every default-shell pane to draw its prompt. - # Because the shells warmed up in parallel during phase one, this single - # shared wait replaces what used to be one blocking wait per pane. + # The shells warmed up in parallel during phase one, so one shared wait + # covers them all. self._wait_for_workspace_ready(window_layout) # Phase two — finish: lay each window out (a single resize with all shells From 60efa182887125f49159edc57bd4ff0fb0b26da8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:47:11 -0500 Subject: [PATCH 06/26] builder(docs[readiness]) note shared-timeout behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The single shared timeout is intentional — shells start concurrently — but its effect on a prompt slower than the timeout was unstated. what: - Document that a slow prompt exceeding the timeout continues without blocking the rest of the workspace. --- src/tmuxp/workspace/builder.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 7690c2bc53..7f96e7ba24 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -67,7 +67,8 @@ def _wait_for_panes_ready( initialize concurrently. Polling every pane in a single loop — rather than blocking on each one to completion before starting the next — observes that concurrency, collapsing the worst case from ``len(panes) * timeout`` of - serial waiting into a single shared ``timeout`` window. + serial waiting into a single shared ``timeout`` window. A slow prompt that + exceeds the timeout continues without blocking the rest of the workspace. Parameters ---------- From 8cb0c37d318a9cb16b0c3c61cd3ba6007959b21e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:48:38 -0500 Subject: [PATCH 07/26] builder(docs[build]) correct phase-one comment why: The phase-one comment claimed "nothing is resized", but the space-reclaim fallback can call select_layout during creation. what: - State that layout and pane commands come after the barrier, dropping the absolute "nothing is resized" claim. --- src/tmuxp/workspace/builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 7f96e7ba24..c79b77f9bc 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -632,8 +632,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: self.session.set_environment(option, value) # Phase one — structure: create every window and its panes first, so all - # of their shells start initializing concurrently. Nothing is resized and - # no keys are sent yet. + # of their shells start initializing concurrently. Layout and pane + # commands come after the barrier. window_layout: list[tuple[Window, dict[str, t.Any], list[_PaneEntry]]] = [] for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) From 4d860008b11e0f2c6bded86c0596c3e2f6b32e4e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:50:03 -0500 Subject: [PATCH 08/26] builder(docs[readiness]) demo predicate in its doctest why: The _pane_has_drawn_prompt doctest showed _wait_for_pane_ready as its primary call, not the predicate it documents. what: - Demote the readiness wait to setup (assigned to _) and show _pane_has_drawn_prompt as the demonstrated call. --- src/tmuxp/workspace/builder.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index c79b77f9bc..0046c4c245 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -48,8 +48,7 @@ def _pane_has_drawn_prompt(pane: Pane) -> bool: Examples -------- >>> pane = session.active_window.active_pane - >>> _wait_for_pane_ready(pane, timeout=5.0) - True + >>> _ = _wait_for_panes_ready([pane], timeout=5.0) >>> _pane_has_drawn_prompt(pane) True """ From 7ec0136ab19409486d2fe253662cc1ea83e41534 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 19:55:32 -0500 Subject: [PATCH 09/26] builder(test[plugins]) pin window hook order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: The two-phase build fires on_window_create for every window before any after_window_finished — observable plugin behavior that was only implied by the build() structure. what: - Document the phase-based hook order in build()'s docstring. - Add a parametrized test asserting the order for one- and two-window workspaces via an inline recording plugin. --- src/tmuxp/workspace/builder.py | 5 ++ tests/workspace/test_builder.py | 102 ++++++++++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 0046c4c245..db543207c0 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -507,6 +507,11 @@ def build(self, session: Session | None = None, append: bool = False) -> None: Without ``session``, it will use :class:`libmtux.Server` at ``self.server`` passed in on initialization to create a new Session object. + Plugin hooks fire by phase: ``on_window_create`` runs for every window + as it is created, before any ``after_window_finished``. Each window's + ``after_window_finished`` still runs once that window has been laid out + and its pane commands dispatched. + Parameters ---------- session : :class:`libtmux.Session` diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 953d09388e..354c2428ea 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -26,6 +26,7 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader from tmuxp.cli.load import load_plugins +from tmuxp.plugin import TmuxpPlugin from tmuxp.workspace import builder as builder_module, loader from tmuxp.workspace.builder import ( WorkspaceBuilder, @@ -2037,6 +2038,107 @@ def traced_layout(self: Window, layout: str | None = None) -> Window: assert events[: events.index("layout")] == ["barrier", "barrier"] +class _HookOrderRecorder(TmuxpPlugin): + """Plugin that records the order its lifecycle hooks fire.""" + + def __init__(self, calls: list[str]) -> None: + self.calls = calls + + def before_workspace_builder(self, session: Session) -> None: + """Record the session-level pre-build hook.""" + self.calls.append("before_workspace_builder") + + def on_window_create(self, window: Window) -> None: + """Record the per-window creation hook.""" + self.calls.append(f"on_window_create:{window.name}") + + def after_window_finished(self, window: Window) -> None: + """Record the per-window completion hook.""" + self.calls.append(f"after_window_finished:{window.name}") + + +class PluginHookOrderFixture(t.NamedTuple): + """Expected plugin hook firing order for a workspace config.""" + + test_id: str + yaml: str + expected_order: list[str] + + +PLUGIN_HOOK_ORDER_FIXTURES: list[PluginHookOrderFixture] = [ + PluginHookOrderFixture( + test_id="single_window", + yaml=textwrap.dedent( + """\ +session_name: hook-order +windows: +- window_name: one + panes: + - shell_command: [] +""", + ), + expected_order=[ + "before_workspace_builder", + "on_window_create:one", + "after_window_finished:one", + ], + ), + PluginHookOrderFixture( + test_id="all_creates_before_any_finish", + yaml=textwrap.dedent( + """\ +session_name: hook-order +windows: +- window_name: one + panes: + - shell_command: [] +- window_name: two + panes: + - shell_command: [] +""", + ), + expected_order=[ + "before_workspace_builder", + "on_window_create:one", + "on_window_create:two", + "after_window_finished:one", + "after_window_finished:two", + ], + ), +] + + +@pytest.mark.parametrize( + list(PluginHookOrderFixture._fields), + PLUGIN_HOOK_ORDER_FIXTURES, + ids=[f.test_id for f in PLUGIN_HOOK_ORDER_FIXTURES], +) +def test_plugin_hook_order( + tmp_path: pathlib.Path, + server: Server, + test_id: str, + yaml: str, + expected_order: list[str], +) -> None: + """on_window_create fires for all windows before any after_window_finished.""" + calls: list[str] = [] + + yaml_workspace = tmp_path / "hook_order.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + plugins=[_HookOrderRecorder(calls)], + server=server, + ) + builder.build() + + assert calls == expected_order + + def test_builder_logs_session_created( server: Server, caplog: pytest.LogCaptureFixture, From f955e6fdcba1a129e96cec07ee4955b958a27490 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Thu, 25 Jun 2026 20:10:14 -0500 Subject: [PATCH 10/26] builder(fix[readiness]) remove split prompt wait why: The pre-split readiness wait restored a serial per-pane prompt wait and made large workspaces slow again. A cheap pane refresh is still needed because libtmux resolves the target pane's window during split-window. what: - Remove the per-split prompt readiness wait and its ordering test - Refresh the split target before splitting without waiting for a prompt - Restore one shared workspace readiness barrier expectations --- src/tmuxp/workspace/builder.py | 16 ++-- tests/workspace/test_builder.py | 141 ++++++++++++++------------------ 2 files changed, 69 insertions(+), 88 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index db543207c0..37f3230542 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -805,10 +805,12 @@ def _create_window_panes( their shells initialize concurrently while the build moves on; readiness, layout, and commands are deferred to later phases. - Each split resizes the pane it targets, so default-shell targets are - waited on before splitting. If a split fails for space, the existing - panes are waited on, ``select_layout`` reclaims space, and the split is - retried. + Each split resizes the pane it targets, and the build only ever splits + the newest pane — one created microseconds earlier, still sourcing its rc + and therefore safe to resize. The exception is running out of room: when + a split fails for space, the existing panes are first waited on (so they + are past their prompt and safe to resize), then ``select_layout`` + reclaims space, and the split is retried. Parameters ---------- @@ -907,9 +909,6 @@ def get_pane_shell( "environment": environment, } - if entries and entries[-1].shell is None: - _wait_for_panes_ready([entries[-1].pane]) - pane = self._split_pane_reclaiming_space( window, pane, @@ -984,6 +983,9 @@ def _split_pane_reclaiming_space( >>> second is not None True """ + # libtmux resolves ``pane.window`` during split; refresh keeps the + # target pane's window id current without waiting for a shell prompt. + pane.refresh() try: return pane.split(**split_kwargs) except LibTmuxException: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 354c2428ea..9f405c4a52 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1728,7 +1728,7 @@ class PaneReadinessFixture(t.NamedTuple): - cmd: echo world """, ), - expected_wait_count=3, + expected_wait_count=2, ), PaneReadinessFixture( test_id="waits_for_pane_without_commands", @@ -1742,7 +1742,7 @@ class PaneReadinessFixture(t.NamedTuple): - shell_command: [] """, ), - expected_wait_count=3, + expected_wait_count=2, ), PaneReadinessFixture( test_id="skips_pane_with_custom_shell", @@ -1758,7 +1758,7 @@ class PaneReadinessFixture(t.NamedTuple): - cmd: echo world """, ), - expected_wait_count=2, + expected_wait_count=1, ), PaneReadinessFixture( test_id="skips_all_panes_with_window_shell", @@ -1815,77 +1815,6 @@ def recording_barrier( assert len(waited) == expected_wait_count -class SplitReadinessFixture(t.NamedTuple): - """Pane split readiness ordering fixture.""" - - test_id: str - yaml: str - expected_events: list[str] - - -SPLIT_READINESS_FIXTURES: list[SplitReadinessFixture] = [ - SplitReadinessFixture( - test_id="default_shell_target", - yaml=textwrap.dedent( - """\ -session_name: split-readiness-test -windows: -- panes: - - shell_command: [] - - shell_command: [] -""", - ), - expected_events=["wait:1", "split"], - ), -] - - -@pytest.mark.parametrize( - list(SplitReadinessFixture._fields), - SPLIT_READINESS_FIXTURES, - ids=[t.test_id for t in SPLIT_READINESS_FIXTURES], -) -def test_default_shell_pane_waits_before_split_resize( - tmp_path: pathlib.Path, - session: Session, - monkeypatch: pytest.MonkeyPatch, - test_id: str, - yaml: str, - expected_events: list[str], -) -> None: - """Default-shell panes are ready before split-window resizes them.""" - assert test_id == "default_shell_target" - events: list[str] = [] - original_split = Pane.split - - def recording_barrier( - panes: list[Pane], - timeout: float = 2.0, - interval: float = 0.05, - ) -> dict[str, bool]: - events.append(f"wait:{len(panes)}") - return {pane.pane_id: True for pane in panes if pane.pane_id is not None} - - def recording_split(self: Pane, *args: t.Any, **kwargs: t.Any) -> Pane: - events.append("split") - return original_split(self, *args, **kwargs) - - monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) - monkeypatch.setattr(Pane, "split", recording_split) - - yaml_workspace = tmp_path / "split_readiness.yaml" - yaml_workspace.write_text(yaml, encoding="utf-8") - workspace = ConfigReader._from_file(yaml_workspace) - workspace = loader.expand(workspace) - workspace = loader.trickle(workspace) - - builder = WorkspaceBuilder(session_config=workspace, server=session.server) - window_config = workspace["windows"][0] - builder._create_window_panes(session.active_window, window_config) - - assert events[:2] == expected_events - - def test_select_layout_called_once_per_window( tmp_path: pathlib.Path, server: Server, @@ -1926,6 +1855,58 @@ def counting_layout(self: Window, layout: str | None = None) -> Window: assert call_count == 1 +def test_split_target_refreshes_without_readiness_wait( + tmp_path: pathlib.Path, + session: Session, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Normal pane splitting refreshes target state without prompt waiting.""" + events: list[str] = [] + original_refresh = Pane.refresh + original_split = Pane.split + + def recording_refresh(self: Pane) -> None: + events.append("refresh") + return original_refresh(self) + + def recording_split(self: Pane, *args: t.Any, **kwargs: t.Any) -> Pane: + events.append("split") + return original_split(self, *args, **kwargs) + + def recording_barrier( + panes: list[Pane], + timeout: float = 2.0, + interval: float = 0.05, + ) -> dict[str, bool]: + events.append("wait") + return {pane.pane_id: True for pane in panes if pane.pane_id is not None} + + monkeypatch.setattr(Pane, "refresh", recording_refresh) + monkeypatch.setattr(Pane, "split", recording_split) + monkeypatch.setattr(builder_module, "_wait_for_panes_ready", recording_barrier) + + yaml_config = textwrap.dedent( + """\ +session_name: split-refresh-test +windows: +- panes: + - shell_command: [] + - shell_command: [] +""", + ) + yaml_workspace = tmp_path / "split_refresh.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder._create_window_panes(session.active_window, workspace["windows"][0]) + + assert "wait" not in events + assert events[:2] == ["refresh", "split"] + + def test_build_waits_for_whole_workspace_in_one_barrier( tmp_path: pathlib.Path, server: Server, @@ -1970,9 +1951,8 @@ def recording_barrier( builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - # The final barrier waits all panes together; earlier one-pane waits protect - # split-window resize targets. - assert barrier_sizes == [1, 1, 4] + # All four panes (across both windows) are awaited together, not per window. + assert barrier_sizes == [4] def test_layout_runs_after_readiness_barrier( @@ -2032,10 +2012,9 @@ def traced_layout(self: Window, layout: str | None = None) -> Window: assert "barrier" in events assert "layout" in events - # All readiness barriers precede every layout pass. - assert events.count("barrier") == 2 - assert events.index("layout") > events.index("barrier") - assert events[: events.index("layout")] == ["barrier", "barrier"] + # The single workspace barrier precedes every layout pass. + assert events.count("barrier") == 1 + assert events.index("barrier") < events.index("layout") class _HookOrderRecorder(TmuxpPlugin): From fb5fd6c1e832f20df82225b38a318a7ef83ae8c0 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Fri, 26 Jun 2026 05:43:40 -0500 Subject: [PATCH 11/26] builder(fix[progress]) sync two-phase progress why: The two-phase build starts every window before any window is finished. Progress rendering still treated the last started window as current during completion, so the bar and window label drifted apart. what: - include window identity on window_done build events - track the current started or completed window in BuildTree - match named window completions during phase two - cover phase-one and phase-two progress synchronization --- src/tmuxp/cli/_progress.py | 70 +++++++++++++--- src/tmuxp/workspace/builder.py | 14 +++- tests/cli/test_progress.py | 133 +++++++++++++++++++++++++++++++ tests/workspace/test_progress.py | 32 ++++++++ 4 files changed, 235 insertions(+), 14 deletions(-) diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py index 01d31d7272..22a0f62e44 100644 --- a/src/tmuxp/cli/_progress.py +++ b/src/tmuxp/cli/_progress.py @@ -432,6 +432,7 @@ def __init__(self, workspace_path: str = "") -> None: self.session_pane_total: int | None = None self.session_panes_done: int = 0 self.windows_done: int = 0 + self._current_window: _WindowStatus | None = None self._before_script_event: threading.Event = threading.Event() def on_event(self, event: dict[str, t.Any]) -> None: @@ -465,26 +466,71 @@ def on_event(self, event: dict[str, t.Any]) -> None: elif kind == "before_script_done": self._before_script_event.clear() elif kind == "window_started": - self.windows.append( - _WindowStatus(name=event["name"], pane_total=event["pane_total"]) + self._current_window = _WindowStatus( + name=event["name"], + pane_total=event["pane_total"], ) + self.windows.append(self._current_window) elif kind == "pane_creating": if self.windows: - w = self.windows[-1] + w = self._current_window or self.windows[-1] w.pane_num = event["pane_num"] w.pane_total = event["pane_total"] elif kind == "window_done": - if self.windows: - w = self.windows[-1] - w.done = True - w.pane_num = None - w.pane_done = w.pane_total or 0 - self.session_panes_done += w.pane_done + target_window = self._window_for_done_event(event) + if target_window is not None: + target_window.done = True + target_window.pane_num = None + target_window.pane_done = target_window.pane_total or 0 + self.session_panes_done += target_window.pane_done self.windows_done += 1 + self._current_window = target_window elif kind == "workspace_built": for w in self.windows: w.done = True + def _window_for_done_event(self, event: dict[str, t.Any]) -> _WindowStatus | None: + """Return the unfinished window targeted by a window_done event. + + Examples + -------- + >>> tree = BuildTree() + >>> tree.on_event({"event": "window_started", "name": "one", "pane_total": 1}) + >>> tree.on_event({"event": "window_started", "name": "two", "pane_total": 1}) + >>> tree._window_for_done_event({"event": "window_done", "name": "one"}).name + 'one' + >>> tree._window_for_done_event({"event": "window_done", "name": "missing"}) + """ + name = event.get("name") + if isinstance(name, str): + for window in self.windows: + if not window.done and window.name == name: + return window + return None + + for window in self.windows: + if not window.done: + return window + return None + + def _current_window_index(self) -> int: + """Return the 1-based index for the current window. + + Examples + -------- + >>> tree = BuildTree() + >>> tree._current_window_index() + 0 + >>> tree.on_event({"event": "window_started", "name": "one", "pane_total": 1}) + >>> tree.on_event({"event": "window_started", "name": "two", "pane_total": 1}) + >>> tree._current_window_index() + 2 + """ + for index, window in enumerate(self.windows, start=1): + if window is self._current_window: + return index + return 0 + def render(self, colors: Colors, width: int) -> list[str]: """Render the current tree state to a list of display strings. @@ -566,8 +612,8 @@ def _context(self) -> dict[str, t.Any]: >>> tree._context()["summary"] '[2 win, 8 panes]' """ - w = self.windows[-1] if self.windows else None - window_idx = len(self.windows) + w = self._current_window if self._current_window else None + window_idx = self._current_window_index() win_tot = self.window_total or 0 pane_idx = (w.pane_num or 0) if w else 0 pane_tot = (w.pane_total or 0) if w else 0 @@ -947,7 +993,7 @@ def _build_extra(self) -> dict[str, t.Any]: # Composite fraction: (windows_done + pane_frac) / window_total if win_tot > 0: - cw = tree.windows[-1] if tree.windows else None + cw = tree._current_window or (tree.windows[-1] if tree.windows else None) pane_frac = 0.0 if cw and not cw.done and cw.pane_total: pane_frac = (cw.pane_num or 0) / cw.pane_total diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 37f3230542..044d5be4ec 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -655,7 +655,10 @@ def build(self, session: Session | None = None, append: bool = False) -> None: # Phase two — finish: lay each window out (a single resize with all shells # ready, so no zsh ``%`` marker) and send its commands. - for window, window_config, entries in window_layout: + for window_index, (window, window_config, entries) in enumerate( + window_layout, + start=1, + ): focus_pane = None for pane, pane_config in self._dispatch_window_commands( window, @@ -679,7 +682,14 @@ def build(self, session: Session | None = None, append: bool = False) -> None: focus_pane.select() if self.on_build_event: - self.on_build_event({"event": "window_done"}) + window_name = window_config.get("window_name") or str(window_index) + self.on_build_event( + { + "event": "window_done", + "name": window_name, + "pane_total": len(window_config["panes"]), + } + ) if focus: focus.select() diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py index 8ace187b65..3194439eb9 100644 --- a/tests/cli/test_progress.py +++ b/tests/cli/test_progress.py @@ -46,6 +46,40 @@ class SpinnerEnablementFixture(t.NamedTuple): ] +class TwoPhaseWindowDoneFixture(t.NamedTuple): + """Test fixture for non-interleaved window completion events.""" + + test_id: str + done_events: tuple[dict[str, t.Any], ...] + expected_done_names: tuple[str, ...] + expected_window: str + expected_progress: str + expected_session_pane_progress: str + + +TWO_PHASE_WINDOW_DONE_FIXTURES: list[TwoPhaseWindowDoneFixture] = [ + TwoPhaseWindowDoneFixture( + test_id="first_window_done", + done_events=({"event": "window_done", "name": "w1"},), + expected_done_names=("w1",), + expected_window="w1", + expected_progress="1/3 win", + expected_session_pane_progress="2/6", + ), + TwoPhaseWindowDoneFixture( + test_id="first_two_windows_done", + done_events=( + {"event": "window_done", "name": "w1"}, + {"event": "window_done", "name": "w2"}, + ), + expected_done_names=("w1", "w2"), + expected_window="w2", + expected_progress="2/3 win", + expected_session_pane_progress="3/6", + ), +] + + @pytest.mark.parametrize( list(SpinnerEnablementFixture._fields), SPINNER_ENABLEMENT_FIXTURES, @@ -247,6 +281,53 @@ def test_build_tree_window_done_shows_checkmark() -> None: assert lines[1] == "- ✓ editor" +def _build_two_phase_progress_tree() -> BuildTree: + tree = BuildTree() + tree.on_event( + { + "event": "session_created", + "name": "my-session", + "window_total": 3, + "session_pane_total": 6, + } + ) + for name, pane_total in (("w1", 2), ("w2", 1), ("w3", 3)): + tree.on_event( + { + "event": "window_started", + "name": name, + "pane_total": pane_total, + } + ) + return tree + + +@pytest.mark.parametrize( + list(TwoPhaseWindowDoneFixture._fields), + TWO_PHASE_WINDOW_DONE_FIXTURES, + ids=[f.test_id for f in TWO_PHASE_WINDOW_DONE_FIXTURES], +) +def test_build_tree_named_window_done_updates_completed_window( + test_id: str, + done_events: tuple[dict[str, t.Any], ...], + expected_done_names: tuple[str, ...], + expected_window: str, + expected_progress: str, + expected_session_pane_progress: str, +) -> None: + """Named window_done events update the completed window, not the last one.""" + tree = _build_two_phase_progress_tree() + + for event in done_events: + tree.on_event(event) + + context = tree._context() + assert tuple(w.name for w in tree.windows if w.done) == expected_done_names + assert context["window"] == expected_window + assert context["progress"] == expected_progress + assert context["session_pane_progress"] == expected_session_pane_progress + + def test_build_tree_workspace_built_marks_all_done() -> None: """workspace_built marks all windows as done.""" from tmuxp.cli._colors import Colors @@ -735,6 +816,58 @@ def test_spinner_pane_bar_preset() -> None: assert "░" in spinner.message or "█" in spinner.message +def test_spinner_default_progress_tracks_started_window_in_phase_one() -> None: + """The default preset shows started-window progress while panes are created.""" + stream = io.StringIO() + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + for event in ( + {"event": "session_created", "name": "s", "window_total": 3}, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_started", "name": "w2", "pane_total": 1}, + ): + spinner.on_build_event(event) + + assert "2/3 win" in spinner.message + assert "0/3" not in spinner.message + assert spinner.message.endswith("w2") + + +def test_spinner_default_progress_tracks_completed_window_in_phase_two() -> None: + """The default preset changes current window when phase two completes it.""" + stream = io.StringIO() + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + for event in ( + { + "event": "session_created", + "name": "s", + "window_total": 3, + "session_pane_total": 6, + }, + {"event": "window_started", "name": "w1", "pane_total": 2}, + {"event": "window_started", "name": "w2", "pane_total": 1}, + {"event": "window_started", "name": "w3", "pane_total": 3}, + {"event": "pane_creating", "pane_num": 3, "pane_total": 3}, + {"event": "window_done", "name": "w1"}, + ): + spinner.on_build_event(event) + + assert "1/3 win" in spinner.message + assert "3/3 win" not in spinner.message + assert render_bar(1, 3) in spinner.message + assert render_bar(2, 3) not in spinner.message + assert spinner.message.endswith("w1") + + def test_spinner_before_script_event_via_events() -> None: """before_script_started / before_script_done toggle the BuildTree Event flag.""" stream = io.StringIO() diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py index 336fa9dafd..6b4707ce94 100644 --- a/tests/workspace/test_progress.py +++ b/tests/workspace/test_progress.py @@ -116,6 +116,38 @@ def test_builder_on_build_event_sequence( assert created["session_pane_total"] == 3 # 2 panes + 1 pane +def test_builder_window_done_events_include_window_identity( + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """window_done events identify the window completed in phase two.""" + monkeypatch.delenv("TMUX", raising=False) + + session_config = { + "session_name": "window-done-identity-test", + "windows": [ + { + "window_name": "editor", + "panes": [{"shell_command": []}, {"shell_command": []}], + }, + {"window_name": "logs", "panes": [{"shell_command": []}]}, + ], + } + events: list[dict[str, t.Any]] = [] + builder = WorkspaceBuilder( + session_config=session_config, + server=server, + on_build_event=events.append, + ) + builder.build() + + done_events = [e for e in events if e["event"] == "window_done"] + assert [(e["name"], e["pane_total"]) for e in done_events] == [ + ("editor", 2), + ("logs", 1), + ] + + def test_builder_on_build_event_session_name( server: Server, monkeypatch: pytest.MonkeyPatch, From a4e1806cae6b1ccc688831ad25827d30ff270644 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:11:26 -0500 Subject: [PATCH 12/26] Load(fix[progress]): Show phased progress why: The load spinner mixed created-window text with finished-window bar state, which made progress appear stuck and caused single-frame line shifts while panes were being created. what: - Emit named window completion events for non-interleaved builds - Render the default progress bar with finished, created, and empty segments - Show finished windows over created windows with stable pane progress text - Document loading workspace phases and progress output --- docs/cli/load.md | 8 +- docs/topics/index.md | 7 ++ docs/topics/loading-workspaces.md | 58 ++++++++++++++ src/tmuxp/cli/_progress.py | 127 ++++++++++++++++++++++++++---- tests/cli/test_progress.py | 108 +++++++++++++++++++++++-- 5 files changed, 285 insertions(+), 23 deletions(-) create mode 100644 docs/topics/loading-workspaces.md diff --git a/docs/cli/load.md b/docs/cli/load.md index 8be9178f29..bf1e46ced9 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -157,13 +157,16 @@ $ tmuxp --log-level [LEVEL] load [filename] --log-file [log_filename] When loading a workspace, tmuxp shows an animated spinner with build progress. The spinner updates as windows and panes are created, giving real-time feedback during session builds. +See {ref}`loading-workspaces` for how tmuxp creates windows and panes during +load and how to read the default progress line. + ### Presets Five built-in presets control the spinner format: | Preset | Format | |--------|--------| -| `default` | `Loading workspace: {session} {bar} {progress} {window}` | +| `default` | `Loading workspace: {session} {bar} {build_progress} {window}` | | `minimal` | `Loading workspace: {session} [{window_progress}]` | | `window` | `Loading workspace: {session} {window_bar} {window_progress_rel}` | | `pane` | `Loading workspace: {session} {pane_bar} {session_pane_progress}` | @@ -199,9 +202,10 @@ Use a custom format string with any of the available tokens: | `{pane_total}` | Total panes in the current window | | `{pane_progress}` | Pane fraction (e.g. `2/4`) | | `{progress}` | Combined progress (e.g. `1/3 win · 2/4 pane`) | +| `{build_progress}` | Finished windows over created windows (e.g. `1/3 win · pane 2/4`) | | `{session_pane_progress}` | Panes completed across the session (e.g. `5/10`) | | `{overall_percent}` | Pane-based completion percentage (0–100) | -| `{bar}` | Composite progress bar | +| `{bar}` | Build progress bar: finished, created, and empty window segments | | `{pane_bar}` | Pane-based progress bar | | `{window_bar}` | Window-based progress bar | | `{status_icon}` | Status icon (⏸ during before_script) | diff --git a/docs/topics/index.md b/docs/topics/index.md index 050ddb9d58..c89086b2aa 100644 --- a/docs/topics/index.md +++ b/docs/topics/index.md @@ -7,6 +7,12 @@ Conceptual guides and workflow documentation. ::::{grid} 1 1 2 2 :gutter: 2 2 3 3 +:::{grid-item-card} Loading workspaces +:link: loading-workspaces +:link-type: doc +How tmuxp creates windows, panes, and progress output. +::: + :::{grid-item-card} Workflows :link: workflows :link-type: doc @@ -46,6 +52,7 @@ instead of raw shell commands, supports JSON and YAML, and can :hidden: workflows +loading-workspaces plugins library-vs-cli troubleshooting diff --git a/docs/topics/loading-workspaces.md b/docs/topics/loading-workspaces.md new file mode 100644 index 0000000000..1a03682208 --- /dev/null +++ b/docs/topics/loading-workspaces.md @@ -0,0 +1,58 @@ +(loading-workspaces)= + +# Loading workspaces + +When you run `tmuxp load`, tmuxp creates the tmux session described by your +workspace file, waits for panes to be ready, then finishes each window by +applying layouts and sending commands. + +## What happens during load + +tmuxp loads a workspace in two broad phases. + +First, tmuxp creates the session structure. It creates each configured window +and its panes so the panes can start their shells in parallel. + +Then, tmuxp waits for the panes to be ready. Once the shells have drawn their +prompts, tmuxp finishes each window by applying layout, sending configured +commands, running window-level configuration, and firing +{meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` plugin hooks. + +This means {meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create` runs while the +window is created, while +{meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` runs later, after that +window has been laid out and configured. + +## Reading the progress line + +The default progress line reports the same build in a compact form: + +```text +Loading workspace: study ▓▓░░░░░░░░ 0/2 win · pane 3/4 learning-asyncio +``` + +The fraction before `win` is finished windows over windows created so far. In +the example above, two windows have been created and neither has finished yet. + +The `pane` fraction describes the current window's pane creation progress. When +a new window starts, this can briefly show `pane 0/N` before tmuxp creates the +first pane for that window. + +The progress bar shows the whole workspace: + +- `█` means a window is finished. +- `▓` means a window has been created but is not finished yet. +- `░` means a window has not been created yet. + +Once tmuxp enters the finish phase, the finished-window count rises: + +```text +Loading workspace: study █▓░░░░░░░░ 1/2 win learning-dsa +``` + +## Why tmuxp loads this way + +Creating all panes before finishing windows lets shell startup happen in +parallel. The later finish phase can then apply layouts and commands after the +panes are ready, which avoids resizing panes while shells are still drawing +their first prompts. diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py index 22a0f62e44..37b69d6d9e 100644 --- a/src/tmuxp/cli/_progress.py +++ b/src/tmuxp/cli/_progress.py @@ -11,6 +11,7 @@ import dataclasses import itertools import logging +import math import shutil import sys import threading @@ -124,7 +125,7 @@ def _truncate_visible(text: str, max_visible: int, suffix: str = "...") -> str: SUCCESS_TEMPLATE = "Loaded workspace: {session} ({workspace_path}) {summary}" PROGRESS_PRESETS: dict[str, str] = { - "default": "Loading workspace: {session} {bar} {progress} {window}", + "default": "Loading workspace: {session} {bar} {build_progress} {window}", "minimal": "Loading workspace: {session} [{window_progress}]", "window": "Loading workspace: {session} {window_bar} {window_progress_rel}", "pane": "Loading workspace: {session} {pane_bar} {session_pane_progress}", @@ -172,6 +173,63 @@ def render_bar(done: int, total: int, width: int = BAR_WIDTH) -> str: return "█" * filled + "░" * (width - filled) +def _progress_cells(done: int, total: int, width: int = BAR_WIDTH) -> int: + """Return visible cells for nonzero progress without completing early. + + Examples + -------- + >>> _progress_cells(0, 11) + 0 + >>> _progress_cells(1, 11) + 1 + >>> _progress_cells(10, 11) + 9 + >>> _progress_cells(11, 11) + 10 + """ + if done <= 0 or total <= 0 or width <= 0: + return 0 + if done >= total: + return width + return min(width - 1, max(1, math.ceil(done / total * width))) + + +def render_build_bar( + windows_done: int, + windows_created: int, + total: int, + width: int = BAR_WIDTH, +) -> str: + """Render a two-phase window build bar. + + ``windows_created`` is the total number of windows created so far, + including completed windows. The rendered bar uses ``█`` for finished + windows, ``▓`` for created-but-unfinished windows, and ``░`` for windows + not created yet. + + Examples + -------- + >>> render_build_bar(0, 0, 11) + '░░░░░░░░░░' + >>> render_build_bar(0, 5, 11) + '▓▓▓▓▓░░░░░' + >>> render_build_bar(1, 5, 11) + '█▓▓▓▓░░░░░' + >>> render_build_bar(11, 11, 11) + '██████████' + """ + if total <= 0 or width <= 0: + return "" + + done = max(0, min(windows_done, total)) + created = max(done, min(windows_created, total)) + done_cells = _progress_cells(done, total, width) + created_cells = _progress_cells(created, total, width) + created_only_cells = max(0, created_cells - done_cells) + empty_cells = max(0, width - created_cells) + return "█" * done_cells + "▓" * created_only_cells + "░" * empty_cells + + class _SafeFormatMap(dict): # type: ignore[type-arg] """dict subclass that returns ``{key}`` for missing keys in format_map.""" @@ -255,6 +313,12 @@ class BuildTree: - ``"N/M"`` when > 0 - — - — + * - ``{window_progress_created}`` + - ``""`` + - ``"0/M"`` + - increments + - — + - — * - ``{windows_done}`` - ``0`` - ``0`` @@ -315,6 +379,12 @@ class BuildTree: - ``"N/M win"`` - ``"N/M win · P/Q pane"`` - — + * - ``{build_progress}`` + - ``""`` + - ``"0/0 win"`` + - denominator increments + - pane progress appended + - numerator increments * - ``{session_pane_total}`` - ``0`` - total @@ -354,9 +424,9 @@ class BuildTree: * - ``{bar}`` (spinner) - ``[░░…]`` - ``[░░…]`` - - starts filling - - fractional - - jumps + - created segment fills + - — + - done segment fills * - ``{pane_bar}`` (spinner) - ``""`` - ``[░░…]`` @@ -586,8 +656,12 @@ def _context(self) -> dict[str, t.Any]: 5 >>> ctx["window_index"] 0 + >>> ctx["windows_created"] + 0 >>> ctx["progress"] '' + >>> ctx["build_progress"] + '0/0 win' >>> ctx["windows_done"] 0 >>> ctx["windows_remaining"] @@ -615,6 +689,7 @@ def _context(self) -> dict[str, t.Any]: w = self._current_window if self._current_window else None window_idx = self._current_window_index() win_tot = self.window_total or 0 + windows_created = len(self.windows) pane_idx = (w.pane_num or 0) if w else 0 pane_tot = (w.pane_total or 0) if w else 0 @@ -628,10 +703,18 @@ def _context(self) -> dict[str, t.Any]: win_done = self.windows_done win_progress_rel = f"{win_done}/{win_tot}" if win_tot else "" - pane_done_cur = ( (w.pane_num or 0) if w and not w.done else (w.pane_done if w else 0) ) + build_pane_progress = ( + f"{pane_done_cur}/{pane_tot}" if w and not w.done and pane_tot else "" + ) + build_progress_parts = [ + f"{win_done}/{windows_created} win", + f"pane {build_pane_progress}" if build_pane_progress else "", + ] + build_progress = " · ".join(p for p in build_progress_parts if p) + pane_remaining = max(0, pane_tot - pane_done_cur) pane_progress_rel = f"{pane_done_cur}/{pane_tot}" if pane_tot else "" @@ -652,11 +735,16 @@ def _context(self) -> dict[str, t.Any]: "window": w.name if w else "", "window_index": window_idx, "window_total": win_tot, + "windows_created": windows_created, "window_progress": win_progress, + "window_progress_created": ( + f"{windows_created}/{win_tot}" if win_tot else "" + ), "pane_index": pane_idx, "pane_total": pane_tot, "pane_progress": pane_progress, "progress": progress, + "build_progress": build_progress, "windows_done": win_done, "windows_remaining": max(0, win_tot - win_done), "window_progress_rel": win_progress_rel, @@ -991,16 +1079,11 @@ def _build_extra(self) -> dict[str, t.Any]: win_tot = tree.window_total or 0 spt = tree.session_pane_total or 0 - # Composite fraction: (windows_done + pane_frac) / window_total - if win_tot > 0: - cw = tree._current_window or (tree.windows[-1] if tree.windows else None) - pane_frac = 0.0 - if cw and not cw.done and cw.pane_total: - pane_frac = (cw.pane_num or 0) / cw.pane_total - composite_done = tree.windows_done + pane_frac - composite_bar = render_bar(int(composite_done * 100), win_tot * 100) - else: - composite_bar = render_bar(0, 0) + build_bar = render_build_bar( + windows_done=tree.windows_done, + windows_created=len(tree.windows), + total=win_tot, + ) pane_bar = render_bar(tree.session_panes_done, spt) window_bar = render_bar(tree.windows_done, win_tot) @@ -1012,9 +1095,21 @@ def _color_bar(plain: str) -> str: empty = plain.count("░") return self.colors.success("█" * filled) + self.colors.muted("░" * empty) + def _color_build_bar(plain: str) -> str: + if not plain: + return plain + return "".join( + { + "█": self.colors.success("█"), + "▓": self.colors.info("▓"), + "░": self.colors.muted("░"), + }.get(char, char) + for char in plain + ) + return { "session": self.colors.highlight(tree.session_name or ""), - "bar": _color_bar(composite_bar), + "bar": _color_build_bar(build_bar), "pane_bar": _color_bar(pane_bar), "window_bar": _color_bar(window_bar), "status_icon": "", diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py index 3194439eb9..868c87f483 100644 --- a/tests/cli/test_progress.py +++ b/tests/cli/test_progress.py @@ -24,6 +24,7 @@ _truncate_visible, _visible_len, render_bar, + render_build_bar, resolve_progress_format, ) @@ -80,6 +81,55 @@ class TwoPhaseWindowDoneFixture(t.NamedTuple): ] +class BuildBarFixture(t.NamedTuple): + """Test fixture for the two-phase build bar renderer.""" + + test_id: str + windows_done: int + windows_created: int + total: int + expected: str + + +BUILD_BAR_FIXTURES: list[BuildBarFixture] = [ + BuildBarFixture( + test_id="none_created", + windows_done=0, + windows_created=0, + total=11, + expected="░░░░░░░░░░", + ), + BuildBarFixture( + test_id="created_before_done", + windows_done=0, + windows_created=5, + total=11, + expected="▓▓▓▓▓░░░░░", + ), + BuildBarFixture( + test_id="done_and_created", + windows_done=1, + windows_created=5, + total=11, + expected="█▓▓▓▓░░░░░", + ), + BuildBarFixture( + test_id="done_does_not_complete_early", + windows_done=10, + windows_created=11, + total=11, + expected="█████████▓", + ), + BuildBarFixture( + test_id="all_done", + windows_done=11, + windows_created=11, + total=11, + expected="██████████", + ), +] + + @pytest.mark.parametrize( list(SpinnerEnablementFixture._fields), SPINNER_ENABLEMENT_FIXTURES, @@ -624,6 +674,22 @@ def test_render_bar_width_constant() -> None: assert len(bar) == BAR_WIDTH +@pytest.mark.parametrize( + list(BuildBarFixture._fields), + BUILD_BAR_FIXTURES, + ids=[f.test_id for f in BUILD_BAR_FIXTURES], +) +def test_render_build_bar( + test_id: str, + windows_done: int, + windows_created: int, + total: int, + expected: str, +) -> None: + """render_build_bar shows done, created, and empty segments.""" + assert render_build_bar(windows_done, windows_created, total) == expected + + # BuildTree new token tests @@ -832,11 +898,45 @@ def test_spinner_default_progress_tracks_started_window_in_phase_one() -> None: ): spinner.on_build_event(event) - assert "2/3 win" in spinner.message - assert "0/3" not in spinner.message + assert "0/2 win · pane 0/1" in spinner.message + assert render_build_bar(0, 2, 3) in spinner.message assert spinner.message.endswith("w2") +def test_spinner_default_bar_tracks_created_windows_before_completion() -> None: + """The default bar shows created windows before any window_done events.""" + stream = io.StringIO() + spinner = Spinner( + message="Building...", + color_mode=ColorMode.NEVER, + stream=stream, + progress_format="default", + ) + spinner.on_build_event( + { + "event": "session_created", + "name": "study", + "window_total": 11, + "session_pane_total": 44, + } + ) + for index in range(1, 6): + spinner.on_build_event( + { + "event": "window_started", + "name": f"w{index}", + "pane_total": 4, + } + ) + spinner.on_build_event( + {"event": "pane_creating", "pane_num": 4, "pane_total": 4} + ) + + assert "0/5 win · pane 4/4" in spinner.message + assert "▓" in spinner.message + assert render_bar(0, 11) not in spinner.message + + def test_spinner_default_progress_tracks_completed_window_in_phase_two() -> None: """The default preset changes current window when phase two completes it.""" stream = io.StringIO() @@ -862,9 +962,7 @@ def test_spinner_default_progress_tracks_completed_window_in_phase_two() -> None spinner.on_build_event(event) assert "1/3 win" in spinner.message - assert "3/3 win" not in spinner.message - assert render_bar(1, 3) in spinner.message - assert render_bar(2, 3) not in spinner.message + assert render_build_bar(1, 3, 3) in spinner.message assert spinner.message.endswith("w1") From 95d0e0231e606182f97d8b6503ba4e8cfa34fcaa Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:47:47 -0500 Subject: [PATCH 13/26] builder(fix[sync]): Skip exited panes why: Panes can legitimately exit during startup commands. The temporary synchronize-panes restore path should not fail loads when a recorded pane is already gone. what: - Skip missing pane targets during sync option handling - Keep non-missing window and pane sync restoration unchanged - Add regression coverage for a pane that exits during command dispatch --- src/tmuxp/workspace/builder.py | 47 +++++++++++++++++++-------- tests/workspace/test_builder.py | 57 +++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 13 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 044d5be4ec..634afe66f4 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -10,7 +10,7 @@ from libtmux._internal.query_list import ObjectDoesNotExist from libtmux.constants import OptionScope -from libtmux.exc import LibTmuxException +from libtmux.exc import LibTmuxException, OptionError from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -1104,23 +1104,44 @@ def _dispatch_window_commands( """ sync_option = "synchronize-panes" window_sync = window.show_option(sync_option, scope=OptionScope.Window) - pane_sync = [ - (entry.pane, entry.pane.show_option(sync_option, scope=OptionScope.Pane)) - for entry in entries - ] + + def target_missing(error: OptionError) -> bool: + message = str(error) + return "no such pane" in message or "no such window" in message + + def show_pane_sync(pane: Pane) -> t.Any | None: + try: + return pane.show_option(sync_option, scope=OptionScope.Pane) + except OptionError as e: + if not target_missing(e): + raise + return None + + def set_pane_sync_off(pane: Pane) -> None: + try: + pane.set_option(sync_option, "off", scope=OptionScope.Pane) + except OptionError as e: + if not target_missing(e): + raise + + pane_sync = [(entry.pane, show_pane_sync(entry.pane)) for entry in entries] def restore_sync(target: Window | Pane, value: t.Any | None) -> None: - if value is None: - target.unset_option(sync_option) - return - target.set_option( - sync_option, - "on" if value is True or value == "on" else "off", - ) + try: + if value is None: + target.unset_option(sync_option) + return + target.set_option( + sync_option, + "on" if value is True or value == "on" else "off", + ) + except OptionError as e: + if not target_missing(e): + raise window.set_option(sync_option, "off", scope=OptionScope.Window) for pane, _value in pane_sync: - pane.set_option(sync_option, "off", scope=OptionScope.Pane) + set_pane_sync_off(pane) try: if "layout" in window_config: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 9f405c4a52..abd144ed2a 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -414,6 +414,35 @@ class SynchronizePanesFixture(t.NamedTuple): ] +class SynchronizePanesExitFixture(t.NamedTuple): + """Synchronize-panes fixture with a pane that exits during setup.""" + + test_id: str + yaml: str + + +SYNCHRONIZE_PANES_EXIT_FIXTURES: list[SynchronizePanesExitFixture] = [ + SynchronizePanesExitFixture( + test_id="pane_exit", + yaml=textwrap.dedent( + """\ +session_name: sync-exit-test +windows: +- window_name: sync-exit + options: + synchronize-panes: on + panes: + - shell_command: + - cmd: printf tmuxp-survivor-marker; sleep 1 + - shell_command: + - cmd: exit + sleep_after: 0.3 +""", + ), + ), +] + + @pytest.mark.parametrize( list(SynchronizePanesFixture._fields), SYNCHRONIZE_PANES_FIXTURES, @@ -487,6 +516,34 @@ def commands_stayed_per_pane() -> bool: ) +@pytest.mark.parametrize( + list(SynchronizePanesExitFixture._fields), + SYNCHRONIZE_PANES_EXIT_FIXTURES, + ids=[t.test_id for t in SYNCHRONIZE_PANES_EXIT_FIXTURES], +) +def test_synchronize_panes_ignores_exited_targets( + tmp_path: pathlib.Path, + session: Session, + test_id: str, + yaml: str, +) -> None: + """Exiting startup panes do not break temporary sync restoration.""" + yaml_workspace = tmp_path / f"{test_id}.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + window = session.active_window + + def pane_count() -> bool: + return len(window.panes) == 1 + + assert retry_until(pane_count, 5, interval=0.1) + + def test_window_shell( session: Session, ) -> None: From 41b5cac844b82b7f113050658eb3ca368d006625 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 04:59:52 -0500 Subject: [PATCH 14/26] Load(fix[before_script]): Preserve CLI streaming why: before_script commands should run like real terminal commands. Capturing their output through tmuxp pipes makes TTY-aware tools switch to non-interactive output and can flood the load display. Workspaces with a before_script should also avoid rendering a transient progress frame before that script takes over the terminal. what: - Let run_before_script inherit stdio when no observer is supplied - Delay spinner startup until after before_script completes - Keep before_script output out of the spinner panel path - Add regressions for inherited stdio and spinner startup ordering --- src/tmuxp/cli/load.py | 54 +++++++++++++++------------- src/tmuxp/util.py | 34 +++++++++++++----- tests/cli/test_load.py | 80 ++++++++++++++++++++++++++++++++++++++++++ tests/test_util.py | 57 +++++++++++++++++++++++------- 4 files changed, 179 insertions(+), 46 deletions(-) diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index 375cdb1b22..c8fea40442 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -568,7 +568,7 @@ def load_workspace( # propagate workspace inheritance (e.g. session -> window, window -> pane) expanded_workspace = loader.trickle(expanded_workspace) - t = Server( # create tmux server object + tmux_server = Server( # create tmux server object socket_name=socket_name, socket_path=socket_path, config_file=tmux_config_file, @@ -582,7 +582,7 @@ def load_workspace( builder = WorkspaceBuilder( session_config=expanded_workspace, plugins=load_plugins(expanded_workspace, colors=cli_colors), - server=t, + server=tmux_server, ) except exc.EmptyWorkspaceException: logger.warning( @@ -669,6 +669,7 @@ def load_workspace( workspace_path=_private_path, ) _success_emitted = False + _has_before_script = "before_script" in expanded_workspace def _emit_success() -> None: nonlocal _success_emitted @@ -677,29 +678,32 @@ def _emit_success() -> None: _success_emitted = True _spinner.success() - with ( - _silence_stream_handlers(), - _spinner as spinner, - ): - builder.on_build_event = spinner.on_build_event - _resolved_panel = ( - _panel_lines if _panel_lines is not None else DEFAULT_OUTPUT_LINES - ) - 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, - ) - if result is not None: - _emit_success() - return result + def _on_build_event(event: dict[str, t.Any]) -> None: + spinner.on_build_event(event) + if event.get("event") == "before_script_done": + spinner.start() + + spinner = _spinner + with _silence_stream_handlers(): + if not _has_before_script: + spinner.start() + builder.on_build_event = _on_build_event + try: + 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 + finally: + spinner.stop() def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentParser: diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 152b1f6c06..738ca604f5 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -36,8 +36,26 @@ def run_before_script( """ script_cmd = shlex.split(str(script_file)) + if on_line is None: + try: + stream_proc: subprocess.Popen[bytes] = subprocess.Popen(script_cmd, cwd=cwd) + except FileNotFoundError as e: + raise exc.BeforeLoadScriptNotExists( + e, + os.path.abspath(script_file), # NOQA: PTH100 + ) from e + + stream_return_code = stream_proc.wait() + if stream_return_code != 0: + raise exc.BeforeLoadScriptError( + stream_return_code, + os.path.abspath(script_file), # NOQA: PTH100 + None, + ) + return stream_return_code + try: - proc = subprocess.Popen( + captured_proc: subprocess.Popen[str] = subprocess.Popen( script_cmd, cwd=cwd, stdout=subprocess.PIPE, @@ -51,8 +69,8 @@ def run_before_script( os.path.abspath(script_file), # NOQA: PTH100 ) from e - out_buffer = [] - err_buffer = [] + out_buffer: list[str] = [] + err_buffer: list[str] = [] # While process is running, read lines from stdout/stderr # and write them to this process's stdout/stderr if isatty @@ -62,13 +80,13 @@ def run_before_script( # You can do a simple loop reading in real-time: while True: # Use .poll() to check if the child has exited - return_code = proc.poll() + captured_return_code = captured_proc.poll() # Read one line from stdout, if available - line_out = proc.stdout.readline() if proc.stdout else "" + line_out = captured_proc.stdout.readline() if captured_proc.stdout else "" # Read one line from stderr, if available - line_err = proc.stderr.readline() if proc.stderr else "" + line_err = captured_proc.stderr.readline() if captured_proc.stderr else "" if line_out and line_out.strip(): out_buffer.append(line_out) @@ -87,11 +105,11 @@ def run_before_script( sys.stderr.flush() # If no more data from pipes and process ended, break - if not line_out and not line_err and return_code is not None: + if not line_out and not line_err and captured_return_code is not None: break # At this point, the process has finished - return_code = proc.wait() + return_code = captured_proc.wait() if return_code != 0: # Join captured stderr lines for your exception diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index ec045dcf3c..a5df3b9615 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -887,6 +887,86 @@ def test_load_workspace_env_progress_disabled( assert session.name == "sample workspace" +def test_load_workspace_pauses_spinner_for_before_script( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """before_script workspaces do not render progress before the script exits.""" + import yaml + + from tmuxp.cli._colors import ColorMode, Colors + + calls: list[str] = [] + captured_script_callback: list[t.Callable[[str], None] | None] = [] + + class FakeSpinner: + def __init__(self, *_args: t.Any, **_kwargs: t.Any) -> None: + pass + + def __enter__(self) -> FakeSpinner: + self.start() + return self + + def __exit__(self, *_args: object) -> t.Literal[False]: + self.stop() + return False + + def start(self) -> None: + calls.append("start") + + def stop(self) -> None: + calls.append("stop") + + def success(self) -> None: + calls.append("success") + + def add_output_line(self, _line: str) -> None: + calls.append("add_output_line") + + def on_build_event(self, event: dict[str, t.Any]) -> None: + calls.append(str(event["event"])) + + def fake_dispatch_build( + builder: WorkspaceBuilder, + *_args: t.Any, + **_kwargs: t.Any, + ) -> None: + captured_script_callback.append(builder.on_script_output) + assert builder.on_before_script is None + assert builder.on_build_event is not None + + builder.on_build_event({"event": "before_script_started"}) + builder.on_build_event({"event": "before_script_done"}) + + config = { + "session_name": "before-script-progress", + "before_script": "echo before", + "windows": [{"window_name": "main"}], + } + config_file = tmp_path / "before-script.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.delenv("TMUX", raising=False) + monkeypatch.setattr("tmuxp.cli.load.Spinner", FakeSpinner) + monkeypatch.setattr("tmuxp.cli.load._dispatch_build", fake_dispatch_build) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + ) + + assert result is None + assert captured_script_callback == [None] + assert calls == [ + "before_script_started", + "before_script_done", + "start", + "stop", + ] + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) diff --git a/tests/test_util.py b/tests/test_util.py index 098c8c212b..7677f2ce61 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -5,6 +5,7 @@ import logging import os import pathlib +import subprocess import sys import typing as t @@ -41,6 +42,40 @@ def test_run_before_script_raise_BeforeLoadScriptError_if_retcode() -> None: run_before_script(script_file) +def test_run_before_script_inherits_stdio_without_observer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """run_before_script() leaves stdio attached unless an observer is needed.""" + captured_kwargs: dict[str, t.Any] = {} + + class EmptyStream: + def readline(self) -> str: + return "" + + class FakeProcess: + returncode = 0 + stdout = EmptyStream() + stderr = EmptyStream() + + def poll(self) -> int: + return self.returncode + + def wait(self) -> int: + return self.returncode + + def fake_popen(_args: list[str], **kwargs: t.Any) -> FakeProcess: + captured_kwargs.update(kwargs) + return FakeProcess() + + monkeypatch.setattr(subprocess, "Popen", fake_popen) + + returncode = run_before_script("echo ok") + + assert returncode == 0 + assert captured_kwargs.get("stdout") is None + assert captured_kwargs.get("stderr") is None + + @pytest.fixture def temp_script(tmp_path: pathlib.Path) -> pathlib.Path: """Fixture of an example script that prints "Hello, world!".""" @@ -56,7 +91,7 @@ def temp_script(tmp_path: pathlib.Path) -> pathlib.Path: class TTYTestFixture(t.NamedTuple): - """Test fixture for isatty behavior verification.""" + """Test fixture for inherited subprocess stdio verification.""" test_id: str isatty_value: bool @@ -70,9 +105,9 @@ class TTYTestFixture(t.NamedTuple): expected_output="Hello, World!", ), TTYTestFixture( - test_id="tty_disabled_suppresses_output", + test_id="tty_disabled_still_streams_output", isatty_value=False, - expected_output="", + expected_output="Hello, World!", ), ] @@ -82,32 +117,28 @@ class TTYTestFixture(t.NamedTuple): TTY_TEST_FIXTURES, ids=[test.test_id for test in TTY_TEST_FIXTURES], ) -def test_run_before_script_isatty( +def test_run_before_script_inherits_stdio_regardless_of_parent_isatty( temp_script: pathlib.Path, monkeypatch: pytest.MonkeyPatch, - capsys: pytest.CaptureFixture[str], + capfd: pytest.CaptureFixture[str], test_id: str, isatty_value: bool, expected_output: str, ) -> None: - """Verify behavior of ``isatty()``, which we mock in `run_before_script()`.""" - # Mock sys.stdout.isatty() to return the desired value. + """run_before_script() lets the child process own its stdout behavior.""" monkeypatch.setattr(sys.stdout, "isatty", lambda: isatty_value) - # Run the script. returncode = run_before_script(temp_script) - # Assert that the script ran successfully. assert returncode == 0 - out, _err = capsys.readouterr() + out, _err = capfd.readouterr() - # In TTY mode, we expect the output; in non-TTY mode, we expect it to be suppressed. assert expected_output in out def test_return_stdout_if_ok( - capsys: pytest.CaptureFixture[str], + capfd: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch, ) -> None: """run_before_script() returns stdout if script succeeds.""" @@ -118,7 +149,7 @@ def test_return_stdout_if_ok( script_file = FIXTURE_PATH / "script_complete.sh" run_before_script(script_file) - out, _err = capsys.readouterr() + out, _err = capfd.readouterr() assert "hello" in out From 3de7951acb97447d190e8da4151ce38ef4d73b26 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:11:00 -0500 Subject: [PATCH 15/26] builder(fix[ordering]): Preserve window order why: Later windows may depend on commands from earlier windows, including created directories used as start_directory values. Creating every window before dispatching any commands broke that existing config-order behavior. what: - Build and finish each window before creating the next one - Keep the pane readiness barrier inside the per-window boundary - Cover start_directory dependencies and plugin hook order --- src/tmuxp/workspace/builder.py | 34 +++++++----------- tests/workspace/test_builder.py | 64 +++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 634afe66f4..b25be737ae 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -635,30 +635,21 @@ 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) - # Phase one — structure: create every window and its panes first, so all - # of their shells start initializing concurrently. Layout and pane - # commands come after the barrier. - window_layout: list[tuple[Window, dict[str, t.Any], list[_PaneEntry]]] = [] - for window, window_config in self.iter_create_windows(session, append): + # Build each window in config order. Pane shells still warm up together + # inside a window, but earlier window commands run before later windows + # are created. + for window_index, (window, window_config) in enumerate( + self.iter_create_windows(session, append), + start=1, + ): assert isinstance(window, Window) for plugin in self.plugins: plugin.on_window_create(window) entries = self._create_window_panes(window, window_config) - window_layout.append((window, window_config, entries)) - - # Barrier — wait once for every default-shell pane to draw its prompt. - # The shells warmed up in parallel during phase one, so one shared wait - # covers them all. - self._wait_for_workspace_ready(window_layout) + self._wait_for_workspace_ready([(window, window_config, entries)]) - # Phase two — finish: lay each window out (a single resize with all shells - # ready, so no zsh ``%`` marker) and send its commands. - for window_index, (window, window_config, entries) in enumerate( - window_layout, - start=1, - ): focus_pane = None for pane, pane_config in self._dispatch_window_commands( window, @@ -1009,9 +1000,9 @@ def _wait_for_workspace_ready( self, window_layout: list[tuple[Window, dict[str, t.Any], list[_PaneEntry]]], ) -> dict[str, bool]: - """Wait for every default-shell pane across the workspace, concurrently. + """Wait for default-shell panes in the provided windows, concurrently. - Collects the panes that draw an interactive prompt from all windows and + Collects the panes that draw an interactive prompt from each window and waits for them in a single shared barrier (:func:`_wait_for_panes_ready`). Parameters @@ -1201,9 +1192,8 @@ def iter_create_panes( Run ``shell_command`` with ``$ tmux send-keys``. Creates the window's panes, waits for their shells concurrently, then - lays out and sends commands. :meth:`build` drives these phases across the - whole workspace for one shared wait; this per-window form is kept for - direct callers. + lays out and sends commands. :meth:`build` uses the same per-window + boundary so config-order command effects are visible to later windows. Parameters ---------- diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index abd144ed2a..0ecf2031ee 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -6,6 +6,7 @@ import logging import os import pathlib +import shlex import textwrap import time import typing as t @@ -728,6 +729,45 @@ def f(path: str, p: Pane) -> bool: assert retry_until(f_) +def test_build_dispatches_window_commands_before_later_start_directory( + session: Session, + tmp_path: pathlib.Path, +) -> None: + """Earlier window commands can prepare a later window's start_directory.""" + late_dir = tmp_path / "late-dir" + yaml_config = textwrap.dedent( + f"""\ +session_name: window-command-order +windows: +- window_name: bootstrap + panes: + - shell_command: + - cmd: mkdir -p {shlex.quote(str(late_dir))} + sleep_after: 0.2 +- window_name: dependent + start_directory: {late_dir!s} + panes: + - shell_command: [] +""", + ) + workspace = ConfigReader._load(fmt="yaml", content=yaml_config) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + dependent = session.windows.get(window_name="dependent") + assert dependent is not None + pane = dependent.active_pane + assert pane is not None + + def has_late_dir() -> bool: + return pane.pane_current_path == str(late_dir) + + assert retry_until(has_late_dir) + + def test_start_directory_relative(session: Session, tmp_path: pathlib.Path) -> None: """Test workspace builder setting start_directory relative to project file. @@ -1964,12 +2004,12 @@ def recording_barrier( assert events[:2] == ["refresh", "split"] -def test_build_waits_for_whole_workspace_in_one_barrier( +def test_build_waits_for_each_window_before_dispatch( tmp_path: pathlib.Path, server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """build() creates every pane up front, then waits for all in one barrier.""" + """build() waits per window so earlier commands run before later windows.""" barrier_sizes: list[int] = [] original = builder_module._wait_for_panes_ready @@ -2008,8 +2048,7 @@ def recording_barrier( builder = WorkspaceBuilder(session_config=workspace, server=server) builder.build() - # All four panes (across both windows) are awaited together, not per window. - assert barrier_sizes == [4] + assert barrier_sizes == [2, 2] def test_layout_runs_after_readiness_barrier( @@ -2017,11 +2056,10 @@ def test_layout_runs_after_readiness_barrier( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """No window is laid out until the shared readiness barrier has completed. + """Each window is laid out after that window's readiness barrier. - This is the issue #365 safety invariant under the parallel structure: a - pane must draw its prompt before it is resized, so every ``select_layout`` - must follow the barrier. + This is the issue #365 safety invariant: a pane must draw its prompt before + it is resized, so every ``select_layout`` must follow a barrier. """ events: list[str] = [] original_barrier = builder_module._wait_for_panes_ready @@ -2069,9 +2107,7 @@ def traced_layout(self: Window, layout: str | None = None) -> Window: assert "barrier" in events assert "layout" in events - # The single workspace barrier precedes every layout pass. - assert events.count("barrier") == 1 - assert events.index("barrier") < events.index("layout") + assert events == ["barrier", "layout", "barrier", "layout"] class _HookOrderRecorder(TmuxpPlugin): @@ -2120,7 +2156,7 @@ class PluginHookOrderFixture(t.NamedTuple): ], ), PluginHookOrderFixture( - test_id="all_creates_before_any_finish", + test_id="interleaves_create_and_finish", yaml=textwrap.dedent( """\ session_name: hook-order @@ -2136,8 +2172,8 @@ class PluginHookOrderFixture(t.NamedTuple): expected_order=[ "before_workspace_builder", "on_window_create:one", - "on_window_create:two", "after_window_finished:one", + "on_window_create:two", "after_window_finished:two", ], ), @@ -2156,7 +2192,7 @@ def test_plugin_hook_order( yaml: str, expected_order: list[str], ) -> None: - """on_window_create fires for all windows before any after_window_finished.""" + """Per-window create and finish hooks follow config order.""" calls: list[str] = [] yaml_workspace = tmp_path / "hook_order.yaml" From 01698497c04f8b2b28e16056c9fc86a0eec3182d Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 05:22:20 -0500 Subject: [PATCH 16/26] Load(fix[progress]): Honor panel opt-in why: Restoring native before_script streaming made progress-lines a no-op. Explicit panel requests should still capture script output, while default loads should leave TTY-aware scripts attached to the terminal. what: - Capture before_script output only for explicit nonzero progress-lines - Keep default and zero-line modes on native script output - Update CLI docs, env docs, changelog, and load tests --- CHANGES | 2 +- docs/cli/load.md | 8 +- docs/configuration/environmental-variables.md | 6 +- src/tmuxp/cli/load.py | 20 ++- tests/cli/test_load.py | 133 ++++++++++++++++++ 5 files changed, 154 insertions(+), 15 deletions(-) diff --git a/CHANGES b/CHANGES index b198181ac6..c5db92132b 100644 --- a/CHANGES +++ b/CHANGES @@ -134,7 +134,7 @@ tmuxp 1.67.0 makes {ref}`tmuxp-load` visibly track the workspace build it is per The {ref}`tmuxp-load` command now shows an animated progress display while it builds a session. Built-in formats cover terse, window-focused, pane-focused, and verbose views, while `--progress-format` and `TMUXP_PROGRESS_FORMAT` allow a custom display. -`--progress-lines` and `TMUXP_PROGRESS_LINES` control how much `before_script` output appears in the panel, and `--no-progress` or `TMUXP_PROGRESS=0` restores quiet output. +`--progress-lines` and `TMUXP_PROGRESS_LINES` capture `before_script` output in the panel when requested, and `--no-progress` or `TMUXP_PROGRESS=0` restores quiet output. ## tmuxp 1.66.0 (2026-03-08) diff --git a/docs/cli/load.md b/docs/cli/load.md index bf1e46ced9..41b7aba2c9 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -218,21 +218,21 @@ $ tmuxp load --progress-format "{session} {bar} {overall_percent}%" myproject ### Panel lines -The spinner shows script output in a panel below the spinner line. Control the panel height with `--progress-lines`: +By default, `before_script` runs with its normal terminal output before the spinner appears. Use `--progress-lines` to capture that output in a panel below the spinner line: -Hide the panel entirely (script output goes to stdout): +Keep native script output and hide the panel: ```console $ tmuxp load --progress-lines 0 myproject ``` -Show unlimited lines (capped to terminal height): +Capture unlimited lines (capped to terminal height): ```console $ tmuxp load --progress-lines -1 myproject ``` -Set a custom height (default is 3): +Capture five lines: ```console $ tmuxp load --progress-lines 5 myproject diff --git a/docs/configuration/environmental-variables.md b/docs/configuration/environmental-variables.md index 73c0d1e373..52782e5b10 100644 --- a/docs/configuration/environmental-variables.md +++ b/docs/configuration/environmental-variables.md @@ -60,15 +60,15 @@ Equivalent to the `--progress-format` CLI flag. ## `TMUXP_PROGRESS_LINES` -Number of script-output lines shown in the spinner panel. Defaults to `3`. +Capture `before_script` output in the spinner panel with this many lines. By default, scripts keep their normal terminal output. -Set to `0` to hide the panel entirely (script output goes to stdout): +Set to `0` to keep native script output and hide the panel: ```console $ TMUXP_PROGRESS_LINES=0 tmuxp load myproject ``` -Set to `-1` for unlimited lines (capped to terminal height): +Set to `-1` to capture unlimited lines (capped to terminal height): ```console $ TMUXP_PROGRESS_LINES=-1 tmuxp load myproject diff --git a/src/tmuxp/cli/load.py b/src/tmuxp/cli/load.py index c8fea40442..68aac5f5bb 100644 --- a/src/tmuxp/cli/load.py +++ b/src/tmuxp/cli/load.py @@ -478,9 +478,9 @@ def load_workspace( progress_format : str, optional Spinner format preset name or custom format string with tokens. panel_lines : int, optional - Number of script-output lines shown in the spinner panel. - Defaults to the :class:`~tmuxp.cli._progress.Spinner` default (3). - Override via ``TMUXP_PROGRESS_LINES`` environment variable. + Nonzero values capture ``before_script`` output in the spinner panel. + By default, scripts keep their normal terminal output. Override via + ``TMUXP_PROGRESS_LINES`` environment variable. no_progress : bool Disable the progress spinner entirely. Default False. Also disabled when ``TMUXP_PROGRESS=0``. @@ -658,6 +658,7 @@ def load_workspace( else: _panel_lines_env_int = None _panel_lines = panel_lines if panel_lines is not None else _panel_lines_env_int + _panel_lines_explicit = panel_lines is not None or _panel_lines_env_int is not None _private_path = str(PrivatePath(workspace_file)) _spinner = Spinner( message=( @@ -670,6 +671,9 @@ def load_workspace( ) _success_emitted = False _has_before_script = "before_script" in expanded_workspace + _capture_script_output = ( + _has_before_script and _panel_lines_explicit and _panel_lines != 0 + ) def _emit_success() -> None: nonlocal _success_emitted @@ -680,12 +684,14 @@ def _emit_success() -> None: def _on_build_event(event: dict[str, t.Any]) -> None: spinner.on_build_event(event) - if event.get("event") == "before_script_done": + if event.get("event") == "before_script_done" and not _capture_script_output: spinner.start() spinner = _spinner with _silence_stream_handlers(): - if not _has_before_script: + if _capture_script_output: + builder.on_script_output = spinner.add_output_line + if not _has_before_script or _capture_script_output: spinner.start() builder.on_build_event = _on_build_event try: @@ -809,8 +815,8 @@ def create_load_subparser(parser: argparse.ArgumentParser) -> argparse.ArgumentP type=int, default=None, help=( - "Number of script-output lines shown in the spinner panel (default: 3). " - "0 hides the panel entirely (script output goes to stdout). " + "Capture before_script output in the spinner panel with N lines. " + "0 keeps native script output and hides the panel. " "-1 shows unlimited lines (capped to terminal height). " "Env: TMUXP_PROGRESS_LINES" ), diff --git a/tests/cli/test_load.py b/tests/cli/test_load.py index a5df3b9615..87977e4832 100644 --- a/tests/cli/test_load.py +++ b/tests/cli/test_load.py @@ -967,6 +967,139 @@ def fake_dispatch_build( ] +class ProgressLinesCaptureFixture(t.NamedTuple): + """Test fixture for explicit progress-lines capture mode.""" + + test_id: str + panel_lines: int | None + env_value: str | None + expected_capture: bool + + +PROGRESS_LINES_CAPTURE_FIXTURES: list[ProgressLinesCaptureFixture] = [ + ProgressLinesCaptureFixture( + test_id="cli_panel_lines", + panel_lines=5, + env_value=None, + expected_capture=True, + ), + ProgressLinesCaptureFixture( + test_id="env_panel_lines", + panel_lines=None, + env_value="4", + expected_capture=True, + ), + ProgressLinesCaptureFixture( + test_id="cli_zero_lines", + panel_lines=0, + env_value=None, + expected_capture=False, + ), + ProgressLinesCaptureFixture( + test_id="env_zero_lines", + panel_lines=None, + env_value="0", + expected_capture=False, + ), +] + + +@pytest.mark.parametrize( + list(ProgressLinesCaptureFixture._fields), + PROGRESS_LINES_CAPTURE_FIXTURES, + ids=[f.test_id for f in PROGRESS_LINES_CAPTURE_FIXTURES], +) +def test_load_workspace_handles_explicit_before_script_progress_lines( + server: Server, + tmp_path: pathlib.Path, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + panel_lines: int | None, + env_value: str | None, + expected_capture: bool, +) -> None: + """Explicit progress-lines controls before_script output capture.""" + import yaml + + from tmuxp.cli._colors import ColorMode, Colors + + calls: list[str] = [] + captured_script_callback: list[t.Callable[[str], None] | None] = [] + + class FakeSpinner: + def __init__(self, *_args: t.Any, **_kwargs: t.Any) -> None: + pass + + def start(self) -> None: + calls.append("start") + + def stop(self) -> None: + calls.append("stop") + + def success(self) -> None: + calls.append("success") + + def add_output_line(self, line: str) -> None: + calls.append(f"add_output_line:{line}") + + def on_build_event(self, event: dict[str, t.Any]) -> None: + calls.append(str(event["event"])) + + def fake_dispatch_build( + builder: WorkspaceBuilder, + *_args: t.Any, + **_kwargs: t.Any, + ) -> None: + captured_script_callback.append(builder.on_script_output) + assert builder.on_build_event is not None + + builder.on_build_event({"event": "before_script_started"}) + if builder.on_script_output is not None: + builder.on_script_output("before line") + builder.on_build_event({"event": "before_script_done"}) + + config = { + "session_name": f"before-script-progress-{test_id}", + "before_script": "echo before", + "windows": [{"window_name": "main"}], + } + config_file = tmp_path / f"{test_id}.yaml" + config_file.write_text(yaml.dump(config)) + + monkeypatch.delenv("TMUX", raising=False) + if env_value is None: + monkeypatch.delenv("TMUXP_PROGRESS_LINES", raising=False) + else: + monkeypatch.setenv("TMUXP_PROGRESS_LINES", env_value) + monkeypatch.setattr("tmuxp.cli.load.Spinner", FakeSpinner) + monkeypatch.setattr("tmuxp.cli.load._dispatch_build", fake_dispatch_build) + + result = load_workspace( + str(config_file), + socket_name=server.socket_name, + cli_colors=Colors(ColorMode.NEVER), + panel_lines=panel_lines, + ) + + assert result is None + assert (captured_script_callback[0] is not None) is expected_capture + if expected_capture: + assert calls == [ + "start", + "before_script_started", + "add_output_line:before line", + "before_script_done", + "stop", + ] + else: + assert calls == [ + "before_script_started", + "before_script_done", + "start", + "stop", + ] + + def test_load_masks_home_in_spinner_message(monkeypatch: pytest.MonkeyPatch) -> None: """Spinner message should mask home directory via PrivatePath.""" monkeypatch.setattr(pathlib.Path, "home", lambda: pathlib.Path("/home/testuser")) From d36ce18f98b561985d1500eda221605a9b6d989f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:41:03 -0500 Subject: [PATCH 17/26] docs(CHANGES) Faster loads and before_script TTY why: Record the load-time and before_script behavior changes shipping with the concurrent pane-readiness work, so the upcoming release notes tell users what they gain and which default changed. what: - Add "Faster workspace loads" under What's new - Note before_script now runs attached to the terminal by default, with panel capture behind --progress-lines --- CHANGES | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGES b/CHANGES index c5db92132b..b756848a5e 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,18 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### What's new + +#### Faster workspace loads (#1056) + +Loading a workspace is faster: {ref}`tmuxp-load` now prepares all of a window's panes at once and waits for their shells together, rather than one pane at a time. Workspaces with multiple panes per window — particularly with a slow interactive shell startup — open noticeably quicker, with no changes to your configuration. + +See {ref}`loading-workspaces` for an overview of how a load proceeds. + +#### `before_script` runs interactively by default (#1056) + +A workspace's `before_script` now runs attached to your terminal, so interactive prompts and TTY-aware tools behave normally. Pass `--progress-lines` (or set `TMUXP_PROGRESS_LINES`) to capture its output into the load progress panel instead. + ## tmuxp 1.71.0 (2026-06-27) tmuxp 1.71.0 bumps libtmux to 0.59.0, adding support for tmux 3.7. From ffeb907d22594d8d1bf8546220064fa929212a2a Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 16:54:59 -0500 Subject: [PATCH 18/26] builder(fix[ordering]): Preserve pane setup why: The pane-readiness branch created every pane in a window before any pane command ran. That regressed configs where an earlier pane command prepares a later pane's split-time start_directory. what: - Add a same-window start_directory regression test - Use sequential pane setup only when a later pane target directory is missing before split - Keep the concurrent per-window setup path for ordinary windows --- src/tmuxp/workspace/builder.py | 194 ++++++++++++++++++++++++++++++-- tests/workspace/test_builder.py | 59 ++++++++++ 2 files changed, 245 insertions(+), 8 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index b25be737ae..28f6b2e205 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -4,6 +4,7 @@ import logging import os +import pathlib import shutil import time import typing as t @@ -647,15 +648,22 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for plugin in self.plugins: plugin.on_window_create(window) - entries = self._create_window_panes(window, window_config) - self._wait_for_workspace_ready([(window, window_config, entries)]) - focus_pane = None - for pane, pane_config in self._dispatch_window_commands( - window, - window_config, - entries, - ): + if self._window_needs_sequential_pane_setup(window_config): + pane_iter = self._iter_create_panes_sequentially( + window, + window_config, + ) + else: + entries = self._create_window_panes(window, window_config) + self._wait_for_workspace_ready([(window, window_config, entries)]) + pane_iter = self._dispatch_window_commands( + window, + window_config, + entries, + ) + + for pane, pane_config in pane_iter: assert isinstance(pane, Pane) if pane_config.get("focus"): @@ -795,6 +803,52 @@ def iter_create_windows( yield window, window_config + def _window_needs_sequential_pane_setup( + self, + window_config: dict[str, t.Any], + ) -> bool: + """Return whether later pane splits depend on missing directories. + + Examples + -------- + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> builder._window_needs_sequential_pane_setup({ + ... 'panes': [ + ... {'shell_command': []}, + ... {'shell_command': [], 'start_directory': '/'}, + ... ], + ... }) + False + >>> builder._window_needs_sequential_pane_setup({ + ... 'panes': [ + ... {'shell_command': []}, + ... { + ... 'shell_command': [], + ... 'start_directory': '/__tmuxp_missing_start_directory__', + ... }, + ... ], + ... }) + True + """ + for pane_config in window_config["panes"][1:]: + start_directory = pane_config.get( + "start_directory", + window_config.get("start_directory"), + ) + if ( + start_directory is not None + and not pathlib.Path( + os.fspath(start_directory), + ) + .expanduser() + .is_dir() + ): + return True + return False + def _create_window_panes( self, window: Window, @@ -937,6 +991,126 @@ def get_pane_shell( return entries + def _iter_create_panes_sequentially( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> Iterator[t.Any]: + """Create, wait, and dispatch a window's panes one at a time. + + This compatibility path preserves configs where an earlier pane command + prepares a later pane's split-time ``start_directory``. + + Examples + -------- + >>> session = server.new_session('create-panes-sequentially') + >>> builder = WorkspaceBuilder( + ... session_config={'session_name': 'x', 'windows': []}, + ... server=server, + ... ) + >>> panes = list( + ... builder._iter_create_panes_sequentially( + ... session.active_window, + ... {'window_name': 'main', 'panes': [{'shell_command': []}]}, + ... ), + ... ) + >>> len(panes) + 1 + """ + assert isinstance(window, Window) + + pane_base_index = window.show_option("pane-base-index", global_=True) + assert pane_base_index is not None + + layout = window_config.get("layout") + pane = None + entries: list[_PaneEntry] = [] + + for pane_index, pane_config in enumerate( + window_config["panes"], + start=pane_base_index, + ): + if self.on_progress: + self.on_progress(f"Creating pane: {pane_index}") + if self.on_build_event: + self.on_build_event( + { + "event": "pane_creating", + "pane_num": pane_index - int(pane_base_index) + 1, + "pane_total": len(window_config["panes"]), + } + ) + + if pane_index == int(pane_base_index): + pane = window.active_pane + else: + + def get_pane_start_directory( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "start_directory" in pane_config: + return pane_config["start_directory"] + if "start_directory" in window_config: + return window_config["start_directory"] + return None + + def get_pane_shell( + pane_config: dict[str, str], + window_config: dict[str, str], + ) -> str | None: + if "shell" in pane_config: + return pane_config["shell"] + if "window_shell" in window_config: + return window_config["window_shell"] + return None + + environment = pane_config.get( + "environment", + window_config.get("environment"), + ) + + assert pane is not None + + split_kwargs: dict[str, t.Any] = { + "attach": True, + "start_directory": get_pane_start_directory( + pane_config=pane_config, + window_config=window_config, + ), + "shell": get_pane_shell( + pane_config=pane_config, + window_config=window_config, + ), + "environment": environment, + } + + pane = self._split_pane_reclaiming_space( + window, + pane, + split_kwargs, + layout, + entries, + ) + + assert isinstance(pane, Pane) + pane_log = TmuxpLoggerAdapter( + logger, + { + "tmux_session": window.session.name or "", + "tmux_window": window.name or "", + "tmux_pane": pane.pane_id or "", + }, + ) + pane_log.debug("pane created") + + pane_shell = pane_config.get("shell", window_config.get("window_shell")) + entry = _PaneEntry(pane=pane, config=pane_config, shell=pane_shell) + entries.append(entry) + + self._wait_for_workspace_ready([(window, window_config, [entry])]) + yield from self._dispatch_window_commands(window, window_config, [entry]) + def _split_pane_reclaiming_space( self, window: Window, @@ -1224,6 +1398,10 @@ def iter_create_panes( >>> len(panes) 1 """ + if self._window_needs_sequential_pane_setup(window_config): + yield from self._iter_create_panes_sequentially(window, window_config) + return + entries = self._create_window_panes(window, window_config) _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) yield from self._dispatch_window_commands(window, window_config, entries) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 0ecf2031ee..f8d256a054 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -768,6 +768,65 @@ def has_late_dir() -> bool: assert retry_until(has_late_dir) +class SameWindowStartDirectoryFixture(t.NamedTuple): + """Same-window start_directory dependency fixture.""" + + test_id: str + later_pane_yaml: str + + +SAME_WINDOW_START_DIRECTORY_FIXTURES: list[SameWindowStartDirectoryFixture] = [ + SameWindowStartDirectoryFixture( + test_id="pane_start_directory", + later_pane_yaml=" - shell_command: []\n start_directory: {late_dir!s}\n", + ), +] + + +@pytest.mark.parametrize( + list(SameWindowStartDirectoryFixture._fields), + SAME_WINDOW_START_DIRECTORY_FIXTURES, + ids=[t.test_id for t in SAME_WINDOW_START_DIRECTORY_FIXTURES], +) +def test_build_dispatches_same_window_commands_before_later_start_directory( + session: Session, + tmp_path: pathlib.Path, + test_id: str, + later_pane_yaml: str, +) -> None: + """Earlier pane commands can prepare a later pane's start_directory.""" + late_dir = tmp_path / test_id / "late-dir" + yaml_config = textwrap.dedent( + f"""\ +session_name: same-window-command-order +windows: +- window_name: dependent + panes: + - shell_command: + - cmd: mkdir -p {shlex.quote(str(late_dir))} + sleep_after: 0.2 +{later_pane_yaml.format(late_dir=late_dir)} +""", + ) + workspace = ConfigReader._load(fmt="yaml", content=yaml_config) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + dependent = session.windows.get(window_name="dependent") + assert dependent is not None + panes = dependent.panes + assert len(panes) == 2 + pane = panes[1] + + def has_late_dir() -> bool: + return pane.pane_current_path == str(late_dir) + + assert retry_until(has_late_dir) + + def test_start_directory_relative(session: Session, tmp_path: pathlib.Path) -> None: """Test workspace builder setting start_directory relative to project file. From e6ad88dd8b9b0de062b87d95fb7cb69765e66386 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 17:01:45 -0500 Subject: [PATCH 19/26] builder(docs[ordering]): Clarify build order why: Follow-up fixes restored per-window ordering and added a dependency fallback, so branch prose that described a global phase model was stale. what: - Clarify build hook/docstring wording around per-window order - Update loading docs and changelog to describe dependency-aware pane setup - Trim stale phase wording from progress test docstrings --- CHANGES | 2 +- docs/topics/loading-workspaces.md | 22 +++++++++++----------- src/tmuxp/workspace/builder.py | 11 +++++------ tests/cli/test_progress.py | 2 +- tests/workspace/test_progress.py | 2 +- 5 files changed, 19 insertions(+), 20 deletions(-) diff --git a/CHANGES b/CHANGES index b756848a5e..7a6d54bc02 100644 --- a/CHANGES +++ b/CHANGES @@ -48,7 +48,7 @@ _Notes on the upcoming release will go here._ #### Faster workspace loads (#1056) -Loading a workspace is faster: {ref}`tmuxp-load` now prepares all of a window's panes at once and waits for their shells together, rather than one pane at a time. Workspaces with multiple panes per window — particularly with a slow interactive shell startup — open noticeably quicker, with no changes to your configuration. +Loading a workspace is faster: {ref}`tmuxp-load` now prepares panes in a window together when config-order dependencies allow it, rather than waiting on every pane one at a time. Workspaces with multiple panes per window — particularly with a slow interactive shell startup — open noticeably quicker, with no changes to your configuration. See {ref}`loading-workspaces` for an overview of how a load proceeds. diff --git a/docs/topics/loading-workspaces.md b/docs/topics/loading-workspaces.md index 1a03682208..da7f37a8b7 100644 --- a/docs/topics/loading-workspaces.md +++ b/docs/topics/loading-workspaces.md @@ -8,14 +8,14 @@ applying layouts and sending commands. ## What happens during load -tmuxp loads a workspace in two broad phases. +tmuxp builds each window in config order. -First, tmuxp creates the session structure. It creates each configured window -and its panes so the panes can start their shells in parallel. +For each window, tmuxp creates the window and prepares its panes. When later +pane `start_directory` values already exist, panes can start their shells +together and tmuxp waits for them together. -Then, tmuxp waits for the panes to be ready. Once the shells have drawn their -prompts, tmuxp finishes each window by applying layout, sending configured -commands, running window-level configuration, and firing +Once the panes are ready, tmuxp applies layout, sends configured commands, +runs window-level configuration, and fires {meth}`~tmuxp.plugin.TmuxpPlugin.after_window_finished` plugin hooks. This means {meth}`~tmuxp.plugin.TmuxpPlugin.on_window_create` runs while the @@ -28,11 +28,11 @@ window has been laid out and configured. The default progress line reports the same build in a compact form: ```text -Loading workspace: study ▓▓░░░░░░░░ 0/2 win · pane 3/4 learning-asyncio +Loading workspace: study ▓░░░░░░░░░ 0/1 win · pane 3/4 learning-asyncio ``` The fraction before `win` is finished windows over windows created so far. In -the example above, two windows have been created and neither has finished yet. +the example above, one window has been created and has not finished yet. The `pane` fraction describes the current window's pane creation progress. When a new window starts, this can briefly show `pane 0/N` before tmuxp creates the @@ -47,12 +47,12 @@ The progress bar shows the whole workspace: Once tmuxp enters the finish phase, the finished-window count rises: ```text -Loading workspace: study █▓░░░░░░░░ 1/2 win learning-dsa +Loading workspace: study █░░░░░░░░░ 1/1 win learning-asyncio ``` ## Why tmuxp loads this way -Creating all panes before finishing windows lets shell startup happen in -parallel. The later finish phase can then apply layouts and commands after the +Preparing a window's panes before layout lets shell startup happen in parallel +when config-order dependencies allow it. Layout and commands then run after the panes are ready, which avoids resizing panes while shells are still drawing their first prompts. diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 28f6b2e205..18d0bf4038 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -508,10 +508,9 @@ def build(self, session: Session | None = None, append: bool = False) -> None: Without ``session``, it will use :class:`libmtux.Server` at ``self.server`` passed in on initialization to create a new Session object. - Plugin hooks fire by phase: ``on_window_create`` runs for every window - as it is created, before any ``after_window_finished``. Each window's - ``after_window_finished`` still runs once that window has been laid out - and its pane commands dispatched. + Plugin hooks fire in config order. For each window, ``on_window_create`` + runs after creation; ``after_window_finished`` runs after layout, + configuration, and pane command dispatch. Parameters ---------- @@ -1182,7 +1181,7 @@ def _wait_for_workspace_ready( Parameters ---------- window_layout : list of tuple - ``(window, window_config, entries)`` triples from phase one + ``(window, window_config, entries)`` triples for created panes Returns ------- @@ -1235,7 +1234,7 @@ def _dispatch_window_commands( window_config : dict config section for window entries : list of :class:`_PaneEntry` - panes created for this window in phase one + panes created for this window Yields ------ diff --git a/tests/cli/test_progress.py b/tests/cli/test_progress.py index 868c87f483..8f27f07dd2 100644 --- a/tests/cli/test_progress.py +++ b/tests/cli/test_progress.py @@ -938,7 +938,7 @@ def test_spinner_default_bar_tracks_created_windows_before_completion() -> None: def test_spinner_default_progress_tracks_completed_window_in_phase_two() -> None: - """The default preset changes current window when phase two completes it.""" + """The default preset changes current window when it completes.""" stream = io.StringIO() spinner = Spinner( message="Building...", diff --git a/tests/workspace/test_progress.py b/tests/workspace/test_progress.py index 6b4707ce94..bce5849a0e 100644 --- a/tests/workspace/test_progress.py +++ b/tests/workspace/test_progress.py @@ -120,7 +120,7 @@ def test_builder_window_done_events_include_window_identity( server: Server, monkeypatch: pytest.MonkeyPatch, ) -> None: - """window_done events identify the window completed in phase two.""" + """window_done events identify the completed window.""" monkeypatch.delenv("TMUX", raising=False) session_config = { From 4b08b1611226dac6aa94f3b8409822b9c99cadf6 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 20:52:28 -0500 Subject: [PATCH 20/26] progress(docs[tokens]): Fix build_progress cell why: The token-lifecycle table listed the pre-session_created value of {build_progress} as empty, but it is computed with no guard and reads "0/0 win" from the first render. what: - Correct the pre-session_created cell for {build_progress} to "0/0 win" --- src/tmuxp/cli/_progress.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py index 37b69d6d9e..bf418aa64c 100644 --- a/src/tmuxp/cli/_progress.py +++ b/src/tmuxp/cli/_progress.py @@ -380,7 +380,7 @@ class BuildTree: - ``"N/M win · P/Q pane"`` - — * - ``{build_progress}`` - - ``""`` + - ``"0/0 win"`` - ``"0/0 win"`` - denominator increments - pane progress appended From 1b740070ea3e3504da2674600e5facbf65e7d968 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 20:55:15 -0500 Subject: [PATCH 21/26] progress(docs[tokens]): Document new format tokens why: windows_created and window_progress_created are usable in custom --progress-format strings but were absent from the docs token table, and windows_created was also missing from the token-lifecycle table. what: - Add {windows_created} and {window_progress_created} to docs/cli/load.md - Add the {windows_created} row to the lifecycle table --- docs/cli/load.md | 2 ++ src/tmuxp/cli/_progress.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/docs/cli/load.md b/docs/cli/load.md index 41b7aba2c9..3b70f03085 100644 --- a/docs/cli/load.md +++ b/docs/cli/load.md @@ -194,7 +194,9 @@ Use a custom format string with any of the available tokens: | `{window}` | Current window name | | `{window_index}` | Current window number (1-based) | | `{window_total}` | Total number of windows | +| `{windows_created}` | Number of windows created so far | | `{window_progress}` | Window fraction (e.g. `1/3`) | +| `{window_progress_created}` | Created windows fraction (e.g. `2/3`) | | `{window_progress_rel}` | Completed windows fraction (e.g. `1/3`) | | `{windows_done}` | Number of completed windows | | `{windows_remaining}` | Number of remaining windows | diff --git a/src/tmuxp/cli/_progress.py b/src/tmuxp/cli/_progress.py index bf418aa64c..2da325f5e1 100644 --- a/src/tmuxp/cli/_progress.py +++ b/src/tmuxp/cli/_progress.py @@ -307,6 +307,12 @@ class BuildTree: - — - — - — + * - ``{windows_created}`` + - ``0`` + - ``0`` + - increments + - — + - — * - ``{window_progress}`` - ``""`` - ``""`` From 6cbb97c4b1816b05c6eb0b34b07efbbfcddf7564 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 20:57:07 -0500 Subject: [PATCH 22/26] util(docs[before_script]): Fix output docstring why: The docstring claimed output is always buffered, but with no on_line callback the script inherits the terminal's stdio and nothing is buffered. what: - Describe both paths: line-forwarded via on_line, else terminal-inherited --- src/tmuxp/util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/tmuxp/util.py b/src/tmuxp/util.py index 738ca604f5..c6a4d00c86 100644 --- a/src/tmuxp/util.py +++ b/src/tmuxp/util.py @@ -32,7 +32,8 @@ def run_before_script( ) -> int: """Execute shell script, streaming output to callback or terminal (if TTY). - Output is buffered and optionally forwarded via the ``on_line`` callback. + Output is forwarded line-by-line via ``on_line`` when given; otherwise it + is inherited by the terminal. """ script_cmd = shlex.split(str(script_file)) From 5049b0c10488f69da1d17d2cfda407d46f2a0514 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 20:59:18 -0500 Subject: [PATCH 23/26] docs(CHANGES) Restore 1.67.0 panel wording why: The shipped 1.67.0 entry had been reworded to describe the new opt-in behavior, mis-stating what 1.67.0 actually shipped, which captured before_script output to the panel by default. what: - Restore the original "control how much ... appears in the panel" wording --- CHANGES | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES b/CHANGES index 7a6d54bc02..98572492f2 100644 --- a/CHANGES +++ b/CHANGES @@ -146,7 +146,7 @@ tmuxp 1.67.0 makes {ref}`tmuxp-load` visibly track the workspace build it is per The {ref}`tmuxp-load` command now shows an animated progress display while it builds a session. Built-in formats cover terse, window-focused, pane-focused, and verbose views, while `--progress-format` and `TMUXP_PROGRESS_FORMAT` allow a custom display. -`--progress-lines` and `TMUXP_PROGRESS_LINES` capture `before_script` output in the panel when requested, and `--no-progress` or `TMUXP_PROGRESS=0` restores quiet output. +`--progress-lines` and `TMUXP_PROGRESS_LINES` control how much `before_script` output appears in the panel, and `--no-progress` or `TMUXP_PROGRESS=0` restores quiet output. ## tmuxp 1.66.0 (2026-03-08) From 3b9e0aaf50733e06692a4c6a62ae085eba2de462 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 21:01:34 -0500 Subject: [PATCH 24/26] docs(CHANGES) Mark before_script change breaking why: The before_script default change (output now on the terminal instead of the progress panel) is a user-visible behavior change; per changelog conventions it belongs under Breaking changes with a migration path, not What's new. what: - Add a Breaking changes section with the restore command - Remove the duplicate What's new entry --- CHANGES | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CHANGES b/CHANGES index 98572492f2..963f35c75c 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,16 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### Breaking changes + +#### `before_script` output now goes to the terminal by default (#1056) + +A workspace's `before_script` now runs attached to your terminal, so interactive prompts and TTY-aware tools behave normally. Previously its output was captured into the load progress panel (up to three lines) by default. To restore panel capture, pass `--progress-lines` (or set `TMUXP_PROGRESS_LINES`): + +```console +$ tmuxp load --progress-lines 3 myworkspace +``` + ### What's new #### Faster workspace loads (#1056) @@ -52,10 +62,6 @@ Loading a workspace is faster: {ref}`tmuxp-load` now prepares panes in a window See {ref}`loading-workspaces` for an overview of how a load proceeds. -#### `before_script` runs interactively by default (#1056) - -A workspace's `before_script` now runs attached to your terminal, so interactive prompts and TTY-aware tools behave normally. Pass `--progress-lines` (or set `TMUXP_PROGRESS_LINES`) to capture its output into the load progress panel instead. - ## tmuxp 1.71.0 (2026-06-27) tmuxp 1.71.0 bumps libtmux to 0.59.0, adding support for tmux 3.7. From 7766aec1715cf63210cbd8c9825c7460bcf82dcc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 21:04:59 -0500 Subject: [PATCH 25/26] builder(docs[readiness]): Note first-split safety why: The concurrent path splits panes without a per-pane readiness wait; the rationale (only the freshest, pre-prompt pane is resized) and its residual #365 edge case were undocumented. what: - Comment the first-split site: why no readiness wait is needed and the slow on_window_create plugin residual risk --- src/tmuxp/workspace/builder.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 18d0bf4038..38895e4b87 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -963,6 +963,11 @@ def get_pane_shell( "environment": environment, } + # Splitting resizes the target, but we only ever split the + # freshest pane (pre-prompt, still sourcing its rc), so no + # readiness wait is needed here; the post-creation barrier + # guards layout and commands. (Residual #365 risk if a slow + # on_window_create plugin lets pane 0 prompt first.) pane = self._split_pane_reclaiming_space( window, pane, From 1ea4b680be35f382a01613ae7c88ea427e136d0e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sat, 27 Jun 2026 21:09:31 -0500 Subject: [PATCH 26/26] builder(fix[reclaim]): Narrow no-space retry why: The space-reclaim retry caught every LibTmuxException, so an unrelated split failure triggered a pointless readiness wait, layout redistribution, and re-split before propagating the same error. what: - Reclaim only when the error message is a no-space failure; re-raise others immediately - Add a parametrized test: no-space reclaims, other errors propagate --- src/tmuxp/workspace/builder.py | 4 +- tests/workspace/test_builder.py | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 38895e4b87..947cfb4274 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -1167,8 +1167,8 @@ def _split_pane_reclaiming_space( pane.refresh() try: return pane.split(**split_kwargs) - except LibTmuxException: - if layout is None: + except LibTmuxException as error: + if layout is None or "no space for" not in str(error): raise _wait_for_panes_ready([e.pane for e in entries if e.shell is None]) window.select_layout(layout) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index f8d256a054..d560cb3159 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -2063,6 +2063,73 @@ def recording_barrier( assert events[:2] == ["refresh", "split"] +class ReclaimSpaceFixture(t.NamedTuple): + """Reclaim-retry exception-matching fixture.""" + + test_id: str + error_message: str + expect_reclaim: bool + + +RECLAIM_SPACE_FIXTURES: list[ReclaimSpaceFixture] = [ + ReclaimSpaceFixture( + test_id="no_space_reclaims", + error_message="no space for new pane", + expect_reclaim=True, + ), + ReclaimSpaceFixture( + test_id="other_error_propagates", + error_message="some other tmux failure", + expect_reclaim=False, + ), +] + + +@pytest.mark.parametrize( + list(ReclaimSpaceFixture._fields), + RECLAIM_SPACE_FIXTURES, + ids=[c.test_id for c in RECLAIM_SPACE_FIXTURES], +) +def test_split_reclaims_only_on_no_space( + session: Session, + test_id: str, + error_message: str, + expect_reclaim: bool, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reclaim retry runs only when a split fails for lack of space.""" + builder = WorkspaceBuilder( + session_config={"session_name": "x", "windows": []}, + server=session.server, + ) + window = session.active_window + pane = window.active_pane + assert pane is not None + + layout_calls: list[str | None] = [] + monkeypatch.setattr( + Window, + "select_layout", + lambda self, layout=None: layout_calls.append(layout), + ) + + def failing_split(self: Pane, *args: t.Any, **kwargs: t.Any) -> Pane: + raise LibTmuxException(error_message) + + monkeypatch.setattr(Pane, "split", failing_split) + + with pytest.raises(LibTmuxException, match=error_message): + builder._split_pane_reclaiming_space( + window, + pane, + {"attach": True}, + "tiled", + [], + ) + + assert bool(layout_calls) == expect_reclaim + + def test_build_waits_for_each_window_before_dispatch( tmp_path: pathlib.Path, server: Server,