Visitar URL original
Workspace: synchronize, `shell_command_after`, clear, pane-title keys by tony · Pull Request #1069 · tmux-python/tmuxp · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
Workspace(feat[builder]): runtime config keys
why: Workspaces could not label panes, run a post-build command wave,
clear panes, or set a final pane-synchronization state without spelling
out raw tmux options. Layer these keys onto the classic builder so the
behavior stays additive and no new builder or entry point is needed.

what:
- Expand session enable_pane_titles/pane_title_position/pane_title_format
  into per-window pane-border options; expand window synchronize into a
  tri-state final sync flag and shell_command_after via expand_cmd
- Force synchronize-panes off (window- and pane-local) while panes and
  post-build commands run, then restore the configured final state
- Send shell_command_after, then clear, then options_after with sync off
  so synchronize:after never duplicates commands across panes
- Apply pane title via pane.set_title after pane commands; warn on empty
  • Loading branch information
tony committed Jul 4, 2026
commit 6854096228e85d07c1ef55a3c61d500b604b241b
278 changes: 264 additions & 14 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.constants import OptionScope
from libtmux.pane import Pane
from libtmux.server import Server
from libtmux.session import Session
Expand All @@ -29,6 +30,13 @@

logger = logging.getLogger(__name__)

SYNCHRONIZE_PANES_OPTION = "synchronize-panes"
SYNCHRONIZE_PANES_FINAL_OPTION = "_synchronize_panes"
SYNCHRONIZE_PANES_FORCED_OFF_OPTION = "_synchronize_panes_forced_off"
SYNCHRONIZE_PANES_PANE_RESTORE_OPTION = "_synchronize_panes_pane_restore"
SYNCHRONIZE_PANES_RESTORE_OPTION = "_synchronize_panes_restore"
_MISSING = object()


