Skip to content

Commit 1ac73ea

Browse files
authored
fix(workspace/builder) Wait for shell prompt before layout and commands (#1018)
Fixes the long-standing zsh partial-line `%` marker that appeared in panes after loading a workspace (issue #365, open since 2018). The root cause: WorkspaceBuilder was sending SIGWINCH (via select_layout) and commands (via send_keys) before the shell finished initializing. The fix polls each pane's cursor position until it moves from origin (0, 0), indicating the shell has drawn its prompt, before applying layout or sending commands. Panes with a custom `shell` or `window_shell` skip the wait since they run a command launcher, not an interactive shell. - **_wait_for_pane_ready()**: New function that polls cursor_x / cursor_y with a 2s timeout and 50ms interval, with structured DEBUG logging - **Deduplicated select_layout**: Removed the redundant second select_layout pass in build(), keeping only the per-pane call in iter_create_panes() - **Test coverage**: Parametrized fixtures for readiness call counting (plain panes, empty commands, pane-level shell, and window_shell skip), plus direct tests for ready/timeout paths - **Doctest resilience**: Increased timeout and added pytest-rerunfailures markers for tmux-dependent doctests Fixes #365
2 parents 3785ebc + 9debb02 commit 1ac73ea

4 files changed

Lines changed: 257 additions & 5 deletions

File tree

CHANGES

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force
3535
_Notes on the upcoming release will go here._
3636
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->
3737

38+
### Bug fixes
39+
40+
#### Fix `%` character appearing in panes on workspace load (#1018)
41+
42+
Fixed long-standing issue ([#365]) where zsh panes displayed an inverse `%` marker after
43+
loading a workspace. WorkspaceBuilder now waits for each pane's shell to be ready before
44+
applying layout or sending commands.
45+
46+
[#365]: https://github.com/tmux-python/tmuxp/issues/365
47+
3848
### Documentation
3949

4050
#### Linkable CLI arguments and options (#1010)

conftest.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ def socket_name(request: pytest.FixtureRequest) -> str:
104104
}
105105

106106

107+
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
108+
"""Add rerun markers to tmux-dependent doctests for flaky shell timing."""
109+
for item in items:
110+
if isinstance(item, DoctestItem):
111+
module_name = item.dtest.globs.get("__name__", "")
112+
if module_name in DOCTEST_NEEDS_TMUX:
113+
item.add_marker(pytest.mark.flaky(reruns=2))
114+
115+
107116
@pytest.fixture(autouse=True)
108117
def add_doctest_fixtures(
109118
request: pytest.FixtureRequest,

src/tmuxp/workspace/builder.py

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,66 @@
2222

2323
logger = logging.getLogger(__name__)
2424

25+
26+
def _wait_for_pane_ready(
27+
pane: Pane,
28+
timeout: float = 2.0,
29+
interval: float = 0.05,
30+
) -> bool:
31+
"""Wait for pane shell to draw its prompt.
32+
33+
Polls the pane's cursor position until it moves from origin (0, 0),
34+
indicating the shell has finished initializing and drawn its prompt.
35+
36+
Parameters
37+
----------
38+
pane : :class:`libtmux.Pane`
39+
pane to wait for
40+
timeout : float
41+
maximum seconds to wait before giving up
42+
interval : float
43+
seconds between polling attempts
44+
45+
Returns
46+
-------
47+
bool
48+
True if pane became ready, False on timeout or error
49+
50+
Examples
51+
--------
52+
>>> pane = session.active_window.active_pane
53+
54+
Wait for the shell to be ready:
55+
56+
>>> _wait_for_pane_ready(pane, timeout=5.0)
57+
True
58+
"""
59+
start = time.monotonic()
60+
while time.monotonic() - start < timeout:
61+
try:
62+
pane.refresh()
63+
except Exception:
64+
logger.debug(
65+
"pane refresh failed during readiness check",
66+
exc_info=True,
67+
extra={"tmux_pane": str(pane.pane_id)},
68+
)
69+
return False
70+
if pane.cursor_x != "0" or pane.cursor_y != "0":
71+
logger.debug(
72+
"pane ready, cursor moved from origin",
73+
extra={"tmux_pane": str(pane.pane_id)},
74+
)
75+
return True
76+
time.sleep(interval)
77+
logger.debug(
78+
"pane readiness check timed out after %.1f seconds",
79+
timeout,
80+
extra={"tmux_pane": str(pane.pane_id)},
81+
)
82+
return False
83+
84+
2585
COLUMNS_FALLBACK = 80
2686

2787

@@ -323,9 +383,6 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
323383
assert isinstance(pane, Pane)
324384
pane = pane
325385

326-
if "layout" in window_config:
327-
window.select_layout(window_config["layout"])
328-
329386
if pane_config.get("focus"):
330387
focus_pane = pane
331388

@@ -507,6 +564,14 @@ def get_pane_shell(
507564

508565
assert isinstance(pane, Pane)
509566

567+
# Skip readiness wait when a custom shell/command launcher is set.
568+
# The shell/window_shell key runs a command (e.g. "top", "sleep 999")
569+
# that replaces the default shell — the pane exits when the command
570+
# exits, so there is no interactive prompt to wait for.
571+
pane_shell = pane_config.get("shell", window_config.get("window_shell"))
572+
if pane_shell is None:
573+
_wait_for_pane_ready(pane)
574+
510575
if "layout" in window_config:
511576
window.select_layout(window_config["layout"])
512577

tests/workspace/test_builder.py

Lines changed: 170 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
from tmuxp import exc
2525
from tmuxp._internal.config_reader import ConfigReader
2626
from tmuxp.cli.load import load_plugins
27-
from tmuxp.workspace import loader
28-
from tmuxp.workspace.builder import WorkspaceBuilder
27+
from tmuxp.workspace import builder as builder_module, loader
28+
from tmuxp.workspace.builder import WorkspaceBuilder, _wait_for_pane_ready
2929

3030
if t.TYPE_CHECKING:
3131
from libtmux.server import Server
@@ -1513,3 +1513,171 @@ def test_issue_800_default_size_many_windows(
15131513

15141514
builder.build()
15151515
assert len(server.sessions) == 1
1516+
1517+
1518+
def test_wait_for_pane_ready_returns_true(session: Session) -> None:
1519+
"""Verify _wait_for_pane_ready detects shell prompt."""
1520+
pane = session.active_window.active_pane
1521+
assert pane is not None
1522+
result = _wait_for_pane_ready(pane, timeout=2.0)
1523+
assert result is True
1524+
1525+
1526+
def test_wait_for_pane_ready_timeout(session: Session) -> None:
1527+
"""Verify _wait_for_pane_ready returns False on timeout for non-shell."""
1528+
window = session.active_window
1529+
assert window.active_pane is not None
1530+
new_pane = window.active_pane.split(shell="sleep 999")
1531+
assert new_pane is not None
1532+
result = _wait_for_pane_ready(new_pane, timeout=0.2)
1533+
assert result is False
1534+
1535+
1536+
class PaneReadinessFixture(t.NamedTuple):
1537+
"""Test fixture for pane readiness call count verification."""
1538+
1539+
test_id: str
1540+
yaml: str
1541+
expected_wait_count: int
1542+
1543+
1544+
PANE_READINESS_FIXTURES: list[PaneReadinessFixture] = [
1545+
PaneReadinessFixture(
1546+
test_id="waits_for_pane_with_commands",
1547+
yaml=textwrap.dedent(
1548+
"""\
1549+
session_name: readiness-test
1550+
windows:
1551+
- panes:
1552+
- shell_command:
1553+
- cmd: echo hello
1554+
- shell_command:
1555+
- cmd: echo world
1556+
""",
1557+
),
1558+
expected_wait_count=2,
1559+
),
1560+
PaneReadinessFixture(
1561+
test_id="waits_for_pane_without_commands",
1562+
yaml=textwrap.dedent(
1563+
"""\
1564+
session_name: readiness-test
1565+
windows:
1566+
- panes:
1567+
- shell_command:
1568+
- cmd: echo hello
1569+
- shell_command: []
1570+
""",
1571+
),
1572+
expected_wait_count=2,
1573+
),
1574+
PaneReadinessFixture(
1575+
test_id="skips_pane_with_custom_shell",
1576+
yaml=textwrap.dedent(
1577+
"""\
1578+
session_name: readiness-test
1579+
windows:
1580+
- panes:
1581+
- shell_command:
1582+
- cmd: echo hello
1583+
- shell: sleep 999
1584+
shell_command:
1585+
- cmd: echo world
1586+
""",
1587+
),
1588+
expected_wait_count=1,
1589+
),
1590+
PaneReadinessFixture(
1591+
test_id="skips_all_panes_with_window_shell",
1592+
yaml=textwrap.dedent(
1593+
"""\
1594+
session_name: readiness-test
1595+
windows:
1596+
- window_shell: top
1597+
panes:
1598+
- shell_command: []
1599+
- shell_command: []
1600+
""",
1601+
),
1602+
expected_wait_count=0,
1603+
),
1604+
]
1605+
1606+
1607+
@pytest.mark.parametrize(
1608+
list(PaneReadinessFixture._fields),
1609+
PANE_READINESS_FIXTURES,
1610+
ids=[t.test_id for t in PANE_READINESS_FIXTURES],
1611+
)
1612+
def test_pane_readiness_call_count(
1613+
tmp_path: pathlib.Path,
1614+
server: Server,
1615+
monkeypatch: pytest.MonkeyPatch,
1616+
test_id: str,
1617+
yaml: str,
1618+
expected_wait_count: int,
1619+
) -> None:
1620+
"""Verify _wait_for_pane_ready is called only for appropriate panes."""
1621+
call_count = 0
1622+
original = builder_module._wait_for_pane_ready
1623+
1624+
def counting_wait(
1625+
pane: Pane,
1626+
timeout: float = 2.0,
1627+
interval: float = 0.05,
1628+
) -> bool:
1629+
nonlocal call_count
1630+
call_count += 1
1631+
return original(pane, timeout=timeout, interval=interval)
1632+
1633+
monkeypatch.setattr(builder_module, "_wait_for_pane_ready", counting_wait)
1634+
1635+
yaml_workspace = tmp_path / "readiness.yaml"
1636+
yaml_workspace.write_text(yaml, encoding="utf-8")
1637+
workspace = ConfigReader._from_file(yaml_workspace)
1638+
workspace = loader.expand(workspace)
1639+
workspace = loader.trickle(workspace)
1640+
1641+
builder = WorkspaceBuilder(session_config=workspace, server=server)
1642+
builder.build()
1643+
assert call_count == expected_wait_count
1644+
1645+
1646+
def test_select_layout_not_called_after_yield(
1647+
tmp_path: pathlib.Path,
1648+
server: Server,
1649+
monkeypatch: pytest.MonkeyPatch,
1650+
) -> None:
1651+
"""Verify select_layout is called once per pane, not duplicated in build()."""
1652+
call_count = 0
1653+
original_select_layout = Window.select_layout
1654+
1655+
def counting_layout(self: Window, layout: str | None = None) -> Window:
1656+
nonlocal call_count
1657+
call_count += 1
1658+
return original_select_layout(self, layout)
1659+
1660+
monkeypatch.setattr(Window, "select_layout", counting_layout)
1661+
1662+
yaml_config = textwrap.dedent(
1663+
"""\
1664+
session_name: layout-test
1665+
windows:
1666+
- layout: main-vertical
1667+
panes:
1668+
- shell_command: []
1669+
- shell_command: []
1670+
- shell_command: []
1671+
""",
1672+
)
1673+
1674+
yaml_workspace = tmp_path / "layout.yaml"
1675+
yaml_workspace.write_text(yaml_config, encoding="utf-8")
1676+
workspace = ConfigReader._from_file(yaml_workspace)
1677+
workspace = loader.expand(workspace)
1678+
workspace = loader.trickle(workspace)
1679+
1680+
builder = WorkspaceBuilder(session_config=workspace, server=server)
1681+
builder.build()
1682+
# 3 panes = 3 layout calls (one per pane in iter_create_panes), not 6
1683+
assert call_count == 3

0 commit comments

Comments
 (0)