8000 fix(workspace/builder): Wait for shell prompt before layout and commands by tony · Pull Request #1018 · tmux-python/tmuxp · GitHub
[go: up one dir, main page]

Skip to content
Merged
10 changes: 10 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ $ pipx install --suffix=@next 'tmuxp' --pip-args '\--pre' --force
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### 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)
Expand Down
9 changes: 9 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
71 changes: 68 additions & 3 deletions src/tmuxp/workspace/builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"])

Comment on lines -326 to -328
Copy link
Member Author
@tony tony Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some code archaeology on this because I had the same concern about reintroducing old layout/space bugs.

This specific select_layout() call looks like the redundant one, not the essential one. The builder still applies select_layout() per-pane in iter_create_panes(), which is the part that actually preserves
the incremental rebalance during pane creation. The call removed here was the second pass in build(), so it was effectively doubling the layout application after each yielded pane.

The historical “no space for new pane” regressions were more closely tied to session geometry than to this duplicate call. In 2022, #793 changed both layout timing and default-size, and the fallout in #800
was mainly from the server starting at 80x24 before a client attached. The later fix path was to size new_session() correctly (8a59203, then 0777c3b/63ac676d), not to keep a second select_layout()
here.

I also checked the current regressions: test_issue_800_default_size_many_windows still passes, and this branch adds test_select_layout_not_called_after_yield specifically to lock in the intended behavior:
call select_layout() once per pane, not twice. So I don’t think removing this duplicate call should reintroduce the lack-of-space issue.

if pane_config.get("focus"):
focus_pane = pane

Expand Down Expand Up @@ -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"])

Expand Down
172 changes: 170 additions & 2 deletions tests/workspace/test_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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")
work 49FF space = 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
0