def _wait_for_pane_ready(
pane: Pane,
Expand Down Expand Up @@ -89,6 +97,43 @@ def _wait_for_pane_ready(
return False


def _get_window_option_value(
window_config: dict[str, t.Any],
section: str,
option: str,
) -> t.Any:
"""Return a window option value or ``_MISSING``.

Parameters
----------
window_config : dict
config section for window
section : str
config section holding tmux options (e.g. ``options`` or
``options_after``)
option : str
tmux option name to read

Returns
-------
typing.Any
the option value, or the module-level ``_MISSING`` sentinel when the
section or option is absent

Examples
--------
>>> cfg = {"options": {"synchronize-panes": "on"}}
>>> _get_window_option_value(cfg, "options", "synchronize-panes")
'on'
>>> _get_window_option_value(cfg, "options_after", "synchronize-panes") is _MISSING
True
"""
options = window_config.get(section)
if isinstance(options, dict) and option in options:
return options[option]
return _MISSING


COLUMNS_FALLBACK = 80


Expand Down Expand Up @@ -580,21 +625,36 @@ def build(self, session: Session | None = None, append: bool = False) -> None:
for window, window_config in self.iter_create_windows(session, append):
assert isinstance(window, Window)

if SYNCHRONIZE_PANES_FINAL_OPTION in window_config:
window.set_option(
SYNCHRONIZE_PANES_OPTION,
window_config[SYNCHRONIZE_PANES_FINAL_OPTION],
)

for plugin in self.plugins:
plugin.on_window_create(window)

self.prepare_window_synchronize_panes(window, window_config)

focus_pane = None
for pane, pane_config in self.iter_create_panes(window, window_config):
assert isinstance(pane, Pane)
pane = pane
try:
for pane, pane_config in self.iter_create_panes(window, window_config):
assert isinstance(pane, Pane)
pane = pane

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

if window_config.get("focus"):
focus = window
if window_config.get("focus"):
focus = window

self.config_after_window(window, window_config)
self.config_after_window(window, window_config)
finally:
self.restore_window_synchronize_panes(
window,
window_config,
apply_options_after=False,
)

for plugin in self.plugins:
plugin.after_window_finished(window)
Expand Down Expand Up @@ -718,6 +778,151 @@ def iter_create_windows(

yield window, window_config

def prepare_window_synchronize_panes(
self,
window: Window,
window_config: dict[str, t.Any],
) -> None:
"""Disable tmux pane synchronization while tmuxp sends setup keys.

tmux broadcasts keystrokes to every pane while ``synchronize-panes`` is
on. Setup commands must reach only their own pane, so the effective
synchronized state is forced off here and restored later by
:meth:`restore_window_synchronize_panes`.

Parameters
----------
window : :class:`libtmux.Window`
window whose synchronization state is being isolated
window_config : dict
config section for window; restore bookkeeping is stashed here

Examples
--------
>>> cfg: dict = {}
>>> builder = ClassicWorkspaceBuilder(
... session_config={"session_name": session.name, "windows": []},
... server=session.server,
... )
>>> scratch = session.new_window(window_name="sync-prepare")
>>> _ = scratch.set_option("synchronize-panes", True)
>>> builder.prepare_window_synchronize_panes(scratch, cfg)
>>> scratch.show_option("synchronize-panes")
False
>>> builder.restore_window_synchronize_panes(scratch, cfg)
>>> scratch.show_option("synchronize-panes")
True
>>> _ = scratch.kill()
"""
local_sync = window.show_option(SYNCHRONIZE_PANES_OPTION)
effective_sync = window.show_option(
SYNCHRONIZE_PANES_OPTION,
include_inherited=True,
)

if effective_sync is True:
window_config[SYNCHRONIZE_PANES_RESTORE_OPTION] = local_sync
window.set_option(SYNCHRONIZE_PANES_OPTION, False)
window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True

pane_restores = []
for pane in window.panes:
local_pane_sync = pane.show_option(
SYNCHRONIZE_PANES_OPTION,
scope=OptionScope.Pane,
)
if local_pane_sync is True:
pane_restores.append((pane, local_pane_sync))
pane.set_option(
SYNCHRONIZE_PANES_OPTION,
False,
scope=OptionScope.Pane,
ignore_errors=True,
)

if pane_restores:
window_config[SYNCHRONIZE_PANES_PANE_RESTORE_OPTION] = pane_restores
window_config[SYNCHRONIZE_PANES_FORCED_OFF_OPTION] = True

def restore_window_synchronize_panes(
self,
window: Window,
window_config: dict[str, t.Any],
*,
apply_options_after: bool = True,
) -> None:
"""Restore the configured final tmux pane synchronization state.

Parameters
----------
window : :class:`libtmux.Window`
window whose synchronization state is being restored
window_config : dict
config section for window; restore bookkeeping is consumed here
apply_options_after : bool
when ``True`` (the default), an ``options_after`` ``synchronize-panes``
value wins. The build-level ``finally`` call passes ``False`` so the
post-build fan-out in :meth:`config_after_window` keeps sole
ownership of applying ``options_after``.

Examples
--------
>>> cfg = {"options_after": {"synchronize-panes": "off"}}
>>> builder = ClassicWorkspaceBuilder(
... session_config={"session_name": session.name, "windows": []},
... server=session.server,
... )
>>> scratch = session.new_window(window_name="sync-restore")
>>> _ = scratch.set_option("synchronize-panes", True)
>>> builder.restore_window_synchronize_panes(scratch, cfg)
>>> scratch.show_option("synchronize-panes")
False
>>> _ = scratch.kill()
"""
after_sync = _MISSING
if apply_options_after:
after_sync = _get_window_option_value(
window_config,
"options_after",
SYNCHRONIZE_PANES_OPTION,
)

pane_restores = window_config.pop(
SYNCHRONIZE_PANES_PANE_RESTORE_OPTION,
[],
)
forced_off = window_config.pop(SYNCHRONIZE_PANES_FORCED_OFF_OPTION, False)
restore_sync = window_config.pop(
SYNCHRONIZE_PANES_RESTORE_OPTION,
_MISSING,
)

if after_sync is not _MISSING:
window.set_option(SYNCHRONIZE_PANES_OPTION, t.cast("int | str", after_sync))
elif forced_off and restore_sync is not _MISSING:
if restore_sync is None:
window.unset_option(SYNCHRONIZE_PANES_OPTION, ignore_errors=True)
else:
window.set_option(
SYNCHRONIZE_PANES_OPTION,
t.cast("int | str", restore_sync),
)

for pane, restore_pane_sync in pane_restores:
if restore_pane_sync is _MISSING or restore_pane_sync is None:
pane.unset_option(
SYNCHRONIZE_PANES_OPTION,
scope=OptionScope.Pane,
ignore_errors=True,
)
else:
pane.set_option(
SYNCHRONIZE_PANES_OPTION,
t.cast("int | str", restore_pane_sync),
scope=OptionScope.Pane,
ignore_errors=True,
)

def iter_create_panes(
self,
window: Window,
Expand Down Expand Up @@ -854,6 +1059,17 @@ def get_pane_shell(
if sleep_after is not None:
time.sleep(sleep_after)

title = pane_config.get("title")
if title:
pane.set_title(title)
elif title is not None:
# tmux discards `select-pane -T ""`; an empty pane title
# cannot be applied.
pane_log.warning(
"tmux ignores empty pane titles; use a single space "
"to blank the label",
)

if pane_config.get("focus"):
assert pane.pane_id is not None
window.select_pane(pane.pane_id)
Expand All @@ -878,12 +1094,46 @@ def config_after_window(
window_config : dict
config section for window
"""
if "options_after" in window_config and isinstance(
window_config["options_after"],
dict,
):
for key, val in window_config["options_after"].items():
window.set_option(key, val)
suppress = window_config.get("suppress_history", True)

try:
if "shell_command_after" in window_config and isinstance(
window_config["shell_command_after"],
dict,
):
for cmd in window_config["shell_command_after"].get(
"shell_command",
[],
):
enter = cmd.get("enter", True)
sleep_before = cmd.get("sleep_before")
sleep_after = cmd.get("sleep_after")
# Sleeps apply once per command wave, not once per pane.
if sleep_before is not None:
time.sleep(sleep_before)
for pane in window.panes:
pane.send_keys(
cmd["cmd"],
suppress_history=suppress,
enter=enter,
)
if sleep_after is not None:
time.sleep(sleep_after)

if window_config.get("clear"):
for pane in window.panes:
pane.send_keys("clear", enter=True, suppress_history=suppress)

if "options_after" in window_config and isinstance(
window_config["options_after"],
dict,
):
for key, val in window_config["options_after"].items():
if key == SYNCHRONIZE_PANES_OPTION:
continue
window.set_option(key, val)
finally:
self.restore_window_synchronize_panes(window, window_config)

def find_current_attached_session(self) -> Session:
"""Return current attached session."""
Expand Down
Loading