From 2b3eb13e5944b2605050abdea4a530cfc448a26f Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 06:46:54 -0500 Subject: [PATCH 1/7] workspace/builder(fix[pane-readiness]): Wait for shell prompt before layout and commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: WorkspaceBuilder sends SIGWINCH (via select_layout) and commands (via send_keys) to panes before their shell finishes initializing (#365, open since 2018). With zsh, this triggers the PROMPT_SP partial-line marker — an inverse+bold "%" — because: tmux: screen_reinit() sets cx=cy=0 on new panes (screen.c:107-108). select_layout → layout_fix_panes → window_pane_resize enqueues a resize, and window_pane_send_resize delivers SIGWINCH via ioctl(TIOCSWINSZ) on the pane's PTY (window.c:449). zsh: preprompt() (utils.c:1530) runs the PROMPT_SP heuristic (utils.c:1545-1566) before every prompt. It outputs the PROMPT_EOL_MARK (default: "%B%S%#%s%b") to detect partial lines, then pads with spaces and returns the cursor. When SIGWINCH arrives mid-init, the interrupted redraw leaves the marker visible. what: - Add _wait_for_pane_ready() that polls cursor position until it moves from origin (0,0), indicating the shell has drawn its prompt - Call readiness check before select_layout in iter_create_panes() so SIGWINCH only arrives after the shell can handle resize gracefully - Wait for all default-shell panes, not just those with commands (blank panes via "- pane" shorthand expand to shell_command: []) - Remove redundant select_layout call in build() that doubled SIGWINCH signals after each pane yield - Add tests for readiness detection, timeout, call count, and layout deduplication --- src/tmuxp/workspace/builder.py | 66 ++++++++++++- tests/workspace/test_builder.py | 158 +++++++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 5 deletions(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 31f76b97f8..72ad5843a8 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -22,6 +22,65 @@ 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=2.0) + True + """ + start = time.monotonic() + while time.monotonic() - start < timeout: + try: + pane.refresh() + except Exception: + logger.debug( + "pane refresh failed during readiness check", + 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 +382,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 +563,10 @@ def get_pane_shell( assert isinstance(pane, Pane) + 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..afdfd80957 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,157 @@ 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, + ), +] + + +@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 From 8db8479bd0a9fcbee091deec58085c6a1fcf60d7 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 07:03:26 -0500 Subject: [PATCH 2/7] docs(CHANGES) Pane-readiness fix for zsh partial-line marker --- CHANGES | 10 ++++++++++ 1 file changed, 10 insertions(+) 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) From f669002eba052d90038bc4072cf7fa880757e195 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 07:35:45 -0500 Subject: [PATCH 3/7] workspace/builder(fix[logging]): Preserve exception traceback in readiness check why: The except block in _wait_for_pane_ready discarded the exception traceback, making it impossible to diagnose pane refresh failures. what: - Add exc_info=True to logger.debug call in the except block --- src/tmuxp/workspace/builder.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 72ad5843a8..041514dafc 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -63,6 +63,7 @@ def _wait_for_pane_ready( except Exception: logger.debug( "pane refresh failed during readiness check", + exc_info=True, extra={"tmux_pane": str(pane.pane_id)}, ) return False From 0139018a5ebd0cef1f55efa71c75851ab421a5d2 Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 07:36:35 -0500 Subject: [PATCH 4/7] tests/workspace/builder(test[pane-readiness]): Add window_shell readiness skip coverage why: The window_config.get("window_shell") fallback path at builder.py:566 was untested. Only pane-level shell: had fixture coverage. what: - Add skips_all_panes_with_window_shell fixture to PANE_READINESS_FIXTURES - Verifies expected_wait_count=0 when window_shell is set --- tests/workspace/test_builder.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/workspace/test_builder.py b/tests/workspace/test_builder.py index afdfd80957..6b78dfcbd3 100644 --- a/tests/workspace/test_builder.py +++ b/tests/workspace/test_builder.py @@ -1587,6 +1587,20 @@ class PaneReadinessFixture(t.NamedTuple): ), 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, + ), ] From d3c0c87f2a2c88e4716368c708ef13f048c353ce Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 07:37:23 -0500 Subject: [PATCH 5/7] workspace/builder(docs[pane-readiness]): Document shell-skip heuristic scope why: The readiness skip for panes with shell/window_shell was undocumented, making it unclear to future maintainers why pane_shell is None is the check. what: - Add comment explaining that shell/window_shell runs a command launcher, not an interactive shell, so there is no prompt to wait for --- src/tmuxp/workspace/builder.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index 041514dafc..b77411b12f 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -564,6 +564,10 @@ 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) From 818466e9238c3d34d6aa28ffaf02dd84dc0e03cb Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 07:40:15 -0500 Subject: [PATCH 6/7] workspace/builder(fix[doctest]): Increase readiness doctest timeout to 5s why: The 2s timeout was too tight for slow environments, causing flaky doctest failures when the shell hadn't drawn its prompt yet. what: - Bump _wait_for_pane_ready doctest timeout from 2.0 to 5.0 seconds --- src/tmuxp/workspace/builder.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tmuxp/workspace/builder.py b/src/tmuxp/workspace/builder.py index b77411b12f..24c93b3c24 100644 --- a/src/tmuxp/workspace/builder.py +++ b/src/tmuxp/workspace/builder.py @@ -53,7 +53,7 @@ def _wait_for_pane_ready( Wait for the shell to be ready: - >>> _wait_for_pane_ready(pane, timeout=2.0) + >>> _wait_for_pane_ready(pane, timeout=5.0) True """ start = time.monotonic() From 9debb0267d5cd63a769402f724192eacf5ead6cf Mon Sep 17 00:00:00 2001 From: Tony Narlock Date: Sun, 8 Mar 2026 07:40:41 -0500 Subject: [PATCH 7/7] conftest(fix[doctest]): Add rerun markers for tmux-dependent doctests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit why: Tmux doctests are inherently timing-sensitive — shell startup races can cause spurious failures even with generous timeouts. what: - Add pytest_collection_modifyitems hook to apply flaky(reruns=2) marker to DoctestItems from DOCTEST_NEEDS_TMUX modules --- conftest.py | 9 +++++++++ 1 file changed, 9 insertions(+) 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,