diff --git a/CHANGES b/CHANGES index 1c006c2e5d..5b78ef3573 100644 --- a/CHANGES +++ b/CHANGES @@ -44,6 +44,26 @@ $ tmuxp@next load yoursession _Notes on the upcoming release will go here._ +### What's new + +#### Faster pane-readiness polling (#1040) + +When the classic builder waits for a pane's shell to draw its prompt, it now +polls every 10ms instead of every 50ms. tmux's shell-ready latency typically +lands in the 50-150ms band, so the finer interval notices readiness sooner and +shortens workspace loads that wait on the prompt, with no change to the +readiness condition itself. + +### Development + +#### Centralized set-option dispatch (#1040) + +The classic builder now applies session, global, per-window, and post-build +window options through one internal entry point instead of four separate loops. +It still issues one `set-option` per option, so behavior is unchanged; the +shared, batch-shaped entry point exists so a future libtmux batching API can cut +the per-option round-trips without revisiting the call sites. + ## tmuxp 1.73.0 (2026-06-28) tmuxp 1.73.0 makes the workspace build step pluggable and tunable. A workspace can now build through a third-party builder selected by registered entry-point name or Python import path, and a new `workspace_builder_options` catalog controls the pane-readiness wait per workspace. The built-in builder stays the default, so existing workspaces keep working — though the new `pane_readiness: auto` default skips the prompt wait on non-zsh shells. See {ref}`custom-workspace-builders` for the guide. diff --git a/src/tmuxp/workspace/builder/classic.py b/src/tmuxp/workspace/builder/classic.py index 03d2fba87c..eb640fe7bb 100644 --- a/src/tmuxp/workspace/builder/classic.py +++ b/src/tmuxp/workspace/builder/classic.py @@ -9,6 +9,7 @@ import typing as t from libtmux._internal.query_list import ObjectDoesNotExist +from libtmux.options import handle_option_error from libtmux.pane import Pane from libtmux.server import Server from libtmux.session import Session @@ -25,7 +26,7 @@ ) if t.TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Iterator, Mapping logger = logging.getLogger(__name__) @@ -33,7 +34,7 @@ def _wait_for_pane_ready( pane: Pane, timeout: float = 2.0, - interval: float = 0.05, + interval: float = 0.01, ) -> bool: """Wait for pane shell to draw its prompt. @@ -425,6 +426,83 @@ def session_exists(self, session_name: str) -> bool: return False return True + def _bulk_set_options( + self, + items: Mapping[str, int | str | bool], + *, + target: str | None, + scope_flag: str, + ) -> None: + """Apply ``set-option`` for each (key, value) pair. + + Mirrors :meth:`libtmux.options.OptionsMixin.set_option`'s + ``True/False -> "on"/"off"`` convention so behaviour matches a plain + loop of ``set_option`` calls. Errors propagate as the same + ``OptionError`` subclasses :func:`libtmux.options.handle_option_error` + produces. + + Currently issues one ``set-option`` round-trip per item. The helper's + API (mapping + scope flag + optional target) is deliberately + batch-shaped so the body can swap to a single pipelined dispatch (e.g. + a future ``Server.batch()``) once libtmux exposes one, without + touching the call sites in :meth:`build`, :meth:`iter_create_windows`, + and :meth:`config_after_window`. + + Parameters + ---------- + items : :class:`collections.abc.Mapping` + Option name -> value pairs. + target : str, optional + Target identifier (session_id / window_id) for ``-t``; pass + ``None`` for global options where ``-g`` already names the scope. + scope_flag : str + ``"-s"``, ``"-g"``, or ``"-w"``. Selects the option scope. + + Examples + -------- + >>> builder = ClassicWorkspaceBuilder( + ... session_config={ + ... "session_name": "bulk-doctest", + ... "windows": [ + ... {"window_name": "main", "panes": [{"shell_command": []}]} + ... ], + ... }, + ... server=server, + ... ) + >>> builder.build() + + Apply session-scoped options in a single call: + + >>> builder._bulk_set_options( + ... {"default-shell": "/bin/sh"}, + ... target=builder.session.session_id, + ... scope_flag="-s", + ... ) + >>> "/bin/sh" in builder.session.show_option("default-shell") + True + + An empty mapping is a no-op: + + >>> builder._bulk_set_options({}, target=None, scope_flag="-g") + """ + if not items: + return + server = self.server + assert server is not None + for key, raw_val in items.items(): + if raw_val is True: + val: int | str = "on" + elif raw_val is False: + val = "off" + else: + val = raw_val + if target is not None: + cmd = server.cmd("set-option", scope_flag, "-t", target, key, val) + else: + cmd = server.cmd("set-option", scope_flag, key, val) + if cmd.stderr: + handle_option_error(cmd.stderr[0]) + def build(self, session: Session | None = None, append: bool = False) -> None: """Build tmux workspace in session. @@ -545,12 +623,18 @@ def build(self, session: Session | None = None, append: bool = False) -> None: self.on_build_event({"event": "before_script_done"}) if "options" in self.session_config: - for option, value in self.session_config["options"].items(): - self.session.set_option(option, value) + self._bulk_set_options( + self.session_config["options"], + target=self.session.session_id, + scope_flag="-s", + ) if "global_options" in self.session_config: - for option, value in self.session_config["global_options"].items(): - self.session.set_option(option, value, global_=True) + self._bulk_set_options( + self.session_config["global_options"], + target=None, + scope_flag="-g", + ) if "environment" in self.session_config: for option, value in self.session_config["environment"].items(): @@ -710,8 +794,11 @@ def iter_create_windows( window_config["options"], dict, ): - for key, val in window_config["options"].items(): - window.set_option(key, val) + self._bulk_set_options( + window_config["options"], + target=window.window_id, + scope_flag="-w", + ) if window_config.get("focus"): window.select() @@ -882,8 +969,11 @@ def config_after_window( window_config["options_after"], dict, ): - for key, val in window_config["options_after"].items(): - window.set_option(key, val) + self._bulk_set_options( + window_config["options_after"], + target=window.window_id, + scope_flag="-w", + ) def find_current_attached_session(self) -> Session: """Return current attached session.""" diff --git a/tests/workspace/test_builder_bulk_options.py b/tests/workspace/test_builder_bulk_options.py new file mode 100644 index 0000000000..a8e93a7de2 --- /dev/null +++ b/tests/workspace/test_builder_bulk_options.py @@ -0,0 +1,152 @@ +"""Tests for :meth:`ClassicWorkspaceBuilder._bulk_set_options`.""" + +from __future__ import annotations + +import typing as t + +import libtmux +import pytest + +from tmuxp.workspace.builder.classic import ClassicWorkspaceBuilder + +if t.TYPE_CHECKING: + from libtmux.session import Session + + +def _build(session: Session) -> ClassicWorkspaceBuilder: + """Build a single-window session and return its builder.""" + session_config = { + "session_name": "bulk-set-options", + "windows": [{"window_name": "main", "panes": [{"shell_command": []}]}], + } + builder = ClassicWorkspaceBuilder( + session_config=session_config, + server=session.server, + ) + builder.build(session=session) + return builder + + +class BulkSetCase(t.NamedTuple): + """Case for :meth:`ClassicWorkspaceBuilder._bulk_set_options` scopes.""" + + test_id: str + scope_flag: str + option: str + raw_value: int | str | bool + expected: int | str | bool + + +BULK_SET_CASES: list[BulkSetCase] = [ + BulkSetCase( + test_id="session-string", + scope_flag="-s", + option="default-shell", + raw_value="/bin/sh", + expected="/bin/sh", + ), + BulkSetCase( + test_id="global-int", + scope_flag="-g", + option="repeat-time", + raw_value=491, + expected=491, + ), + BulkSetCase( + test_id="global-bool-true", + scope_flag="-g", + option="visual-silence", + raw_value=True, + expected=True, + ), + BulkSetCase( + test_id="window-bool-true", + scope_flag="-w", + option="automatic-rename", + raw_value=True, + expected=True, + ), + BulkSetCase( + test_id="window-bool-false", + scope_flag="-w", + option="automatic-rename", + raw_value=False, + expected=False, + ), + BulkSetCase( + test_id="window-int", + scope_flag="-w", + option="main-pane-height", + raw_value=7, + expected=7, + ), + BulkSetCase( + test_id="window-string", + scope_flag="-w", + option="pane-border-format", + raw_value=" #P ", + expected=" #P ", + ), +] + + +@pytest.mark.parametrize( + "case", + BULK_SET_CASES, + ids=[c.test_id for c in BULK_SET_CASES], +) +def test_bulk_set_options_applies_and_normalizes( + session: Session, + case: BulkSetCase, +) -> None: + """Helper sets each scope and normalizes True/False to on/off. + + A ``bool`` raw value lands as the on/off-derived bool tmux reports back, + proving the helper mirrors ``set_option``'s normalization. + """ + builder = _build(session) + sess = builder.session + window = sess.active_window + + if case.scope_flag == "-w": + target: str | None = window.window_id + elif case.scope_flag == "-g": + target = None + else: + target = sess.session_id + + builder._bulk_set_options( + {case.option: case.raw_value}, + target=target, + scope_flag=case.scope_flag, + ) + + if case.scope_flag == "-w": + assert window.show_option(case.option) == case.expected + elif case.scope_flag == "-g": + assert sess.show_option(case.option, global_=True) == case.expected + else: + assert sess.show_option(case.option) == case.expected + + +def test_bulk_set_options_empty_is_noop(session: Session) -> None: + """An empty mapping returns before issuing any tmux command. + + A bogus ``scope_flag`` would make tmux error if a command were dispatched, + so a clean return proves the empty-mapping guard short-circuits first. + """ + builder = _build(session) + builder._bulk_set_options({}, target=None, scope_flag="not-a-flag") + + +def test_bulk_set_options_propagates_unknown_option_error( + session: Session, +) -> None: + """A bad option surfaces as :exc:`libtmux.exc.OptionError`.""" + builder = _build(session) + with pytest.raises(libtmux.exc.OptionError): + builder._bulk_set_options( + {"this-option-does-not-exist": "value"}, + target=builder.session.session_id, + scope_flag="-s", + )