diff --git a/CHANGES b/CHANGES index 8986bd2e86..e2340ae967 100644 --- a/CHANGES +++ b/CHANGES @@ -35,6 +35,16 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force _Notes on the upcoming release will go here._ +### Bug fixes + +#### Fix `%` character appearing in panes on workspace load (#1018) + +Fixed long-standing issue ([#365]) where zsh panes displayed an inverse `%` marker after +loading a workspace. WorkspaceBuilder now waits for each pane's shell to be ready before +applying layout or sending commands. + +[#365]: https://github.com/tmux-python/tmuxp/issues/365 + ### Documentation #### Linkable CLI arguments and options (#1010) diff --git a/conftest.py b/conftest.py index 0cd35869ca..5ae04a57a3 100644 --- a/conftest.py +++ b/conftest.py @@ -104,6 +104,15 @@ def socket_name(request: pytest.FixtureRequest) -> str: } +def pytest_collection_modifyitems(items: list[pytest.Item]) -> None: + """Add rerun markers to tmux-dependent doctests for flaky shell timing.""" + for item in items: + if isinstance(item, DoctestItem): + module_name = item.dtest.globs.get("__name__", "") + if module_name in DOCTEST_NEEDS_TMUX: + item.add_marker(pytest.mark.flaky(reruns=2)) + + @pytest.fixture(autouse=True) def add_doctest_fixtures( request: pytest.FixtureRequest, diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 31f76b97f8..24c93b3c24 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -22,6 +22,66 @@ logger = logging.getLogger(__name__) + +def _wait_for_pane_ready( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, +) -> bool: + """Wait for pane shell to draw its prompt. + + Polls the pane's cursor position until it moves from origin (0, 0), + indicating the shell has finished initializing and drawn its prompt. + + Parameters + ---------- + pane : :class:`libtmux.Pane` + pane to wait for + timeout : float + maximum seconds to wait before giving up + interval : float + seconds between polling attempts + + Returns + ------- + bool + True if pane became ready, False on timeout or error + + Examples + -------- + >>> pane = session.active_window.active_pane + + Wait for the shell to be ready: + + >>> _wait_for_pane_ready(pane, timeout=5.0) + True + """ + start = time.monotonic() + while time.monotonic() - start < timeout: + try: + pane.refresh() + except Exception: + logger.debug( + "pane refresh failed during readiness check", + exc_info=True, + extra={"tmux_pane": str(pane.pane_id)}, + ) + return False + if pane.cursor_x != "0" or pane.cursor_y != "0": + logger.debug( + "pane ready, cursor moved from origin", + extra={"tmux_pane": str(pane.pane_id)}, + ) + return True + time.sleep(interval) + logger.debug( + "pane readiness check timed out after %.1f seconds", + timeout, + extra={"tmux_pane": str(pane.pane_id)}, + ) + return False + + COLUMNS_FALLBACK = 80 @@ -323,9 +383,6 @@ def build(self, session: Session | None = None, append: bool = False) -> None: assert isinstance(pane, Pane) pane = pane - if "layout" in window_config: - window.select_layout(window_config["layout"]) - if pane_config.get("focus"): focus_pane = pane @@ -507,6 +564,14 @@ def get_pane_shell( assert isinstance(pane, Pane) + # Skip readiness wait when a custom shell/command launcher is set. + # The shell/window_shell key runs a command (e.g. "top", "sleep 999") + # that replaces the default shell — the pane exits when the command + # exits, so there is no interactive prompt to wait for. + pane_shell = pane_config.get("shell", window_config.get("window_shell")) + if pane_shell is None: + _wait_for_pane_ready(pane) + if "layout" in window_config: window.select_layout(window_config["layout"]) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index 6917260be6..6b78dfcbd3 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -24,8 +24,8 @@ from tmuxp import exc from tmuxp._internal.config_reader import ConfigReader from tmuxp.cli.load import load_plugins -from tmuxp.workspace import loader -from tmuxp.workspace.builder import WorkspaceBuilder +from tmuxp.workspace import builder as builder_module, loader +from tmuxp.workspace.builder import WorkspaceBuilder, _wait_for_pane_ready if t.TYPE_CHECKING: from libtmux.server import Server @@ -1513,3 +1513,171 @@ def test_issue_800_default_size_many_windows( builder.build() assert len(server.sessions) == 1 + + +def test_wait_for_pane_ready_returns_true(session: Session) -> None: + """Verify _wait_for_pane_ready detects shell prompt.""" + pane = session.active_window.active_pane + assert pane is not None + result = _wait_for_pane_ready(pane, timeout=2.0) + assert result is True + + +def test_wait_for_pane_ready_timeout(session: Session) -> None: + """Verify _wait_for_pane_ready returns False on timeout for non-shell.""" + window = session.active_window + assert window.active_pane is not None + new_pane = window.active_pane.split(shell="sleep 999") + assert new_pane is not None + result = _wait_for_pane_ready(new_pane, timeout=0.2) + assert result is False + + +class PaneReadinessFixture(t.NamedTuple): + """Test fixture for pane readiness call count verification.""" + + test_id: str + yaml: str + expected_wait_count: int + + +PANE_READINESS_FIXTURES: list[PaneReadinessFixture] = [ + PaneReadinessFixture( + test_id="waits_for_pane_with_commands", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell_command: + - cmd: echo world +""", + ), + expected_wait_count=2, + ), + PaneReadinessFixture( + test_id="waits_for_pane_without_commands", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell_command: [] +""", + ), + expected_wait_count=2, + ), + PaneReadinessFixture( + test_id="skips_pane_with_custom_shell", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +windows: +- panes: + - shell_command: + - cmd: echo hello + - shell: sleep 999 + shell_command: + - cmd: echo world +""", + ), + expected_wait_count=1, + ), + PaneReadinessFixture( + test_id="skips_all_panes_with_window_shell", + yaml=textwrap.dedent( + """\ +session_name: readiness-test +windows: +- window_shell: top + panes: + - shell_command: [] + - shell_command: [] +""", + ), + expected_wait_count=0, + ), +] + + +@pytest.mark.parametrize( + list(PaneReadinessFixture._fields), + PANE_READINESS_FIXTURES, + ids=[t.test_id for t in PANE_READINESS_FIXTURES], +) +def test_pane_readiness_call_count( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, + test_id: str, + yaml: str, + expected_wait_count: int, +) -> None: + """Verify _wait_for_pane_ready is called only for appropriate panes.""" + call_count = 0 + original = builder_module._wait_for_pane_ready + + def counting_wait( + pane: Pane, + timeout: float = 2.0, + interval: float = 0.05, + ) -> bool: + nonlocal call_count + call_count += 1 + return original(pane, timeout=timeout, interval=interval) + + monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait) + + yaml_workspace = tmp_path / "readiness.yaml" + yaml_workspace.write_text(yaml, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + assert call_count == expected_wait_count + + +def test_select_layout_not_called_after_yield( + tmp_path: pathlib.Path, + server: Server, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify select_layout is called once per pane, not duplicated in build().""" + call_count = 0 + original_select_layout = Window.select_layout + + def counting_layout(self: Window, layout: str | None = None) -> Window: + nonlocal call_count + call_count += 1 + return original_select_layout(self, layout) + + monkeypatch.setattr(Window, "select_layout", counting_layout) + + yaml_config = textwrap.dedent( + """\ +session_name: layout-test +windows: +- layout: main-vertical + panes: + - shell_command: [] + - shell_command: [] + - shell_command: [] +""", + ) + + yaml_workspace = tmp_path / "layout.yaml" + yaml_workspace.write_text(yaml_config, encoding="utf-8") + workspace = ConfigReader._from_file(yaml_workspace) + workspace = loader.expand(workspace) + workspace = loader.trickle(workspace) + + builder = WorkspaceBuilder(session_config=workspace, server=server) + builder.build() + # 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6 + assert call_count == 3