From 287d7a0b88f4eb6f5c39315c1a1efa7ba6eccc87 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:29:59 -0500 Subject: [PATCH 01/14] Workspace(feat[runtime]): Add pane title and synchronization keys why: Expose the core tmux runtime primitives needed for parity before layering importer translations on top. what: - Normalize synchronize, shell_command_after, clear, and pane title config keys in the loader - Apply after-commands and clear before options_after so synchronized panes do not duplicate commands --- src/tmuxp/workspace/builder.py | 17 ++++++++++++++++ src/tmuxp/workspace/loader.py | 37 ++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 728b477963..82e59b8879 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -813,6 +813,9 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) + if pane_config.get("title"): + pane.set_title(pane_config["title"]) + if pane_config.get("focus"): assert pane.pane_id is not None window.select_pane(pane.pane_id) @@ -837,6 +840,20 @@ def config_after_window( window_config : dict config section for window """ + if "shell_command_after" in window_config and isinstance( + window_config["shell_command_after"], + dict, + ): + for cmd in window_config["shell_command_after"].get("shell_command", []): + for pane in window.panes: + pane.send_keys(cmd["cmd"]) + + if window_config.get("clear"): + for pane in window.panes: + pane.send_keys("clear", enter=True) + + # Keep options_after last. synchronize-panes mirrors send-keys to every + # pane, so enabling it before the fan-out above duplicates commands. if "options_after" in window_config and isinstance( window_config["options_after"], dict, diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 9efcd05b52..27997f3ef7 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -138,6 +138,13 @@ def expand( val = str(cwd / val) workspace_dict["options"][key] = val + if "synchronize" in workspace_dict: + sync = workspace_dict.pop("synchronize") + if sync is True or sync == "before": + workspace_dict.setdefault("options", {})["synchronize-panes"] = "on" + elif sync == "after": + workspace_dict.setdefault("options_after", {})["synchronize-panes"] = "on" + # Any workspace section, session, window, pane that can contain the # 'shell_command' value if "start_directory" in workspace_dict: @@ -175,6 +182,36 @@ def expand( workspace_dict["shell_command_before"] = expand_cmd(shell_command_before) + if "shell_command_after" in workspace_dict: + shell_command_after = workspace_dict["shell_command_after"] + + workspace_dict["shell_command_after"] = expand_cmd(shell_command_after) + + if workspace_dict.get("enable_pane_titles") and "windows" in workspace_dict: + valid_positions = {"top", "bottom", "off"} + position = workspace_dict.pop("pane_title_position", "top") + if position not in valid_positions: + logger.warning( + "invalid pane_title_position %r, expected one of %s; " + "defaulting to 'top'", + position, + valid_positions, + ) + position = "top" + pane_title_format = workspace_dict.pop( + "pane_title_format", + "#{pane_index}: #{pane_title}", + ) + workspace_dict.pop("enable_pane_titles") + for window in workspace_dict["windows"]: + window.setdefault("options", {}) + window["options"].setdefault("pane-border-status", position) + window["options"].setdefault("pane-border-format", pane_title_format) + elif "enable_pane_titles" in workspace_dict: + workspace_dict.pop("enable_pane_titles") + workspace_dict.pop("pane_title_position", None) + workspace_dict.pop("pane_title_format", None) + # recurse into window and pane workspace items if "windows" in workspace_dict: workspace_dict["windows"] = [ From 2ad1d798beac8c84891a61e22e15b8cf37e025b8 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:31:22 -0500 Subject: [PATCH 02/14] test(workspace[runtime]): Cover pane title and synchronization keys why: Pin the loader desugaring and builder behavior for the new runtime config keys. what: - Builder integration tests for synchronize, shell_command_after, clear, and pane titles - Loader expand tests for key normalization and defaults --- tests/workspace/test_builder.py | 211 ++++++++++++++++++++++++++++++++ tests/workspace/test_config.py | 211 ++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index da95168f46..393f592c6d 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -359,6 +359,217 @@ def f() -> bool: ), "Synchronized command did not execute properly" +class SynchronizeBuilderFixture(t.NamedTuple): + """Fixture for synchronize shorthand builder behavior.""" + + test_id: str + synchronize: bool | str + expected_synchronized: bool | None + + +SYNCHRONIZE_BUILDER_FIXTURES: list[SynchronizeBuilderFixture] = [ + SynchronizeBuilderFixture( + test_id="true", + synchronize=True, + expected_synchronized=True, + ), + SynchronizeBuilderFixture( + test_id="before", + synchronize="before", + expected_synchronized=True, + ), + SynchronizeBuilderFixture( + test_id="after", + synchronize="after", + expected_synchronized=True, + ), + SynchronizeBuilderFixture( + test_id="false", + synchronize=False, + expected_synchronized=None, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizeBuilderFixture._fields), + SYNCHRONIZE_BUILDER_FIXTURES, + ids=[fixture.test_id for fixture in SYNCHRONIZE_BUILDER_FIXTURES], +) +def test_synchronize_builder_options( + session: Session, + test_id: str, + synchronize: bool | str, + expected_synchronized: bool, +) -> None: + """Synchronize shorthand sets synchronize-panes on the built window.""" + workspace: dict[str, t.Any] = { + "session_name": f"sync-builder-{test_id}", + "windows": [ + { + "window_name": "main", + "synchronize": synchronize, + "panes": ["echo pane0", "echo pane1"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert session.windows[0].show_option("synchronize-panes") is expected_synchronized + + +def test_synchronize_after_runs_shell_command_after_once_per_pane( + session: Session, +) -> None: + """synchronize: after does not mirror shell_command_after across panes.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "sync-after-cmds", + "synchronize": "after", + "shell_command_after": [ + "echo __SYNC_AF''TER__", + "echo __SYNC_DO''NE__", + ], + "panes": ["echo pane0", "echo pane1"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("synchronize-panes") is True + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + for pane in window.panes: + + def done(p: Pane = pane) -> bool: + return output_lines(p, "__SYNC_DONE__") >= 1 + + assert retry_until(done), f"Expected __SYNC_DONE__ in pane {pane.pane_id}" + count = output_lines(pane, "__SYNC_AFTER__") + assert count == 1, ( + f"Pane {pane.pane_id} ran shell_command_after {count} times; " + "synchronize-panes was enabled before the fan-out" + ) + + +def test_pane_titles( + session: Session, +) -> None: + """Pane title config sets pane-border options and pane titles.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "enable_pane_titles": True, + "windows": [ + { + "window_name": "titled", + "panes": [ + {"title": "editor", "shell_command": ["echo pane0"]}, + {"title": "runner", "shell_command": ["echo pane1"]}, + {"shell_command": ["echo pane2"]}, + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("pane-border-status") == "top" + assert window.show_option("pane-border-format") == "#{pane_index}: #{pane_title}" + + panes = window.panes + assert len(panes) == 3 + + def check_title(pane: Pane, expected: str) -> bool: + pane.refresh() + return pane.pane_title == expected + + assert retry_until(functools.partial(check_title, panes[0], "editor")), ( + f"Expected title 'editor', got {panes[0].pane_title!r}" + ) + assert retry_until(functools.partial(check_title, panes[1], "runner")), ( + f"Expected title 'runner', got {panes[1].pane_title!r}" + ) + + +class ClearBuilderFixture(t.NamedTuple): + """Fixture for clear window behavior.""" + + test_id: str + clear: bool + marker_should_remain: bool + + +CLEAR_BUILDER_FIXTURES: list[ClearBuilderFixture] = [ + ClearBuilderFixture( + test_id="clear-true", + clear=True, + marker_should_remain=False, + ), + ClearBuilderFixture( + test_id="clear-false", + clear=False, + marker_should_remain=True, + ), +] + + +@pytest.mark.parametrize( + list(ClearBuilderFixture._fields), + CLEAR_BUILDER_FIXTURES, + ids=[fixture.test_id for fixture in CLEAR_BUILDER_FIXTURES], +) +def test_clear_window( + session: Session, + test_id: str, + clear: bool, + marker_should_remain: bool, +) -> None: + """Clear sends clear to panes only when enabled.""" + marker = f"__{test_id.upper().replace('-', '_')}__" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "clear-test", + "clear": clear, + "panes": [{"shell_command": [f"echo {marker}"]}], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + pane = session.windows[0].panes[0] + + def marker_visible() -> bool: + return marker in "\n".join(pane.capture_pane()) + + if marker_should_remain: + assert retry_until(marker_visible) + else: + + def marker_cleared() -> bool: + return not marker_visible() + + assert retry_until(marker_cleared, raises=False) + + def test_window_shell( session: Session, ) -> None: diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index fc6d5ccd5b..268a9b14f1 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -333,6 +333,217 @@ def test_validate_plugins() -> None: assert excinfo.match("only supports list type") +class SynchronizeFixture(t.NamedTuple): + """Fixture for synchronize shorthand expansion.""" + + test_id: str + synchronize: bool | str + expected_section: str | None + + +SYNCHRONIZE_FIXTURES: list[SynchronizeFixture] = [ + SynchronizeFixture( + test_id="true-enables-before", + synchronize=True, + expected_section="options", + ), + SynchronizeFixture( + test_id="before-enables-before", + synchronize="before", + expected_section="options", + ), + SynchronizeFixture( + test_id="after-enables-after", + synchronize="after", + expected_section="options_after", + ), + SynchronizeFixture( + test_id="false-only-removes-key", + synchronize=False, + expected_section=None, + ), +] + + +@pytest.mark.parametrize( + list(SynchronizeFixture._fields), + SYNCHRONIZE_FIXTURES, + ids=[fixture.test_id for fixture in SYNCHRONIZE_FIXTURES], +) +def test_expand_synchronize( + test_id: str, + synchronize: bool | str, + expected_section: str | None, +) -> None: + """expand() desugars synchronize into tmux window options.""" + workspace: dict[str, t.Any] = { + "session_name": f"sync-{test_id}", + "windows": [ + { + "window_name": "main", + "synchronize": synchronize, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + + result = loader.expand(workspace) + window = result["windows"][0] + + assert "synchronize" not in window + if expected_section is None: + assert "synchronize-panes" not in window.get("options", {}) + assert "synchronize-panes" not in window.get("options_after", {}) + else: + assert window[expected_section]["synchronize-panes"] == "on" + + +class ShellCommandAfterFixture(t.NamedTuple): + """Fixture for shell_command_after expansion.""" + + test_id: str + shell_command_after: str | list[str] + expected_commands: list[str] + + +SHELL_COMMAND_AFTER_FIXTURES: list[ShellCommandAfterFixture] = [ + ShellCommandAfterFixture( + test_id="string-command", + shell_command_after="echo done", + expected_commands=["echo done"], + ), + ShellCommandAfterFixture( + test_id="list-commands", + shell_command_after=["echo done", "echo bye"], + expected_commands=["echo done", "echo bye"], + ), +] + + +@pytest.mark.parametrize( + list(ShellCommandAfterFixture._fields), + SHELL_COMMAND_AFTER_FIXTURES, + ids=[fixture.test_id for fixture in SHELL_COMMAND_AFTER_FIXTURES], +) +def test_expand_shell_command_after( + test_id: str, + shell_command_after: str | list[str], + expected_commands: list[str], +) -> None: + """expand() normalizes shell_command_after like shell_command_before.""" + workspace: dict[str, t.Any] = { + "session_name": f"after-{test_id}", + "windows": [ + { + "window_name": "main", + "shell_command_after": shell_command_after, + "panes": [{"shell_command": ["echo hi"]}], + }, + ], + } + + result = loader.expand(workspace) + after = result["windows"][0]["shell_command_after"] + + assert [cmd["cmd"] for cmd in after["shell_command"]] == expected_commands + + +class PaneTitleFixture(t.NamedTuple): + """Fixture for pane title option expansion.""" + + test_id: str + enabled: bool + position: str | None + expected_position: str | None + expect_warning: bool + + +PANE_TITLE_FIXTURES: list[PaneTitleFixture] = [ + PaneTitleFixture( + test_id="enabled-defaults", + enabled=True, + position=None, + expected_position="top", + expect_warning=False, + ), + PaneTitleFixture( + test_id="enabled-bottom", + enabled=True, + position="bottom", + expected_position="bottom", + expect_warning=False, + ), + PaneTitleFixture( + test_id="enabled-invalid-falls-back", + enabled=True, + position="invalid", + expected_position="top", + expect_warning=True, + ), + PaneTitleFixture( + test_id="disabled-removes-session-keys", + enabled=False, + position="bottom", + expected_position=None, + expect_warning=False, + ), +] + + +@pytest.mark.parametrize( + list(PaneTitleFixture._fields), + PANE_TITLE_FIXTURES, + ids=[fixture.test_id for fixture in PANE_TITLE_FIXTURES], +) +def test_expand_pane_titles( + caplog: pytest.LogCaptureFixture, + test_id: str, + enabled: bool, + position: str | None, + expected_position: str | None, + expect_warning: bool, +) -> None: + """expand() turns session pane title keys into tmux window options.""" + workspace: dict[str, t.Any] = { + "session_name": f"title-{test_id}", + "enable_pane_titles": enabled, + "pane_title_format": " #T ", + "windows": [ + { + "window_name": "main", + "panes": [ + {"title": "editor", "shell_command": ["echo hi"]}, + {"shell_command": ["echo bye"]}, + ], + }, + ], + } + if position is not None: + workspace["pane_title_position"] = position + + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.loader"): + result = loader.expand(workspace) + + window = result["windows"][0] + assert "enable_pane_titles" not in result + assert "pane_title_position" not in result + assert "pane_title_format" not in result + assert window["panes"][0]["title"] == "editor" + + if expected_position is None: + assert "pane-border-status" not in window.get("options", {}) + else: + assert window["options"]["pane-border-status"] == expected_position + assert window["options"]["pane-border-format"] == " #T " + + warnings = [ + record for record in caplog.records if record.levelno == logging.WARNING + ] + assert any("pane_title_position" in record.message for record in warnings) is ( + expect_warning + ) + + def test_expand_logs_debug( tmp_path: pathlib.Path, caplog: pytest.LogCaptureFixture, From d82d605cfb0c3c7be8b9e7df3356fc1cad20294f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:32:43 -0500 Subject: [PATCH 03/14] docs(workspace[runtime]): Document pane title and synchronization keys why: Give users working references for the new runtime config keys. what: - Top-level configuration sections for pane titles, synchronize, shell_command_after, and clear - Example workspace files for pane titles and synchronize shorthand --- docs/configuration/examples.md | 23 ++++++++ docs/configuration/top-level.md | 88 +++++++++++++++++++++++++++++ examples/pane-titles.yaml | 15 +++++ examples/synchronize-shorthand.yaml | 16 ++++++ 4 files changed, 142 insertions(+) create mode 100644 examples/pane-titles.yaml create mode 100644 examples/synchronize-shorthand.yaml diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 9651341309..084cf2015c 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -785,6 +785,29 @@ windows: [poetry]: https://python-poetry.org/ [uv]: https://github.com/astral-sh/uv +## Synchronize Panes Shorthand + +The `synchronize` window key provides a shorthand for enabling +`synchronize-panes` without spelling out tmux options directly: + +````{tab} YAML +```{literalinclude} ../../examples/synchronize-shorthand.yaml +:language: yaml + +``` +```` + +## Pane Titles + +Pane title keys turn on tmux pane border titles and label individual panes: + +````{tab} YAML +```{literalinclude} ../../examples/pane-titles.yaml +:language: yaml + +``` +```` + ## Kung fu :::{note} diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 72eb24f32f..a98a45934a 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -40,3 +40,91 @@ Notes: ``` Above: Use `tmux` directly to attach _banana_. + +## Pane Titles + +Enable pane border titles to display labels on each pane: + +```yaml +session_name: myproject +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: dev + panes: + - title: editor + shell_command: + - vim + - title: tests + shell_command: + - uv run pytest --watch + - shell_command: + - git status +``` + +| Key | Level | Description | +|-----|-------|-------------| +| `enable_pane_titles` | session | Enable pane border titles (`true` or `false`). | +| `pane_title_position` | session | Position of the title bar (`top`, `bottom`, or `off`). | +| `pane_title_format` | session | Format string using tmux variables. | +| `title` | pane | Title text for an individual pane. | + +## synchronize + +Window-level shorthand for setting `synchronize-panes`. It accepts +`before`, `after`, or `true`: + +```yaml +session_name: sync-demo +windows: + - window_name: synced + synchronize: after + panes: + - echo pane0 + - echo pane1 + - window_name: not-synced + panes: + - echo pane0 + - echo pane1 +``` + +| Value | Behavior | +|-------|----------| +| `before` | Enable `synchronize-panes` before sending pane commands. | +| `after` | Enable `synchronize-panes` after sending pane commands. | +| `true` | Same as `before`. | + +## shell_command_after + +Window-level commands sent to every pane after all panes have been created and +their individual commands executed: + +```yaml +session_name: myproject +windows: + - window_name: servers + shell_command_after: + - echo "All panes ready" + panes: + - ./start-api.sh + - ./start-worker.sh +``` + +`shell_command_after` runs before `options_after`, so `synchronize: after` does +not duplicate the commands across synchronized panes. + +## clear + +Window-level boolean. When `true`, sends `clear` to every pane after all +commands, including `shell_command_after`, have completed: + +```yaml +session_name: myproject +windows: + - window_name: dev + clear: true + panes: + - cd src + - cd tests +``` diff --git a/examples/pane-titles.yaml b/examples/pane-titles.yaml new file mode 100644 index 0000000000..37c5de17fb --- /dev/null +++ b/examples/pane-titles.yaml @@ -0,0 +1,15 @@ +session_name: pane titles +enable_pane_titles: true +pane_title_position: top +pane_title_format: "#{pane_index}: #{pane_title}" +windows: + - window_name: titled + panes: + - title: editor + shell_command: + - echo pane0 + - title: runner + shell_command: + - echo pane1 + - shell_command: + - echo pane2 diff --git a/examples/synchronize-shorthand.yaml b/examples/synchronize-shorthand.yaml new file mode 100644 index 0000000000..7fd507b809 --- /dev/null +++ b/examples/synchronize-shorthand.yaml @@ -0,0 +1,16 @@ +session_name: synchronize shorthand +windows: + - window_name: synced-before + synchronize: before + panes: + - echo 0 + - echo 1 + - window_name: synced-after + synchronize: after + panes: + - echo 0 + - echo 1 + - window_name: not-synced + panes: + - echo 0 + - echo 1 From 5c45a5ee1e9f13bef22c1819f2cd21d9966f6c25 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:34:15 -0500 Subject: [PATCH 04/14] Workspace(fix[runtime]): Honor suppress_history for post-build commands why: Preserve tmuxp's default shell history suppression for post-build window commands. what: - Pass the window suppress_history setting to shell_command_after and clear send_keys calls --- src/tmuxp/workspace/builder.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 82e59b8879..436d7ff25c 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -840,17 +840,19 @@ def config_after_window( window_config : dict config section for window """ + suppress = window_config.get("suppress_history", True) + if "shell_command_after" in window_config and isinstance( window_config["shell_command_after"], dict, ): for cmd in window_config["shell_command_after"].get("shell_command", []): for pane in window.panes: - pane.send_keys(cmd["cmd"]) + pane.send_keys(cmd["cmd"], suppress_history=suppress) if window_config.get("clear"): for pane in window.panes: - pane.send_keys("clear", enter=True) + pane.send_keys("clear", enter=True, suppress_history=suppress) # Keep options_after last. synchronize-panes mirrors send-keys to every # pane, so enabling it before the fan-out above duplicates commands. From 61a6df4e676879de54115f7d6e0f0f58bb400289 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 05:35:41 -0500 Subject: [PATCH 05/14] test(workspace[runtime]): Cover post-build suppress_history behavior why: Pin default and explicit shell history suppression for shell_command_after and clear. what: - Parametrized builder coverage for default-on, explicit-off, and explicit-on suppress_history with post-build commands --- tests/workspace/test_builder.py | 92 +++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 393f592c6d..b367373a16 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -570,6 +570,98 @@ def marker_cleared() -> bool: assert retry_until(marker_cleared, raises=False) +class PostBuildSuppressHistoryFixture(t.NamedTuple): + """Fixture for post-build suppress_history behavior.""" + + test_id: str + suppress_history: bool | None + expected_suppress_history: bool + + +POST_BUILD_SUPPRESS_HISTORY_FIXTURES: list[PostBuildSuppressHistoryFixture] = [ + PostBuildSuppressHistoryFixture( + test_id="default", + suppress_history=None, + expected_suppress_history=True, + ), + PostBuildSuppressHistoryFixture( + test_id="explicit-false", + suppress_history=False, + expected_suppress_history=False, + ), +] + + +@pytest.mark.parametrize( + list(PostBuildSuppressHistoryFixture._fields), + POST_BUILD_SUPPRESS_HISTORY_FIXTURES, + ids=[fixture.test_id for fixture in POST_BUILD_SUPPRESS_HISTORY_FIXTURES], +) +def test_post_build_commands_honor_suppress_history( + session: Session, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + suppress_history: bool | None, + expected_suppress_history: bool, +) -> None: + """Post-build commands use the window suppress_history setting.""" + sent: list[tuple[str | None, bool | None]] = [] + original_send_keys = Pane.send_keys + + def spy_send_keys( + self: Pane, + cmd: str | None = None, + enter: bool | None = True, + suppress_history: bool | None = False, + literal: bool | None = False, + reset: bool | None = None, + copy_mode_cmd: str | None = None, + repeat: int | None = None, + expand_formats: bool | None = None, + hex_keys: bool | None = None, + target_client: str | None = None, + key_name: bool | None = None, + ) -> None: + sent.append((cmd, suppress_history)) + original_send_keys( + self, + cmd=cmd, + enter=enter, + suppress_history=suppress_history, + literal=literal, + reset=reset, + copy_mode_cmd=copy_mode_cmd, + repeat=repeat, + expand_formats=expand_formats, + hex_keys=hex_keys, + target_client=target_client, + key_name=key_name, + ) + + monkeypatch.setattr(Pane, "send_keys", spy_send_keys) + + window_config: dict[str, t.Any] = { + "window_name": f"post-build-history-{test_id}", + "shell_command_after": ["echo __POST_BUILD_AFTER__"], + "clear": True, + "panes": ["echo pane"], + } + if suppress_history is not None: + window_config["suppress_history"] = suppress_history + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + assert ("echo __POST_BUILD_AFTER__", expected_suppress_history) in sent + assert ("clear", expected_suppress_history) in sent + + def test_window_shell( session: Session, ) -> None: From d8315cec0134a48cc436d07422ee76024be643b5 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 08:09:08 -0500 Subject: [PATCH 06/14] Workspace(fix[runtime]): Attach session context to pane title validation warning why: The warning carried no structured fields, forcing the test to match message substrings against the logging standards. what: - Add tmux_session extra to the invalid pane_title_position warning - Assert the warning via schema fields instead of message text --- src/tmuxp/workspace/loader.py | 3 +++ tests/workspace/test_config.py | 12 +++++++----- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index 27997f3ef7..f6e32629fd 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -196,6 +196,9 @@ def expand( "defaulting to 'top'", position, valid_positions, + extra={ + "tmux_session": str(workspace_dict.get("session_name") or ""), + }, ) position = "top" pane_title_format = workspace_dict.pop( diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 268a9b14f1..44a1445056 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -536,12 +536,14 @@ def test_expand_pane_titles( assert window["options"]["pane-border-status"] == expected_position assert window["options"]["pane-border-format"] == " #T " - warnings = [ - record for record in caplog.records if record.levelno == logging.WARNING + position_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_session") ] - assert any("pane_title_position" in record.message for record in warnings) is ( - expect_warning - ) + assert bool(position_warnings) is expect_warning + if expect_warning: + assert position_warnings[0].tmux_session == f"title-{test_id}" def test_expand_logs_debug( From d6114d3e93040aacfb706e47214bd384f1ff68ff Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 09:21:26 -0500 Subject: [PATCH 07/14] docs(CHANGES): Pane titles and window post-build keys why: Document the runtime config deliverables for the upcoming release: native pane labeling and the synchronize, shell_command_after, and clear window keys. what: - Add What's new deliverable entries below the unreleased placeholder - Cross-reference the top-level configuration docs --- CHANGES | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/CHANGES b/CHANGES index 35e90e910a..e71228218a 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,23 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### What's new + +#### Pane titles (#1047) + +Workspace configs can label panes natively: session-level +`enable_pane_titles`, `pane_title_position`, and `pane_title_format` +turn on tmux's pane border titles, and a pane-level `title` names +individual panes — replacing the escape-sequence workaround previously +required. See {ref}`top-level` for examples. + +#### New window keys: `synchronize`, `shell_command_after`, `clear` (#1047) + +`synchronize: before/after/true` mirrors keystrokes across a window's +panes; `shell_command_after` runs commands in every pane after the +window is built; `clear: true` clears each pane once its commands +complete. See {ref}`top-level`. + ## tmuxp 1.70.0 (2026-05-23) tmuxp 1.70.0 bumps libtmux to 0.58.0, fixing session and window listing on systems whose locale is not UTF-8. From 997a9e8ff71e4bef0390b836df5945f8afdc3086 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 09:34:51 -0500 Subject: [PATCH 08/14] Workspace(fix[runtime]): Honor per-command options in shell_command_after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: expand_cmd normalizes after-commands into the same mappings as shell_command, but the post-build fan-out sent only the command text — enter: false and sleep_before/sleep_after were silently dropped, unlike regular pane commands using identical syntax. what: - Read enter and sleep options per command in config_after_window; sleeps run once per command wave, not once per pane - Spy-based tests pinning enter propagation and per-wave sleep counts - Document the mapping form in the shell_command_after section --- docs/configuration/top-level.md | 12 ++++ src/tmuxp/workspace/builder.py | 10 ++- tests/workspace/test_builder.py | 116 ++++++++++++++++++++++++++++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index a98a45934a..cff349b97e 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -114,6 +114,18 @@ windows: `shell_command_after` runs before `options_after`, so `synchronize: after` does not duplicate the commands across synchronized panes. +Entries accept the same command mappings as `shell_command` — `enter`, +`sleep_before`, and `sleep_after` apply per command (sleeps run once per +command, before and after it is sent to every pane): + +```yaml +shell_command_after: + - cmd: ./healthcheck.sh + sleep_before: 2 + - cmd: tail -f app.log + enter: false +``` + ## clear Window-level boolean. When `true`, sends `clear` to every pane after all diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 436d7ff25c..0da9398150 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -847,8 +847,16 @@ def config_after_window( dict, ): for cmd in window_config["shell_command_after"].get("shell_command", []): + enter = cmd.get("enter", True) + sleep_before = cmd.get("sleep_before") + sleep_after = cmd.get("sleep_after") + # Sleeps apply once per command wave, not once per pane. + if sleep_before is not None: + time.sleep(sleep_before) for pane in window.panes: - pane.send_keys(cmd["cmd"], suppress_history=suppress) + pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) + if sleep_after is not None: + time.sleep(sleep_after) if window_config.get("clear"): for pane in window.panes: diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index b367373a16..712632cf45 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -2071,3 +2071,119 @@ def test_builder_logs_window_and_pane_creation( assert len(cmd_logs) >= 1 builder.session.kill() + + +class AfterCommandOptionsFixture(t.NamedTuple): + """Fixture for per-command options in shell_command_after mappings.""" + + test_id: str + command: dict[str, t.Any] + expected_enter: bool + expected_sleeps: list[float] + + +AFTER_COMMAND_OPTIONS_FIXTURES: list[AfterCommandOptionsFixture] = [ + AfterCommandOptionsFixture( + test_id="enter_false_respected", + command={"cmd": "echo __AFTER_OPT__", "enter": False}, + expected_enter=False, + expected_sleeps=[], + ), + AfterCommandOptionsFixture( + test_id="sleeps_once_per_wave", + command={ + "cmd": "echo __AFTER_OPT__", + "sleep_before": 0.21, + "sleep_after": 0.31, + }, + expected_enter=True, + expected_sleeps=[0.21, 0.31], + ), +] + + +@pytest.mark.parametrize( + list(AfterCommandOptionsFixture._fields), + AFTER_COMMAND_OPTIONS_FIXTURES, + ids=[fixture.test_id for fixture in AFTER_COMMAND_OPTIONS_FIXTURES], +) +def test_shell_command_after_honors_command_options( + session: Session, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + command: dict[str, t.Any], + expected_enter: bool, + expected_sleeps: list[float], +) -> None: + """shell_command_after mappings keep their enter and sleep options. + + The pane command loop honors per-command enter/sleep_before/ + sleep_after; the post-build fan-out accepts the same mapping syntax + and must behave the same. Sleeps apply once per command wave, not + once per pane. + """ + sent: list[tuple[str | None, bool | None]] = [] + original_send_keys = Pane.send_keys + + def spy_send_keys( + self: Pane, + cmd: str | None = None, + enter: bool | None = True, + suppress_history: bool | None = False, + literal: bool | None = False, + reset: bool | None = None, + copy_mode_cmd: str | None = None, + repeat: int | None = None, + expand_formats: bool | None = None, + hex_keys: bool | None = None, + target_client: str | None = None, + key_name: bool | None = None, + ) -> None: + sent.append((cmd, enter)) + original_send_keys( + self, + cmd=cmd, + enter=enter, + suppress_history=suppress_history, + literal=literal, + reset=reset, + copy_mode_cmd=copy_mode_cmd, + repeat=repeat, + expand_formats=expand_formats, + hex_keys=hex_keys, + target_client=target_client, + key_name=key_name, + ) + + monkeypatch.setattr(Pane, "send_keys", spy_send_keys) + + slept: list[float] = [] + original_sleep = time.sleep + + def spy_sleep(seconds: float) -> None: + slept.append(seconds) + original_sleep(0) + + monkeypatch.setattr("tmuxp.workspace.builder.time.sleep", spy_sleep) + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": f"after-options-{test_id}", + "shell_command_after": [command], + "panes": ["echo pane0", "echo pane1"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + after_sends = [entry for entry in sent if entry[0] == "echo __AFTER_OPT__"] + assert len(after_sends) == 2, "after-command should reach both panes" + assert all(enter is expected_enter for _, enter in after_sends) + + option_sleeps = [s for s in slept if s in (0.21, 0.31)] + assert sorted(option_sleeps) == sorted(expected_sleeps) From a2292677fbb4b8e52a303ca4e5369a6b558a2ea1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 09:42:25 -0500 Subject: [PATCH 09/14] Workspace(fix[runtime]): Warn when an empty pane title cannot apply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: An explicit title: "" was silently dropped by a truthiness check, and forwarding it would no-op anyway — tmux discards select-pane -T "" entirely. Users blanking a label deserve a signal instead of silence. what: - Warn with pane context when title is explicitly empty; non-empty titles apply as before - Cover the warning via schema-field assertions in the pane title test - Document the tmux limitation and the single-space workaround --- docs/configuration/top-level.md | 5 +++++ src/tmuxp/workspace/builder.py | 12 ++++++++++-- tests/workspace/test_builder.py | 18 +++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index cff349b97e..0f4d914c85 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -70,6 +70,11 @@ windows: | `pane_title_format` | session | Format string using tmux variables. | | `title` | pane | Title text for an individual pane. | +```{note} +tmux ignores empty pane titles — `title: ""` logs a warning and keeps the +default label. Use a single space (`title: " "`) to visually blank one. +``` + ## synchronize Window-level shorthand for setting `synchronize-panes`. It accepts diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 0da9398150..bc9d84319e 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -813,8 +813,16 @@ def get_pane_shell( if sleep_after is not None: time.sleep(sleep_after) - if pane_config.get("title"): - pane.set_title(pane_config["title"]) + title = pane_config.get("title") + if title: + pane.set_title(title) + elif title is not None: + # tmux discards `select-pane -T ""`; an empty pane title + # cannot be applied. + pane_log.warning( + "tmux ignores empty pane titles; use a single space " + "to blank the label", + ) if pane_config.get("focus"): assert pane.pane_id is not None diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 712632cf45..e00b89b885 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -465,6 +465,7 @@ def done(p: Pane = pane) -> bool: def test_pane_titles( session: Session, + caplog: pytest.LogCaptureFixture, ) -> None: """Pane title config sets pane-border options and pane titles.""" workspace: dict[str, t.Any] = { @@ -476,7 +477,8 @@ def test_pane_titles( "panes": [ {"title": "editor", "shell_command": ["echo pane0"]}, {"title": "runner", "shell_command": ["echo pane1"]}, - {"shell_command": ["echo pane2"]}, + {"title": "", "shell_command": ["echo pane2"]}, + {"shell_command": ["echo pane3"]}, ], }, ], @@ -484,14 +486,15 @@ def test_pane_titles( workspace = loader.expand(workspace) builder = WorkspaceBuilder(session_config=workspace, server=session.server) - builder.build(session=session) + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.builder"): + builder.build(session=session) window = session.windows[0] assert window.show_option("pane-border-status") == "top" assert window.show_option("pane-border-format") == "#{pane_index}: #{pane_title}" panes = window.panes - assert len(panes) == 3 + assert len(panes) == 4 def check_title(pane: Pane, expected: str) -> bool: pane.refresh() @@ -504,6 +507,15 @@ def check_title(pane: Pane, expected: str) -> bool: f"Expected title 'runner', got {panes[1].pane_title!r}" ) + # tmux discards empty titles; an explicit title: "" warns instead + blank_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_pane") + ] + assert len(blank_warnings) == 1 + assert blank_warnings[0].tmux_pane == panes[2].pane_id + class ClearBuilderFixture(t.NamedTuple): """Fixture for clear window behavior.""" From dab7070240409b8ff36f3e79f5ce7ef29bdeb4dc Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 10:14:00 -0500 Subject: [PATCH 10/14] Workspace(fix[runtime]): Send post-build commands once when panes are synchronized MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: With synchronize-panes already on (synchronize: before/true or an explicit option), the fan-out sent to every pane while tmux also mirrored each send — shell_command_after and clear ran once per pane per send. The options_after ordering only protects synchronize: after. what: - Read the live synchronize-panes state and send to a single pane when on, letting tmux broadcast - Generalize the once-per-pane test across after, before, true, and explicit-option modes --- src/tmuxp/workspace/builder.py | 13 ++++++-- tests/workspace/test_builder.py | 55 +++++++++++++++++++++++++-------- 2 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index bc9d84319e..6ad3fd9445 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -850,6 +850,15 @@ def config_after_window( """ suppress = window_config.get("suppress_history", True) + # With synchronize-panes already on (synchronize: before/true or an + # explicit option), tmux mirrors each send to every pane — send to + # one pane and let tmux broadcast. + fanout_panes = ( + window.panes[:1] + if window.show_option("synchronize-panes") is True + else window.panes + ) + if "shell_command_after" in window_config and isinstance( window_config["shell_command_after"], dict, @@ -861,13 +870,13 @@ def config_after_window( # Sleeps apply once per command wave, not once per pane. if sleep_before is not None: time.sleep(sleep_before) - for pane in window.panes: + for pane in fanout_panes: pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) if sleep_after is not None: time.sleep(sleep_after) if window_config.get("clear"): - for pane in window.panes: + for pane in fanout_panes: pane.send_keys("clear", enter=True, suppress_history=suppress) # Keep options_after last. synchronize-panes mirrors send-keys to every diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index e00b89b885..74a9e480ad 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -421,23 +421,52 @@ def test_synchronize_builder_options( assert session.windows[0].show_option("synchronize-panes") is expected_synchronized -def test_synchronize_after_runs_shell_command_after_once_per_pane( +class SyncFanoutFixture(t.NamedTuple): + """Fixture for shell_command_after under synchronize-panes modes.""" + + test_id: str + window_extra: dict[str, t.Any] + + +SYNC_FANOUT_FIXTURES: list[SyncFanoutFixture] = [ + SyncFanoutFixture(test_id="sync_after", window_extra={"synchronize": "after"}), + SyncFanoutFixture(test_id="sync_before", window_extra={"synchronize": "before"}), + SyncFanoutFixture(test_id="sync_true", window_extra={"synchronize": True}), + SyncFanoutFixture( + test_id="explicit_option", + window_extra={"options": {"synchronize-panes": "on"}}, + ), +] + + +@pytest.mark.parametrize( + list(SyncFanoutFixture._fields), + SYNC_FANOUT_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_FANOUT_FIXTURES], +) +def test_shell_command_after_runs_once_per_pane_when_synchronized( session: Session, + test_id: str, + window_extra: dict[str, t.Any], ) -> None: - """synchronize: after does not mirror shell_command_after across panes.""" + """shell_command_after runs exactly once per pane in every sync mode. + + tmux mirrors send-keys across panes while synchronize-panes is on, + whether the option was enabled before the build (before/true or an + explicit option) or queued for after (options_after ordering). + """ + window_config: dict[str, t.Any] = { + "window_name": f"sync-cmds-{test_id}", + "shell_command_after": [ + "echo __SYNC_AF''TER__", + "echo __SYNC_DO''NE__", + ], + "panes": ["echo pane0", "echo pane1"], + } + window_config.update(window_extra) workspace: dict[str, t.Any] = { "session_name": session.name, - "windows": [ - { - "window_name": "sync-after-cmds", - "synchronize": "after", - "shell_command_after": [ - "echo __SYNC_AF''TER__", - "echo __SYNC_DO''NE__", - ], - "panes": ["echo pane0", "echo pane1"], - }, - ], + "windows": [window_config], } workspace = loader.expand(workspace) From e2f1117377178cda59ef14875e49243f14b8462e Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 11:17:01 -0500 Subject: [PATCH 11/14] Workspace(fix[runtime]): Keep pane synchronization off during setup why: synchronize-panes broadcasts tmux input whenever it is active, so applying it before tmuxp sends setup or post-build commands can duplicate commands across panes. what: - Normalize synchronize as a final window state instead of an active setup mode - Keep synchronize-panes disabled while tmuxp sends pane setup, clear, and shell_command_after keys - Restore explicit, inherited, and plugin-set final synchronization states after command fan-out - Cover shorthand, raw option, inherited global, plugin, and invalid-value cases --- CHANGES | 8 +- docs/configuration/examples.md | 11 +- docs/configuration/top-level.md | 18 ++- examples/synchronize-shorthand.yaml | 9 +- src/tmuxp/workspace/builder.py | 212 +++++++++++++++++++++++----- src/tmuxp/workspace/loader.py | 23 ++- tests/workspace/test_builder.py | 183 +++++++++++++++++++++++- tests/workspace/test_config.py | 52 +++++-- 8 files changed, 437 insertions(+), 79 deletions(-) diff --git a/CHANGES b/CHANGES index e71228218a..c508c75a44 100644 --- a/CHANGES +++ b/CHANGES @@ -56,10 +56,10 @@ required. See {ref}`top-level` for examples. #### New window keys: `synchronize`, `shell_command_after`, `clear` (#1047) -`synchronize: before/after/true` mirrors keystrokes across a window's -panes; `shell_command_after` runs commands in every pane after the -window is built; `clear: true` clears each pane once its commands -complete. See {ref}`top-level`. +`synchronize` sets the final pane synchronization state while tmuxp keeps +setup and post-build commands isolated per pane; `shell_command_after` runs +commands in every pane after the window is built; `clear: true` clears each +pane once its commands complete. See {ref}`top-level`. ## tmuxp 1.70.0 (2026-05-23) diff --git a/docs/configuration/examples.md b/docs/configuration/examples.md index 084cf2015c..f99caee2b9 100644 --- a/docs/configuration/examples.md +++ b/docs/configuration/examples.md @@ -533,9 +533,10 @@ Including `automatic-rename`, `default-shell`, ## Set window options after pane creation -Apply window options after panes have been created. Useful for -`synchronize-panes` option after executing individual commands in each -pane during creation. +Apply window options after panes have been created. When +`synchronize-panes` appears in `options` or `options_after`, tmuxp keeps it +disabled while sending configured commands and restores the requested final +state after the window is ready. ````{tab} YAML ```{literalinclude} ../../examples/2-pane-synchronized.yaml @@ -787,8 +788,8 @@ windows: ## Synchronize Panes Shorthand -The `synchronize` window key provides a shorthand for enabling -`synchronize-panes` without spelling out tmux options directly: +The `synchronize` window key provides a shorthand for the final +`synchronize-panes` state without spelling out tmux options directly: ````{tab} YAML ```{literalinclude} ../../examples/synchronize-shorthand.yaml diff --git a/docs/configuration/top-level.md b/docs/configuration/top-level.md index 0f4d914c85..de54d912e9 100644 --- a/docs/configuration/top-level.md +++ b/docs/configuration/top-level.md @@ -77,8 +77,10 @@ default label. Use a single space (`title: " "`) to visually blank one. ## synchronize -Window-level shorthand for setting `synchronize-panes`. It accepts -`before`, `after`, or `true`: +Window-level shorthand for the final `synchronize-panes` state. tmuxp keeps +pane synchronization disabled while it builds panes and sends configured +commands, then restores the requested synchronized state after the window is +ready. ```yaml session_name: sync-demo @@ -96,9 +98,10 @@ windows: | Value | Behavior | |-------|----------| -| `before` | Enable `synchronize-panes` before sending pane commands. | -| `after` | Enable `synchronize-panes` after sending pane commands. | -| `true` | Same as `before`. | +| `after` | Synchronize panes after tmuxp finishes building the window. | +| `before` | Compatibility alias for the same final synchronized state. | +| `true` | Compatibility alias for the same final synchronized state. | +| `false` | Force the final window state to unsynchronized. | ## shell_command_after @@ -116,8 +119,9 @@ windows: - ./start-worker.sh ``` -`shell_command_after` runs before `options_after`, so `synchronize: after` does -not duplicate the commands across synchronized panes. +tmuxp keeps `synchronize-panes` disabled while `shell_command_after` runs, then +restores the final synchronized state afterward. This prevents tmux from +duplicating post-build commands across panes. Entries accept the same command mappings as `shell_command` — `enter`, `sleep_before`, and `sleep_after` apply per command (sleeps run once per diff --git a/examples/synchronize-shorthand.yaml b/examples/synchronize-shorthand.yaml index 7fd507b809..e4d0345544 100644 --- a/examples/synchronize-shorthand.yaml +++ b/examples/synchronize-shorthand.yaml @@ -1,16 +1,17 @@ session_name: synchronize shorthand windows: - - window_name: synced-before - synchronize: before + - window_name: synced-after + synchronize: after panes: - echo 0 - echo 1 - - window_name: synced-after - synchronize: after + - window_name: synced-compat-before + synchronize: before panes: - echo 0 - echo 1 - window_name: not-synced + synchronize: false panes: - echo 0 - echo 1 diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 6ad3fd9445..2fb716e139 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -23,6 +23,12 @@ logger = logging.getLogger(__name__) +SYNCHRONIZE_PANES_OPTION = "synchronize-panes" +SYNCHRONIZE_PANES_FINAL_OPTION = "_synchronize_panes" +SYNCHRONIZE_PANES_FORCED_OFF_OPTION = "_synchronize_panes_forced_off" +SYNCHRONIZE_PANES_RESTORE_OPTION = "_synchronize_panes_restore" +_MISSING = object() + def _wait_for_pane_ready( pane: Pane, @@ -83,6 +89,62 @@ def _wait_for_pane_ready( return False +def _get_window_option_value( + window_config: dict[str, t.Any], + section: str, + option: str, +) -> t.Any: + """Return a window option value or ``_MISSING``. + + Examples + -------- + >>> cfg = {"options": {"synchronize-panes": "on"}} + >>> _get_window_option_value(cfg, "options", "synchronize-panes") + 'on' + >>> _get_window_option_value(cfg, "options_after", "synchronize-panes") is _MISSING + True + """ + options = window_config.get(section) + if isinstance(options, dict) and option in options: + return options[option] + return _MISSING + + +def _get_final_synchronize_panes(window_config: dict[str, t.Any]) -> t.Any: + """Return the final configured ``synchronize-panes`` value. + + ``options_after`` wins because it is the final tmux option bucket. + + Examples + -------- + >>> cfg = { + ... "options": {"synchronize-panes": "off"}, + ... "_synchronize_panes": True, + ... "options_after": {"synchronize-panes": "on"}, + ... } + >>> _get_final_synchronize_panes(cfg) + 'on' + >>> _get_final_synchronize_panes({}) is _MISSING + True + """ + final_sync = _get_window_option_value( + window_config, + "options", + SYNCHRONIZE_PANES_OPTION, + ) + if SYNCHRONIZE_PANES_FINAL_OPTION in window_config: + final_sync = window_config[SYNCHRONIZE_PANES_FINAL_OPTION] + + after_sync = _get_window_option_value( + window_config, + "options_after", + SYNCHRONIZE_PANES_OPTION, + ) + if after_sync is not _MISSING: + final_sync = after_sync + return final_sync + + COLUMNS_FALLBACK = 80 @@ -544,6 +606,8 @@ def build(self, session: Session | None = None, append: bool = False) -> None: for plugin in self.plugins: plugin.on_window_create(window) + self.prepare_window_synchronize_panes(window, window_config) + focus_pane = None for pane, pane_config in self.iter_create_panes(window, window_config): assert isinstance(pane, Pane) @@ -672,6 +736,8 @@ def iter_create_windows( dict, ): for key, val in window_config["options"].items(): + if key == SYNCHRONIZE_PANES_OPTION: + continue window.set_option(key, val) if window_config.get("focus"): @@ -679,6 +745,77 @@ def iter_create_windows( yield window, window_config + def prepare_window_synchronize_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> None: + """Disable tmux pane synchronization while tmuxp sends setup keys. + + Examples + -------- + >>> cfg = {"_synchronize_panes": True} + >>> builder = WorkspaceBuilder( + ... session_config={"session_name": session.name, "windows": []}, + ... server=session.server, + ... ) + >>> scratch = session.new_window(window_name="sync-prepare") + >>> builder.prepare_window_synchronize_panes(scratch, cfg) + >>> scratch.show_option("synchronize-panes") + False + >>> builder.restore_window_synchronize_panes(scratch, cfg) + >>> scratch.show_option("synchronize-panes") + True + >>> _ = scratch.kill() + """ + final_sync = _get_final_synchronize_panes(window_config) + local_sync = window.show_option(SYNCHRONIZE_PANES_OPTION) + effective_sync = window.show_option( + SYNCHRONIZE_PANES_OPTION, + include_inherited=True, + ) + + if final_sync is not _MISSING or effective_sync is True: + if final_sync is _MISSING: + window_config[SYNCHRONIZE_PANES_RESTORE_OPTION] = local_sync + window.set_option(SYNCHRONIZE_PANES_OPTION, False) + window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True + + def restore_window_synchronize_panes( + self, + window: Window, + window_config: dict[str, t.Any], + ) -> None: + """Restore the configured final tmux pane synchronization state. + + Examples + -------- + >>> cfg = {"_synchronize_panes": False} + >>> builder = WorkspaceBuilder( + ... session_config={"session_name": session.name, "windows": []}, + ... server=session.server, + ... ) + >>> scratch = session.new_window(window_name="sync-restore") + >>> builder.restore_window_synchronize_panes(scratch, cfg) + >>> scratch.show_option("synchronize-panes") + False + >>> _ = scratch.kill() + """ + final_sync = _get_final_synchronize_panes(window_config) + if final_sync is _MISSING: + if window_config.get(SYNCHRONIZE_PANES_FORCED_OFF_OPTION): + restore_sync = window_config.get( + SYNCHRONIZE_PANES_RESTORE_OPTION, + _MISSING, + ) + if restore_sync is _MISSING or restore_sync is None: + window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True) + else: + window.set_option(SYNCHRONIZE_PANES_OPTION, restore_sync) + return + + window.set_option(SYNCHRONIZE_PANES_OPTION, final_sync) + def iter_create_panes( self, window: Window, @@ -850,43 +987,44 @@ def config_after_window( """ suppress = window_config.get("suppress_history", True) - # With synchronize-panes already on (synchronize: before/true or an - # explicit option), tmux mirrors each send to every pane — send to - # one pane and let tmux broadcast. - fanout_panes = ( - window.panes[:1] - if window.show_option("synchronize-panes") is True - else window.panes - ) - - if "shell_command_after" in window_config and isinstance( - window_config["shell_command_after"], - dict, - ): - for cmd in window_config["shell_command_after"].get("shell_command", []): - enter = cmd.get("enter", True) - sleep_before = cmd.get("sleep_before") - sleep_after = cmd.get("sleep_after") - # Sleeps apply once per command wave, not once per pane. - if sleep_before is not None: - time.sleep(sleep_before) - for pane in fanout_panes: - pane.send_keys(cmd["cmd"], suppress_history=suppress, enter=enter) - if sleep_after is not None: - time.sleep(sleep_after) - - if window_config.get("clear"): - for pane in fanout_panes: - pane.send_keys("clear", enter=True, suppress_history=suppress) - - # Keep options_after last. synchronize-panes mirrors send-keys to every - # pane, so enabling it before the fan-out above duplicates commands. - if "options_after" in window_config and isinstance( - window_config["options_after"], - dict, - ): - for key, val in window_config["options_after"].items(): - window.set_option(key, val) + try: + if "shell_command_after" in window_config and isinstance( + window_config["shell_command_after"], + dict, + ): + for cmd in window_config["shell_command_after"].get( + "shell_command", + [], + ): + enter = cmd.get("enter", True) + sleep_before = cmd.get("sleep_before") + sleep_after = cmd.get("sleep_after") + # Sleeps apply once per command wave, not once per pane. + if sleep_before is not None: + time.sleep(sleep_before) + for pane in window.panes: + pane.send_keys( + cmd["cmd"], + suppress_history=suppress, + enter=enter, + ) + if sleep_after is not None: + time.sleep(sleep_after) + + if window_config.get("clear"): + for pane in window.panes: + pane.send_keys("clear", enter=True, suppress_history=suppress) + + if "options_after" in window_config and isinstance( + window_config["options_after"], + dict, + ): + for key, val in window_config["options_after"].items(): + if key == SYNCHRONIZE_PANES_OPTION: + continue + window.set_option(key, val) + finally: + self.restore_window_synchronize_panes(window, window_config) def find_current_attached_session(self) -> Session: """Return current attached session.""" diff --git a/src/tmuxp/workspace/loader.py b/src/tmuxp/workspace/loader.py index f6e32629fd..49f28f72b7 100644 --- a/src/tmuxp/workspace/loader.py +++ b/src/tmuxp/workspace/loader.py @@ -9,6 +9,8 @@ logger = logging.getLogger(__name__) +SYNCHRONIZE_PANES_FINAL_OPTION = "_synchronize_panes" + def expandshell(value: str) -> str: """Resolve shell variables based on user's ``$HOME`` and ``env``. @@ -140,10 +142,23 @@ def expand( if "synchronize" in workspace_dict: sync = workspace_dict.pop("synchronize") - if sync is True or sync == "before": - workspace_dict.setdefault("options", {})["synchronize-panes"] = "on" - elif sync == "after": - workspace_dict.setdefault("options_after", {})["synchronize-panes"] = "on" + if sync is True or sync in ["before", "after"]: + workspace_dict[SYNCHRONIZE_PANES_FINAL_OPTION] = True + elif sync is False: + workspace_dict[SYNCHRONIZE_PANES_FINAL_OPTION] = False + else: + session_name = workspace_dict.get("session_name") + if session_name is None and isinstance(parent, dict): + session_name = parent.get("session_name") + logger.warning( + "invalid synchronize value %r, expected true, false, " + "'before', or 'after'", + sync, + extra={ + "tmux_session": str(session_name or ""), + "tmux_window": str(workspace_dict.get("window_name") or ""), + }, + ) # Any workspace section, session, window, pane that can contain the # 'shell_command' value diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 74a9e480ad..f3bd89ab27 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -386,7 +386,7 @@ class SynchronizeBuilderFixture(t.NamedTuple): SynchronizeBuilderFixture( test_id="false", synchronize=False, - expected_synchronized=None, + expected_synchronized=False, ), ] @@ -400,7 +400,7 @@ def test_synchronize_builder_options( session: Session, test_id: str, synchronize: bool | str, - expected_synchronized: bool, + expected_synchronized: bool | None, ) -> None: """Synchronize shorthand sets synchronize-panes on the built window.""" workspace: dict[str, t.Any] = { @@ -421,6 +421,180 @@ def test_synchronize_builder_options( assert session.windows[0].show_option("synchronize-panes") is expected_synchronized +class SyncIsolationFixture(t.NamedTuple): + """Fixture for build-time synchronize-panes isolation.""" + + test_id: str + window_extra: dict[str, t.Any] + global_synchronize: bool + expected_local_sync: bool | None + expected_effective_sync: bool + + +SYNC_ISOLATION_FIXTURES: list[SyncIsolationFixture] = [ + SyncIsolationFixture( + test_id="sync_before", + window_extra={"synchronize": "before"}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="sync_true", + window_extra={"synchronize": True}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="sync_after", + window_extra={"synchronize": "after"}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="explicit_options_on", + window_extra={"options": {"synchronize-panes": "on"}}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="explicit_options_after_on", + window_extra={"options_after": {"synchronize-panes": "on"}}, + global_synchronize=False, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="inherits_global_on", + window_extra={}, + global_synchronize=True, + expected_local_sync=None, + expected_effective_sync=True, + ), + SyncIsolationFixture( + test_id="sync_false_overrides_global_on", + window_extra={"synchronize": False}, + global_synchronize=True, + expected_local_sync=False, + expected_effective_sync=False, + ), +] + + +@pytest.mark.parametrize( + list(SyncIsolationFixture._fields), + SYNC_ISOLATION_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_ISOLATION_FIXTURES], +) +def test_synchronize_keeps_setup_commands_isolated( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + global_synchronize: bool, + expected_local_sync: bool | None, + expected_effective_sync: bool, +) -> None: + """Pane setup commands are never broadcast while a window is building.""" + if global_synchronize: + session.server.cmd("set-window-option", "-g", "synchronize-panes", "on") + + window_config: dict[str, t.Any] = { + "window_name": f"sync-isolated-{test_id}", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + builder.build(session=session) + + window = session.windows[0] + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return ( + output_lines(panes[0], "__PANE0__") >= 1 + and output_lines(panes[1], "__PANE1__") >= 1 + ) + + assert retry_until(setup_complete), "Expected setup markers in their own panes" + assert output_lines(panes[0], "__PANE1__") == 0 + assert output_lines(panes[1], "__PANE0__") == 0 + assert window.show_option("synchronize-panes") is expected_local_sync + assert ( + window.show_option("synchronize-panes", include_inherited=True) + is expected_effective_sync + ) + + +def test_synchronize_preserves_plugin_final_state(session: Session) -> None: + """Plugin-set synchronize-panes is restored after isolated setup.""" + + class SyncOnWindowCreatePlugin: + """Plugin that chooses synchronized panes as the final window state.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the final synchronized state before pane setup starts.""" + window.set_option("synchronize-panes", True) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": "sync-plugin-final", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + server=session.server, + plugins=[SyncOnWindowCreatePlugin()], + ) + builder.build(session=session) + + window = session.windows[0] + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return ( + output_lines(panes[0], "__PANE0__") >= 1 + and output_lines(panes[1], "__PANE1__") >= 1 + ) + + assert retry_until(setup_complete), "Expected setup markers in their own panes" + assert output_lines(panes[0], "__PANE1__") == 0 + assert output_lines(panes[1], "__PANE0__") == 0 + assert window.show_option("synchronize-panes") is True + + class SyncFanoutFixture(t.NamedTuple): """Fixture for shell_command_after under synchronize-panes modes.""" @@ -451,9 +625,8 @@ def test_shell_command_after_runs_once_per_pane_when_synchronized( ) -> None: """shell_command_after runs exactly once per pane in every sync mode. - tmux mirrors send-keys across panes while synchronize-panes is on, - whether the option was enabled before the build (before/true or an - explicit option) or queued for after (options_after ordering). + tmuxp keeps synchronize-panes off while it sends post-build commands, + then restores the final configured synchronize-panes state afterward. """ window_config: dict[str, t.Any] = { "window_name": f"sync-cmds-{test_id}", diff --git a/tests/workspace/test_config.py b/tests/workspace/test_config.py index 44a1445056..dfbe29942d 100644 --- a/tests/workspace/test_config.py +++ b/tests/workspace/test_config.py @@ -338,29 +338,40 @@ class SynchronizeFixture(t.NamedTuple): test_id: str synchronize: bool | str - expected_section: str | None + expected_final_sync: bool | None + expect_warning: bool SYNCHRONIZE_FIXTURES: list[SynchronizeFixture] = [ SynchronizeFixture( test_id="true-enables-before", synchronize=True, - expected_section="options", + expected_final_sync=True, + expect_warning=False, ), SynchronizeFixture( test_id="before-enables-before", synchronize="before", - expected_section="options", + expected_final_sync=True, + expect_warning=False, ), SynchronizeFixture( test_id="after-enables-after", synchronize="after", - expected_section="options_after", + expected_final_sync=True, + expect_warning=False, ), SynchronizeFixture( - test_id="false-only-removes-key", + test_id="false-disables-final-sync", synchronize=False, - expected_section=None, + expected_final_sync=False, + expect_warning=False, + ), + SynchronizeFixture( + test_id="invalid-warns-and-removes-key", + synchronize="during", + expected_final_sync=None, + expect_warning=True, ), ] @@ -371,11 +382,13 @@ class SynchronizeFixture(t.NamedTuple): ids=[fixture.test_id for fixture in SYNCHRONIZE_FIXTURES], ) def test_expand_synchronize( + caplog: pytest.LogCaptureFixture, test_id: str, synchronize: bool | str, - expected_section: str | None, + expected_final_sync: bool | None, + expect_warning: bool, ) -> None: - """expand() desugars synchronize into tmux window options.""" + """expand() normalizes synchronize without enabling tmux during build.""" workspace: dict[str, t.Any] = { "session_name": f"sync-{test_id}", "windows": [ @@ -387,15 +400,28 @@ def test_expand_synchronize( ], } - result = loader.expand(workspace) + with caplog.at_level(logging.WARNING, logger="tmuxp.workspace.loader"): + result = loader.expand(workspace) window = result["windows"][0] assert "synchronize" not in window - if expected_section is None: - assert "synchronize-panes" not in window.get("options", {}) - assert "synchronize-panes" not in window.get("options_after", {}) + assert "synchronize-panes" not in window.get("options", {}) + assert "synchronize-panes" not in window.get("options_after", {}) + if expected_final_sync is None: + assert "_synchronize_panes" not in window else: - assert window[expected_section]["synchronize-panes"] == "on" + assert window["_synchronize_panes"] is expected_final_sync + + synchronize_warnings = [ + record + for record in caplog.records + if record.levelno == logging.WARNING and hasattr(record, "tmux_window") + ] + assert bool(synchronize_warnings) is expect_warning + if expect_warning: + warning = t.cast(t.Any, synchronize_warnings[0]) + assert warning.tmux_session == f"sync-{test_id}" + assert warning.tmux_window == "main" class ShellCommandAfterFixture(t.NamedTuple): From 3ee79c1657daa39572b8e6ce1bb271e88ac00cb1 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 11:41:50 -0500 Subject: [PATCH 12/14] Workspace(fix[runtime]): Preserve synchronize option in iterators why: Lower-level callers can use iter_create_windows() directly and expect raw window options to apply immediately. Deferring synchronize-panes unconditionally made that option a no-op outside the high-level build flow. what: - Apply synchronize-panes in direct iter_create_windows() calls by default - Let build() opt into deferring synchronize-panes until the safe restore phase - Add iterator-level coverage for raw synchronize-panes on/off options --- src/tmuxp/workspace/builder.py | 12 ++++++-- tests/workspace/test_builder.py | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 2fb716e139..9672db7ffd 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -600,7 +600,11 @@ 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) - for window, window_config in self.iter_create_windows(session, append): + for window, window_config in self.iter_create_windows( + session, + append, + defer_synchronize_panes=True, + ): assert isinstance(window, Window) for plugin in self.plugins: @@ -643,6 +647,7 @@ def iter_create_windows( self, session: Session, append: bool = False, + defer_synchronize_panes: bool = False, ) -> Iterator[t.Any]: """Return :class:`libtmux.Window` iterating through session config dict. @@ -657,6 +662,9 @@ def iter_create_windows( session to create windows in append : bool append windows in current active session + defer_synchronize_panes : bool + defer ``synchronize-panes`` until the high-level build can restore + it after setup commands run Returns ------- @@ -736,7 +744,7 @@ def iter_create_windows( dict, ): for key, val in window_config["options"].items(): - if key == SYNCHRONIZE_PANES_OPTION: + if defer_synchronize_panes and key == SYNCHRONIZE_PANES_OPTION: continue window.set_option(key, val) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index f3bd89ab27..d021099284 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -421,6 +421,58 @@ def test_synchronize_builder_options( assert session.windows[0].show_option("synchronize-panes") is expected_synchronized +class IteratorSynchronizeOptionFixture(t.NamedTuple): + """Fixture for direct iterator synchronize-panes option behavior.""" + + test_id: str + option_value: str + expected_synchronized: bool + + +ITERATOR_SYNCHRONIZE_OPTION_FIXTURES: list[IteratorSynchronizeOptionFixture] = [ + IteratorSynchronizeOptionFixture( + test_id="on", + option_value="on", + expected_synchronized=True, + ), + IteratorSynchronizeOptionFixture( + test_id="off", + option_value="off", + expected_synchronized=False, + ), +] + + +@pytest.mark.parametrize( + list(IteratorSynchronizeOptionFixture._fields), + ITERATOR_SYNCHRONIZE_OPTION_FIXTURES, + ids=[fixture.test_id for fixture in ITERATOR_SYNCHRONIZE_OPTION_FIXTURES], +) +def test_iter_create_windows_preserves_raw_synchronize_panes_option( + session: Session, + test_id: str, + option_value: str, + expected_synchronized: bool, +) -> None: + """Direct iter_create_windows() calls apply raw synchronize-panes options.""" + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [ + { + "window_name": f"iterator-sync-{test_id}", + "options": {"synchronize-panes": option_value}, + "panes": ["echo pane0"], + }, + ], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=session.server) + window, _window_config = next(builder.iter_create_windows(session=session)) + + assert window.show_option("synchronize-panes") is expected_synchronized + + class SyncIsolationFixture(t.NamedTuple): """Fixture for build-time synchronize-panes isolation.""" From 566f3c7be0e5528fd9a22279a58c8a6183b77a38 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 12:23:21 -0500 Subject: [PATCH 13/14] Workspace(fix[runtime]): Preserve synchronize plugin overrides why: synchronize-panes was replayed from parsed config after on_window_create, unlike ordinary window options. That erased plugin changes on configured windows and made the temporary synchronization guard own final state. what: - Apply initial synchronize settings as live window state before on_window_create - Restore captured live sync state after tmuxp-owned key fan-out unless options_after supplies the late value - Cover on_window_create, options_after, and after_window_finished precedence for synchronize-panes --- src/tmuxp/workspace/builder.py | 95 +++++--------- tests/workspace/test_builder.py | 213 +++++++++++++++++++++++++++----- 2 files changed, 211 insertions(+), 97 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 9672db7ffd..d8397a6015 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -110,41 +110,6 @@ def _get_window_option_value( return _MISSING -def _get_final_synchronize_panes(window_config: dict[str, t.Any]) -> t.Any: - """Return the final configured ``synchronize-panes`` value. - - ``options_after`` wins because it is the final tmux option bucket. - - Examples - -------- - >>> cfg = { - ... "options": {"synchronize-panes": "off"}, - ... "_synchronize_panes": True, - ... "options_after": {"synchronize-panes": "on"}, - ... } - >>> _get_final_synchronize_panes(cfg) - 'on' - >>> _get_final_synchronize_panes({}) is _MISSING - True - """ - final_sync = _get_window_option_value( - window_config, - "options", - SYNCHRONIZE_PANES_OPTION, - ) - if SYNCHRONIZE_PANES_FINAL_OPTION in window_config: - final_sync = window_config[SYNCHRONIZE_PANES_FINAL_OPTION] - - after_sync = _get_window_option_value( - window_config, - "options_after", - SYNCHRONIZE_PANES_OPTION, - ) - if after_sync is not _MISSING: - final_sync = after_sync - return final_sync - - COLUMNS_FALLBACK = 80 @@ -600,13 +565,15 @@ 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) - for window, window_config in self.iter_create_windows( - session, - append, - defer_synchronize_panes=True, - ): + for window, window_config in self.iter_create_windows(session, append): assert isinstance(window, Window) + if SYNCHRONIZE_PANES_FINAL_OPTION in window_config: + window.set_option( + SYNCHRONIZE_PANES_OPTION, + window_config[SYNCHRONIZE_PANES_FINAL_OPTION], + ) + for plugin in self.plugins: plugin.on_window_create(window) @@ -647,7 +614,6 @@ def iter_create_windows( self, session: Session, append: bool = False, - defer_synchronize_panes: bool = False, ) -> Iterator[t.Any]: """Return :class:`libtmux.Window` iterating through session config dict. @@ -662,9 +628,6 @@ def iter_create_windows( session to create windows in append : bool append windows in current active session - defer_synchronize_panes : bool - defer ``synchronize-panes`` until the high-level build can restore - it after setup commands run Returns ------- @@ -744,8 +707,6 @@ def iter_create_windows( dict, ): for key, val in window_config["options"].items(): - if defer_synchronize_panes and key == SYNCHRONIZE_PANES_OPTION: - continue window.set_option(key, val) if window_config.get("focus"): @@ -762,12 +723,13 @@ def prepare_window_synchronize_panes( Examples -------- - >>> cfg = {"_synchronize_panes": True} + >>> cfg = {} >>> builder = WorkspaceBuilder( ... session_config={"session_name": session.name, "windows": []}, ... server=session.server, ... ) >>> scratch = session.new_window(window_name="sync-prepare") + >>> _ = scratch.set_option("synchronize-panes", True) >>> builder.prepare_window_synchronize_panes(scratch, cfg) >>> scratch.show_option("synchronize-panes") False @@ -776,16 +738,14 @@ def prepare_window_synchronize_panes( True >>> _ = scratch.kill() """ - final_sync = _get_final_synchronize_panes(window_config) local_sync = window.show_option(SYNCHRONIZE_PANES_OPTION) effective_sync = window.show_option( SYNCHRONIZE_PANES_OPTION, include_inherited=True, ) - if final_sync is not _MISSING or effective_sync is True: - if final_sync is _MISSING: - window_config[SYNCHRONIZE_PANES_RESTORE_OPTION] = local_sync + if effective_sync is True: + window_config[SYNCHRONIZE_PANES_RESTORE_OPTION] = local_sync window.set_option(SYNCHRONIZE_PANES_OPTION, False) window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True @@ -798,31 +758,38 @@ def restore_window_synchronize_panes( Examples -------- - >>> cfg = {"_synchronize_panes": False} + >>> cfg = {"options_after": {"synchronize-panes": "off"}} >>> builder = WorkspaceBuilder( ... session_config={"session_name": session.name, "windows": []}, ... server=session.server, ... ) >>> scratch = session.new_window(window_name="sync-restore") + >>> _ = scratch.set_option("synchronize-panes", True) >>> builder.restore_window_synchronize_panes(scratch, cfg) >>> scratch.show_option("synchronize-panes") False >>> _ = scratch.kill() """ - final_sync = _get_final_synchronize_panes(window_config) - if final_sync is _MISSING: - if window_config.get(SYNCHRONIZE_PANES_FORCED_OFF_OPTION): - restore_sync = window_config.get( - SYNCHRONIZE_PANES_RESTORE_OPTION, - _MISSING, - ) - if restore_sync is _MISSING or restore_sync is None: - window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True) - else: - window.set_option(SYNCHRONIZE_PANES_OPTION, restore_sync) + after_sync = _get_window_option_value( + window_config, + "options_after", + SYNCHRONIZE_PANES_OPTION, + ) + if after_sync is not _MISSING: + window.set_option(SYNCHRONIZE_PANES_OPTION, after_sync) return - window.set_option(SYNCHRONIZE_PANES_OPTION, final_sync) + if not window_config.get(SYNCHRONIZE_PANES_FORCED_OFF_OPTION): + return + + restore_sync = window_config.get( + SYNCHRONIZE_PANES_RESTORE_OPTION, + _MISSING, + ) + if restore_sync is _MISSING or restore_sync is None: + window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True) + else: + window.set_option(SYNCHRONIZE_PANES_OPTION, restore_sync) def iter_create_panes( self, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index d021099284..d72e8dab1b 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -592,59 +592,206 @@ def setup_complete() -> bool: ) -def test_synchronize_preserves_plugin_final_state(session: Session) -> None: - """Plugin-set synchronize-panes is restored after isolated setup.""" +class SyncPluginOverrideFixture(t.NamedTuple): + """Fixture for plugin synchronize-panes override behavior.""" - class SyncOnWindowCreatePlugin: - """Plugin that chooses synchronized panes as the final window state.""" + test_id: str + window_extra: dict[str, t.Any] + plugin_synchronize: bool + expected_synchronized: bool - def before_workspace_builder(self, session: Session) -> None: - """No-op workspace hook.""" - def on_window_create(self, window: Window) -> None: - """Set the final synchronized state before pane setup starts.""" - window.set_option("synchronize-panes", True) +SYNC_PLUGIN_OVERRIDE_FIXTURES: list[SyncPluginOverrideFixture] = [ + SyncPluginOverrideFixture( + test_id="no_config_plugin_on", + window_extra={}, + plugin_synchronize=True, + expected_synchronized=True, + ), + SyncPluginOverrideFixture( + test_id="options_on_plugin_off", + window_extra={"options": {"synchronize-panes": "on"}}, + plugin_synchronize=False, + expected_synchronized=False, + ), + SyncPluginOverrideFixture( + test_id="options_off_plugin_on", + window_extra={"options": {"synchronize-panes": "off"}}, + plugin_synchronize=True, + expected_synchronized=True, + ), + SyncPluginOverrideFixture( + test_id="synchronize_true_plugin_off", + window_extra={"synchronize": True}, + plugin_synchronize=False, + expected_synchronized=False, + ), + SyncPluginOverrideFixture( + test_id="synchronize_false_plugin_on", + window_extra={"synchronize": False}, + plugin_synchronize=True, + expected_synchronized=True, + ), +] - def after_window_finished(self, window: Window) -> None: - """No-op window hook.""" +def _assert_synchronize_setup_isolated(window: Window) -> None: + """Assert pane setup commands did not broadcast across synchronized panes.""" + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return ( + output_lines(panes[0], "__PANE0__") >= 1 + and output_lines(panes[1], "__PANE1__") >= 1 + ) + + assert retry_until(setup_complete), "Expected setup markers in their own panes" + assert output_lines(panes[0], "__PANE1__") == 0 + assert output_lines(panes[1], "__PANE0__") == 0 + + +def _build_synchronize_plugin_workspace( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + plugins: list[t.Any], +) -> Window: + """Build a two-pane workspace for synchronize-panes plugin tests.""" + window_config: dict[str, t.Any] = { + "window_name": f"sync-plugin-{test_id}", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) workspace: dict[str, t.Any] = { "session_name": session.name, - "windows": [ - { - "window_name": "sync-plugin-final", - "panes": [ - "printf '__PANE0__\\n'", - "printf '__PANE1__\\n'", - ], - }, - ], + "windows": [window_config], } workspace = loader.expand(workspace) builder = WorkspaceBuilder( session_config=workspace, server=session.server, - plugins=[SyncOnWindowCreatePlugin()], + plugins=plugins, ) builder.build(session=session) window = session.windows[0] - panes = window.panes + _assert_synchronize_setup_isolated(window) + return window - def output_lines(pane: Pane, marker: str) -> int: - return sum(1 for line in pane.capture_pane() if line.strip() == marker) - def setup_complete() -> bool: - return ( - output_lines(panes[0], "__PANE0__") >= 1 - and output_lines(panes[1], "__PANE1__") >= 1 - ) +@pytest.mark.parametrize( + list(SyncPluginOverrideFixture._fields), + SYNC_PLUGIN_OVERRIDE_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_PLUGIN_OVERRIDE_FIXTURES], +) +def test_synchronize_preserves_on_window_create_plugin_override( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + plugin_synchronize: bool, + expected_synchronized: bool, +) -> None: + """on_window_create can override configured initial synchronize-panes state.""" - assert retry_until(setup_complete), "Expected setup markers in their own panes" - assert output_lines(panes[0], "__PANE1__") == 0 - assert output_lines(panes[1], "__PANE0__") == 0 - assert window.show_option("synchronize-panes") is True + class SyncOnWindowCreatePlugin: + """Plugin that chooses the final sync state before pane setup.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the final synchronized state before pane setup starts.""" + window.set_option("synchronize-panes", plugin_synchronize) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + window = _build_synchronize_plugin_workspace( + session=session, + test_id=test_id, + window_extra=window_extra, + plugins=[SyncOnWindowCreatePlugin()], + ) + + assert window.show_option("synchronize-panes") is expected_synchronized + + +class SyncPluginPrecedenceFixture(t.NamedTuple): + """Fixture for synchronize-panes plugin/config precedence.""" + + test_id: str + window_extra: dict[str, t.Any] + on_window_create_synchronize: bool + after_window_finished_synchronize: bool | None + expected_synchronized: bool + + +SYNC_PLUGIN_PRECEDENCE_FIXTURES: list[SyncPluginPrecedenceFixture] = [ + SyncPluginPrecedenceFixture( + test_id="options_after_on_overrides_on_window_create_off", + window_extra={"options_after": {"synchronize-panes": "on"}}, + on_window_create_synchronize=False, + after_window_finished_synchronize=None, + expected_synchronized=True, + ), + SyncPluginPrecedenceFixture( + test_id="after_window_finished_overrides_options_after", + window_extra={"options_after": {"synchronize-panes": "on"}}, + on_window_create_synchronize=False, + after_window_finished_synchronize=False, + expected_synchronized=False, + ), +] + + +@pytest.mark.parametrize( + list(SyncPluginPrecedenceFixture._fields), + SYNC_PLUGIN_PRECEDENCE_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_PLUGIN_PRECEDENCE_FIXTURES], +) +def test_synchronize_options_after_and_plugin_precedence( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + on_window_create_synchronize: bool, + after_window_finished_synchronize: bool | None, + expected_synchronized: bool, +) -> None: + """options_after and after_window_finished keep their usual late precedence.""" + + class SyncPrecedencePlugin: + """Plugin that mutates sync state at both window hook boundaries.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the synchronized state before pane setup starts.""" + window.set_option("synchronize-panes", on_window_create_synchronize) + + def after_window_finished(self, window: Window) -> None: + """Optionally set the synchronized state after options_after.""" + if after_window_finished_synchronize is not None: + window.set_option( + "synchronize-panes", + after_window_finished_synchronize, + ) + + window = _build_synchronize_plugin_workspace( + session=session, + test_id=test_id, + window_extra=window_extra, + plugins=[SyncPrecedencePlugin()], + ) + + assert window.show_option("synchronize-panes") is expected_synchronized class SyncFanoutFixture(t.NamedTuple): From a45ab9b591ba5d31028d943e4788d481018ef268 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 7 Jun 2026 13:42:32 -0500 Subject: [PATCH 14/14] Workspace(fix[synchronize-panes]): Restore sync state on setup failure why: Window setup temporarily disables tmux pane synchronization so pane setup commands do not broadcast across panes. If pane creation or layout application fails, that temporary state must still be restored so partial windows and existing/global tmux state are not left with an unintended local override. what: - Restore synchronize-panes from a build-level finally block when pane setup aborts - Preserve and restore pane-local synchronize-panes overrides while setup is isolated - Cover raw options, inherited global state, options_after, and plugin-set synchronization state in regression tests --- src/tmuxp/workspace/builder.py | 101 ++++++++++---- tests/workspace/test_builder.py | 234 ++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+), 24 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index d8397a6015..bba40570b6 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.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -26,6 +27,7 @@ SYNCHRONIZE_PANES_OPTION = "synchronize-panes" SYNCHRONIZE_PANES_FINAL_OPTION = "_synchronize_panes" SYNCHRONIZE_PANES_FORCED_OFF_OPTION = "_synchronize_panes_forced_off" +SYNCHRONIZE_PANES_PANE_RESTORE_OPTION = "_synchronize_panes_pane_restore" SYNCHRONIZE_PANES_RESTORE_OPTION = "_synchronize_panes_restore" _MISSING = object() @@ -580,17 +582,24 @@ def build(self, session: Session | None = None, append: bool = False) -> None: self.prepare_window_synchronize_panes(window, window_config) focus_pane = None - for pane, pane_config in self.iter_create_panes(window, window_config): - assert isinstance(pane, Pane) - pane = pane + try: + for pane, pane_config in self.iter_create_panes(window, window_config): + assert isinstance(pane, Pane) + pane = pane - if pane_config.get("focus"): - focus_pane = pane + if pane_config.get("focus"): + focus_pane = pane - if window_config.get("focus"): - focus = window + if window_config.get("focus"): + focus = window - self.config_after_window(window, window_config) + self.config_after_window(window, window_config) + finally: + self.restore_window_synchronize_panes( + window, + window_config, + apply_options_after=False, + ) for plugin in self.plugins: plugin.after_window_finished(window) @@ -749,10 +758,31 @@ def prepare_window_synchronize_panes( window.set_option(SYNCHRONIZE_PANES_OPTION, False) window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True + pane_restores = [] + for pane in window.panes: + local_pane_sync = pane.show_option( + SYNCHRONIZE_PANES_OPTION, + scope=OptionScope.Pane, + ) + if local_pane_sync is True: + pane_restores.append((pane, local_pane_sync)) + pane.set_option( + SYNCHRONIZE_PANES_OPTION, + False, + scope=OptionScope.Pane, + ignore_errors=True, + ) + + if pane_restores: + window_config[SYNCHRONIZE_PANES_PANE_RESTORE_OPTION] = pane_restores + window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True + def restore_window_synchronize_panes( self, window: Window, window_config: dict[str, t.Any], + *, + apply_options_after: bool = True, ) -> None: """Restore the configured final tmux pane synchronization state. @@ -770,26 +800,49 @@ def restore_window_synchronize_panes( False >>> _ = scratch.kill() """ - after_sync = _get_window_option_value( - window_config, - "options_after", - SYNCHRONIZE_PANES_OPTION, - ) - if after_sync is not _MISSING: - window.set_option(SYNCHRONIZE_PANES_OPTION, after_sync) - return - - if not window_config.get(SYNCHRONIZE_PANES_FORCED_OFF_OPTION): - return + after_sync = _MISSING + if apply_options_after: + after_sync = _get_window_option_value( + window_config, + "options_after", + SYNCHRONIZE_PANES_OPTION, + ) - restore_sync = window_config.get( + pane_restores = window_config.pop( + SYNCHRONIZE_PANES_PANE_RESTORE_OPTION, + [], + ) + forced_off = window_config.pop(SYNCHRONIZE_PANES_FORCED_OFF_OPTION, False) + restore_sync = window_config.pop( SYNCHRONIZE_PANES_RESTORE_OPTION, _MISSING, ) - if restore_sync is _MISSING or restore_sync is None: - window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True) - else: - window.set_option(SYNCHRONIZE_PANES_OPTION, restore_sync) + + if after_sync is not _MISSING: + window.set_option(SYNCHRONIZE_PANES_OPTION, t.cast(int | str, after_sync)) + elif forced_off and restore_sync is not _MISSING: + if restore_sync is None: + window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True) + else: + window.set_option( + SYNCHRONIZE_PANES_OPTION, + t.cast(int | str, restore_sync), + ) + + for pane, restore_pane_sync in pane_restores: + if restore_pane_sync is _MISSING or restore_pane_sync is None: + pane.unset_option( + SYNCHRONIZE_PANES_OPTION, + scope=OptionScope.Pane, + ignore_errors=True, + ) + else: + pane.set_option( + SYNCHRONIZE_PANES_OPTION, + t.cast(int | str, restore_pane_sync), + scope=OptionScope.Pane, + ignore_errors=True, + ) def iter_create_panes( self, diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index d72e8dab1b..da681f585f 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 @@ -592,6 +593,239 @@ def setup_complete() -> bool: ) +class SyncSetupAbortFixture(t.NamedTuple): + """Fixture for synchronize-panes cleanup after pane setup aborts.""" + + test_id: str + window_extra: dict[str, t.Any] + global_synchronize: bool + plugin_synchronize: bool | None + expected_local_sync: bool | None + expected_effective_sync: bool + + +SYNC_SETUP_ABORT_FIXTURES: list[SyncSetupAbortFixture] = [ + SyncSetupAbortFixture( + test_id="sync_true", + window_extra={"synchronize": True}, + global_synchronize=False, + plugin_synchronize=None, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="raw_option_on", + window_extra={"options": {"synchronize-panes": "on"}}, + global_synchronize=False, + plugin_synchronize=None, + expected_local_sync=True, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="inherits_global_on", + window_extra={}, + global_synchronize=True, + plugin_synchronize=None, + expected_local_sync=None, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="options_after_off_keeps_inherited_global_on", + window_extra={"options_after": {"synchronize-panes": "off"}}, + global_synchronize=True, + plugin_synchronize=None, + expected_local_sync=None, + expected_effective_sync=True, + ), + SyncSetupAbortFixture( + test_id="options_after_on_does_not_run_on_setup_abort", + window_extra={"options_after": {"synchronize-panes": "on"}}, + global_synchronize=False, + plugin_synchronize=None, + expected_local_sync=None, + expected_effective_sync=False, + ), + SyncSetupAbortFixture( + test_id="plugin_on", + window_extra={}, + global_synchronize=False, + plugin_synchronize=True, + expected_local_sync=True, + expected_effective_sync=True, + ), +] + + +@pytest.mark.parametrize( + list(SyncSetupAbortFixture._fields), + SYNC_SETUP_ABORT_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_SETUP_ABORT_FIXTURES], +) +def test_synchronize_restores_state_when_pane_setup_aborts( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + global_synchronize: bool, + plugin_synchronize: bool | None, + expected_local_sync: bool | None, + expected_effective_sync: bool, +) -> None: + """Pane setup failures restore temporarily disabled synchronize-panes state.""" + + class SyncOnWindowCreatePlugin: + """Plugin that optionally sets the sync state before pane setup.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Set the synchronized state before pane setup starts.""" + if plugin_synchronize is not None: + window.set_option("synchronize-panes", plugin_synchronize) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + try: + if global_synchronize: + session.server.cmd("set-window-option", "-g", "synchronize-panes", "on") + + window_config: dict[str, t.Any] = { + "window_name": f"sync-abort-{test_id}", + "layout": "not-a-layout", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + plugins: list[t.Any] = [] + if plugin_synchronize is not None: + plugins.append(SyncOnWindowCreatePlugin()) + builder = WorkspaceBuilder( + session_config=workspace, + server=session.server, + plugins=plugins, + ) + + with pytest.raises(LibTmuxException, match="invalid layout"): + builder.build(session=session) + + window = session.windows[0] + assert window.show_option("synchronize-panes") is expected_local_sync + assert ( + window.show_option("synchronize-panes", include_inherited=True) + is expected_effective_sync + ) + finally: + session.server.cmd("set-window-option", "-gu", "synchronize-panes") + + +class SyncPaneLocalPluginFixture(t.NamedTuple): + """Fixture for plugin-created pane-local synchronize-panes state.""" + + test_id: str + window_extra: dict[str, t.Any] + expected_window_sync: bool | None + + +SYNC_PANE_LOCAL_PLUGIN_FIXTURES: list[SyncPaneLocalPluginFixture] = [ + SyncPaneLocalPluginFixture( + test_id="window_option_on", + window_extra={"options": {"synchronize-panes": "on"}}, + expected_window_sync=True, + ), + SyncPaneLocalPluginFixture( + test_id="synchronize_false", + window_extra={"synchronize": False}, + expected_window_sync=False, + ), +] + + +@pytest.mark.parametrize( + list(SyncPaneLocalPluginFixture._fields), + SYNC_PANE_LOCAL_PLUGIN_FIXTURES, + ids=[fixture.test_id for fixture in SYNC_PANE_LOCAL_PLUGIN_FIXTURES], +) +def test_synchronize_disables_plugin_pane_local_state_during_setup( + session: Session, + test_id: str, + window_extra: dict[str, t.Any], + expected_window_sync: bool | None, +) -> None: + """Pane-local plugin sync state does not broadcast tmuxp setup commands.""" + + class PaneLocalSyncPlugin: + """Plugin that creates panes with pane-local sync before tmuxp setup.""" + + def before_workspace_builder(self, session: Session) -> None: + """No-op workspace hook.""" + + def on_window_create(self, window: Window) -> None: + """Create pane-local sync state before tmuxp sends setup keys.""" + first = window.active_pane + assert isinstance(first, Pane) + second = first.split(attach=True) + assert isinstance(second, Pane) + first.set_option( + "synchronize-panes", + True, + scope=OptionScope.Pane, + ) + second.set_option( + "synchronize-panes", + True, + scope=OptionScope.Pane, + ) + + def after_window_finished(self, window: Window) -> None: + """No-op window hook.""" + + window_config: dict[str, t.Any] = { + "window_name": f"sync-pane-local-{test_id}", + "panes": [ + "printf '__PANE0__\\n'", + "printf '__PANE1__\\n'", + ], + } + window_config.update(window_extra) + workspace: dict[str, t.Any] = { + "session_name": session.name, + "windows": [window_config], + } + workspace = loader.expand(workspace) + + builder = WorkspaceBuilder( + session_config=workspace, + server=session.server, + plugins=[PaneLocalSyncPlugin()], + ) + builder.build(session=session) + + window = session.windows[0] + panes = window.panes + + def output_lines(pane: Pane, marker: str) -> int: + return sum(1 for line in pane.capture_pane() if line.strip() == marker) + + def setup_complete() -> bool: + return sum(output_lines(pane, "__PANE1__") for pane in panes) == 1 + + assert retry_until(setup_complete), "Expected plugin-pane setup to finish" + assert sum(output_lines(pane, "__PANE0__") for pane in panes) == 1 + assert sum(output_lines(pane, "__PANE1__") for pane in panes) == 1 + assert panes[0].show_option("synchronize-panes") is True + assert panes[1].show_option("synchronize-panes") is True + assert window.show_option("synchronize-panes") is expected_window_sync + + class SyncPluginOverrideFixture(t.NamedTuple): """Fixture for plugin synchronize-panes override behavior."""