Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ $ tmuxp@next load yoursession
_Notes on the upcoming release will go here._
<!-- END PLACEHOLDER - ADD NEW CHANGELOG ENTRIES BELOW THIS LINE -->

### 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.
Expand Down
110 changes: 100 additions & 10 deletions src/tmuxp/workspace/builder/classic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -25,15 +26,15 @@
)

if t.TYPE_CHECKING:
from collections.abc import Iterator
from collections.abc import Iterator, Mapping

logger = logging.getLogger(__name__)


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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."""
Expand Down
152 changes: 152 additions & 0 deletions tests/workspace/test_builder_bulk_options.py
Original file line number Diff line number Diff line change
@@ -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",
)